pax_global_header00006660000000000000000000000064147441313320014514gustar00rootroot0000000000000052 comment=42a6b9eb4ed1e8c4dc688ea0bee67364bf570875 napari-0.5.6/000077500000000000000000000000001474413133200127765ustar00rootroot00000000000000napari-0.5.6/.circleci/000077500000000000000000000000001474413133200146315ustar00rootroot00000000000000napari-0.5.6/.circleci/config.yml000066400000000000000000000040361474413133200166240ustar00rootroot00000000000000# As much as possible, this file should be kept in sync with: # https://github.com/napari/docs/blob/main/.circleci/config.yaml # Use the latest 2.1 version of CircleCI pipeline process engine. # See: https://circleci.com/docs/2.1/configuration-reference version: 2.1 # Orbs are reusable packages of CircleCI configuration that you may share across projects. # See: https://circleci.com/docs/2.1/orb-intro/ orbs: python: circleci/python@1.5.0 jobs: build-docs: docker: # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python - image: cimg/python:3.10.13 steps: - checkout: path: napari - run: name: Clone docs repo into a subdirectory command: git clone git@github.com:napari/docs.git docs - run: name: Install qt libs + xvfb command: sudo apt-get update && sudo apt-get install -y xvfb libegl1 libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 x11-utils - run: name: Setup virtual environment command: | python -m venv venv . venv/bin/activate python -m pip install --upgrade pip - run: name: Install napari-dev command: | . venv/bin/activate python -m pip install -e "napari/[pyside,dev]" environment: PIP_CONSTRAINT: napari/resources/constraints/constraints_py3.10_docs.txt - run: name: Build docs command: | . venv/bin/activate cd docs xvfb-run --auto-servernum make docs environment: PIP_CONSTRAINT: ../napari/resources/constraints/constraints_py3.10_docs.txt - store_artifacts: path: docs/docs/_build/html/ - persist_to_workspace: root: . paths: - docs/docs/_build/html/ workflows: build-docs: jobs: - build-docs napari-0.5.6/.devcontainer/000077500000000000000000000000001474413133200155355ustar00rootroot00000000000000napari-0.5.6/.devcontainer/Dockerfile000066400000000000000000000007211474413133200175270ustar00rootroot00000000000000# 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 sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ && sudo apt-get -y install --no-install-recommends \ libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ libxcb-render-util0 libxcb-xinerama0 libxkbcommon-x11-0 napari-0.5.6/.devcontainer/add-notice.sh000066400000000000000000000015441474413133200201040ustar00rootroot00000000000000# 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.6/.devcontainer/devcontainer.json000066400000000000000000000027601474413133200211160ustar00rootroot00000000000000// 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.6/.env_sample000066400000000000000000000030431474413133200151300ustar00rootroot00000000000000# 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.6/.gitattributes000066400000000000000000000000441474413133200156670ustar00rootroot00000000000000napari_gui/_version.py export-subst napari-0.5.6/.github/000077500000000000000000000000001474413133200143365ustar00rootroot00000000000000napari-0.5.6/.github/BOT_REPO_UPDATE_FAIL_TEMPLATE.md000066400000000000000000000004441474413133200212630ustar00rootroot00000000000000--- title: "{{ env.TITLE }}" labels: [bug] --- Update of {{ env.BOT_REPO }} failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 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.6/.github/CODEOWNERS000066400000000000000000000021231474413133200157270ustar00rootroot00000000000000# anything with no explicit code owners will be tagged to @core-devs * @napari/core-devs # submodules napari/_vispy/ @brisvag @melonora napari/_qt/ @Czaki @DragaDoncila @psobolewskiPhD napari/_app_model/ @lucyleeow @DragaDoncila napari/benchmarks/ @Czaki @jni napari/plugins/ @Czaki @DragaDoncila @lucyleeow napari/qt/ @Czaki @jni napari/settings/ @Czaki @jni # specific layers napari/layers/image/ @Czaki @brisvag @andy-sweet @kephale napari/layers/labels/ @jni @Czaki @brisvag napari/layers/points/ @brisvag @kevinyamauchi @andy-sweet @DragaDoncila @kephale napari/layers/shapes/ @kevinyamauchi @DragaDoncila @melonora napari/layers/surface/ @brisvag @kevinyamauchi @Czaki napari/layers/tracks/ @jni @andy-sweet napari/layers/vectors/ @brisvag @kevinyamauchi @andy-sweet # docs examples/ @melissawm @psobolewskiPhD @lucyleeow .github/workflows/build_docs.yml @melissawm @psobolewskiPhD @lucyleeow .github/workflows/deploy_docs.yml @melissawm @psobolewskiPhD .github/workflows/circleci.yml @melissawm @psobolewskiPhD napari-0.5.6/.github/CONTRIBUTING.md000066400000000000000000000044071474413133200165740ustar00rootroot00000000000000# Contributing to GitHub workflows and actions *Created: 2024-11-11; Updated:* See the napari website for more detailed contributor information: - [deployment](https://napari.org/stable/developers/contributing/documentation/docs_deployment.html) - [contributing guide](https://napari.org/stable/developers/contributing/index.html) - [core developer guide](https://napari.org/stable/developers/coredev/core_dev_guide.html) ## Workflows and actions There are over 20 GitHub workflows found in `.github/workflows`. The team creates a workflow to automate manual actions and steps. This results in improved accuracy and quality. Some key workflows: - `actionlint.yml` does static testing of GitHub action workflows - benchmarks - `reusable_run_tox_test.yml` uses our constraint files to install the compatible dependencies for each test environment which may differ by OS and qt versions. It is called from `test_pull_request.yml` and `test_comprehensive.yml`, not directly. - `upgrade_test_constraints.yml` automates upgrading dependencies for our test environments. It also has extensive commenting on what the upgrade process entails. If adding a workflow, please take a moment to explain its purpose at the top of its file. ## Templates Used to provide a consistent user experience when submitting an issue or PR. napari uses the following: - `PULL_REQUEST_TEMPLATE.md` - `ISSUE_TEMPLATE` directory containing: - `config.yml` to add the menu selector when "New Issue" button is pressed - `design_related.md` - `documentation.md` - `feature_request.md` - `bug_report.yml` config file to provide text areas for users to complete for bug reports. - `FUNDING.yml`: redirect GitHub to napari NumFOCUS account - Testing and bots - `missing_translations.md`: used if an action detects a missing language translation - `dependabot.yml`: opens a PR to notify maintainers of updates to dependencies - `labeler.yml` is a labels config file for labeler action - `BOT_REPO_UPDATE_FAIL_TEMPLATE.md` is an bot failure notification template - `TEST_FAIL_TEMPLATE.md` is a test failure notification template ## CODEOWNERS This `CODEOWNERS` file identifies which individuals are notified if a particular file or directory is found in a PR. Core team members can update if desired. napari-0.5.6/.github/FUNDING.yml000066400000000000000000000000761474413133200161560ustar00rootroot00000000000000github: numfocus custom: http://numfocus.org/donate-to-napari napari-0.5.6/.github/ISSUE_TEMPLATE/000077500000000000000000000000001474413133200165215ustar00rootroot00000000000000napari-0.5.6/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000057211474413133200214210ustar00rootroot00000000000000name: "\U0001F41B Bug Report" description: Report a bug encountered while using napari labels: - "bug" body: - type: markdown attributes: value: | Thanks for taking the time to report this issue! 🙏🏼 Please fill out the sections below to help us reproduce the problem. If you've found a problem with content on the napari documentation site (napari.org) or with the rendering of the content, please let us know [here](https://github.com/napari/docs/issues) - type: textarea id: bug-report attributes: label: "\U0001F41B Bug Report" description: "Please provide a clear and concise description of the bug." placeholder: "What went wrong? What did you expect to happen?" validations: required: true - type: textarea id: steps-to-reproduce attributes: label: "\U0001F4A1 Steps to Reproduce" description: "Please provide a minimal code snippet or list of steps to reproduce the bug." placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected-behavior attributes: label: "\U0001F4A1 Expected Behavior" description: "Please provide a clear and concise description of what you expected to happen." placeholder: "What did you expect to happen?" - type: textarea id: environment attributes: label: "\U0001F30E Environment" description: | Please provide detailed information regarding your environment. Please paste the output of `napari --info` here or copy the information from the "napari info" dialog in the napari Help menu. Otherwise, please provide information regarding your operating system (OS), Python version, napari version, Qt backend and version, Qt platform, method of installation, and any other relevant information related to your environment. placeholder: | napari: 0.5.0 Platform: macOS-13.2.1-arm64-arm-64bit System: MacOS 13.2.1 Python: 3.11.4 (main, Aug 7 2023, 20:34:01) [Clang 14.0.3 (clang-1403.0.22.14.1)] Qt: 5.15.10 PyQt5: 5.15.10 NumPy: 1.25.1 SciPy: 1.11.1 Dask: 2023.7.1 VisPy: 0.13.0 magicgui: 0.7.2 superqt: 0.5.4 in-n-out: 0.1.8 app-model: 0.2.0 npe2: 0.7.2 OpenGL: - GL version: 2.1 Metal - 83 - MAX_TEXTURE_SIZE: 16384 Screens: - screen 1: resolution 1512x982, scale 2.0 Settings path: - /Users/.../napari/napari_5c6993c40c104085444cfc0c77fa392cb5cb8f56/settings.yaml validations: required: true - type: textarea id: additional-context attributes: label: "\U0001F4A1 Additional Context" description: "Please provide any additional information or context regarding the problem here." placeholder: "Add any other context about the problem here." napari-0.5.6/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000012551474413133200205140ustar00rootroot00000000000000# 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 documentation url: https://github.com/napari/docs/issues about: | If you've found a problem with content on the napari documentation site (napari.org) or with the rendering of the content, please let us know here. - 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.6/.github/ISSUE_TEMPLATE/design_related.md000066400000000000000000000032301474413133200220120ustar00rootroot00000000000000--- 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.6/.github/ISSUE_TEMPLATE/documentation.md000066400000000000000000000006451474413133200217210ustar00rootroot00000000000000--- name: "\U0001F4DA Documentation" about: Report an issue with napari code documentation title: '' labels: documentation assignees: '' --- ## 📚 Documentation napari-0.5.6/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000013651474413133200222530ustar00rootroot00000000000000--- 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.6/.github/ISSUE_TEMPLATE/task.md000066400000000000000000000003031474413133200200010ustar00rootroot00000000000000--- name: "\U0001F9F0 Task" about: Submit a proposal/request for a new napari feature title: '' labels: task assignees: '' --- ## 🧰 Task napari-0.5.6/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000025311474413133200201400ustar00rootroot00000000000000# References and relevant issues # Description napari-0.5.6/.github/TEST_FAIL_TEMPLATE.md000066400000000000000000000006251474413133200175300ustar00rootroot00000000000000--- 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.6/.github/dependabot.yml000066400000000000000000000005641474413133200171730ustar00rootroot00000000000000# 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):" labels: - "maintenance" groups: actions: patterns: - "*" napari-0.5.6/.github/labeler.yml000066400000000000000000000016351474413133200164740ustar00rootroot00000000000000# This config file maps code base files to GitHub labels. # We use `.github/workflow/labeler.yml` action and use this file to apply labels to PRs. # Repo: https://github.com/actions/labeler # Marketplace Action docs: https://github.com/marketplace/actions/labeler design: - changed-files: - any-glob-to-any-file: 'napari/_qt/qt_resources/**/*' preferences: - changed-files: - any-glob-to-any-file: - 'napari/_qt/**/*/preferences_dialog.py' - 'napari/settings/**/*.py' qt: - changed-files: - any-glob-to-any-file: - 'napari/_qt/**/*.py' - 'napari/_qt/**/*.py' - 'napari/qt/**/*.py' - 'napari/qt/**/*.py' tests: - changed-files: - any-glob-to-any-file: '**/*/_tests/**/*.py' vispy: - changed-files: - any-glob-to-any-file: 'napari/_vispy' maintenance: - changed-files: - any-glob-to-any-file: - '.pre-commit-config.yaml' - '.github/**/*' napari-0.5.6/.github/missing_translations.md000066400000000000000000000011251474413133200211310ustar00rootroot00000000000000--- 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.6/.github/workflows/000077500000000000000000000000001474413133200163735ustar00rootroot00000000000000napari-0.5.6/.github/workflows/actionlint.yml000066400000000000000000000007021474413133200212610ustar00rootroot00000000000000name: Actionlint # https://github.com/rhysd/actionlint on: pull_request: paths: - '.github/**' jobs: actionlint: name: Action lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check workflow files run: | bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -color -ignore SC2129 shell: bash napari-0.5.6/.github/workflows/benchmarks.yml000066400000000000000000000176571474413133200212530ustar00rootroot00000000000000# 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=900" # --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 }} permissions: contents: read issues: write 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@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 name: Install Python with: python-version: "3.11" cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 - name: Setup asv run: python -m pip install "asv[virtualenv]" env: PIP_CONSTRAINT: resources/constraints/benchmark.txt - 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 HEAD_LABEL: ${{ github.event.pull_request.head.label }} PIP_CONSTRAINT: ${{ github.workspace }}/resources/constraints/benchmark.txt run: | set -euxo pipefail read -ra cmd_options <<< "$ASV_OPTIONS" # 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} ($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 "${cmd_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 awk '/Benchmark.*Parameter/,/SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY/' asv_continuous.log \ >> .asv/results/message_${{ matrix.benchmark-name }}.txt fi - uses: actions/upload-artifact@v4 if: always() with: name: asv-benchmark-results-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.benchmark-name }} path: .asv/results combine-artifacts: runs-on: ubuntu-latest needs: benchmark if: always() steps: - name: Download artifact uses: actions/download-artifact@v4 with: pattern: asv-benchmark-results* path: asv_result merge-multiple: true - name: Upload artifact uses: actions/upload-artifact@v4 with: name: asv-benchmark-results-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} path: asv_result napari-0.5.6/.github/workflows/benchmarks_report.yml000066400000000000000000000070551474413133200226350ustar00rootroot00000000000000# 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 permissions: pull-requests: write issues: write jobs: download: runs-on: ubuntu-latest steps: - name: "Download artifact" uses: actions/github-script@v7 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}` console.log(`Artifact name: ${artifactName}`); console.log(`All artifacts: ${JSON.stringify(allArtifacts.data.artifacts)}`); let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { return artifact.name == artifactName })[0]; if (matchArtifact === undefined) { throw TypeError('Build Artifact not found!'); } 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@v7 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, }); - name: "Remove run-benchmarks label" if: env.PR_NUMBER != '' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | let fs = require('fs'); let issue_number = Number(process.env.PR_NUMBER); await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, name: 'run-benchmarks', }); napari-0.5.6/.github/workflows/build_docs.yml000066400000000000000000000045601474413133200212320ustar00rootroot00000000000000# As much as possible, this file should be kept in sync with # https://github.com/napari/docs/blob/main/.github/workflows/build_docs.yml name: Build PR Docs on: push: branches: - docs tags: - 'v*' workflow_dispatch: workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build-and-upload: name: Build & Upload Artifact runs-on: ubuntu-latest steps: - name: Clone docs repo uses: actions/checkout@v4 with: path: docs # place in a named directory repository: napari/docs - name: Clone main repo uses: actions/checkout@v4 with: path: napari # place in a named directory # ensure version metadata is proper fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.10" cache-dependency-path: | napari/pyproject.toml docs/requirements.txt - uses: tlambert03/setup-qt-libs@v1 - name: Install Dependencies run: | python -m pip install --upgrade pip python -m pip install "napari/[all]" python -m pip install -r docs/requirements.txt env: PIP_CONSTRAINT: ${{ github.workspace }}/napari/resources/constraints/constraints_py3.10_docs.txt - 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@v2 env: GOOGLE_CALENDAR_ID: ${{ secrets.GOOGLE_CALENDAR_ID }} GOOGLE_CALENDAR_API_KEY: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} PIP_CONSTRAINT: ${{ github.workspace }}/napari/resources/constraints/constraints_py3.10_docs.txt with: run: make -C docs docs # skipping setup stops the action from running the default (tiling) window manager # the window manager is not necessary for docs builds at this time and it was causing # problems with screenshots (https://github.com/napari/docs/issues/285) linux-setup: "echo 'skip setup'" linux-teardown: "echo 'skip teardown'" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: docs path: docs/docs/_build napari-0.5.6/.github/workflows/circleci.yml000066400000000000000000000015271474413133200207000ustar00rootroot00000000000000# To enable this workflow on a fork, comment out: # # if: github.repository == 'napari/docs' name: CircleCI artifact redirector concurrency: group: docs-preview-${{ github.ref }} cancel-in-progress: true on: [status] jobs: circleci_artifacts_redirector_job: runs-on: ubuntu-latest if: "github.event.context == 'ci/circleci: build-docs'" permissions: statuses: write name: Run CircleCI artifacts redirector # if: github.repository == 'napari/docs' steps: - name: GitHub Action step uses: larsoner/circleci-artifacts-redirector-action@master with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} artifact-path: 0/docs/docs/_build/html/index.html circleci-jobs: build-docs job-title: Check the rendered docs here! napari-0.5.6/.github/workflows/citation_cff_validate.yml000066400000000000000000000006521474413133200234220ustar00rootroot00000000000000on: push: paths: - CITATION.cff pull_request: paths: - CITATION.cff workflow_dispatch: name: CITATION.cff jobs: Validate-CITATION-cff: runs-on: ubuntu-latest name: Validate CITATION.cff env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 - name: Validate CITATION.cff uses: dieghernan/cff-validator@v3 napari-0.5.6/.github/workflows/deploy_docs.yml000066400000000000000000000024221474413133200214220ustar00rootroot00000000000000name: Build Docs on: push: branches: - main tags: - "v*" 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: get directory name # if this is a tag, use the tag name as the directory name else dev env: REF: ${{ github.ref }} run: | TAG="${GITHUB_REF/refs\/tags\/v/}" VER="${TAG/a*/}" # remove alpha identifier VER="${VER/b*/}" # remove beta identifier VER="${VER/rc*/}" # remove rc identifier VER="${VER/post*/}" # remove post identifier if [[ "$REF" == "refs/tags/v"* ]]; then echo "branch_name=$VER" >> "$GITHUB_ENV" else echo "branch_name=dev" >> "$GITHUB_ENV" fi - 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: build_and_deploy.yml trigger_workflow: true wait_workflow: true client_payload: '{"target_directory": "${{ env.branch_name }}"}' napari-0.5.6/.github/workflows/docker-publish.yml000066400000000000000000000062361474413133200220400ustar00rootroot00000000000000name: Docker 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: pull_request: paths: - '.github/workflows/docker-publish.yml' # 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@v4 # 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@v3.3.0 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@v5 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@v6 id: docker_build with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} file: "dockerfile" target: ${{ matrix.target }} # We build from the the tag name if triggered by tag, otherwise from the commit hash build-args: | NAPARI_COMMIT=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} - name: Test Docker image run: | docker run --rm --entrypoint=/bin/bash ${{ steps.docker_build.outputs.imageid }} -ec "python3 -m napari --version" napari-0.5.6/.github/workflows/edit_pr_description.yml000066400000000000000000000021231474413133200231450ustar00rootroot00000000000000name: Clean up PR description on: # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target pull_request_target: types: - opened - synchronize - reopened - edited workflow_call: # see https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs permissions: pull-requests: write jobs: check_labels: name: Remove html comments runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: repository: napari/napari # install python and requests - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip pip install requests - name: Remove html comments env: GH_PR_NUMBER: ${{ github.event.number }} GH_REPO_URL: ${{ github.event.repository.url}} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python tools/remove_html_comments_from_pr.py napari-0.5.6/.github/workflows/label_and_milestone_checker.yml000066400000000000000000000054531474413133200245710ustar00rootroot00000000000000name: Labels and milestone on: pull_request: types: - opened - synchronize - reopened - labeled - unlabeled - milestoned - demilestoned merge_group: # to be prepared on merge queue types: [checks_requested] jobs: check_labels: if: (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ready to merge')) name: Check ready-to-merge PR has at least one type label runs-on: ubuntu-latest steps: - name: Check labels uses: docker://agilepathway/pull-request-label-checker:latest with: any_of: bugfix,feature,documentation,performance,enhancement,maintenance repo_token: ${{ secrets.GITHUB_TOKEN }} check_next_milestone: name: Check milestone is next release if: (github.event_name == 'pull_request' && github.event.pull_request.milestone != null) runs-on: ubuntu-latest steps: - name: Check milestone for closest due date env: GH_TOKEN: ${{ github.token }} PR_MILESTONE_NAME: ${{ github.event.pull_request.milestone.title }} run: | # Install GitHub CLI if necessary # sudo apt-get install -y gh IFS='/' read -r repoOwner repoName <<< "${{ github.repository }}" # Fetch the closest future milestone # shellcheck disable=SC2016 CLOSEST_MILESTONE=$(gh api graphql -f query=' query($repoName: String!, $repoOwner: String!) { repository(name: $repoName, owner: $repoOwner) { milestones(states: OPEN, orderBy: {field: DUE_DATE, direction: ASC}, first: 100) { nodes { title number dueOn } } } }' --jq '.data.repository.milestones.nodes | map(select(.dueOn >= now)) | .[0].number' -f repoName="$repoName" -f repoOwner="$repoOwner") # Extract the milestone number of the current PR PR_MILESTONE_NUMBER=$(gh api "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" --jq '.milestone.number') CLOSEST_MILESTONE_NAME=$(gh api "/repos/${{ github.repository }}/milestones/$CLOSEST_MILESTONE" --jq '.title') # Check if the PR's milestone is the closest future milestone if [ "$CLOSEST_MILESTONE" != "$PR_MILESTONE_NUMBER" ]; then echo "If this PR can be merged in time for the earlier milestone," echo "changing the milestone to $CLOSEST_MILESTONE_NAME will make the check pass." echo "If this PR must wait until $PR_MILESTONE_NAME," echo "remove the ready-to-merge tag to skip this check," echo "and re-add it when all earlier milestones are completed." exit 1 fi napari-0.5.6/.github/workflows/labeler.yml000066400000000000000000000004621474413133200205260ustar00rootroot00000000000000# https://github.com/marketplace/actions/labeler name: Add labels on: - pull_request_target jobs: labeler: permissions: contents: read pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" napari-0.5.6/.github/workflows/make_bundle_conda.yml000066400000000000000000000007201474413133200225270ustar00rootroot00000000000000name: 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: permissions: contents: write uses: napari/packaging/.github/workflows/make_bundle_conda.yml@main secrets: inherit with: event_name: ${{ github.event_name }} napari-0.5.6/.github/workflows/make_release.yml000066400000000000000000000045251474413133200215410ustar00rootroot00000000000000on: push: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 pull_request: paths: - .github/workflows/make_release.yml name: Create Release jobs: build: permissions: contents: write id-token: write name: Create Release runs-on: ubuntu-latest if: github.repository == 'napari/napari' steps: - name: Checkout code uses: actions/checkout@v4 - name: Checkout docs uses: actions/checkout@v4 with: repository: napari/docs path: docs - name: Install Python uses: actions/setup-python@v5 with: python-version: 3.11 cache-dependency-path: pyproject.toml - 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 VER="${TAG/a*/}" # remove alpha identifier VER="${VER/b*/}" # remove beta identifier VER="${VER/rc*/}" # remove rc identifier VER="${VER/post*/}" # remove post identifier RELEASE_NOTES_PATH="docs/docs/release/release_${VER//./_}.md" echo "tag=${TAG}" >> "$GITHUB_ENV" echo "release_notes_path=${RELEASE_NOTES_PATH}" >> "$GITHUB_ENV" echo tag: "${TAG}" echo release_notes_path: "${RELEASE_NOTES_PATH}" ls docs/docs/release - name: Create Release uses: "softprops/action-gh-release@v2" if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') with: tag_name: ${{ github.ref }} name: ${{ env.tag }} body: pre-release ${{ env.tag }} body_path: ${{ env.release_notes_path }} draft: false prerelease: ${{ contains(env.tag, 'rc') || contains(env.tag, 'a') || contains(env.tag, 'b') }} target_commitish: ${{ github.sha }} files: | dist/* - name: Publish PyPI Package if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: pypa/gh-action-pypi-publish@release/v1 napari-0.5.6/.github/workflows/remove_ready_to_merge.yml000066400000000000000000000022351474413133200234620ustar00rootroot00000000000000name: Remove "ready to merge" label on: pull_request_target: types: [closed] workflow_call: permissions: pull-requests: write jobs: remove_label: runs-on: ubuntu-latest steps: - name: Remove label uses: actions/github-script@v7 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | const { owner, repo, number: pull_number } = context.issue; const { data: pullRequest } = await github.rest.pulls.get({ owner, repo, pull_number }); if (!pullRequest.merged) { console.log("Pull request not merged, skipping."); return; } const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: pull_number }); const labelToRemove = labels.find(label => label.name === "ready to merge"); if (!labelToRemove) { console.log("Label not found on pull request, skipping."); return; } await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name: "ready to merge" }); console.log("Label removed."); napari-0.5.6/.github/workflows/reusable_build_wheel.yml000066400000000000000000000013271474413133200232660ustar00rootroot00000000000000name: Build wheel workflow on: workflow_call: jobs: build_wheel: name: Build wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: 3.11 cache: "pip" cache-dependency-path: pyproject.toml - name: Install Dependencies run: | pip install --upgrade pip pip install build wheel - name: Build wheel run: | python -m build --outdir dist/ - name: Upload wheel uses: actions/upload-artifact@v4 with: name: wheel path: dist/*.whl napari-0.5.6/.github/workflows/reusable_coverage_upload.yml000066400000000000000000000022351474413133200241410ustar00rootroot00000000000000name: Upload coverage on: workflow_call: secrets: codecov_token: required: true jobs: upload_coverage: name: Upload coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" cache-dependency-path: pyproject.toml cache: 'pip' - name: Install Dependencies run: | pip install --upgrade pip pip install codecov - name: Download coverage data uses: actions/download-artifact@v4 with: pattern: coverage reports* path: coverage merge-multiple: true - name: combine coverage data run: | python -Im coverage combine coverage python -Im coverage xml -o coverage.xml # Report and write to summary. python -Im coverage report --format=markdown --skip-empty --skip-covered >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage data uses: codecov/codecov-action@v4 with: fail_ci_if_error: true token: ${{ secrets.codecov_token }} version: v0.6.0 napari-0.5.6/.github/workflows/reusable_pip_test.yml000066400000000000000000000035271474413133200226360ustar00rootroot00000000000000name: Test installed from pip on: workflow_call: jobs: test_pip_install: name: ubuntu-latest 3.9 pip install runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: path: napari-from-github - name: Set up Python 3.9 uses: actions/setup-python@v5 with: python-version: 3.9 cache: "pip" cache-dependency-path: napari-from-github/pyproject.toml - uses: tlambert03/setup-qt-libs@v1 - name: Build wheel run: | pip install --upgrade pip build python -m build "./napari-from-github" # there is a bug in build/setuptools that build only wheel will ignore manifest content. # so we need to build sdist first and then build wheel - name: get wheel path run: | WHEEL_PATH=$(ls napari-from-github/dist/*.whl) echo "WHEEL_PATH=$WHEEL_PATH" >> "$GITHUB_ENV" - name: Install napari from wheel run: | pip install "${{ env.WHEEL_PATH }}[pyqt,testing]" shell: bash env: PIP_CONSTRAINT: napari-from-github/resources/constraints/constraints_py3.9.txt - name: uninstall numba run: | pip uninstall -y numba - name: Test uses: aganders3/headless-gui@v2 with: run: | python -m pytest --pyargs napari --color=yes --basetemp=.pytest_tmp --config-file=napari-from-github/pyproject.toml python -m pytest --pyargs napari_builtins --color=yes --basetemp=.pytest_tmp --config-file=napari-from-github/pyproject.toml - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4.4.0 with: name: test artifacts pip install path: .pytest_tmp include-hidden-files: true napari-0.5.6/.github/workflows/reusable_run_tox_test.yml000066400000000000000000000212441474413133200235400ustar00rootroot00000000000000name: Run test by tox on: workflow_call: inputs: python_version: required: true type: string platform: required: false type: string default: "ubuntu-latest" toxenv: required: false type: string default: "" qt_backend: required: false type: string default: "headless" min_req: required: false type: string default: "" coverage: required: false type: string default: no_cov timeout: required: false type: number default: 40 constraints_suffix: required: false type: string default: "" tox_extras: required: false type: string default: "" jobs: test: name: ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} ${{ inputs.MIN_REQ && 'min_req' }} ${{ inputs.coverage }} runs-on: ${{ inputs.platform }} env: TOXENV: ${{ inputs.toxenv }} NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ inputs.MIN_REQ || 1 }} PYVISTA_OFF_SCREEN: True MIN_REQ: ${{ inputs.min_req }} FORCE_COLOR: 1 PIP_CONSTRAINT: ${{ github.workspace }}/resources/constraints/constraints_py${{ inputs.python_version }}${{ ((startsWith(inputs.platform, 'windows') && '_windows') || '') }}${{ inputs.min_req && '_min_req' }}${{ inputs.constraints_suffix }}.txt UV_CONSTRAINT: ${{ github.workspace }}/resources/constraints/constraints_py${{ inputs.python_version }}${{ ((startsWith(inputs.platform, 'windows') && '_windows') || '') }}${{ inputs.min_req && '_min_req' }}${{ inputs.constraints_suffix }}.txt # Above we calculate path to constraints file based on python version and platform # Because there is no single PyQt5-Qt5 package version available for all platforms we was forced to use # different constraints files for Windows. An example with macOS arm64: # ${{ (((inputs.platform == 'macos-latest') && '_macos_arm') || '') }} - if platform is macOS-latest then add '_macos_arm' to constraints file name, else add nothing # ${{ inputs.min_req && '_min_req' }} - if min_req is set then add '_min_req' to constraints file name, else add nothing # ${{ inputs.constraints_suffix }} - additional suffix for constraints file name (used for example testing). COVERAGE: ${{ inputs.coverage }} TOX_WORK_DIR: .tox TOX_EXTRAS: ${{ inputs.tox_extras }} steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: wheel path: dist - name: Set wheel path run: echo "WHEEL_PATH=$(ls dist/*.whl)" >> "$GITHUB_ENV" shell: bash - name: Set up Python ${{ inputs.python_version }} uses: actions/setup-python@v5 with: python-version: ${{ inputs.python_version }} cache: "pip" cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 continue-on-error: true - name: Set Windows resolution if: runner.os == 'Windows' run: Set-DisplayResolution -Width 1920 -Height 1080 -Force shell: powershell # 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} shell: powershell - name: Disable ptrace security restrictions if: runner.os == 'Linux' run: | echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope # tox and tox-gh-actions will take care of the "actual" installation # of python dependencies into a virtualenv. see tox.ini for more - name: Install dependencies run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions tox-min-req tox-uv env: PIP_CONSTRAINT: "" - name: create _version.py file # workaround for not using src layout run: | echo "__version__ = version = '0.5.5a2.dev364'" > napari/_version.py echo "__version_tuple__ = version_tuple = (0, 5, 5, 'dev364', '')" >> napari/_version.py # 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: Split qt backend # This is a hack to split the qt_backend variable into four parts # This is required as github actions allow setting only one environment variable in # a single line (redirection to $GITHUB_ENV). # # For example, if qt_backend is set to "pyqt5,pyside2", then the following four # environment variables will be set: # MAIN=pyqt5 # SECOND=pyside2 # THIRD=none # FOURTH=none shell: bash run: | python tools/split_qt_backend.py 0 ${{ inputs.qt_backend }} >> "$GITHUB_ENV" python tools/split_qt_backend.py 1 ${{ inputs.qt_backend }} >> "$GITHUB_ENV" python tools/split_qt_backend.py 2 ${{ inputs.qt_backend }} >> "$GITHUB_ENV" python tools/split_qt_backend.py 3 ${{ inputs.qt_backend }} >> "$GITHUB_ENV" - name: Test with tox main timeout-minutes: ${{ inputs.timeout }} uses: aganders3/headless-gui@v2 with: shell: bash run: | echo ${{ env.MAIN }} tox --version python -m tox run --installpkg ${{ env.WHEEL_PATH }} -- --basetemp=.pytest_tmp rm -r .tox env: BACKEND: ${{ env.MAIN }} TOX_WORK_DIR: .tox - name: Test with tox second timeout-minutes: ${{ inputs.timeout }} uses: aganders3/headless-gui@v2 if : ${{ env.SECOND != 'none' }} with: shell: bash run: | python -m tox run --installpkg ${{ env.WHEEL_PATH }} -- --basetemp=.pytest_tmp rm -r .tox env: BACKEND: ${{ env.SECOND }} NAPARI_TEST_SUBSET: qt - name: Test with tox third timeout-minutes: ${{ inputs.timeout }} uses: aganders3/headless-gui@v2 if : ${{ env.THIRD != 'none' }} with: shell: bash run: | python -m tox run --installpkg ${{ env.WHEEL_PATH }} -- --basetemp=.pytest_tmp rm -r .tox env: BACKEND: ${{ env.THIRD }} NAPARI_TEST_SUBSET: qt - name: Test with tox fourth timeout-minutes: ${{ inputs.timeout }} uses: aganders3/headless-gui@v2 if: ${{ env.FOURTH != 'none' }} with: shell: bash run: | python -m tox run --installpkg ${{ env.WHEEL_PATH }} -- --basetemp=.pytest_tmp rm -r .tox env: BACKEND: ${{ env.FOURTH }} NAPARI_TEST_SUBSET: qt - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4.4.0 with: name: test artifacts ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} path: .pytest_tmp include-hidden-files: true - name: Upload leaked viewer graph if: failure() uses: actions/upload-artifact@v4 with: name: leaked ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} path: ./*leak-backref-graph*.pdf - name: Upload pytest timing reports as json ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} uses: actions/upload-artifact@v4 with: name: upload pytest timing json ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} ${{ inputs.tox_extras }} path: | ./report-*.json - name: Upload coverage data uses: actions/upload-artifact@v4.4.0 if: ${{ inputs.coverage == 'cov' }} with: name: coverage reports ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} include-hidden-files: true path: | ./.coverage.* napari-0.5.6/.github/workflows/test_comprehensive.yml000066400000000000000000000076031474413133200230320ustar00rootroot00000000000000# 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 pull_request: paths: - '.github/workflows/test_comprehensive.yml' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: env: COLUMNS: 120 concurrency: group: comprehensive-test jobs: manifest: name: Check Manifest runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: "Set up Python 3.11" uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: | pip install --upgrade pip pip install check-manifest - name: Check Manifest run: check-manifest build_wheel: name: Build wheel uses: ./.github/workflows/reusable_build_wheel.yml test: name: ${{ matrix.platform }} uses: ./.github/workflows/reusable_run_tox_test.yml needs: build_wheel strategy: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] python: ["3.9", "3.10", "3.11", "3.12"] backend: [pyqt5, pyside2] include: - python: "3.10" platform: macos-latest backend: pyqt5 # test with minimum specified requirements - python: "3.9" platform: ubuntu-20.04 backend: pyqt5 MIN_REQ: 1 # test without any Qt backends - python: "3.9" platform: ubuntu-20.04 backend: headless - python: "3.12" platform: ubuntu-latest backend: pyqt6 tox_extras: "testing_extra" - python: "3.11" platform: ubuntu-latest backend: pyside6 tox_extras: "testing_extra" exclude: - python: "3.11" backend: pyside2 - python: "3.12" backend: pyside2 - platform: windows-latest backend: pyside2 with: python_version: ${{ matrix.python }} platform: ${{ matrix.platform }} qt_backend: ${{ matrix.backend }} min_req: ${{ matrix.MIN_REQ }} coverage: cov tox_extras: ${{ matrix.tox_extras }} test_pip_install: name: pip install uses: ./.github/workflows/reusable_pip_test.yml test_examples: name: test examples uses: ./.github/workflows/reusable_run_tox_test.yml needs: build_wheel with: toxenv: py39-linux-pyside2-examples-cov timeout: 60 python_version: 3.9 constraints_suffix: _examples coverage: cov coverage_report: if: ${{ always() }} needs: - test - test_examples uses: ./.github/workflows/reusable_coverage_upload.yml secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} synchronize_bot_repository: name: Synchronize bot repository runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.repository == 'napari/napari' permissions: contents: read issues: write steps: - uses: actions/checkout@v4 with: token: ${{ secrets.GHA_TOKEN_BOT_REPO_WORKFLOW }} - name: Synchronize bot repository run: | git remote add napari-bot https://github.com/napari-bot/napari.git git fetch napari-bot git push --force --set-upstream napari-bot main - name: Report Failures if: ${{ failure() }} uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RUN_ID: ${{ github.run_id }} TITLE: '[bot-repo] bot repo update is failing' with: filename: .github/BOT_REPO_UPDATE_FAIL_TEMPLATE.md update_existing: true napari-0.5.6/.github/workflows/test_prereleases.yml000066400000000000000000000061621474413133200224740ustar00rootroot00000000000000# 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: pull_request: paths: - '.github/workflows/test_prereleases.yml' - 'resources/constraints/version_denylist.txt' env: COLUMNS: 120 jobs: test: name: ${{ matrix.platform }} py${{ matrix.python }} ${{ matrix.backend }} --pre timeout-minutes: 40 runs-on: ${{ matrix.platform }} permissions: contents: read issues: write if: ${{ github.repository == 'napari/napari' || github.event_name == 'workflow_dispatch' }} strategy: fail-fast: false matrix: platform: [windows-latest, macos-latest, ubuntu-latest] python: [3.12, 3.13] backend: [pyqt5, pyqt6] include: - platform: ubuntu-latest python: 3.12 backend: pyqt6_no_numba steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 continue-on-error: true - name: Set Windows resolution if: runner.os == 'Windows' run: Set-DisplayResolution -Width 1920 -Height 1080 -Force shell: powershell - 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} shell: powershell - 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@v2 with: run: python -m tox -v --pre env: PLATFORM: ${{ matrix.platform }} BACKEND: ${{ matrix.backend }} PYTHON: ${{ matrix.python }} COVERAGE: "no_cov" PYVISTA_OFF_SCREEN: True # required for opengl on windows PIP_CONSTRAINT: resources/constraints/version_denylist.txt # If something goes wrong, we can open an issue in the repo - name: Report Failures if: ${{ failure() && github.event_name == 'schedule' }} 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.6/.github/workflows/test_pull_requests.yml000066400000000000000000000165601474413133200230740ustar00rootroot00000000000000# 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 env: COLUMNS: 120 jobs: import_lint: name: Import lint timeout-minutes: 5 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: 3.11 - name: Install dependencies run: | pip install --upgrade pip pip install tox - name: Run import lint run: tox -e import-lint 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@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache-dependency-path: pyproject.toml cache: 'pip' - name: Install Dependencies run: pip install --upgrade pip - name: Install Napari dev run: pip install -e .[build] env: PIP_CONSTRAINT: resources/constraints/constraints_py3.11.txt - name: Make Typestubs run: | make typestubs - name: Check Manifest run: | 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@v4 - uses: actions/setup-python@v5 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 build_wheel: name: Build wheel uses: ./.github/workflows/reusable_build_wheel.yml test_initial: name: Initial test uses: ./.github/workflows/reusable_run_tox_test.yml needs: build_wheel strategy: fail-fast: false matrix: include: - python: 3.9 platform: ubuntu-latest backend: pyqt5 pydantic: "_pydantic_1" coverage: no_cov min_req: "" - python: 3.12 platform: ubuntu-latest backend: pyqt6 pydantic: "" coverage: no_cov min_req: "" with: python_version: ${{ matrix.python }} platform: ${{ matrix.platform }} qt_backend: ${{ matrix.backend }} coverage: ${{ matrix.coverage }} min_req: ${{ matrix.MIN_REQ }} constraints_suffix: ${{ matrix.pydantic }} test: name: ${{ matrix.platform }} uses: ./.github/workflows/reusable_run_tox_test.yml needs: test_initial strategy: fail-fast: false matrix: platform: [ ubuntu-latest ] python: [ "3.10", "3.11" ] backend: [ "pyqt5,pyside6" ] coverage: [ cov ] pydantic: ["_pydantic_1"] include: # Windows py310 - python: "3.10" platform: windows-latest backend: pyqt5 #,pyside2 coverage: no_cov - python: "3.12" platform: windows-latest backend: pyqt6 coverage: cov - python: "3.12" platform: macos-13 backend: pyqt5 coverage: no_cov - python: "3.12" platform: macos-latest backend: pyqt5 coverage: no_cov # minimum specified requirements - python: "3.9" platform: ubuntu-20.04 backend: pyqt5 MIN_REQ: 1 coverage: cov - python: "3.11" platform: ubuntu-22.04 backend: pyqt5 coverage: cov pydantic: "" tox_extras: "optional" # test without any Qt backends - python: "3.10" platform: ubuntu-20.04 backend: headless coverage: no_cov - python: "3.12" platform: ubuntu-latest backend: pyqt6 coverage: cov tox_extras: "testing_extra" # test with no numba - python: "3.12" platform: ubuntu-latest backend: pyqt6_no_numba coverage: cov pydantic: "" # pyside2 test - python: "3.10" platform: ubuntu-latest backend: pyside2 coverage: no_cov with: python_version: ${{ matrix.python }} platform: ${{ matrix.platform }} qt_backend: ${{ matrix.backend }} min_req: ${{ matrix.MIN_REQ }} coverage: ${{ matrix.coverage }} tox_extras: ${{ matrix.tox_extras }} constraints_suffix: ${{ matrix.pydantic }} test_pip_install: needs: test_initial name: pip install uses: ./.github/workflows/reusable_pip_test.yml test_examples: name: test examples uses: ./.github/workflows/reusable_run_tox_test.yml needs: test_initial with: toxenv: py39-linux-pyside2-examples-cov timeout: 60 python_version: 3.9 constraints_suffix: _examples coverage: cov coverage_report: if: ${{ always() }} needs: - test - test_examples uses: ./.github/workflows/reusable_coverage_upload.yml secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} test_benchmarks: name: test benchmarks runs-on: ubuntu-latest needs: test_initial timeout-minutes: 60 env: GIT_LFS_SKIP_SMUDGE: 1 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: 3.11 cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 - uses: octokit/request-action@v2.x # here we get hash of the latest release commit to compare with PR id: latest_release with: route: GET /repos/{owner}/{repo}/releases/latest owner: napari repo: napari env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: install dependencies run: | pip install --upgrade pip pip install "asv[virtualenv]" env: PIP_CONSTRAINT: resources/constraints/benchmark.txt - name: asv machine run: asv machine --yes - name: Run benchmarks PR uses: aganders3/headless-gui@v2 with: run: | asv run --show-stderr --quick --attribute timeout=300 HEAD^! env: PR: 1 # prevents asv from running very compute-intensive benchmarks PIP_CONSTRAINT: ${{ github.workspace }}/resources/constraints/benchmark.txt - name: Run benchmarks latest release # here we check if the benchmark on the latest release is not broken uses: aganders3/headless-gui@v2 with: run: | asv run --show-stderr --quick --attribute timeout=300 ${{ fromJSON(steps.latest_release.outputs.data).target_commitish }}^! env: PR: 1 # prevents asv from running very compute-intensive benchmarks PIP_CONSTRAINT: ${{ github.workspace }}/resources/constraints/benchmark.txt napari-0.5.6/.github/workflows/test_translations.yml000066400000000000000000000016771474413133200227110ustar00rootroot00000000000000name: 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 permissions: contents: read issues: write steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" cache-dependency-path: pyproject.toml - 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.6/.github/workflows/test_typing.yml000066400000000000000000000007551474413133200214760ustar00rootroot00000000000000name: Type-check on: pull_request: branches: - main concurrency: group: typing-${{ github.ref }} cancel-in-progress: true jobs: mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache-dependency-path: pyproject.toml - name: Install tox run: | pip install tox - name: Run mypy on typed modules run: tox -e mypy napari-0.5.6/.github/workflows/test_vendored.yml000066400000000000000000000030451474413133200217650ustar00rootroot00000000000000name: Test vendored on: workflow_dispatch: # Allow running on-demand 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@v4 - name: Set up Python 3.9 uses: actions/setup-python@v5 with: python-version: 3.9 - name: Run check id: check_v run: python tools/check_vendored_modules.py --ci - name: Set variables run: echo "vendored=$(cat 'tools/vendored_modules.txt')" >> "$GITHUB_OUTPUT" shell: bash - name: Create PR updating vendored modules uses: peter-evans/create-pull-request@v7 with: commit-message: Update vendored modules. branch: update-vendored-examples delete-branch: true title: "[Automatic] Update ${{ steps.check_v.outputs.vendored }} vendored module" labels: maintenance 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. token: ${{ secrets.GHA_TOKEN }} author: napari-bot # Token permissions required by the action: # * pull requests: write and read # * repository contents: read and write # for screenshots please see https://github.com/napari/napari/pull/5777 napari-0.5.6/.github/workflows/upgrade_test_constraints.yml000066400000000000000000000243551474413133200242440ustar00rootroot00000000000000# This is a workflow for upgrading test constraints. It is triggered by: # - On-demand workflow_dispatch # - weekly schedule (every Monday at 8:00 UTC) # - issue_comment with text "@napari-bot update constraints" # - pull_request with changed .github/workflows/upgrade_test_constraints.yml # # The GitHub workflows have the following limitations: # - There is no pull request comment trigger for workflows. # The `issue_comment` trigger is used instead, because it is used for pull requests too. # - If a workflow is triggered by pull_requests from forked repository, # then it does not have access to secrets. # So it is not possible to create PR from forked repository. # - If workflow is triggered by issue comment, then it is triggered on the main branch # and not on the branch where the comment was created. # - In workflow triggers it is only possible to depend on created event, without any conditions. # So it is not possible to trigger workflow only when the comment contains specific text. # So we need to check it in the workflow. # It will produce multiple empty runs that will create multiple empty entries in actions # making it harder to find the right one. # - It is not possible to update pull request from forked repository using Fine grained personal token. # - It is not possible to create pull request to forked repository using Fine grained personal token. # - There is no interface indicator that the workflow triggered by issue comment is running. # # Also, for safety reason, it is better to store automatically generated changes outside the main repository. # # Because of the above limitations, the following approach is used: # - We use `napari-bot/napari` repository to store automatically generated changes. # - We don't push changes to `napari-bot/napari` repository if PR contains changes in workflows. # - We use two checkouts of repository: # * First into `.` directory from which we run the workflow. # * Second into `napari_repo` directory from which we create pull request. # If comment triggers the workflow, then this will contain the branch from which the PR was created. # Changes will be pushed to `napari-bot/napari` repository. # If schedule or workflow_dispatch triggers workflow, then this will contain the main branch. # Changes will not be pushed to `napari/napari` repository. # If pull_request triggers workflow, then this will contain PR branch. # Changes will be only accessible as artifacts. # - If workflow is triggered by issue comment, # then we add eyes reaction to the comment to show that workflow has started. # After finishing calculation of new constraints, we add rocket reaction to the comment. # Then pushing changes to `napari-bot/napari` repository and adding comment to the PR is # done by `tools/create_pr_or_update_existing_one.py`. name: Upgrade test constraints on: workflow_dispatch: # Allow running on-demand schedule: # Runs every Monday at 8:00 UTC (4:00 Eastern) - cron: '0 8 * * 1' issue_comment: types: [ created ] pull_request: paths: - '.github/workflows/upgrade_test_constraints.yml' jobs: upgrade: permissions: pull-requests: write issues: write name: Upgrade & Open Pull Request if: (github.event.issue.pull_request != '' && contains(github.event.comment.body, '@napari-bot update constraints')) || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Add eyes reaction # show that workflow has started if: github.event_name == 'issue_comment' run: | COMMENT_ID=${{ github.event.comment.id }} curl \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ "https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions" \ -d '{"content": "eyes"}' - name: Get PR details # extract PR number and branch name from issue_comment event if: github.event_name == 'issue_comment' run: | PR_number=${{ github.event.issue.number }} PR_data=$(curl \ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_number" \ ) FULL_NAME=$(echo "${PR_data}" | jq -r .head.repo.full_name) echo "FULL_NAME=$FULL_NAME" >> "$GITHUB_ENV" BRANCH=$(echo "${PR_data}" | jq -r .head.ref) echo "BRANCH=$BRANCH" >> "$GITHUB_ENV" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Get repo info # when schedule or workflow_dispatch triggers workflow, then we need to get info about which branch to use if: github.event_name != 'issue_comment' && github.event_name != 'pull_request' run: | echo "FULL_NAME=${{ github.repository }}" >> "$GITHUB_ENV" echo "BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Clone docs repo uses: actions/checkout@v4 with: path: docs # place in a named directory repository: napari/docs - name: Clone target repo (remote) uses: actions/checkout@v4 if: github.event_name == 'issue_comment' with: path: napari_repo # place in a named directory repository: ${{ env.FULL_NAME }} ref: ${{ env.BRANCH }} token: ${{ secrets.GHA_TOKEN_BOT_REPO }} - name: Clone target repo (pull request) # we need separate step as passing empty token to actions/checkout@v4 will not work uses: actions/checkout@v4 if: github.event_name == 'pull_request' with: path: napari_repo # place in a named directory - name: Clone target repo (main) uses: actions/checkout@v4 if: github.event_name != 'issue_comment' && github.event_name != 'pull_request' with: path: napari_repo # place in a named directory repository: ${{ env.FULL_NAME }} ref: ${{ env.BRANCH }} token: ${{ secrets.GHA_TOKEN_NAPARI_BOT_MAIN_REPO }} - name: Add napari-bot/napari to napari_repo upstreams run: | cd napari_repo git remote -v git remote add napari-bot https://github.com/napari-bot/napari.git git remote -v # START PYTHON DEPENDENCIES - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip cache-dependency-path: 'pyproject.toml' - name: Upgrade Python dependencies # ADD YOUR CUSTOM DEPENDENCY UPGRADE COMMANDS BELOW run: | set -x pip install -U uv flags=(--quiet --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional) # Explanation of below commands # uv pip compile --python-version 3.9 - call uv pip compile but ensure proper interpreter # --upgrade upgrade to the latest possible version. Without this pip-compile will take a look to output files and reuse versions (so will ad something on when adding dependency. # -o resources/constraints/constraints_py3.9.txt - output file # pyproject.toml resources/constraints/version_denylist.txt - source files. the resources/constraints/version_denylist.txt - contains our test specific constraints like pytes-cov` # # --extra pyqt5 etc - names of extra sections from pyproject.toml that should be checked for the dependencies list (maybe we could create a super extra section to collect them all in) prefix="napari_repo" pyproject_toml="${prefix}/pyproject.toml" constraints="${prefix}/resources/constraints" for pyv in 3.9 3.10 3.11 3.12; do uv pip compile --python-version ${pyv} --upgrade --output-file ${constraints}/constraints_py${pyv}.txt ${pyproject_toml} ${constraints}/version_denylist.txt "${flags[@]}" uv pip compile --python-version ${pyv} --upgrade --output-file ${constraints}/constraints_py${pyv}_pydantic_1.txt ${pyproject_toml} ${constraints}/version_denylist.txt ${constraints}/pydantic_le_2.txt "${flags[@]}" uv pip compile --python-platform windows --python-version ${pyv} --upgrade --output-file ${constraints}/constraints_py${pyv}_windows.txt ${pyproject_toml} $constraints/version_denylist.txt "${flags[@]}" done uv pip compile --python-version 3.9 --upgrade --output-file ${constraints}/constraints_py3.9_examples.txt ${pyproject_toml} ${constraints}/version_denylist.txt ${constraints}/version_denylist_examples.txt "${flags[@]}" uv pip compile --python-version 3.10 --upgrade --output-file ${constraints}/constraints_py3.10_docs.txt ${pyproject_toml} ${constraints}/version_denylist.txt ${constraints}/version_denylist_examples.txt docs/requirements.txt ${constraints}/pydantic_le_2.txt "${flags[@]}" uv pip compile --python-version 3.11 --upgrade --output-file ${prefix}/resources/requirements_mypy.txt ${prefix}/resources/requirements_mypy.in # END PYTHON DEPENDENCIES - name: Upload constraints uses: actions/upload-artifact@v4 with: name: constraints path: | napari_repo/resources/constraints/constraints*.txt - name: Add rocket reaction # inform that new constraints are available in artifacts if: github.event_name == 'issue_comment' run: | COMMENT_ID=${{ github.event.comment.id }} curl \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ "https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions" \ -d '{"content": "rocket"}' - name: Create commit run: | pip install requests python tools/create_pr_or_update_existing_one.py env: GHA_TOKEN_MAIN_REPO: ${{ secrets.GHA_TOKEN_NAPARI_BOT_MAIN_REPO }} PR_NUMBER: ${{ github.event.issue.number }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} napari-0.5.6/.gitignore000066400000000000000000000044301474413133200147670ustar00rootroot00000000000000docs # Byte-compiled / optimized / DLL files tools/vendored_modules.txt tools/matplotlib __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 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.6/.pre-commit-config.yaml000066400000000000000000000015431474413133200172620ustar00rootroot00000000000000exclude: _vendor|vendored repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.2 hooks: - id: ruff-format exclude: examples - id: ruff - repo: https://github.com/seddonym/import-linter rev: v2.1 hooks: - id: import-linter stages: [manual] - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.31.0 hooks: - id: check-github-workflows - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 # .py files are skipped cause already checked by other hooks hooks: - id: check-yaml - id: check-toml - id: check-merge-conflict exclude: .*\.py - id: end-of-file-fixer exclude: .*\.py - id: trailing-whitespace # trailing whitespace has meaning in markdown https://www.markdownguide.org/hacks/#indent-tab exclude: .*\.py|.*\.md - id: mixed-line-ending exclude: .*\.py napari-0.5.6/CITATION.cff000066400000000000000000000237201474413133200146740ustar00rootroot00000000000000cff-version: 1.2.0 message: If you use this software, please cite it using these metadata. title: 'napari: a multi-dimensional image viewer for Python' identifiers: - type: doi value: 10.5281/zenodo.3555620 authors: - given-names: Nicholas family-names: Sofroniew affiliation: Chan Zuckerberg Initiative orcid: https://orcid.org/0000-0002-3426-0914 alias: sofroniewn - given-names: Talley family-names: Lambert affiliation: Harvard Medical School orcid: https://orcid.org/0000-0002-2409-0181 alias: tlambert03 - given-names: Grzegorz family-names: Bokota affiliation: University of Warsaw, Faculty of Mathematics, Informatics, and Mechanics orcid: https://orcid.org/0000-0002-5470-1676 alias: Czaki - given-names: Juan family-names: Nunez-Iglesias affiliation: Monash eResearch Centre, Monash University orcid: https://orcid.org/0000-0002-7239-5828 alias: jni - given-names: Peter family-names: Sobolewski affiliation: The Jackson Laboratory orcid: https://orcid.org/0000-0002-2097-0990 alias: psobolewskiPhD - given-names: Andrew family-names: Sweet affiliation: Chan Zuckerberg Initiative alias: andy-sweet - given-names: Lorenzo family-names: Gaifas affiliation: Gutsche Lab - University of Grenoble orcid: https://orcid.org/0000-0003-4875-9422 alias: brisvag - given-names: Kira family-names: Evans affiliation: Chan Zuckerberg Initiative alias: kne42 - given-names: Alister family-names: Burt affiliation: MRC-LMB alias: alisterburt - given-names: Draga family-names: Doncila Pop affiliation: Monash University alias: DragaDoncila - given-names: Kevin family-names: Yamauchi affiliation: Iber Lab - ETH Zürich alias: kevinyamauchi - given-names: Melissa family-names: Weber Mendonça affiliation: Quansight orcid: https://orcid.org/0000-0002-3212-402X alias: melissawm - given-names: Genevieve family-names: Buckley affiliation: Monash University orcid: https://orcid.org/0000-0003-2763-492X alias: GenevieveBuckley - given-names: Wouter-Michiel family-names: Vierdag affiliation: European Molecular Biology Laboratory, Genome Biology Unit, Heidelberg, Germany orcid: https://orcid.org/0000-0003-1666-5421 alias: melonora - given-names: Loic family-names: Royer affiliation: Chan Zuckerberg Biohub alias: royerloic - given-names: Ahmet family-names: Can Solak affiliation: Chan Zuckerberg Biohub alias: AhmetCanSolak - given-names: Kyle I. S. family-names: Harrington affiliation: Chan Zuckerberg Initiative orcid: https://orcid.org/0000-0002-7237-1973 alias: kephale - given-names: Jannis family-names: Ahlers affiliation: Monash University orcid: https://orcid.org/0000-0003-0630-1819 alias: jnahlers - given-names: Daniel family-names: Althviz Moré affiliation: Quansight orcid: https://orcid.org/0000-0003-1759-4194 alias: dalthviz - given-names: Oren family-names: Amsalem affiliation: Harvard Medical School, BIDMC orcid: https://orcid.org/0000-0002-8070-0378 alias: orena1 - given-names: Ashley family-names: Anderson affiliation: Chan Zuckerberg Initiative orcid: https://orcid.org/0000-0002-3841-8344 alias: aganders3 - given-names: Andrew family-names: Annex affiliation: SETI Institute/NASA ARC orcid: https://orcid.org/0000-0002-0253-2313 alias: AndrewAnnex - given-names: Peter family-names: Boone alias: boonepeter - given-names: Jordão family-names: Bragantini affiliation: Chan Zuckerberg Biohub alias: JoOkuma - given-names: Matthias family-names: Bussonnier affiliation: Quansight Labs orcid: https://orcid.org/0000-0002-7636-8632 alias: Carreau - given-names: Clément family-names: Caporal affiliation: Laboratory for Optics and Biosciences, Ecole Polytechnique, INSERM, CNRS, Palaiseau, France orcid: https://orcid.org/0000-0002-9441-9173 alias: ClementCaporal - given-names: Jan family-names: Eglinger affiliation: Friedrich Miescher Institute for Biomedical Research (FMI), Basel (Switzerland) orcid: https://orcid.org/0000-0001-7234-1435 alias: imagejan - given-names: Andreas family-names: Eisenbarth affiliation: EMBL Heidelberg, Germany orcid: https://orcid.org/0000-0002-1113-9556 alias: aeisenbarth - given-names: Jeremy family-names: Freeman affiliation: Chan Zuckerberg Initiative alias: freeman-lab - given-names: Christoph family-names: Gohlke affiliation: University of California, Irvine alias: cgohlke - given-names: Kabilar family-names: Gunalan alias: kabilar - given-names: Hagai family-names: Har-Gil affiliation: Tel Aviv University, Israel alias: HagaiHargil - given-names: Mark family-names: Harfouche affiliation: Ramona Optics Inc, Durham, North Carolina, USA orcid: https://orcid.org/0000-0002-4657-4603 alias: hmaarrfk - given-names: Volker family-names: Hilsenstein affiliation: EMBL Heidelberg, Germany orcid: https://orcid.org/0000-0002-2255-2960 alias: VolkerH - given-names: Katherine family-names: Hutchings affiliation: University College London alias: katherine-hutchings - given-names: Jessy family-names: Lauer affiliation: Swiss Federal Institute of Technology (EPFL), Lausanne, Switzerland orcid: https://orcid.org/0000-0002-3656-2449 alias: jeylau - given-names: Gregor family-names: Lichtner affiliation: Universitätsmedizin Greifswald orcid: https://orcid.org/0000-0002-5890-1958 alias: glichtner - given-names: Ziyang family-names: Liu affiliation: Chan Zuckerberg Initiative Foundation alias: liu-ziyang - given-names: Lucy family-names: Liu affiliation: Quansight alias: lucyleeow - given-names: Alan family-names: Lowe affiliation: UCL & The Alan Turing Institute alias: quantumjot - given-names: Luca family-names: Marconato affiliation: EMBL Heidelberg orcid: https://orcid.org/0000-0003-3198-1326 alias: LucaMarconato - given-names: Sean family-names: Martin affiliation: MetaCell orcid: https://orcid.org/0000-0001-7600-0291 alias: seankmartin - given-names: Abigail family-names: McGovern affiliation: Monash University alias: AbigailMcGovern - given-names: Lukasz family-names: Migas affiliation: Delft University of Technology alias: lukasz-migas - given-names: Nadalyn family-names: Miller affiliation: Apex Systems orcid: https://orcid.org/0009-0007-6993-1267 alias: Nadalyn-CZI - given-names: Hector family-names: Muñoz affiliation: University of California, Los Angeles orcid: https://orcid.org/0000-0001-7851-2549 alias: hectormz - given-names: Jan-Hendrik family-names: Müller affiliation: Georg-August-Universität Göttingen orcid: https://orcid.org/0009-0007-3670-9969 alias: kolibril13 - given-names: Christopher family-names: Nauroth-Kreß affiliation: University Hospital Würzburg - Institute of Neuroradiology alias: Chris-N-K - given-names: David family-names: Palecek affiliation: Algarve Centre of Marine Sciences (CCMAR) orcid: https://orcid.org/0009-0003-9328-8540 alias: palec87 - given-names: Constantin family-names: Pape affiliation: Georg-August-Universität Göttingen orcid: https://orcid.org/0000-0001-6562-7187 alias: constantinpape - given-names: Eric family-names: Perlman affiliation: Yikes LLC orcid: https://orcid.org/0000-0001-5542-1302 alias: perlman - given-names: Kim family-names: Pevey alias: kcpevey - given-names: Gonzalo family-names: Peña-Castellanos affiliation: Quansight orcid: https://orcid.org/0000-0002-1214-4680 alias: goanpeca - given-names: Andrea family-names: Pierré affiliation: Brown University orcid: https://orcid.org/0000-0003-4501-5428 alias: kir0ul - given-names: David family-names: Pinto alias: MarchisLost - given-names: Jaime family-names: Rodríguez-Guerra affiliation: Quansight Labs orcid: https://orcid.org/0000-0001-8974-1566 alias: jaimergp - given-names: David family-names: Ross affiliation: NanoString Technologies, Inc. orcid: https://orcid.org/0000-0001-9998-3817 alias: davidpross - given-names: Craig T. family-names: Russell affiliation: European Bioinformatics Institute - European Molecular Biology Laboratory orcid: https://orcid.org/0000-0002-2447-5911 alias: ctr26 - given-names: James family-names: Ryan alias: jamesyan-git - given-names: Gabriel family-names: Selzer affiliation: University of Wisconsin-Madison orcid: https://orcid.org/0009-0002-2400-1940 alias: gselzer - given-names: MB family-names: Smith affiliation: AI lab for Living Technologies, University Medical Centre Utrecht (The Netherlands) orcid: https://orcid.org/0000-0002-1405-0100 alias: odinsbane - given-names: Paul family-names: Smith affiliation: University College London orcid: https://orcid.org/0000-0002-3676-5318 alias: p-j-smith - given-names: Konstantin family-names: Sofiiuk alias: ksofiyuk - given-names: Johannes family-names: Soltwedel affiliation: DFG cluster of excellence 'Physics of Life', TU Dresden orcid: https://orcid.org/0000-0003-1273-2412 alias: jo-mueller - given-names: David family-names: Stansby affiliation: University College London orcid: https://orcid.org/0000-0002-1365-1908 alias: dstansby - given-names: Jules family-names: Vanaret affiliation: Aix Marseille University, CNRS, Fresnel, I2M, IBDM, Turing Centre for Living systems orcid: https://orcid.org/0009-0004-6070-2263 alias: jules-vanaret - given-names: Pam family-names: Wadhwa affiliation: Quansight Labs alias: ppwadhwa - given-names: Martin family-names: Weigert affiliation: TU-Dresden / EPFL orcid: https://orcid.org/0000-0002-7780-9057 alias: maweigert - given-names: Jonas family-names: Windhager affiliation: ETH Zurich / University of Zurich orcid: https://orcid.org/0000-0002-2111-5291 alias: jwindhager - given-names: Philip family-names: Winston affiliation: Tobeva Software alias: pwinston - given-names: Rubin family-names: Zhao affiliation: Chinese Academy of Sciences - SIAT, Shenzhen, China orcid: https://orcid.org/0009-0005-8264-5682 alias: BeanLi repository-code: https://github.com/napari/napari license: BSD-3-Clause napari-0.5.6/EULA.md000066400000000000000000002002141474413133200140450ustar00rootroot00000000000000## 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.6/LICENSE000066400000000000000000000027421474413133200140100ustar00rootroot00000000000000BSD 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.6/MANIFEST.in000066400000000000000000000012571474413133200145410ustar00rootroot00000000000000include 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 recursive-include napari *.py_tmpl # 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/* include napari/benchmarks/utils.py recursive-exclude resources * recursive-exclude binder * recursive-exclude examples * exclude dockerfile exclude EULA.md napari-0.5.6/Makefile000066400000000000000000000021661474413133200144430ustar00rootroot00000000000000.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: tox -e mypy 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.6/README.md000066400000000000000000000200101474413133200142460ustar00rootroot00000000000000# 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) [![NEP29](https://raster.shields.io/badge/Follows-NEP29-brightgreen.png)](https://numpy.org/neps/nep-0029-deprecation_policy.html) **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. If you want to refer to our documentation, please go to [napari.org](https://www.napari.org). If you want to contribute to it, please refer to the *contributing* section below. We're working on [tutorials](https://napari.org/stable/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 pyqt` See here for the full [installation guide](https://napari.org/stable/tutorials/fundamentals/installation.html). ## 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.](./napari/resources/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/stable/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/stable/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/stable/roadmaps/index.html). ## contributing Contributions are encouraged! Please read our [contributing guide](https://napari.org/dev/developers/contributing/index.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. If you want to contribute or edit to our documentation, please go to [napari/docs](https://github.com/napari/docs). ## code of conduct `napari` has a [Code of Conduct](https://napari.org/stable/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/stable/community/governance.html), which includes information about, and ways to contact the [@napari/steering-council and @napari/core-devs](https://napari.org/stable/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 napari-0.5.6/asv.conf.json000066400000000000000000000036451474413133200154160ustar00rootroot00000000000000{ // 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.11"], // 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 benchmarks // 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.6/binder/000077500000000000000000000000001474413133200142415ustar00rootroot00000000000000napari-0.5.6/binder/Desktop/000077500000000000000000000000001474413133200156525ustar00rootroot00000000000000napari-0.5.6/binder/Desktop/napari.desktop000077500000000000000000000002131474413133200205160ustar00rootroot00000000000000[Desktop Entry] Version=1.0 Type=Application Name=napari Exec=napari Icon=/home/jovyan/napari/resources/icon.ico Path=/home/jovyan/Desktop napari-0.5.6/binder/apt.txt000066400000000000000000000004201474413133200155620ustar00rootroot00000000000000dbus-x11 xfce4 xfce4-panel xfce4-session xfce4-settings xorg xubuntu-icon-theme tigervnc-standalone-server tigervnc-xorg-extension libegl1 libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev libqt5x11extras5-dev libxcb-xinerama0 napari-0.5.6/binder/environment.yml000066400000000000000000000005721474413133200173340ustar00rootroot00000000000000channels: # install a nightly build of napari - napari/label/nightly - conda-forge # Used by jupyter-desktop-server dependencies: - python = 3.12 - napari # additional dependencies for convenience in conda-forge - fsspec - pyqt - scikit-image - zarr # Required for desktop view on mybinder.org - websockify - pip - pip: - jupyter-remote-desktop-proxy napari-0.5.6/binder/postBuild000077500000000000000000000004601474413133200161340ustar00rootroot00000000000000#!/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/ napari-0.5.6/binder/xfce4-panel.xml000066400000000000000000000027241474413133200170760ustar00rootroot00000000000000 napari-0.5.6/binder/xsettings.xml000066400000000000000000000033211474413133200170120ustar00rootroot00000000000000 napari-0.5.6/codecov.yml000066400000000000000000000016171474413133200151500ustar00rootroot00000000000000ignore: - 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: 1 comment: require_changes: true # if true: only post the PR comment if coverage changes after_n_builds: 1 napari-0.5.6/dockerfile000066400000000000000000000053651474413133200150410ustar00rootroot00000000000000FROM --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 ARG NAPARI_COMMIT=main # 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 \ x11-utils \ libegl1-mesa \ libopengl0 \ 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 from repo # see https://github.com/pypa/pip/issues/6548#issuecomment-498615461 for syntax RUN pip install --upgrade pip && \ pip install "napari[all] @ git+https://github.com/napari/napari.git@${NAPARI_COMMIT}" # copy examples COPY examples /tmp/examples ENTRYPOINT ["python3", "-m", "napari"] ######################################################### # Extend napari with a preconfigured Xpra server target # ######################################################### FROM napari AS napari-xpra ARG DEBIAN_FRONTEND=noninteractive # Install Xpra and dependencies RUN apt-get update && apt-get install -y wget gnupg2 apt-transport-https \ software-properties-common ca-certificates && \ wget -O "/usr/share/keyrings/xpra.asc" https://xpra.org/xpra.asc && \ wget -O "/etc/apt/sources.list.d/xpra.sources" https://raw.githubusercontent.com/Xpra-org/xpra/master/packaging/repos/jammy/xpra.sources 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 or $(hostname -i):$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.6/examples/000077500000000000000000000000001474413133200146145ustar00rootroot00000000000000napari-0.5.6/examples/3D_paths.py000066400000000000000000000017251474413133200166400ustar00rootroot00000000000000""" 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.6/examples/3Dimage_plane_rendering.py000066400000000000000000000026531474413133200216610ustar00rootroot00000000000000""" 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.6/examples/3d_kymograph_.py000066400000000000000000000124511474413133200177170ustar00rootroot00000000000000""" 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 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) try: from tqdm import tqdm except ModuleNotFoundError: print('Could not import tqdm which is') print('required to show progress when downloading the sample datasets.') print('Please install tqdm:') print('https://pypi.org/project/tqdm/') exit(-1) import napari 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.6/examples/README.rst000066400000000000000000000000001474413133200162710ustar00rootroot00000000000000napari-0.5.6/examples/action_manager.py000066400000000000000000000071451474413133200201440ustar00rootroot00000000000000""" 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 enforced later), we do give an action name which is prefixed # 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.6/examples/add-points-3d.py000066400000000000000000000012251474413133200175340ustar00rootroot00000000000000""" 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.6/examples/add_3D_image.py000066400000000000000000000006141474413133200174070ustar00rootroot00000000000000""" 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.6/examples/add_grayscale_image.py000066400000000000000000000007121474413133200211120ustar00rootroot00000000000000""" 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.6/examples/add_image.py000066400000000000000000000004401474413133200170560ustar00rootroot00000000000000""" 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.6/examples/add_image_transformed.py000066400000000000000000000005561474413133200214720ustar00rootroot00000000000000""" 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.6/examples/add_labels.py000066400000000000000000000016261474413133200172450ustar00rootroot00000000000000""" 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).astype('uint8') # 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.6/examples/add_labels_with_features.py000066400000000000000000000034711474413133200221760ustar00rootroot00000000000000""" 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', *coin_sizes], # background is size: none } colors = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow', None: 'magenta'} # Here we provide a dict with color mappings for a subset of labels; # when passed to `add_labels`, using the `colormap` kwarg, it will be # internally converted to a `napari.utils.colormaps.DirectLabelColormap` # Note: we also provide a default color (`None` key) which will be used # by all other labels # add the labels label_layer = viewer.add_labels( label_image, name='segmentation', features=label_features, colormap=colors, ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/add_multiscale_image.py000066400000000000000000000011201474413133200212740ustar00rootroot00000000000000""" 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, channel_axis=-1) ) 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.6/examples/add_points.py000066400000000000000000000022421474413133200173120ustar00rootroot00000000000000""" 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.6/examples/add_points_on_nD_shapes.py000066400000000000000000000034421474413133200217750ustar00rootroot00000000000000""" 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.6/examples/add_points_with_features.py000066400000000000000000000037031474413133200222460ustar00rootroot00000000000000""" 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 border_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, size=20, border_width=7, border_width_is_relative=False, border_color='confidence', border_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle ) # set the border_color mode to colormap points_layer.border_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.6/examples/add_points_with_multicolor_text.py000066400000000000000000000025641474413133200236710ustar00rootroot00000000000000""" 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 border_color is set via a color map (grayscale) on the confidence # feature points_layer = viewer.add_points( points, features=features, text=text, size=20, border_width=7, border_width_is_relative=False, border_color='confidence', border_colormap='gray', face_color='good_point', face_color_cycle=color_cycle, ) # set the border_color mode to colormap points_layer.border_color_mode = 'colormap' if __name__ == '__main__': napari.run() napari-0.5.6/examples/add_points_with_text.py000066400000000000000000000024241474413133200214130ustar00rootroot00000000000000""" 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 border_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, text=text, size=20, border_width=7, border_width_is_relative=False, border_color='confidence', border_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle, ) # set the border_color mode to colormap points_layer.border_color_mode = 'colormap' if __name__ == '__main__': napari.run() napari-0.5.6/examples/add_shapes.py000066400000000000000000000042541474413133200172660ustar00rootroot00000000000000""" 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.6/examples/add_shapes_with_features.py000066400000000000000000000036711474413133200222210ustar00rootroot00000000000000""" 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.6/examples/add_shapes_with_text.py000066400000000000000000000025541474413133200213660ustar00rootroot00000000000000""" 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.6/examples/add_surface_2D.py000066400000000000000000000005701474413133200177550ustar00rootroot00000000000000""" 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.6/examples/add_vectors.py000066400000000000000000000020051474413133200174600ustar00rootroot00000000000000""" 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.6/examples/add_vectors_color_by_angle.py000066400000000000000000000025231474413133200225230ustar00rootroot00000000000000""" 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.6/examples/add_vectors_image.py000066400000000000000000000021121474413133200206210ustar00rootroot00000000000000""" 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.6/examples/affine_transforms.py000066400000000000000000000033361474413133200207010ustar00rootroot00000000000000""" 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.6/examples/annotate-2d.py000066400000000000000000000007551474413133200173110ustar00rootroot00000000000000""" 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.6/examples/annotate_segmentation_with_text.py000066400000000000000000000065651474413133200236670ustar00rootroot00000000000000""" Annotate segmentation with text =============================== Perform a segmentation and annotate the results with bounding boxes and text. This example is fully explained in the following tutorial: https://napari.org/stable/tutorials/segmentation/annotate_segmentation.html .. 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.6/examples/bbox_annotator.py000066400000000000000000000072511474413133200202120ustar00rootroot00000000000000""" 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.6/examples/clipboard_.py000066400000000000000000000023551474413133200172710ustar00rootroot00000000000000""" 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.clipboard(canvas_only=True)) widget.copy_viewer_btn.clicked.connect(lambda: viewer.window.clipboard(canvas_only=False)) return widget widget = create_grabber_widget() viewer.window.add_dock_widget(widget) if __name__ == '__main__': napari.run() napari-0.5.6/examples/clipping_planes_interactive_.py000066400000000000000000000152131474413133200230730ustar00rootroot00000000000000""" 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): return bool(np.all(point > bounding_box[0]) and np.all(point < bounding_box[1])) @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.canvas.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.mouse_pan = False labels_layer.mouse_pan = False labels_layer.mouse_pan = False points_layer.mouse_pan = False surface_layer.mouse_pan = False shapes_layer.mouse_pan = False vectors_layer.mouse_pan = 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 coordinates) 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.mouse_pan = True labels_layer.mouse_pan = True points_layer.mouse_pan = True surface_layer.mouse_pan = True shapes_layer.mouse_pan = True vectors_layer.mouse_pan = True viewer.axes.visible = True viewer.camera.angles = (45, 45, 45) viewer.camera.zoom = 5 viewer.text_overlay.update({ '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.6/examples/concentric-spheres.py000066400000000000000000000006751474413133200207740ustar00rootroot00000000000000""" 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.6/examples/cursor_position.py000066400000000000000000000012011474413133200204210ustar00rootroot00000000000000""" 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.6/examples/cursor_ray.py000066400000000000000000000033241474413133200173600ustar00rootroot00000000000000""" 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.6/examples/custom_key_bindings.py000066400000000000000000000021011474413133200212170ustar00rootroot00000000000000""" 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) set_layer_data(viewer) @viewer.bind_key('r') def reject_image(viewer): msg = 'this is a bad image' viewer.status = msg print(msg) set_layer_data(viewer) def set_layer_data(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.6/examples/custom_mouse_functions.py000066400000000000000000000042751474413133200220100ustar00rootroot00000000000000""" 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.6/examples/dask_nD_image.py000066400000000000000000000012331474413133200176720ustar00rootroot00000000000000""" 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.6/examples/dev/000077500000000000000000000000001474413133200153725ustar00rootroot00000000000000napari-0.5.6/examples/dev/demo_shape_creation.py000066400000000000000000000050521474413133200217360ustar00rootroot00000000000000import 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 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 = { '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) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/grin.svg000066400000000000000000000015421474413133200170540ustar00rootroot00000000000000 napari-0.5.6/examples/dev/gui_notifications.py000066400000000000000000000011611474413133200214600ustar00rootroot00000000000000import 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) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/gui_notifications_threaded.py000066400000000000000000000013741474413133200233260ustar00rootroot00000000000000import 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) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/issue-6456.py000066400000000000000000000007261474413133200175030ustar00rootroot00000000000000 import numpy as np import napari # Set the number of steps num_steps = 2**17 base = np.linspace(start=1, stop=num_steps, num=num_steps).astype('uint32') label_img = np.repeat( base.reshape([1, base.shape[0]]), int(num_steps/1000), axis=0 ) viewer = napari.Viewer() viewer.add_image( label_img, scale=(100, 1), colormap='viridis', contrast_limits=(0, num_steps), ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/leaking_check.py000066400000000000000000000027531474413133200205220ustar00rootroot00000000000000import 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.6/examples/dev/overlays.py000066400000000000000000000102221474413133200176050ustar00rootroot00000000000000import warnings from magicgui import magicgui from vispy.scene.visuals import Ellipse import napari from napari._vispy.overlays.base import ViewerOverlayMixin, VispyCanvasOverlay from napari._vispy.utils.visual import overlay_to_visual from napari.components._viewer_constants import CanvasPosition from napari.components.overlays import CanvasOverlay from napari.utils.color import ColorValue # the overlay model should inherit from either CanvasOverlay or SceneOverlay # depending on if it needs to live in "screen space" or "scene space" # (i.e: if it should be affected by camera, dims, ndisplay, ...) class DotOverlay(CanvasOverlay): """ Example overlay using a colored dot to show some state """ color: ColorValue = (0, 1, 0, 1) size: int = 10 # the vispy overlay class should handle connecting the model to the vispy visual # we use the ViewerOverlayMixin because this overlay is attached to the viewer, # and not a specific layer class VispyDotOverlay(ViewerOverlayMixin, VispyCanvasOverlay): # all arguments are keyword-only. viewer, overlay and parent should always be present. def __init__(self, *, viewer, overlay, parent=None): # the node argument for the base class is the vispy visual # note that the center is (0, 0), cause we handle the shift with transforms super().__init__( node=Ellipse(center=(0, 0)), viewer=viewer, overlay=overlay, parent=parent, ) # we also need to connect events from the model to callbacks that update the visual self.overlay.events.color.connect(self._on_color_change) self.overlay.events.size.connect(self._on_size_change) # no need to connect position, since that's in the base classes of CanvasOverlay # at the end of the init of subclasses of VispyBaseOverlay we always # need to call reset to initialize properly self.reset() def _on_color_change(self, event=None): self.node.color = self.overlay.color def _on_position_change(self, event=None): # we can overload the position changing to account for the size, so that the dot # always sticks to the edge; there are `offset` attributes specifically for this self.x_offset = self.y_offset = self.overlay.size / 2 super()._on_position_change() def _on_size_change(self, event=None): self.node.radius = self.overlay.size / 2 # trigger position update since the radius changed self._on_position_change() # we should always add all the new callbacks to the reset() method def reset(self): super().reset() self._on_color_change() self._on_size_change() # for napari to know how to use this overlay, we need to add it to the overlay_to_visual dict # this will ideally be exposed at some point overlay_to_visual[DotOverlay] = VispyDotOverlay viewer = napari.Viewer() # we also need to add at least a layer to see any overlay, # since the canvas is otherwise covered by the welcome widget viewer.add_shapes() # note that we're accessing private attributes externally, which triggers a bunch of warnings. # suppress them for the purpose of this example with warnings.catch_warnings(): warnings.simplefilter('ignore') # add the overlay to the viewer (currently private attribute) viewer._overlays['dot'] = DotOverlay(visible=True) # there is currently no automation on adding a new overlay, so we also need to # manually trigger the generation of the visual viewer.window._qt_viewer.canvas._add_overlay_to_visual(viewer._overlays['dot']) # let's make a simple widget to control the overlay @magicgui( auto_call=True, color={'choices': ['red', 'blue', 'green', 'magenta']}, size={'widget_type': 'Slider', 'min': 1, 'max': 100} ) def control_dot(viewer: napari.Viewer, color='red', size=20, position: CanvasPosition = 'top_left'): with warnings.catch_warnings(): warnings.simplefilter('ignore') dot = viewer._overlays['dot'] dot.color = color dot.size = size dot.position = position viewer.window.add_dock_widget(control_dot) control_dot() if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/plot_2d_edge_meshes.py000066400000000000000000000022441474413133200216410ustar00rootroot00000000000000import 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) if __name__ == '__main__': plt.show() napari-0.5.6/examples/dev/q_list_view.py000066400000000000000000000026611474413133200202760ustar00rootroot00000000000000"""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_qapp from napari.utils.events import SelectableEventedList get_qapp() 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}') ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/q_node_tree.py000066400000000000000000000037761474413133200202450ustar00rootroot00000000000000"""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_qapp from napari.utils.tree import Group, Node get_qapp() # 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}') ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/dev/slicing/000077500000000000000000000000001474413133200170225ustar00rootroot00000000000000napari-0.5.6/examples/dev/slicing/README.md000066400000000000000000000031401474413133200202770ustar00rootroot00000000000000# 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.6/examples/dev/slicing/ebi_empiar_3D_with_labels.py000066400000000000000000000023311474413133200243720ustar00rootroot00000000000000import 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.6/examples/dev/slicing/janelia_s3_n5_multiscale.py000066400000000000000000000017501474413133200242330ustar00rootroot00000000000000import 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(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.6/examples/dev/slicing/points_example_smlm.py000066400000000000000000000016641474413133200234620ustar00rootroot00000000000000import 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.6/examples/dev/slicing/profile_points.py000066400000000000000000000012151474413133200224270ustar00rootroot00000000000000import 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.6/examples/dev/slicing/random_points.py000066400000000000000000000006621474413133200222540ustar00rootroot00000000000000import 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.6/examples/dev/slicing/random_shapes.py000066400000000000000000000040301474413133200222140ustar00rootroot00000000000000import 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.6/examples/dev/triangle_edge.py000066400000000000000000000324211474413133200205370ustar00rootroot00000000000000"""Visualize the triangulation algorithms used by the Shapes layer. This example uses napari layers to draw each of the components of a face and edge triangulation in a Shapes layer. Shapes layers don't "just" draw polygons, ellipses, and so on: each shape, as well as its borders, is broken down into a collection of triangles (this is called a *triangulation*), which are then sent to OpenGL for drawing: drawing triangles is one of the "visualization primitives" in OpenGL and most 2D and 3D drawing frameworks. It turns out that converting arbitrary shapes into a collection of triangles can be quite tricky: very shallow angles cause errors in the algorithms, and can also make certain desirable properties (such as edges not overlapping with each other when a polygon makes a sharp turn) actually impossible to achieve with fast (single-pass) algorithms. This example draws the Shapes layer normally, but also overlays all the elements of the triangulation: the triangles themselves, and the normal vectors on each polygon vertex, from which the triangulation is computed. """ from dataclasses import dataclass, fields from functools import partial try: import numba except ImportError: # Create a dummy numba.njit to allow running this script without numba import toolz as tz class numba: @tz.curry def njit(func, cache=True, inline=None): return func import numpy as np import napari from napari.layers import Shapes from napari.layers.shapes._shapes_utils import generate_2D_edge_meshes def generate_regular_polygon(n, radius=1): """Generate vertices of a regular n-sided polygon centered at the origin. Parameters ---------- n : int The number of sides (vertices). radius : float, optional The radius of the circumscribed circle. Returns ------- np.ndarray An array of shape (n, 2) containing the vertex coordinates. """ angles = np.linspace(0, 2 * np.pi, n, endpoint=False) return np.column_stack((radius * np.cos(angles), radius * np.sin(angles))) def get_reference_edge_triangulation_points(shape: Shapes) -> np.ndarray: """Get the non-accelerated points""" shapes = shape._data_view.shapes path_list = [(x.data, x._closed) for x in shapes] mesh_list = [generate_2D_edge_meshes(path, closed) for path, closed in path_list] mesh = tuple(np.concatenate(el, axis=0) for el in zip(*mesh_list)) return mesh[0] + mesh[1] def generate_order_vectors(path_, closed) -> np.ndarray: """Generate the vectors tangent to the path. Parameters ---------- path_ : np.ndarray, shape (N, 2) A list of 2D path coordinates. closed : bool Whether the coordinates represent a closed polygon or an open line/path. Returns ------- vec : np.ndarray, shape (N, 2, 2) A set of vectors, defined by a 2D position and a 2D projection. """ raw_vecs = np.diff(path_, axis=0) norm = np.linalg.norm(raw_vecs, axis=1, keepdims=True) directions = raw_vecs / norm vec = np.empty((path_.shape[0], 2, 2)) vec[:, 0] = path_ vec[:-1, 1] = directions if closed: # point from the last vertex towards the first vertex vec[-1, 1] = ( (path_[0] - path_[-1]) / np.linalg.norm(path_[-1] - path_[0]) ) else: # point back towards the penultimate vertex vec[-1, 1] = -vec[-2, 1] return vec def generate_miter_helper_vectors( direction_vectors_array: np.ndarray ) -> np.ndarray: """Generate the miter helper vectors. The miter helper vectors are a pair of vectors for each point in the path, which together help define whether a bevel join will be needed. The first vector is half of the normalized direction vector for that vertex, and the second is *minus* half of the normalized direction vector for the previous vertex. Their angle is the angle of the edges at that vertex. Parameters ---------- direction_vectors_array : array of shape (n, 2) array of normalized (length 1) direction vectors for each point in the path. Returns ------- array of shape (n, 2, 2) array of miter helper vectors """ half_direction = direction_vectors_array.copy() half_direction[:, 1] *= 0.5 half_prev_direction = half_direction.copy() half_prev_direction[:, 1] *= -1 half_prev_direction[:, 1] = np.roll(half_prev_direction[:, 1], 1, axis=0) half_prev_direction[:, 0] += half_direction[:, 1] return np.concatenate([half_direction, half_prev_direction], axis=0) @numba.njit def generate_orthogonal_vectors(direction_vectors: np.ndarray) -> np.ndarray: """Generate the orthogonal vectors to the direction vectors. The orthogonal vector starts at the middle of the direction vector and is orthogonal to it in the positive orientation. Its length is half of the direction vector, to align with the normalized edge width. Parameters ---------- direction_vectors : array, shape (n, 2, 2) The direction vector start points (``direction_vectors[:, 0, :]``) and directions (``direction_vectors[:, 1, :]``). Returns ------- orthogonal_vectors : array, shape(n, 2, 2) The orthogonal vector start points and directions. """ position = 0 vector = 1 y, x = 0, 1 half_direction = 0.5 * direction_vectors[:, 1, :] orthogonal_vectors = direction_vectors.copy() orthogonal_vectors[:, position] = ( direction_vectors[:, position] + half_direction ) orthogonal_vectors[:, vector, y] = -half_direction[:, x] orthogonal_vectors[:, vector, x] = half_direction[:, y] return orthogonal_vectors @numba.njit def generate_miter_vectors( mesh: tuple[np.ndarray, np.ndarray, np.ndarray] ) -> np.ndarray: """For each point on path, generate the vectors pointing to miter points. Parameters ---------- mesh : tuple[np.ndarray] vertices, offsets, and triangles of the mesh corresponding to the edges of a single shape. Returns ------- np.ndarray, shape (n, 2, 2) Positions and projections of vectors corresponding to the miter points offset from the path points. """ vec_points = np.empty((mesh[0].shape[0], 2, 2)) vec_points[:, 0] = mesh[0] vec_points[:, 1] = mesh[1] return vec_points @numba.njit def generate_edge_triangle_borders(centers, offsets, triangles) -> np.ndarray: """Generate 3 vectors that represent the borders of the triangle. These are vectors to show the *ordering* of the triangles in the data. Parameters ---------- centers, offsets, triangles : np.ndarray of float The mesh triangulation of the shape's edge path. Returns ------- borders : np.ndarray of float Positions and projections corresponding to each triangle edge in the triangulation of the path. """ borders = np.empty((len(triangles)*3, 2, 2), dtype=centers.dtype) position = 0 projection = 1 for i, triangle in enumerate(triangles): a, b, c = triangle a1 = centers[a] + offsets[a] b1 = centers[b] + offsets[b] c1 = centers[c] + offsets[c] borders[i * 3, position] = a1 borders[i * 3, projection] = (b1 - a1) borders[i * 3 + 1, position] = b1 borders[i * 3 + 1, projection] = (c1 - b1) borders[i * 3 + 2, position] = c1 borders[i * 3 + 2, projection] = (a1 - c1) return borders @numba.njit def generate_face_triangle_borders(vertices, triangles) -> np.ndarray: """For each triangle in mesh generate 3 vectors that represent the borders of the triangle. """ borders = np.empty((len(triangles)*3, 2, 2), dtype=vertices.dtype) for i, triangle in enumerate(triangles): a, b, c = triangle a1 = vertices[a] b1 = vertices[b] c1 = vertices[c] borders[i * 3, 0] = a1 borders[i * 3, 1] = (b1 - a1) borders[i * 3 + 1, 0] = b1 borders[i * 3 + 1, 1] = (c1 - b1) borders[i * 3 + 2, 0] = c1 borders[i * 3 + 2, 1] = (a1 - c1) return borders @dataclass class Helpers: """Simple class to hold all auxiliary vector data for a shapes layer.""" reference_join_points: np.ndarray join_points: np.ndarray direction_vectors: np.ndarray miter_helper_vectors: np.ndarray orthogonal_vectors: np.ndarray miter_vectors: np.ndarray triangles_vectors: np.ndarray face_triangles_vectors: np.ndarray def asdict(dataclass_instance): """Shallow copy version of `dataclasses.asdict`.""" return {f.name: getattr(dataclass_instance, f.name) for f in fields(dataclass_instance)} def get_helper_data_from_shapes(shapes_layer: Shapes) -> Helpers: """Function to generate all auxiliary data for a shapes layer.""" shapes = shapes_layer._data_view.shapes mesh_list = [(s._edge_vertices, s._edge_offsets, s._edge_triangles) for s in shapes] path_list = [(s.data, s._closed) for s in shapes] mesh = tuple(np.concatenate(el, axis=0) for el in zip(*mesh_list)) face_mesh_list = [(s._face_vertices, s._face_triangles) for s in shapes if len(s._face_vertices) > 0] order_vectors_list = [generate_order_vectors(path_, closed) for path_, closed in path_list] helpers = Helpers( reference_join_points=get_reference_edge_triangulation_points( shapes_layer ), join_points=mesh[0] + mesh[1], direction_vectors=np.concatenate(order_vectors_list, axis=0), miter_helper_vectors=np.concatenate( [generate_miter_helper_vectors(o) for o in order_vectors_list], axis=0, ), orthogonal_vectors=np.concatenate( [generate_orthogonal_vectors(o) for o in order_vectors_list], axis=0, ), miter_vectors=np.concatenate( [generate_miter_vectors(m) for m in mesh_list], axis=0, ), triangles_vectors=np.concatenate( [generate_edge_triangle_borders(*m) for m in mesh_list], axis=0, ), face_triangles_vectors=np.concatenate( [generate_face_triangle_borders(*m) for m in face_mesh_list], axis=0, ), ) return helpers def update_helper_layers(viewer: napari.Viewer, source_layer: Shapes): updated_helpers = get_helper_data_from_shapes(source_layer) for name, data in asdict(updated_helpers).items(): viewer.layers[name].data = data def add_helper_layers(viewer: napari.Viewer, source_layer): """Add helper layers to the viewer that track with the source shapes.""" helpers = get_helper_data_from_shapes(source_layer) # sizes and colors are hardcoded based on vibes sizes = [0.2, 0.1, 0.1, 0.06, 0.04, 0.05, 0.04, 0.04] colors = ['yellow', 'white', 'red', 'blue', 'green', 'yellow', 'pink', 'magenta'] for (name, data), size, color in zip( asdict(helpers).items(), sizes, colors ): if 'points' in name: viewer.add_points(data, name=name, size=size, face_color=color) else: # all other fields are vectors viewer.add_vectors( data, name=name, vector_style='arrow', edge_width=size, edge_color=color, ) source_layer.events.set_data.connect( partial(update_helper_layers, viewer=viewer, source_layer=source_layer) ) path = np.array([[0,0], [0,1], [1,1], [1,0]]) * 10 sparkle = np.array([[1, 1], [10, 0], [1, -1], [0, -10], [-1, -1], [-10, 0], [-1, 1], [0, 10]]) fork = np.array([[2, 10], [0, -5], [-2, 10], [-2, -10], [2, -10]]) polygons = [ # square generate_regular_polygon(4, radius=1) * 10, # decagon generate_regular_polygon(10, radius=1) * 10 + np.array([[25, 0]]), # triangle generate_regular_polygon(3, radius=1) * 10 + np.array([[0, 25]]), # two sharp prongs fork + np.array([[25, 25]]), # a four-sided star sparkle + np.array([[50, 0]]), # same star, but winding in the opposite direction sparkle[::-1] + np.array([[50, 26]]), # problem shape — # lighting bolt with sharp angles and overlapping edge widths np.array([[10.97627008, 14.30378733], [12.05526752, 10.89766366], [8.47309599, 12.91788226], [8.75174423, 17.83546002], [19.27325521, 7.66883038], [15.83450076, 10.5778984]], ) + np.array([[60, -15]]), ] paths = [ # a simple backwards-c shape path + np.array([[0, 50]]), # an unclosed decagon generate_regular_polygon(10, radius=1) * 10 + np.array([[25, 50]]), # a three-point straight line (tests collinear points in path) np.array([[0, -10], [0, 0], [0, 10]]) + np.array([[50, 50]]), ] shapes = polygons + paths shape_types=['polygon'] * len(polygons) + ['path'] * len(paths) viewer = napari.Viewer() shapes_layer = viewer.add_shapes(shapes, shape_type=shape_types, name='shapes') add_helper_layers(viewer, source_layer=shapes_layer) viewer.layers.selection = {shapes_layer} viewer.reset_view() if __name__ == '__main__': napari.run() napari-0.5.6/examples/dynamic-projections-dask.py000066400000000000000000000040601474413133200220670ustar00rootroot00000000000000""" 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.6/examples/embed_ipython_.py000066400000000000000000000015401474413133200201530ustar00rootroot00000000000000""" 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' # initialize 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.6/examples/export_figure.py000066400000000000000000000056231474413133200200560ustar00rootroot00000000000000""" Export Figure ============= Display a variety of layer types in the napari viewer and export the figure with `viewer.export_figure()`. The exported figure is then added back as an image layer. Exported figures include the extent of all data in 2D view, and does not presently work for 3D views. To capture the extent of the canvas, instead of the layers, see `viewer.screenshot()`: :ref:`sphx_glr_gallery_to_screenshot.py` and :ref:`sphx_glr_gallery_screenshot_and_export_figure.py`. .. tags:: visualization-advanced """ import numpy as np from skimage import data 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', ) # 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', ) 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) # Add scale bar of a defined length to the exported figure viewer.scale_bar.visible = True viewer.scale_bar.length = 250 # Export figure and change theme before and after exporting to show that the background canvas margins # are not in the exported figure. viewer.theme = "light" # Optionally for saving the exported figure: viewer.export_figure(path="export_figure.png") export_figure = viewer.export_figure() scaled_export_figure = viewer.export_figure(scale_factor=5) viewer.theme = "dark" viewer.add_image(export_figure, rgb=True, name='exported_figure') viewer.add_image(scaled_export_figure, rgb=True, name='scaled_exported_figure') viewer.reset_view() if __name__ == '__main__': napari.run() napari-0.5.6/examples/export_rois.py000066400000000000000000000064311474413133200175470ustar00rootroot00000000000000""" Export regions of interest (ROIs) to png ========================================== Display multiple layer types one of them representing ROIs, add scale bar, and export unscaled and scaled rois. The scale bar will always be within the screenshot of the ROI. Currently, `export_rois` does not support the 3D view or any other shape than 2D rectangles. In the final grid state shown below, from left to right, top to bottom, the first 4 images display the scaled roi screenshots with scale bar and the last 4 display the unscaled roi screenshots with scale bar. .. tags:: visualization-advanced """ import numpy as np from skimage import data import napari # Create a napari viewer with multiple layer types and add a scale bar. # One of the polygon shapes exists outside the image extent, which is # useful in displaying how figure export handles the extent of all layers. viewer = napari.Viewer() # add a 2D image layer img_layer = viewer.add_image(data.lily(), name='lily', channel_axis=2) # Rectangular shapes encapsulating rois for the lily data rois = [np.array([[149.38401138, 61.80004348], [149.38401138, 281.95358131], [364.2538643 , 281.95358131], [364.2538643 , 61.80004348]]), np.array([[316.70070013, 346.23841435], [316.70070013, 660.61766637], [673.34943141, 660.61766637], [673.34943141, 346.23841435]]), np.array([[579.12371722, 16.88872176], [579.12371722, 176.27988315], [768.45575975, 176.27988315], [768.45575975, 16.88872176]]), np.array([[ 43.42954911, 445.29831816], [ 43.42954911, 871.04161258], [285.58890617, 871.04161258], [285.58890617, 445.29831816]])] # Add lily rois to the viewer for visualization purposes, not required for exporting roi screenshots. roi_layer = viewer.add_shapes( rois, # in case of a shapes layer containing rectangular rois, pass on layer.data directly. edge_width=10, edge_color='green', # Optionally, set to [(0, 0, 0, 0)] * 4 to prevent edge color from showing in screenshots. face_color=[(0, 0, 0, 0)] * 4, # We do not want the face color to show up in the screenshots shape_type='rectangle', name='rois', ) # add scale_bar with background box viewer.scale_bar.visible = True viewer.scale_bar.box = True # viewer.scale_bar.length = 150 # prevent dynamic adjustment of scale bar length # Take screenshots of the rois. screenshot_rois = viewer.export_rois(rois) # Optionally, save the exported rois in a directory of choice with name `roi_n.png` where n is the index of the roi: # viewer.export_rois(rois, paths='home/data/exported_rois') # Optionally, save the exported rois while specifying the location for each roi to be stored: # viewer.export_rois(rois, paths=['first_roi.png', 'second_roi.png', 'third_roi.png', 'fourth_roi.png']) # Also take scaled roi screenshots. screenshot_rois_scaled = viewer.export_rois(rois, scale=2 ) viewer.layers.select_all() viewer.layers.toggle_selected_visibility() for index, roi in enumerate(screenshot_rois): viewer.add_image(roi, name=f'roi_{index}_unscaled') for index, roi in enumerate(screenshot_rois_scaled): viewer.add_image(roi, name=f'roi_{index}_scaled') viewer.grid.enabled = True viewer.grid.shape = (3, 3) if __name__ == '__main__': napari.run() napari-0.5.6/examples/fourier_transform_playground.py000066400000000000000000000107011474413133200231770ustar00rootroot00000000000000""" Fourier transform playground ============================ Generate an image by adding arbitrary 2D sine waves and observe how the fourier transform's real and imaginary components are affected by the changes. Threading is used to smoothly animate the waves. .. tags:: interactivity, gui """ from time import sleep, time import numpy as np from magicgui import magic_factory from scipy.fft import fft2, fftshift import napari from napari.qt.threading import thread_worker IMAGE_SIZE = 100 FPS = 20 # meshgrid used to calculate the 2D sine waves x = np.arange(IMAGE_SIZE) - IMAGE_SIZE / 2 X, Y = np.meshgrid(x, x) def wave_2d(frequency, angle, phase_shift): """Generate a 2D sine wave based on angle, frequency and phase shift.""" angle = np.deg2rad(angle) phase_shift = np.deg2rad(phase_shift) wave = 2 * np.pi * (X * np.cos(angle) + Y * np.sin(angle)) * frequency return np.sin(wave + phase_shift) # set up viewer with grid-mode enabled viewer = napari.Viewer() viewer.grid.enabled = True def update_layer(name, data, **kwargs): """Update a layer in the viewer with new data. If data is None, then the layer is removed. If the layer is not present, it's added to the viewer. """ if data is None: if name in viewer.layers: viewer.layers.pop(name) viewer.reset_view() elif name not in viewer.layers: viewer.add_image(data, name=name, interpolation2d='spline36', **kwargs) viewer.reset_view() else: viewer.layers[name].data = data def combine_and_set_data(wave_args): """Merge 2D waves, calculate the FT and update the viewer. The wave phases are offset by the current time multiplied by an arbitrary speed value; this generates an animated wave if called repeatedly. """ if not wave_args: # this happens on yielding from the thread, no need to update anything return t = time() waves = { wave_id: wave_2d(frequency, angle, phase_shift + t * speed * 100) if frequency else None for wave_id, (frequency, angle, phase_shift, speed) in wave_args.items() } to_add = [d for d in waves.values() if d is not None] if to_add: mean = np.mean(to_add, axis=0) ft = fftshift(fft2(mean)) power_spectrum = abs(ft) phase = np.angle(ft) * power_spectrum power_spectrum = np.log10(power_spectrum + 10) else: mean = power_spectrum = phase = None # for visualisation, it's clearer to use: # phase * ps instead of phase # and log10(ps + 1) instead of ps update_layer('phase * power_spectrum', phase, colormap=('blue', 'black', 'red')) update_layer('log10(power_spectrum + 1)', power_spectrum) update_layer('mean', mean) for name, data in waves.items(): update_layer(f'wave {name}', data) @thread_worker(connect={'yielded': combine_and_set_data}) def update_viewer(): # keep track of each wave in a dictionary by id, this way we can modify/remove # existing waves or add new ones wave_args = {} new_params = None while True: sleep(1 / FPS) # see https://napari.org/stable/guides/threading.html#full-two-way-communication # this receives new_params from thread.send() and yields {} for the `yielded` callback new_params = yield wave_args if new_params is not None: # note that these come from thread.send() in moving_wave()! wave_id, *args = new_params wave_args[wave_id] = args yield wave_args # start the thread responsible for updating the viewer thread = update_viewer() @magic_factory( auto_call=True, frequency={'widget_type': 'FloatSlider', 'min': 0, 'max': 1, 'step': 0.01}, angle={'widget_type': 'Slider', 'min': 0, 'max': 180}, phase_shift={'widget_type': 'Slider', 'min': 0, 'max': 180}, speed={'widget_type': 'FloatSlider', 'min': -10, 'max': 10, 'step': 0.1}, ) def moving_wave( wave_id: int = 0, frequency: float = 0.2, angle: int = 0, phase_shift: int = 0, speed: float = 1, run=True, ): """Send new parameters to the listening thread to update the 2D waves. The `run` checkbox can be disabled to stop sending values to the thread while changing parameters. """ if run: thread.send((wave_id, frequency, angle, phase_shift, speed)) wdg = moving_wave() # add the widget to the window and run it once viewer.window.add_dock_widget(wdg, area='bottom') wdg() napari.run() thread.quit() napari-0.5.6/examples/get_current_viewer.py000066400000000000000000000013071474413133200210710ustar00rootroot00000000000000""" 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 numpy as np import napari # create viewer viewer = napari.Viewer() # lose reference to viewer viewer = 'oops no viewer here' # get that reference again viewer = napari.current_viewer() # work with the viewer x = np.arange(256) y = np.arange(256).reshape((256, 1)) # from: https://botsin.space/@bitartbot/113553754823363986 image = (-(~((y - x) ^ (y + x)))) % 11 layer = viewer.add_image(image) layer.contrast_limits = (8.5, 10) if __name__ == '__main__': napari.run() napari-0.5.6/examples/image-points-3d.py000066400000000000000000000007731474413133200200750ustar00rootroot00000000000000""" 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.6/examples/image_custom_kernel.py000066400000000000000000000044251474413133200212070ustar00rootroot00000000000000""" Custom image interpolation kernels ================================== When interpolation is set to 'custom', the convolution kernel provided by `custom_interpolation_kernel_2d` is used to convolve the image on the gpu. In this example, we use custom gaussian kernels of arbitrary size, a sharpening kernel and a ridge detection kernel. Under the hood, this works by by sampling the image texture with `linear` interpolation in a regular grid (of size = of the kernel) around each fragment, and then using the weights in the kernel to add up the final fragment value. .. tags:: gui, visualization-nD """ import numpy as np from magicgui import magicgui from scipy.signal.windows import gaussian from skimage import data import napari viewer = napari.view_image(data.astronaut(), rgb=True, interpolation='custom') def gaussian_kernel(size, sigma): window = gaussian(size, sigma) kernel = np.outer(window, window) return kernel / kernel.sum() def sharpen_kernel(): return np.array([ [ 0, -1, 0], [-1, 5, -1], [ 0, -1, 0], ]) def ridge_detection_kernel(): return np.array([ [-1, -1, -1], [-1, 9, -1], [-1, -1, -1], ]) @magicgui( auto_call=True, kernel_size={'widget_type': 'Slider', 'min': 1, 'max': 20}, sigma={'widget_type': 'FloatSlider', 'min': 0.1, 'max': 5, 'step': 0.1}, kernel_type={'choices': ['none', 'gaussian', 'sharpen', 'ridge_detection']}, ) def gpu_kernel(image: napari.layers.Image, kernel_type: str = 'gaussian', kernel_size: int = 5, sigma: float = 1): if kernel_type == 'none': image.interpolation2d = 'linear' else: image.interpolation2d = 'custom' if kernel_type == 'gaussian': gpu_kernel.kernel_size.show() gpu_kernel.sigma.show() else: gpu_kernel.kernel_size.hide() gpu_kernel.sigma.hide() if kernel_type == 'gaussian': image.custom_interpolation_kernel_2d = gaussian_kernel(kernel_size, sigma) elif kernel_type == 'sharpen': image.custom_interpolation_kernel_2d = sharpen_kernel() elif kernel_type == 'ridge_detection': image.custom_interpolation_kernel_2d = ridge_detection_kernel() viewer.window.add_dock_widget(gpu_kernel) gpu_kernel() if __name__ == '__main__': napari.run() napari-0.5.6/examples/image_depth.py000066400000000000000000000007671474413133200174460ustar00rootroot00000000000000""" 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.6/examples/inherit_viewer_style.py000066400000000000000000000042371474413133200214370ustar00rootroot00000000000000""" 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.6/examples/interaction_box_image.py000066400000000000000000000007041474413133200215200ustar00rootroot00000000000000""" 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.6/examples/interactive_move_rectangle_3d.py000066400000000000000000000034001474413133200231400ustar00rootroot00000000000000""" 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.mouse_pan = 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.mouse_pan = True if __name__ == '__main__': napari.run() napari-0.5.6/examples/interactive_scripting.py000066400000000000000000000012371474413133200215700ustar00rootroot00000000000000""" 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.6/examples/labels-2d.py000066400000000000000000000014441474413133200167360ustar00rootroot00000000000000""" 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, channel_axis=-1, 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.6/examples/labels3d.py000066400000000000000000000017621474413133200166650ustar00rootroot00000000000000""" 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) viewer.dims.ndisplay = 3 if __name__ == '__main__': napari.run() napari-0.5.6/examples/layers.py000066400000000000000000000013701474413133200164660ustar00rootroot00000000000000""" 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.6/examples/linked_layers.py000066400000000000000000000016131474413133200200140ustar00rootroot00000000000000""" 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.6/examples/live_tiffs_.py000066400000000000000000000103351474413133200174610ustar00rootroot00000000000000""" 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.6/examples/live_tiffs_generator_.py000066400000000000000000000027541474413133200215350ustar00rootroot00000000000000""" 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 generates 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 with open(os.path.join(outdir, 'final.log'), 'w'): pass if __name__ == '__main__': main() napari-0.5.6/examples/magic_image_arithmetic.py000066400000000000000000000032671474413133200216310ustar00rootroot00000000000000""" 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) return None # 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.6/examples/magic_parameter_sweep.py000066400000000000000000000035371474413133200215210ustar00rootroot00000000000000""" 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 typing from typing import Annotated import skimage.data import skimage.filters 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 information 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', ) -> 'typing.Optional[napari.types.ImageData]': """Apply a gaussian blur to ``layer``.""" if layer: return skimage.filters.gaussian(layer.data, sigma=sigma, mode=mode) return None # 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.6/examples/magic_viewer.py000066400000000000000000000011061474413133200176250ustar00rootroot00000000000000""" magicgui viewer =============== Example showing how to access the current viewer from a function widget. .. tags:: gui """ import napari # annotating a parameter 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.6/examples/mgui_dask_delayed_.py000066400000000000000000000014351474413133200207620ustar00rootroot00000000000000""" 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.6/examples/mgui_with_threadpoolexec_.py000066400000000000000000000032231474413133200224070ustar00rootroot00000000000000""" 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 """ 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 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 = { 'size': blobs[:, -1], 'border_color': 'red', 'border_width': 2, 'border_width_is_relative': False, '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.6/examples/mgui_with_threadworker_.py000066400000000000000000000035131474413133200221040ustar00rootroot00000000000000""" 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 typing import Annotated from magicgui import magic_factory, widgets from skimage import data from skimage.feature import blob_log 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 = { 'size': blobs[:, -1], 'border_color': 'red', 'border_width': 2, 'border_width_is_relative': False, '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.6/examples/minimum_blending.py000066400000000000000000000025311474413133200205040ustar00rootroot00000000000000""" 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.6/examples/mixed-dimensions-labels.py000066400000000000000000000020031474413133200216750ustar00rootroot00000000000000""" 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.6/examples/mouse_drag_callback.py000066400000000000000000000024161474413133200211320ustar00rootroot00000000000000""" 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.6/examples/mpl_plot_.py000066400000000000000000000020241474413133200171510ustar00rootroot00000000000000""" 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.6/examples/multiple_viewer_widget.py000066400000000000000000000365351474413133200217610ustar00rootroot00000000000000""" 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 from typing import Optional 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 not 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: Optional[str] = None, layer_type: Optional[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 if len(self.viewer.layers) != len(model.layers): 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) event.value.events.labels_update.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) event.value.events.labels_update.connect(self._set_data_refresh) self.viewer_model1.layers[ event.value.name ].events.labels_update.connect(self._set_data_refresh) self.viewer_model2.layers[ event.value.name ].events.labels_update.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') view.open_sample('napari', 'cells3d') napari.run() napari-0.5.6/examples/multiple_viewers.py000066400000000000000000000007471474413133200205750ustar00rootroot00000000000000""" 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.6/examples/multithreading_simple_.py000066400000000000000000000026041474413133200217200ustar00rootroot00000000000000""" 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.6/examples/multithreading_two_way_.py000066400000000000000000000072511474413133200221230ustar00rootroot00000000000000""" 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.6/examples/nD_image.py000066400000000000000000000007311474413133200166720ustar00rootroot00000000000000""" 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.6/examples/nD_labels.py000066400000000000000000000010071474413133200170470ustar00rootroot00000000000000""" 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.6/examples/nD_multiscale_image.py000066400000000000000000000012331474413133200211120ustar00rootroot00000000000000""" 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, channel_axis=-1) ) 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.6/examples/nD_multiscale_image_non_uniform.py000066400000000000000000000013341474413133200235250ustar00rootroot00000000000000""" 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, channel_axis=-1) ) 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.6/examples/nD_points.py000066400000000000000000000015561474413133200171320ustar00rootroot00000000000000""" 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=10, face_color='blue', out_of_slice_display=True ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/nD_points_with_features.py000066400000000000000000000026721474413133200220630ustar00rootroot00000000000000""" 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) ) border_feature = np.array(['A', 'B', 'C', 'D', 'E'] * int(len(points) / 5)) features = { 'face_feature': face_feature, 'border_feature': border_feature, } points_layer = viewer.add_points( points, features=features, size=3, border_width=5, border_width_is_relative=False, border_color='border_feature', face_color='face_feature', out_of_slice_display=False, ) # change the face color cycle points_layer.face_color_cycle = ['white', 'black'] # change the border_color cycle. # there are 4 colors for 5 categories, so 'c' will be recycled points_layer.border_color_cycle = ['c', 'm', 'y', 'k'] if __name__ == '__main__': napari.run() napari-0.5.6/examples/nD_shapes.py000066400000000000000000000030171474413133200170730ustar00rootroot00000000000000""" 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.6/examples/nD_shapes_with_text.py000066400000000000000000000012271474413133200211730ustar00rootroot00000000000000""" 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.6/examples/nD_surface.py000066400000000000000000000006771474413133200172510ustar00rootroot00000000000000""" 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.6/examples/nD_vectors.py000066400000000000000000000026171474413133200173020ustar00rootroot00000000000000""" 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.6/examples/nD_vectors_image.py000066400000000000000000000015151474413133200204400ustar00rootroot00000000000000""" 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.6/examples/new_theme.py000066400000000000000000000017741474413133200171520ustar00rootroot00000000000000""" 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') 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 blue_theme.font_size = '10pt' # you can provide a font size in points (pt) for the application 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.6/examples/notebook.ipynb000066400000000000000000000023021474413133200174740ustar00rootroot00000000000000{ "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", "\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.6/examples/paint-nd.py000066400000000000000000000012651474413133200167040ustar00rootroot00000000000000""" 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' if __name__ == '__main__': napari.run() napari-0.5.6/examples/pass_colormaps.py000066400000000000000000000011361474413133200202140ustar00rootroot00000000000000""" 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.6/examples/point_cloud.py000066400000000000000000000010031474413133200174770ustar00rootroot00000000000000""" 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.6/examples/points-over-time.py000066400000000000000000000021641474413133200204120ustar00rootroot00000000000000""" 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.6/examples/progress_bar_minimal_.py000066400000000000000000000077351474413133200215370ustar00rootroot00000000000000""" 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 cancelable_progress, 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) def cancelable_iterable(): """We can allow expensive computations to be cancelable """ # Note that if canceled, for loop will terminate prematurely # You can use cancel_callback to close files, clean up state, etc # if the user cancels the operation. def cancel_callback(): print('Operation canceled - cleaning up!') for _ in cancelable_progress(range(100), cancel_callback=cancel_callback): np.random.rand(128, 128, 128).mean(0) 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) cancel_iter_btn = QPushButton('Cancelable Iterable') cancel_iter_btn.clicked.connect(cancelable_iterable) button_layout.addWidget(cancel_iter_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.6/examples/progress_bar_segmentation_.py000066400000000000000000000120321474413133200225700ustar00rootroot00000000000000""" 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.6/examples/progress_bar_threading_.py000066400000000000000000000053031474413133200220430ustar00rootroot00000000000000""" 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_val = 0 for i in range(10): sum_val += i sleep(0.1) return sum_val 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.6/examples/reader_plugin.py000066400000000000000000000016201474413133200200050ustar00rootroot00000000000000""" 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.6/examples/scale_bar.py000066400000000000000000000017101474413133200171000ustar00rootroot00000000000000""" 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 # Text options viewer.scale_bar.unit = 'um' # set to None to diplay no unit viewer.scale_bar.length = 23 # length, in units, of the scale bar viewer.scale_bar.font_size = 20 # default is 10 # Text color viewer.scale_bar.colored = True # default value is False viewer.scale_bar.color = 'yellow' # default value is magenta: (1,0,1,1) # Background box viewer.scale_bar.box = True # add background box, default is False viewer.scale_bar.box_color = (0, 1, 1, 0.2) # cyan with alpha=0.2 # Scale bar position viewer.scale_bar.position = 'bottom_left' # default is 'bottom_right' if __name__ == '__main__': napari.run() napari-0.5.6/examples/screenshot_and_export_figure.py000066400000000000000000000076641474413133200231440ustar00rootroot00000000000000""" Comparison of Screenshot and Figure Export ========================================== Display multiple layer types, add scale bar, and take a screenshot or export a figure from a 'light' canvas. Then switch to a 'dark' canvas and display the screenshot and figure. Compare the limits of each export method. The screenshot will include the entire canvas, and results in some layers being clipped if it extends outside the canvas. This also means that screenshots will reflect the current zoom. In comparison, the `export_figure` will always include the extent of the layers and any other elements overlayed on the canvas, such as the scale bar. Exported figures also move the scale bar to within the margins of the canvas. Currently, 'export_figure` does not support the 3D view, but screenshot does. In the final grid state shown below, the first row represents exported images. The first two show that zoom is not reflected in the exported figure. The final one shows how the exported figure adapts to change in the layer extent. In the second row are the screenshots, showing the fact that the entire canvas is captured and that zoom is preserved. .. tags:: visualization-advanced """ import numpy as np from skimage import data import napari # Create a napari viewer with multiple layer types and add a scale bar. # One of the polygon shapes exists outside the image extent, which is # useful in displaying how figure export handles the extent of all layers. viewer = napari.Viewer() # add a 2D image layer img_layer = viewer.add_image(data.camera(), name='photographer') img_layer.colormap = 'gray' # polygon within image extent layer_within = viewer.add_shapes( np.array([[11, 13], [111, 113], [22, 246]]), shape_type='polygon', face_color='coral', name='shapes_within', ) # add a polygon shape layer layer_outside = viewer.add_shapes( np.array([[572, 222], [305, 292], [577, 440]]), shape_type='polygon', face_color='royalblue', name='shapes_outside', ) # add scale_bar with background box viewer.scale_bar.visible = True viewer.scale_bar.box = True # viewer.scale_bar.length = 150 # prevent dynamic adjustment of scale bar length # Take screenshots and export figures in 'light' theme, to show the canvas # margins and the extent of the exported figure. viewer.theme = 'light' screenshot = viewer.screenshot() figure = viewer.export_figure() # optionally, save the exported figure: viewer.export_figure(path='export_figure.png') # or screenshot: viewer.screenshot(path='screenshot.png') # Zoom in and take another screenshot and export figure to show the different # extents of the exported figure and screenshot. viewer.camera.zoom = 3 screenshot_zoomed = viewer.screenshot() figure_zoomed = viewer.export_figure() # Remove the layer that exists outside the image extent and take another # figure export to show the extent of the exported figure without the # layer that exists outside the camera image extent. viewer.layers.remove(layer_outside) figure_no_outside_shape = viewer.export_figure() # Display the screenshots and figures in 'dark' theme, and switch to grid mode # for comparison. In the final grid state shown, the first row represents exported # images. The first two show that zoom is not reflected in the exported figure. # The final one shows how the exported figure adapts to change in the layer extent. # In the second row are the screenshots, showing the fact that the entire canvas # is captured and that zoom is preserved. viewer.theme = 'dark' viewer.layers.select_all() viewer.layers.remove_selected() viewer.add_image(screenshot_zoomed, rgb=True, name='screenshot_zoomed') viewer.add_image(screenshot, rgb=True, name='screenshot') viewer.add_image(figure_no_outside_shape, rgb=True, name='figure_no_outside_shape') viewer.add_image(figure_zoomed, rgb=True, name='figure_zoomed') viewer.add_image(figure, rgb=True, name='figure') viewer.grid.enabled = True viewer.grid.shape = (2, 3) if __name__ == '__main__': napari.run() napari-0.5.6/examples/set_colormaps.py000066400000000000000000000016021474413133200200370ustar00rootroot00000000000000""" 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.6/examples/set_theme.py000066400000000000000000000005221474413133200171420ustar00rootroot00000000000000""" 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.6/examples/shapes_to_labels.py000066400000000000000000000043611474413133200205010ustar00rootroot00000000000000""" 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.6/examples/show_points_based_on_feature.py000066400000000000000000000016541474413133200231150ustar00rootroot00000000000000""" Show points based on feature ============================ .. tags:: visualization-advanced """ 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 visibility # 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.6/examples/spheres_.py000066400000000000000000000007511474413133200170010ustar00rootroot00000000000000""" 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.6/examples/spherical_points.py000066400000000000000000000011001474413133200205240ustar00rootroot00000000000000""" 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', border_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.6/examples/surface_multi_texture.py000066400000000000000000000132701474413133200216130ustar00rootroot00000000000000""" Surface with multiple textures ============================== This example demonstrates one possible method for displaying a 3D surface with multiple textures. Thanks to Emmanuel Reynaud and Luis Gutierrez for providing the gorgeous coral model for this demo. You can find the data on FigShare: https://zenodo.org/records/13380203 More information on the methods used to generate this model can be found in *L. Gutierrez-Heredia, C. Keogh, E. G. Reynaud, Assessing the Capabilities of Additive Manufacturing Technologies for Coral Studies, Education, and Monitoring. Front. Mar. Sci. 5 (2018), doi:10.3389/fmars.2018.00278.* A bit about 3D models --------------------- A standard way to define a 3D model (mesh, or Surface in napari) is by listing vertices (3D point coordinates) and faces (triplets of vertex indices - each face is a triangle in 3D space). Meshes are often stored in "Wavefront" (.obj) files, which may have companion material (.mtl) files that describe some shading properties (base color, shinyness, etc.) for different parts of the model. In some cases, the color of a vertex is given by a single point value that is then colormapped on the fly (`vertex_values`). In other cases, each vertex or face may be assigned a specific color (`vertex_colors`). These methods are demonstrated in :ref:`sphx_glr_gallery_surface_texture_and_colors.py`. In the case of "photorealistic" models, the color of each vertex is instead determined by mapping a vertex to a point in an image called a texture using 2D texture coordinates in the range [0, 1]. The color of each individual pixel is smoothly interpolated (sampled) on the fly from the texture (the GPU makes this interpolation very fast). Napari does not (yet) support models with multiple textures or materials. If the textures don't overlap, you can display them on separate meshes as shown in this demo. If the textures do overlap, you may instead be able to combine the textures as images. This relies on textures having the same texture coordinates, and may require resizing the textures to match each other. .. tags:: visualization-nD """ import os import matplotlib.pyplot as plt import pooch from vispy.io import imread, read_mesh import napari ############################################################################### # Download the model # ------------------ download = pooch.DOIDownloader(progressbar=True) doi = '10.5281/zenodo.13380203' tmp_dir = pooch.os_cache('napari-surface-texture-example') os.makedirs(tmp_dir, exist_ok=True) data_files = { 'mesh': 'PocilloporaDamicornisSkin.obj', # "materials": "PocilloporaVerrugosaSkinCrop.mtl", # not yet supported 'Texture_0': 'PocilloporaDamicornisSkin_Texture_0.jpg', 'GeneratedMat2': 'PocilloporaDamicornisSkin_GeneratedMat2.png', } print(f'downloading data into {tmp_dir}') for file_name in data_files.values(): if not (tmp_dir / file_name).exists(): print(f'downloading {file_name}') download( f'doi:{doi}/{file_name}', output_file=tmp_dir / file_name, pooch=None, ) else: print(f'using cached {tmp_dir / file_name}') ############################################################################### # Load the model # -------------- # Next, read the model data from the .obj file. Currently napari/vispy do not # support reading material properties (.mtl files) nor separate texture and # vertex indices (i.e. repeated vertices). Normal vectors read from the file # are also ignored and re-calculated from the faces. vertices, faces, _normals, texcoords = read_mesh(tmp_dir / data_files['mesh']) ############################################################################### # Load the textures # ----------------- # This model comes with two textures: `Texture_0` is generated from # photogrammetry of the actual object, and `GeneratedMat2` is a generated # material to fill in parts of the model lacking photographic texture. photo_texture = imread(tmp_dir / data_files['Texture_0']) generated_texture = imread(tmp_dir / data_files['GeneratedMat2']) ############################################################################### # This is what the texture images look like in 2D: fig, axs = plt.subplots(1, 2) axs[0].set_title(f'Texture_0 {photo_texture.shape}') axs[0].imshow(photo_texture) axs[0].set_xticks((0, photo_texture.shape[1]), labels=(0.0, 1.0)) axs[0].set_yticks((0, photo_texture.shape[0]), labels=(0.0, 1.0)) axs[1].set_title(f'GeneratedMat2 {generated_texture.shape}') axs[1].imshow(generated_texture) axs[1].set_xticks((0, generated_texture.shape[1]), labels=(0.0, 1.0)) axs[1].set_yticks((0, generated_texture.shape[0]), labels=(0.0, 1.0)) fig.show() ############################################################################### # Create the napari layers # ------------------------ # Next create two separate layers with the same mesh - once with each texture. # In this example the texture coordinates happen to be the same for each # texture, but this is not a strict requirement. photo_texture_layer = napari.layers.Surface( (vertices, faces), texture=photo_texture, texcoords=texcoords, name='Texture_0', ) generated_texture_layer = napari.layers.Surface( (vertices, faces), texture=generated_texture, texcoords=texcoords, name='GeneratedMat2', ) ############################################################################### # Add the layers to a viewer # -------------------------- # Finally, create the viewer and add the Surface layers. # sphinx_gallery_thumbnail_number = 2 viewer = napari.Viewer(ndisplay=3) viewer.add_layer(photo_texture_layer) viewer.add_layer(generated_texture_layer) viewer.camera.angles = (90.0, 0.0, -75.0) viewer.camera.zoom = 75 if __name__ == '__main__': napari.run() napari-0.5.6/examples/surface_normals_wireframe.py000066400000000000000000000012161474413133200224120ustar00rootroot00000000000000""" 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.6/examples/surface_texture_and_colors.py000066400000000000000000000042031474413133200226000ustar00rootroot00000000000000""" Surface with texture and vertex_colors ====================================== Display a 3D surface with both texture and color maps. This example demonstrates how surfaces may be colored by: * setting `vertex_values`, which colors the surface with the selected `colormap` * setting `vertex_colors`, which replaces/overrides any color from `vertex_values` * setting both `texture` and `texcoords`, which blends a the value from a texture (image) with the underlying color from `vertex_values` or `vertex_colors`. Blending is achieved by multiplying the texture color by the underlying color - an underlying value of "white" will result in the unaltered texture color. .. tags:: visualization-nD """ import numpy as np from vispy.io import imread, load_data_file, read_mesh import napari # load the model and texture mesh_path = load_data_file('spot/spot.obj.gz') vertices, faces, _normals, texcoords = read_mesh(mesh_path) n = len(vertices) texture_path = load_data_file('spot/spot.png') texture = imread(texture_path) flat_spot = napari.layers.Surface( (vertices, faces), translate=(1, 0, 0), texture=texture, texcoords=texcoords, shading='flat', name='texture only', ) np.random.seed(0) plasma_spot = napari.layers.Surface( (vertices, faces, np.random.random((3, 3, n))), texture=texture, texcoords=texcoords, colormap='plasma', shading='smooth', name='vertex_values and texture', ) rainbow_spot = napari.layers.Surface( (vertices, faces), translate=(-1, 0, 0), texture=texture, texcoords=texcoords, # the vertices are _roughly_ in [-1, 1] for this model and RGB values just # get clipped to [0, 1], adding 0.5 brightens it up a little :) vertex_colors=vertices + 0.5, shading='none', name='vertex_colors and texture', ) # create the viewer and window viewer = napari.Viewer(ndisplay=3) viewer.add_layer(flat_spot) viewer.add_layer(plasma_spot) viewer.add_layer(rainbow_spot) viewer.camera.center = (0.0, 0.0, 0.0) viewer.camera.angles = (25.0, -50.0, -125.0) viewer.camera.zoom = 150 if __name__ == '__main__': napari.run() napari-0.5.6/examples/surface_timeseries_.py000066400000000000000000000041721474413133200212120ustar00rootroot00000000000000""" Surface timeseries ================== Display a surface timeseries using data from nilearn .. tags:: experimental """ from importlib.metadata import version try: from packaging.version import parse except ModuleNotFoundError: raise ModuleNotFoundError( "You must have packaging installed to run this example. " "For that you will need to run, depending on your package manager, " "something like 'pip install packaging' or 'conda install packaging'" ) from None if parse(version("numpy")) >= parse('1.24') and parse(version("nilearn")) < parse('0.10.1'): raise RuntimeError( 'Incompatible numpy version. ' 'You must have numpy less than 1.24 for nilearn 0.10.1 and below to ' 'work and download the example data' ) try: from nilearn import datasets, surface except ModuleNotFoundError: raise ModuleNotFoundError( "You must have nilearn installed to run this example. " "For that you will need to run, depending on your package manager, " "something like 'pip install nilearn' or 'conda install nilearn'" ) 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.6/examples/swap_dims.py000066400000000000000000000014161474413133200171560ustar00rootroot00000000000000""" 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=10, face_color='blue', out_of_slice_display=True ) viewer.dims.order = (0, 2, 1, 3) if __name__ == '__main__': napari.run() napari-0.5.6/examples/tiled-rendering-2d_.py000066400000000000000000000025551474413133200207130ustar00rootroot00000000000000""" 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 import napari 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.6/examples/to_screenshot.py000066400000000000000000000071601474413133200200510ustar00rootroot00000000000000""" To screenshot ============= Display a variety of layer types in the napari viewer and take a screenshot of the viewer canvas with `viewer.screenshot()`. The screenshot is then added back as an image layer. Screenshots include all visible layers, bounded by the extent of the canvas, and is functional for 2D and 3D views. To capture the extent of all data in 2D view, see `viewer.export_figure()`: :ref:`sphx_glr_gallery_export_figure.py` and :ref:`sphx_glr_gallery_screenshot_and_export_figure.py`. This example code demonstrates screenshot shortcuts that do not include the viewer (e.g. `File` -> `Copy Screenshot to Clipboard`). To include the napari viewer in the screenshot, use `viewer.screenshot(canvas_only=False)` or e.g. `File` -> `Copy Screenshot with Viewer to Clipboard`). .. 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() # optionally for saving the exported screenshot: viewer.screenshot(path="screenshot.png") viewer.add_image(screenshot, rgb=True, name='screenshot') if __name__ == '__main__': napari.run() napari-0.5.6/examples/tracks_2d.py000066400000000000000000000025421474413133200170450ustar00rootroot00000000000000""" 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.6/examples/tracks_3d.py000066400000000000000000000035461474413133200170530ustar00rootroot00000000000000""" 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.6/examples/tracks_3d_with_graph.py000066400000000000000000000024471474413133200212660ustar00rootroot00000000000000""" 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.6/examples/update_console.py000066400000000000000000000030411474413133200201700ustar00rootroot00000000000000""" Update console ============== Display one shapes layer on top of one image layer using the add_shapes and add_image APIs. .. 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 = viewer.add_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.6/examples/viewer_fps_label.py000066400000000000000000000011221474413133200204720ustar00rootroot00000000000000""" 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 # note: this is using a private attribute, so it might break # without warning in future versions! viewer.window._qt_viewer.canvas._scene_canvas.measure_fps(callback=update_fps) if __name__ == '__main__': napari.run() napari-0.5.6/examples/viewer_loop_reproducible_screenshots.md000066400000000000000000000062011474413133200246460ustar00rootroot00000000000000--- 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.0) 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.6/examples/vortex.py000066400000000000000000000042501474413133200165160ustar00rootroot00000000000000""" Visualizing optical flow in napari ================================== Adapted from the scikit-image gallery [1]_. In napari, we can show the flowing vortex as an additional dimension in the image, visible by moving the slider. .. tags:: visualization-advanced, layers .. [1] https://scikit-image.org/docs/stable/auto_examples/registration/plot_opticalflow.html """ import numpy as np from skimage.data import vortex from skimage.registration import optical_flow_ilk import napari ####################################################################### # First, we load the vortex image as a 3D array. (time, row, column) vortex_im = np.asarray(vortex()) ####################################################################### # We compute the optical flow using scikit-image. (Note: as of # scikit-image 0.21, there seems to be a transposition of the image in # the output, which we account for later.) u, v = optical_flow_ilk(vortex_im[0], vortex_im[1], radius=15) ####################################################################### # Compute the flow magnitude, for visualization. magnitude = np.sqrt(u ** 2 + v ** 2) ####################################################################### # We subsample the vector field to display it — it's too # messy otherwise! And we transpose the rows/columns axes to match the # current scikit-image output. nvec = 21 nr, nc = magnitude.shape step = max(nr//nvec, nc//nvec) offset = step // 2 usub = u[offset::step, offset::step] vsub = v[offset::step, offset::step] vectors_field = np.transpose( # transpose required — skimage bug? np.stack([usub, vsub], axis=-1), (1, 0, 2), ) ####################################################################### # Finally, we create a viewer, and add the vortex frames, the flow # magnitude, and the vector field. viewer, vortex_layer = napari.imshow(vortex_im) mag_layer = viewer.add_image(magnitude, colormap='magma', opacity=0.3) flow_layer = viewer.add_vectors( vectors_field, name='optical flow', scale=[step, step], translate=[offset, offset], edge_width=0.3, length=0.3, ) if __name__ == '__main__': napari.run() napari-0.5.6/examples/without_gui_qt.py000066400000000000000000000023331474413133200202420ustar00rootroot00000000000000""" 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.6/examples/xarray_nD_image_.py000066400000000000000000000011121474413133200204110ustar00rootroot00000000000000""" 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.6/examples/zarr_nD_image_.py000066400000000000000000000012241474413133200200650ustar00rootroot00000000000000""" 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.6/napari/000077500000000000000000000000001474413133200142505ustar00rootroot00000000000000napari-0.5.6/napari/__init__.py000066400000000000000000000033171474413133200163650ustar00rootroot00000000000000import os from lazy_loader import attach as _attach from napari._check_numpy_version import limit_numpy1x_threads_on_macos_arm 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') limit_numpy1x_threads_on_macos_arm() del limit_numpy1x_threads_on_macos_arm 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__ = _attach( __name__, submodules=_proto_all_, submod_attrs=_submod_attrs ) del _attach napari-0.5.6/napari/__init__.pyi000066400000000000000000000013251474413133200165330ustar00rootroot00000000000000import napari.utils.notifications from napari._qt.qt_event_loop import gui_qt, run from napari.plugins.io import save_layers from napari.view_layers import ( view_image, view_labels, view_path, view_points, view_shapes, view_surface, view_tracks, view_vectors, ) from napari.viewer import Viewer, current_viewer __version__: str notification_manager: napari.utils.notifications.NotificationManager __all__ = ( 'Viewer', '__version__', 'current_viewer', 'gui_qt', 'notification_manager', 'run', 'save_layers', 'view_image', 'view_labels', 'view_path', 'view_points', 'view_shapes', 'view_surface', 'view_tracks', 'view_vectors', ) napari-0.5.6/napari/__main__.py000066400000000000000000000510161474413133200163450ustar00rootroot00000000000000""" 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 from napari.errors import ReaderPluginError 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()) # noqa: T201 print('Plugins:') # noqa: T201 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) # noqa: T201 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, raw_arg in enumerate(unknown): if not raw_arg.startswith('--'): continue arg = raw_arg.lstrip('-') key, *values = arg.split('=', maxsplit=1) key = key.replace('-', '_') if key not in valid: sys.exit(f'error: unrecognized argument: {raw_arg}') if values: value = values[0] else: if len(unknown) <= i + 1 or unknown[i + 1].startswith('--'): sys.exit(f'error: argument {raw_arg} expected one argument') value = unknown[i + 1] 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() -> None: 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 : %(threadName)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 warning 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, (w_pname, wnames) in _npe2.widget_iterator(): if name == 'dock' and pname == w_pname: npe2_plugins.append(plugin) if '__all__' in wnames: wnames = wnames break for name2, ( w_pname, wnames_dict, ) in plugin_manager.iter_widgets(): if name2 == 'dock' and pname == w_pname: plugin_manager_plugins.append(plugin) if '__all__' in wnames: # Plugin_manager iter_widgets return wnames as dict keys wnames = list(wnames_dict) warnings.warn( trans._( 'Non-npe2 plugin {pname} detected. Disable tabify for this plugin.', deferred=True, pname=pname, ), RuntimeWarning, stacklevel=3, ) 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 try: viewer._window._qt_viewer._qt_open( args.paths, stack=args.stack, plugin=args.plugin, layer_type=args.layer_type, **kwargs, ) except ReaderPluginError: logging.exception( 'Loading %s with %s failed with errors', args.paths, args.plugin, ) 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_collection) in chain( _npe2.widget_iterator(), plugin_manager.iter_widgets() ): if name == 'dock' and pname == _pname: if isinstance(wnames_collection, dict): # Plugin_manager iter_widgets return wnames as dict keys wnames = list(wnames_collection.keys()) else: wnames = wnames_collection 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_constructor_app, ) if running_as_constructor_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' or 'pdb' in sys.modules or 'pydevd' in sys.modules ): 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, stacklevel=2) # 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'] # this fixes issues running from a venv/virtualenv based virtual # environment with certain python distributions (e.g. pyenv, asdf) env['PYTHONEXECUTABLE'] = sys.executable # 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.6/napari/_app_model/000077500000000000000000000000001474413133200163475ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/__init__.py000066400000000000000000000001421474413133200204550ustar00rootroot00000000000000from napari._app_model._app import get_app, get_app_model __all__ = ['get_app', 'get_app_model'] napari-0.5.6/napari/_app_model/_app.py000066400000000000000000000051161474413133200176430ustar00rootroot00000000000000from __future__ import annotations from functools import lru_cache from warnings import warn from app_model import Application from napari._app_model.actions._layerlist_context_actions import ( LAYERLIST_CONTEXT_ACTIONS, LAYERLIST_CONTEXT_SUBMENUS, ) from napari.utils.translations import trans 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.register_actions(LAYERLIST_CONTEXT_ACTIONS) self.menus.append_menu_items(LAYERLIST_CONTEXT_SUBMENUS) @classmethod def get_app_model(cls, app_name: str = APP_NAME) -> NapariApplication: return Application.get_app(app_name) or cls() # type: ignore[return-value] @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), } # TODO: Remove in 0.6.0 def get_app() -> NapariApplication: """Get the Napari Application singleton. Now deprecated, use `get_app_model`.""" warn( trans._( '`NapariApplication` instance access through `get_app` is deprecated and will be removed in 0.6.0.\n' 'Please use `get_app_model` instead.\n', deferred=True, ), category=FutureWarning, stacklevel=2, ) return get_app_model() def get_app_model() -> NapariApplication: """Get the Napari Application singleton.""" return NapariApplication.get_app_model() napari-0.5.6/napari/_app_model/_tests/000077500000000000000000000000001474413133200176505ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/_tests/test_app.py000066400000000000000000000021151474413133200220400ustar00rootroot00000000000000import pytest from napari._app_model import get_app, get_app_model from napari.layers import Points def test_app(mock_app_model): """just make sure our app model is registering menus and commands""" app = get_app_model() assert app.name == 'test_app' assert list(app.menus) assert list(app.commands) # assert list(app.keybindings) # don't have any yet with pytest.warns( FutureWarning, match='`NapariApplication` instance access through `get_app` is deprecated and will be removed in 0.6.0.\nPlease use `get_app_model` instead.\n', ): deprecated_app = get_app() assert app == deprecated_app def test_app_injection(mock_app_model): """Simple test to make sure napari namespaces are working in app injection.""" app = get_app_model() 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.6/napari/_app_model/_tests/test_constants.py000066400000000000000000000002741474413133200233000ustar00rootroot00000000000000from napari._app_model.constants import MenuId def test_menus(): """make sure all menus start with napari/""" for menu in MenuId: assert menu.value.startswith('napari/') napari-0.5.6/napari/_app_model/_tests/test_context.py000066400000000000000000000025461474413133200227540ustar00rootroot00000000000000from unittest.mock import Mock import pytest from napari._app_model.context._context import ( ContextMapping, create_context, get_context, ) def test_simple_mapping(): data = {'a': 1, 'b': 2} mapping = ContextMapping(data) assert mapping['a'] == 1 assert mapping['b'] == 2 assert 'a' in mapping assert list(mapping) == ['a', 'b'] assert len(mapping) == 2 def test_missed_key(): data = {'a': 1, 'b': 2} mapping = ContextMapping(data) with pytest.raises(KeyError): mapping['c'] def test_callable_value(): data = {'a': 1, 'b': Mock(return_value=2)} mapping = ContextMapping(data) assert mapping['a'] == 1 assert mapping['b'] == 2 assert mapping['b'] == 2 # it is important to use [] twice in this test data['b'].assert_called_once() def test_context_integration(): obj = {1, 2, 3} ctx = create_context(obj) ctx['a'] = 1 ctx['b'] = Mock(return_value=2) assert isinstance(get_context(obj), ContextMapping) mapping = get_context(obj) assert mapping['a'] == 1 assert mapping['b'] == 2 assert mapping['b'] == 2 # it is important to use [] twice in this test ctx['b'].assert_called_once() mapping2 = get_context(obj) assert mapping2['b'] == 2 assert ctx['b'].call_count == 2 assert mapping2['b'] == 2 assert ctx['b'].call_count == 2 napari-0.5.6/napari/_app_model/actions/000077500000000000000000000000001474413133200200075ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/actions/__init__.py000066400000000000000000000000001474413133200221060ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/actions/_layerlist_context_actions.py000066400000000000000000000202201474413133200260100ustar00rootroot00000000000000"""This module defines actions (functions) that operate on layers and its submenus. 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 from app_model.types import Action, SubmenuItem from napari._app_model.constants import MenuGroup, MenuId from napari._app_model.context import LayerListSelectionContextKeys as LLSCK from napari.layers import _layer_actions from napari.utils.translations import trans if TYPE_CHECKING: from app_model.types import MenuRuleDict # Layer submenus LAYERLIST_CONTEXT_SUBMENUS = [ ( MenuId.LAYERLIST_CONTEXT, SubmenuItem( submenu=MenuId.LAYERS_CONTEXT_CONVERT_DTYPE, title=trans._('Convert data type'), group=MenuGroup.LAYERLIST_CONTEXT.CONVERSION, order=None, enablement=LLSCK.all_selected_layers_labels, ), ), ( MenuId.LAYERLIST_CONTEXT, SubmenuItem( submenu=MenuId.LAYERS_CONTEXT_PROJECT, title=trans._('Projections'), group=MenuGroup.LAYERLIST_CONTEXT.SPLIT_MERGE, order=None, enablement=LLSCK.active_layer_is_image_3d, ), ), ( MenuId.LAYERLIST_CONTEXT, SubmenuItem( submenu=MenuId.LAYERS_CONTEXT_COPY_SPATIAL, title=trans._('Copy scale and transforms'), group=MenuGroup.LAYERLIST_CONTEXT.COPY_SPATIAL, order=None, enablement=(LLSCK.num_selected_layers == 1), ), ), ] # 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. LAYERLIST_CONTEXT_ACTIONS: list[Action] = [ Action( id='napari.layer.duplicate', title=trans._('Duplicate Layer'), callback=_layer_actions._duplicate_layer, menus=[LAYERCTX_SPLITMERGE], ), Action( id='napari.layer.split_stack', title=trans._('Split Stack'), callback=_layer_actions._split_stack, menus=[{**LAYERCTX_SPLITMERGE, 'when': ~LLSCK.active_layer_is_rgb}], enablement=LLSCK.active_layer_is_image_3d, ), Action( id='napari.layer.split_rgb', title=trans._('Split RGB'), callback=_layer_actions._split_rgb, menus=[{**LAYERCTX_SPLITMERGE, 'when': LLSCK.active_layer_is_rgb}], enablement=LLSCK.active_layer_is_rgb, ), Action( id='napari.layer.merge_rgb', title=trans._('Merge to RGB'), callback=partial(_layer_actions._merge_stack, rgb=True), enablement=( (LLSCK.num_selected_layers == 3) & (LLSCK.num_selected_image_layers == LLSCK.num_selected_layers) & LLSCK.all_selected_layers_same_shape ), menus=[LAYERCTX_SPLITMERGE], ), Action( id='napari.layer.convert_to_labels', title=trans._('Convert to Labels'), callback=_layer_actions._convert_to_labels, enablement=( ( (LLSCK.num_selected_image_layers >= 1) | (LLSCK.num_selected_shapes_layers >= 1) ) & LLSCK.all_selected_layers_same_type & ~LLSCK.selected_empty_shapes_layer ), menus=[LAYERCTX_CONVERSION], ), Action( id='napari.layer.convert_to_image', title=trans._('Convert to Image'), callback=_layer_actions._convert_to_image, enablement=( (LLSCK.num_selected_labels_layers >= 1) & LLSCK.all_selected_layers_same_type ), menus=[LAYERCTX_CONVERSION], ), Action( id='napari.layer.merge_stack', title=trans._('Merge to Stack'), callback=_layer_actions._merge_stack, enablement=( (LLSCK.num_selected_layers > 1) & (LLSCK.num_selected_image_layers == LLSCK.num_selected_layers) & LLSCK.all_selected_layers_same_shape ), menus=[LAYERCTX_SPLITMERGE], ), Action( id='napari.layer.toggle_visibility', title=trans._('Toggle visibility'), callback=_layer_actions._toggle_visibility, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.layer.link_selected_layers', title=trans._('Link Layers'), callback=_layer_actions._link_selected_layers, enablement=( (LLSCK.num_selected_layers > 1) & ~LLSCK.num_selected_layers_linked ), menus=[{**LAYERCTX_LINK, 'when': ~LLSCK.num_selected_layers_linked}], ), Action( id='napari.layer.unlink_selected_layers', title=trans._('Unlink Layers'), callback=_layer_actions._unlink_selected_layers, enablement=LLSCK.num_selected_layers_linked, menus=[{**LAYERCTX_LINK, 'when': LLSCK.num_selected_layers_linked}], ), Action( id='napari.layer.select_linked_layers', title=trans._('Select Linked Layers'), callback=_layer_actions._select_linked_layers, enablement=LLSCK.num_unselected_linked_layers, menus=[LAYERCTX_LINK], ), Action( id='napari.layer.show_selected', title=trans._('Show All Selected Layers'), callback=_layer_actions._show_selected, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.layer.hide_selected', title=trans._('Hide All Selected Layers'), callback=_layer_actions._hide_selected, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.layer.show_unselected', title=trans._('Show All Unselected Layers'), callback=_layer_actions._show_unselected, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.layer.hide_unselected', title=trans._('Hide All Unselected Layers'), callback=_layer_actions._hide_unselected, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), ] for _dtype in ( 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64', ): LAYERLIST_CONTEXT_ACTIONS.append( Action( id=f'napari.layer.convert_to_{_dtype}', title=trans._('Convert to {dtype}', dtype=_dtype), callback=partial(_layer_actions._convert_dtype, mode=_dtype), enablement=( LLSCK.all_selected_layers_labels & (LLSCK.active_layer_dtype != _dtype) ), menus=[{'id': MenuId.LAYERS_CONTEXT_CONVERT_DTYPE}], ) ) for mode in ('max', 'min', 'std', 'sum', 'mean', 'median'): LAYERLIST_CONTEXT_ACTIONS.append( Action( id=f'napari.layer.project_{mode}', title=trans._('{mode} projection', mode=mode), callback=partial(_layer_actions._project, mode=mode), enablement=LLSCK.active_layer_is_image_3d, menus=[{'id': MenuId.LAYERS_CONTEXT_PROJECT}], ) ) napari-0.5.6/napari/_app_model/constants/000077500000000000000000000000001474413133200203635ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/constants/__init__.py000066400000000000000000000001441474413133200224730ustar00rootroot00000000000000from napari._app_model.constants._menus import MenuGroup, MenuId __all__ = ['MenuGroup', 'MenuId'] napari-0.5.6/napari/_app_model/constants/_menus.py000066400000000000000000000100721474413133200222230ustar00rootroot00000000000000"""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 napari.utils.compat import StrEnum class MenuId(StrEnum): """Id representing a menu somewhere in napari.""" MENUBAR_FILE = 'napari/file' FILE_OPEN_WITH_PLUGIN = 'napari/file/open_with_plugin' FILE_SAMPLES = 'napari/file/samples' FILE_NEW_LAYER = 'napari/file/new_layer' FILE_IO_UTILITIES = 'napari/file/io_utilities' FILE_ACQUIRE = 'napari/file/acquire' MENUBAR_VIEW = 'napari/view' VIEW_AXES = 'napari/view/axes' VIEW_SCALEBAR = 'napari/view/scalebar' MENUBAR_LAYERS = 'napari/layers' LAYERS_VISUALIZE = 'napari/layers/visualize' LAYERS_ANNOTATE = 'napari/layers/annotate' LAYERS_DATA = 'napari/layers/data' LAYERS_LAYER_TYPE = 'napari/layers/layer_type' LAYERS_TRANSFORM = 'napari/layers/transform' LAYERS_MEASURE = 'napari/layers/measure' LAYERS_FILTER = 'napari/layers/filter' LAYERS_REGISTER = 'napari/layers/register' LAYERS_PROJECT = 'napari/layers/project' LAYERS_SEGMENT = 'napari/layers/segment' LAYERS_TRACK = 'napari/layers/track' LAYERS_CLASSIFY = 'napari/layers/classify' MENUBAR_WINDOW = 'napari/window' MENUBAR_PLUGINS = 'napari/plugins' MENUBAR_HELP = 'napari/help' MENUBAR_DEBUG = 'napari/debug' DEBUG_PERFORMANCE = 'napari/debug/performance_trace' LAYERLIST_CONTEXT = 'napari/layers/context' LAYERS_CONTEXT_CONVERT_DTYPE = 'napari/layers/context/convert_dtype' LAYERS_CONTEXT_PROJECT = 'napari/layers/contxt/project' LAYERS_CONTEXT_COPY_SPATIAL = 'napari/layers/context/copy_spatial' def __str__(self) -> str: return self.value @classmethod def contributables(cls) -> set['MenuId']: """Set of all menu ids that can be contributed to by plugins.""" # TODO: add these to docs, with a lookup for what each menu is/does. _contributables = { cls.FILE_IO_UTILITIES, cls.FILE_ACQUIRE, cls.FILE_NEW_LAYER, cls.LAYERS_VISUALIZE, cls.LAYERS_ANNOTATE, cls.LAYERS_DATA, cls.LAYERS_LAYER_TYPE, cls.LAYERS_FILTER, cls.LAYERS_TRANSFORM, cls.LAYERS_MEASURE, cls.LAYERS_REGISTER, cls.LAYERS_PROJECT, cls.LAYERS_SEGMENT, cls.LAYERS_TRACK, cls.LAYERS_CLASSIFY, } return _contributables # 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' # View menu ZOOM = 'zoom' # Plugins menubar PLUGINS = '1_plugins' PLUGIN_MULTI_SUBMENU = '2_plugin_multi_submenu' PLUGIN_SINGLE_CONTRIBUTIONS = '3_plugin_contributions' # File menubar OPEN = '1_open' UTIL = '2_util' PREFERENCES = '3_preferences' SAVE = '4_save' CLOSE = '5_close' class LAYERLIST_CONTEXT: CONVERSION = '1_conversion' COPY_SPATIAL = '4_copy_spatial' SPLIT_MERGE = '5_split_merge' LINK = '9_link' class LAYERS: CONVERT = '1_convert' GEOMETRY = '2_geometry' GENERATE = '3_generate' 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 MenuId.contributables() if menu_id.startswith('napari/') # TODO: this is intended to allow plugins to contribute to other plugins' menus but we # need to perform a more thorough check (probably not here though) else True ) napari-0.5.6/napari/_app_model/context/000077500000000000000000000000001474413133200200335ustar00rootroot00000000000000napari-0.5.6/napari/_app_model/context/__init__.py000066400000000000000000000005471474413133200221520ustar00rootroot00000000000000from napari._app_model.context._context import ( Context, create_context, get_context, ) from napari._app_model.context._layerlist_context import ( LayerListContextKeys, LayerListSelectionContextKeys, ) __all__ = [ 'Context', 'LayerListContextKeys', 'LayerListSelectionContextKeys', 'create_context', 'get_context', ] napari-0.5.6/napari/_app_model/context/_context.py000066400000000000000000000103241474413133200222300ustar00rootroot00000000000000from __future__ import annotations import collections.abc from typing import TYPE_CHECKING, Any, Final, Optional from app_model.expressions import ( Context, create_context as _create_context, get_context as _get_context, ) from napari.utils.translations import trans if TYPE_CHECKING: from napari.utils.events import Event __all__ = ['Context', 'SettingsAwareContext', 'create_context', 'get_context'] class ContextMapping(collections.abc.Mapping): """Wrap app-model contexts, allowing keys to be evaluated at query time. `ContextMapping` objects are created from a context any time someone calls `NapariApplication.get_context`. This usually happens just before a menu is about to be shown, when we update the menu's actions' states based on the values of the context keys. The call to `get_context` triggers the creation of the `ContextMapping` which stores (or, in the case of functional keys, evaluates then stores) the value of each context key. Once keys are evaluated, they are cached within the object for future accessing of the same keys. However, any new `get_context` calls will create a brand new `ContextMapping` object. """ def __init__(self, initial_values: collections.abc.Mapping): self._initial_context_mapping = initial_values self._evaluated_context_mapping: dict[str, Any] = {} def __getitem__(self, key): if key in self._evaluated_context_mapping: return self._evaluated_context_mapping[key] if key not in self._initial_context_mapping: raise KeyError(f'Key {key!r} not found') value = self._initial_context_mapping[key] if callable(value): value = value() self._evaluated_context_mapping[key] = value return value def __contains__(self, item): return item in self._initial_context_mapping def __len__(self): return len(self._initial_context_mapping) def __iter__(self): return iter(self._initial_context_mapping) 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, ) -> Context: return _create_context( obj=obj, max_depth=max_depth, start=start, root=root, root_class=SettingsAwareContext, ) def get_context(obj: object) -> ContextMapping: return ContextMapping(_get_context(obj)) napari-0.5.6/napari/_app_model/context/_context_keys.py000066400000000000000000000011411474413133200232600ustar00rootroot00000000000000from 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.6/napari/_app_model/context/_layerlist_context.py000066400000000000000000000207141474413133200243240ustar00rootroot00000000000000from __future__ import annotations import contextlib from functools import partial from typing import TYPE_CHECKING, Callable, Optional, Union from weakref import ref 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 weakref import ReferenceType from numpy.typing import DTypeLike from napari.components.layerlist import LayerList from napari.layers import Layer from napari.utils.events import Selection LayerSel = Selection[Layer] def _len(layers: Union[LayerSel, LayerList]) -> int: return len(layers) class LayerListContextKeys(ContextNamespace['Layer']): """These are the available context keys relating to a LayerList. Consists of a default value, a description, and a function to retrieve the current value from `layers`. """ num_layers = ContextKey( 0, trans._('Number of layers.'), _len, ) 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 true when all given layers have the same shape. Notes ----- The cast to tuple() is needed because some array libraries, specifically Apple's mlx [1]_, return a list, which is not hashable and thus causes the set (``{}``) to fail. The Data APIs Array spec specifies that ``.shape`` should be a tuple, or, if a custom type, it should be an immutable type [2]_, so in time, the cast to tuple could be removed, once all major libraries support the spec. References ---------- .. [1] https://github.com/ml-explore/mlx .. [2] https://data-apis.org/array-api/latest/API_specification/generated/array_api.array.shape.html """ return len({tuple(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: _activ_ndim = _active_ndim(s) return ( _active_type(s) == 'image' and _activ_ndim is not None and (_activ_ndim > 3 or ((_activ_ndim) > 2 and not _is_rgb(s))) ) def _shapes_selection_check(s: ReferenceType[LayerSel]) -> bool: s_ = s() if s_ is None: return False return any(x._type_string == 'shapes' and not len(x.data) for x in s_) def _empty_shapes_layer_selected(s: LayerSel) -> Callable[[], bool]: check_fun = partial(_shapes_selection_check, ref(s)) return check_fun class LayerListSelectionContextKeys(ContextNamespace['LayerSel']): """Available context keys relating to the selection in a LayerList. Consists of a 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, ) selected_empty_shapes_layer = ContextKey( False, trans._('True when there is a shapes layer without data selected.'), _empty_shapes_layer_selected, ) napari-0.5.6/napari/_app_model/utils.py000066400000000000000000000061001474413133200200560ustar00rootroot00000000000000from typing import Union from app_model.expressions import parse_expression from app_model.types import Action, MenuItem, SubmenuItem from napari._app_model import get_app_model from napari._app_model.constants import MenuGroup, MenuId MenuOrSubmenu = Union[MenuItem, SubmenuItem] def to_id_key(menu_path: str) -> str: """Return final part of the menu path. Parameters ---------- menu_path : str full string delineating the menu path Returns ------- str final part of the menu path """ return menu_path.split('/')[-1] def to_action_id(id_key: str) -> str: """Return dummy action ID for the given id_key. Parameters ---------- id_key : str key to use in action ID Returns ------- str dummy action ID """ return f'napari.{id_key}.empty_dummy' def contains_dummy_action(menu_items: list[MenuOrSubmenu]) -> bool: """True if one of the menu_items is the dummy action, otherwise False. Parameters ---------- menu_items : list[MenuOrSubmenu] menu items belonging to a given menu Returns ------- bool True if menu_items contains dummy item otherwise false """ for item in menu_items: if hasattr(item, 'command') and 'empty_dummy' in item.command.id: return True return False def is_empty_menu(menu_id: str) -> bool: """Return True if the given menu_id is empty, otherwise False Parameters ---------- menu_id : str id of the menu to check Returns ------- bool True if the given menu_id is empty, otherwise False """ app = get_app_model() if menu_id not in app.menus: return True if len(app.menus.get_menu(menu_id)) == 0: return True return bool( len(app.menus.get_menu(menu_id)) == 1 and contains_dummy_action(app.menus.get_menu(menu_id)) ) def no_op() -> None: """Fully qualified no-op to use for dummy actions.""" def get_dummy_action(menu_id: MenuId) -> tuple[Action, str]: """Return a dummy action to be used for the given menu. The part of the menu_id after the final `/` will form a unique id_key used for the action ID and the when expression context key. Parameters ---------- menu_id: MenuId id of the menu to add the dummy action to Returns ------- tuple[Action, str] dummy action and the `when` expression context key """ # NOTE: this assumes the final word of each contributable # menu path is unique, otherwise, we will clash. Once we # move to using short menu keys, the key itself will be used # here and this will no longer be a concern. id_key = to_id_key(menu_id) action = Action( id=to_action_id(id_key), title='Empty', callback=no_op, menus=[ { 'id': menu_id, 'group': MenuGroup.NAVIGATION, 'when': parse_expression(context_key := f'{id_key}_empty'), } ], enablement=False, ) return action, context_key napari-0.5.6/napari/_check_numpy_version.py000066400000000000000000000065711474413133200210440ustar00rootroot00000000000000""" This module is used to prevent a known issue with numpy<2 on macOS arm64 architecture installed from pypi wheels (https://github.com/numpy/numpy/issues/21799). We use a method to set thread limits based on the threadpoolctl package, but reimplemented locally to prevent adding the dependency to napari. Note: if any issues surface with the method below, we could fall back on depending on threadpoolctl directly. TODO: This module can be removed once the minimum numpy version is 2+. """ import ctypes import logging import os import platform from pathlib import Path import numpy as np from packaging.version import parse as parse_version # if run with numpy<2 on macOS arm64 architecture compiled from pypi wheels, # then it will crash with bus error if numpy is used in different thread # Issue reported https://github.com/numpy/numpy/issues/21799 if ( parse_version(np.__version__) < parse_version('2') and platform.system() == 'Darwin' and platform.machine() == 'arm64' ): # pragma: no cover try: NUMPY_VERSION_IS_THREADSAFE = ( 'cibw-run' not in np.show_config('dicts')['Python Information']['path'] # type: ignore ) except (KeyError, TypeError): NUMPY_VERSION_IS_THREADSAFE = True else: NUMPY_VERSION_IS_THREADSAFE = True def limit_numpy1x_threads_on_macos_arm() -> ( None ): # pragma: no cover (macos only code) """Set openblas to use single thread on macOS arm64 to prevent numpy crash. On NumPy version<2 wheels on macOS ARM64 architectures, a BusError is raised, crashing Python, if NumPy is accessed from multiple threads. (See https://github.com/numpy/numpy/issues/21799.) This function uses the global check above (NUMPY_VERSION_IS_THREADSAFE), and, if False, it loads the linked OpenBLAS library and sets the number of threads to 1. This has performance implications but prevents nasty crashes, and anyway can be avoided by using more recent versions of NumPy. This function is loading openblas library from numpy and set number of threads to 1. See also: https://github.com/OpenMathLib/OpenBLAS/wiki/faq#how-can-i-use-openblas-in-multi-threaded-applications These changes seem to be sufficient to prevent the crashes. """ if NUMPY_VERSION_IS_THREADSAFE: return # find openblas library numpy_dir = Path(np.__file__).parent if not (numpy_dir / '.dylibs').exists(): logging.warning( 'numpy .dylibs directory not found during try to prevent numpy crash' ) # Recent numpy versions are built with cibuildwheel. # Internally, it uses delocate, which stores the openblas # library in the .dylibs directory. # Since we only patch numpy<2, we can just search for the libopenblas # dynamic library at this location. blas_lib = list((numpy_dir / '.dylibs').glob('libopenblas*.dylib')) if not blas_lib: logging.warning( 'libopenblas not found during try to prevent numpy crash' ) return blas = ctypes.CDLL(str(blas_lib[0]), mode=os.RTLD_NOLOAD) for suffix in ('', '64_', '_64'): openblas_set_num_threads = getattr( blas, f'openblas_set_num_threads{suffix}', None ) if openblas_set_num_threads is not None: openblas_set_num_threads(1) break else: logging.warning('openblas_set_num_threads not found') napari-0.5.6/napari/_event_loop.py000066400000000000000000000005621474413133200171360ustar00rootroot00000000000000try: 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( *, force=False, gui_exceptions=False, max_loop_level=1, _func_name='run', ): raise exc napari-0.5.6/napari/_pydantic_compat.py000066400000000000000000000042051474413133200201400ustar00rootroot00000000000000try: # pydantic v2 from pydantic.v1 import ( BaseModel, BaseSettings, Extra, Field, PositiveInt, PrivateAttr, ValidationError, color, conlist, constr, errors, main, parse_obj_as, root_validator, types, utils, validator, ) from pydantic.v1.env_settings import ( EnvSettingsSource, SettingsError, SettingsSourceCallable, ) from pydantic.v1.error_wrappers import ErrorWrapper, display_errors from pydantic.v1.fields import SHAPE_LIST, ModelField from pydantic.v1.generics import GenericModel from pydantic.v1.main import ClassAttribute, ModelMetaclass from pydantic.v1.utils import ROOT_KEY, sequence_like except ImportError: # pydantic v1 from pydantic import ( BaseModel, BaseSettings, Extra, Field, PositiveInt, PrivateAttr, ValidationError, color, conlist, constr, errors, main, parse_obj_as, root_validator, types, utils, validator, ) from pydantic.env_settings import ( EnvSettingsSource, SettingsError, SettingsSourceCallable, ) from pydantic.error_wrappers import ErrorWrapper, display_errors from pydantic.fields import SHAPE_LIST, ModelField from pydantic.generics import GenericModel from pydantic.main import ClassAttribute, ModelMetaclass from pydantic.utils import ROOT_KEY, sequence_like Color = color.Color __all__ = ( 'ROOT_KEY', 'SHAPE_LIST', 'BaseModel', 'BaseSettings', 'ClassAttribute', 'Color', 'EnvSettingsSource', 'ErrorWrapper', 'Extra', 'Field', 'GenericModel', 'ModelField', 'ModelMetaclass', 'PositiveInt', 'PrivateAttr', 'SettingsError', 'SettingsSourceCallable', 'ValidationError', 'color', 'conlist', 'constr', 'display_errors', 'errors', 'main', 'parse_obj_as', 'root_validator', 'sequence_like', 'types', 'utils', 'validator', ) napari-0.5.6/napari/_qt/000077500000000000000000000000001474413133200150335ustar00rootroot00000000000000napari-0.5.6/napari/_qt/__init__.py000066400000000000000000000066561474413133200171610ustar00rootroot00000000000000import 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): from inspect import cleandoc installed_with_conda = list( Path(sys.prefix, 'conda-meta').glob('napari-*.json') ) raise ImportError( trans._( cleandoc( """ No Qt bindings could be found. napari requires either PyQt5 (default) or PySide2 to be installed in the environment. With pip, you can install either with: $ pip install -U 'napari[all]' # default choice $ pip install -U 'napari[pyqt5]' $ pip install -U 'napari[pyside2]' With conda, you need to do: $ conda install -c conda-forge pyqt $ conda install -c conda-forge pyside2 Our heuristics suggest you are using '{tool}' to manage your packages. """ ), deferred=True, tool='conda' if installed_with_conda else 'pip', ) ) 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 assert isinstance(QT_VERSION, str) 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, stacklevel=1) from napari._qt.qt_event_loop import get_app, get_qapp, gui_qt, quit_app, run from napari._qt.qt_main_window import Window __all__ = ['Window', 'get_app', 'get_qapp', 'gui_qt', 'quit_app', 'run'] napari-0.5.6/napari/_qt/_qapp_model/000077500000000000000000000000001474413133200173135ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qapp_model/__init__.py000066400000000000000000000002421474413133200214220ustar00rootroot00000000000000"""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.6/napari/_qt/_qapp_model/_menus.py000066400000000000000000000015021474413133200211510ustar00rootroot00000000000000from 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_model().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_model return QModelMenu( menu_id=menu_id, app=get_app_model(), title=title, parent=parent ) napari-0.5.6/napari/_qt/_qapp_model/_tests/000077500000000000000000000000001474413133200206145ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qapp_model/_tests/test_debug_menu.py000066400000000000000000000117731474413133200243500ustar00rootroot00000000000000import importlib from unittest import mock import pytest from napari._app_model import get_app_model from napari._qt._qapp_model._tests.utils import get_submenu_action @pytest.fixture(params=[True, False]) def perfmon_activation(monkeypatch, request): if request.param: # If perfmon needs to be active add env var and reload modules monkeypatch.setenv('NAPARI_PERFMON', '1') from napari.utils import perf importlib.reload(perf._config) importlib.reload(perf._timers) importlib.reload(perf) # Check `USE_PERFMON` values correspond to env var state assert perf.perf_config is not None assert perf._timers.USE_PERFMON assert perf.USE_PERFMON else: # If perfmon doesn't need to be active remove env var and reload modules monkeypatch.delenv('NAPARI_PERFMON', raising=False) from napari.utils import perf importlib.reload(perf._config) importlib.reload(perf._timers) importlib.reload(perf) # Check `USE_PERFMON` values correspond to env var state assert perf.perf_config is None assert not perf._timers.USE_PERFMON assert not perf.USE_PERFMON yield request.param # On teardown always try to remove env var and reload `perf` module monkeypatch.delenv('NAPARI_PERFMON', raising=False) from napari.utils import perf importlib.reload(perf._config) importlib.reload(perf._timers) importlib.reload(perf) # Check `USE_PERFMON` values correspond to env var state assert perf.perf_config is None assert not perf._timers.USE_PERFMON assert not perf.USE_PERFMON @pytest.mark.filterwarnings( 'ignore:Using NAPARI_PERFMON with an already-running QtApp' ) # TODO: remove once napari/napari#6957 resolved def test_debug_menu_exists(perfmon_activation, make_napari_viewer, qtbot): """Test debug menu existence following performance monitor usage.""" use_perfmon = perfmon_activation viewer = make_napari_viewer() # Check the menu exists following `NAPARI_PERFMON` value # * `NAPARI_PERFMON=1` -> `perf.USE_PERFMON==True` -> Debug menu available # * `NAPARI_PERFMON=` -> `perf.USE_PERFMON==False` -> Debug menu shouldn't exist assert bool(getattr(viewer.window, '_debug_menu', None)) == use_perfmon # Stop perf widget timer to prevent test failure on teardown when needed if use_perfmon: viewer.window._qt_viewer.dockPerformance.widget().timer.stop() @pytest.mark.filterwarnings( 'ignore:Using NAPARI_PERFMON with an already-running QtApp' ) # TODO: remove once napari/napari#6957 resolved def test_start_stop_trace_actions( perfmon_activation, make_napari_viewer, tmp_path, qtbot ): """Test start and stop recording trace actions.""" use_perfmon = perfmon_activation if use_perfmon: trace_file = tmp_path / 'trace.json' app = get_app_model() viewer = make_napari_viewer() # Check Debug menu exists and actions state assert getattr(viewer.window, '_debug_menu', None) is not None start_action, menu = get_submenu_action( viewer.window._debug_menu, 'Performance Trace', 'Start Recording...', ) stop_action, menu = get_submenu_action( viewer.window._debug_menu, 'Performance Trace', 'Stop Recording...' ) # Check initial actions state viewer.window._debug_menu.aboutToShow.emit() assert start_action.isEnabled() assert not stop_action.isEnabled() # Check start action execution def assert_start_recording(): viewer.window._debug_menu.aboutToShow.emit() assert not start_action.isEnabled() assert stop_action.isEnabled() with mock.patch( 'napari._qt._qapp_model.qactions._debug.QFileDialog' ) as mock_dialog: mock_dialog_instance = mock_dialog.return_value mock_save = mock_dialog_instance.getSaveFileName mock_save.return_value = (str(trace_file), None) app.commands.execute_command( 'napari.window.debug.start_trace_dialog' ) mock_dialog.assert_called_once() mock_save.assert_called_once() assert not trace_file.exists() qtbot.waitUntil(assert_start_recording) # Check stop action execution def assert_stop_recording(): viewer.window._debug_menu.aboutToShow.emit() assert start_action.isEnabled() assert not stop_action.isEnabled() assert trace_file.exists() app.commands.execute_command('napari.window.debug.stop_trace') qtbot.waitUntil(assert_stop_recording) # Stop perf widget timer to prevent test failure on teardown viewer.window._qt_viewer.dockPerformance.widget().timer.stop() qtbot.waitUntil( lambda: not viewer.window._qt_viewer.dockPerformance.widget().timer.isActive() ) else: # Nothing to test pytest.skip('Perfmon is disabled') napari-0.5.6/napari/_qt/_qapp_model/_tests/test_dummy_actions.py000066400000000000000000000013221474413133200250760ustar00rootroot00000000000000from napari._app_model.constants._menus import MenuId from napari._app_model.utils import to_id_key def assert_empty_keys_in_context(viewer): context = viewer.window._qt_viewer._layers.model().sourceModel()._root._ctx for menu_id in MenuId.contributables(): context_key = f'{to_id_key(menu_id)}_empty' assert context_key in context def test_menu_viewer_relaunch(make_napari_viewer): viewer = make_napari_viewer() assert_empty_keys_in_context(viewer) viewer.close() viewer2 = make_napari_viewer() # prior to #7106, this would fail assert_empty_keys_in_context(viewer2) viewer2.close() # prior to #7106, creating this viewer would error make_napari_viewer() napari-0.5.6/napari/_qt/_qapp_model/_tests/test_file_menu.py000066400000000000000000000372501474413133200241770ustar00rootroot00000000000000from unittest import mock import numpy as np import pytest from app_model.types import MenuItem, SubmenuItem from npe2 import DynamicPlugin from npe2.manifest.contributions import SampleDataURI from qtpy.QtGui import QGuiApplication from napari._app_model import get_app_model from napari._app_model.constants import MenuId from napari._qt._qapp_model._tests.utils import get_submenu_action from napari.layers import Image from napari.plugins._tests.test_npe2 import mock_pm # noqa: F401 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] app = get_app_model() # Configures `app`, registers actions and initializes plugins make_napari_viewer() with mock.patch( 'napari._qt.dialogs.qt_reader_dialog.handle_gui_reading' ) as mock_read: app.commands.execute_command('tmp_plugin:tmp-sample') # 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, ): """Check 'display_name' used for submenu when plugin has >1 sample data.""" app = get_app_model() viewer = make_napari_viewer() # builtins provides more than one sample, # so the submenu should use the `display_name` from manifest samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert samples_menu[0].title == 'napari builtins' # Now ensure that the actions are still correct # trigger the action, opening the first sample: `Astronaut` assert 'napari:astronaut' in app.commands assert len(viewer.layers) == 0 app.commands.execute_command('napari:astronaut') assert len(viewer.layers) == 1 assert viewer.layers[0].name == 'astronaut' def test_sample_menu_plugin_state_change( make_napari_viewer, tmp_plugin: DynamicPlugin, ): """Check samples submenu correct after plugin changes state.""" app = get_app_model() pm = tmp_plugin.plugin_manager # Check no samples menu before plugin registration with pytest.raises(KeyError): app.menus.get_menu(MenuId.FILE_SAMPLES) sample1 = SampleDataURI( key='tmp-sample-1', display_name='Temp Sample One', uri='some-file.tif', ) sample2 = SampleDataURI( key='tmp-sample-2', display_name='Temp Sample Two', uri='some-file.tif', ) tmp_plugin.manifest.contributions.sample_data = [sample1, sample2] # Configures `app`, registers actions and initializes plugins make_napari_viewer() samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert len(samples_menu) == 1 assert isinstance(samples_menu[0], SubmenuItem) assert samples_menu[0].title == tmp_plugin.display_name samples_sub_menu = app.menus.get_menu(MenuId.FILE_SAMPLES + '/tmp_plugin') assert len(samples_sub_menu) == 2 assert isinstance(samples_sub_menu[0], MenuItem) assert samples_sub_menu[0].command.title == 'Temp Sample One' assert 'tmp_plugin:tmp-sample-1' in app.commands # Disable plugin pm.disable(tmp_plugin.name) with pytest.raises(KeyError): app.menus.get_menu(MenuId.FILE_SAMPLES) assert 'tmp_plugin:tmp-sample-1' not in app.commands # Enable plugin pm.enable(tmp_plugin.name) samples_sub_menu = app.menus.get_menu(MenuId.FILE_SAMPLES + '/tmp_plugin') assert len(samples_sub_menu) == 2 assert 'tmp_plugin:tmp-sample-1' in app.commands def test_sample_menu_single_data( make_napari_viewer, tmp_plugin: DynamicPlugin, ): """Checks sample submenu correct when plugin has single sample data.""" app = get_app_model() sample = SampleDataURI( key='tmp-sample-1', display_name='Temp Sample One', uri='some-file.tif', ) tmp_plugin.manifest.contributions.sample_data = [sample] # Configures `app`, registers actions and initializes plugins make_napari_viewer() samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert isinstance(samples_menu[0], MenuItem) assert len(samples_menu) == 1 assert samples_menu[0].command.title == 'Temp Sample One (Temp Plugin)' assert 'tmp_plugin:tmp-sample-1' in app.commands def test_sample_menu_sorted( mock_pm, # noqa: F811 mock_app_model, tmp_plugin: DynamicPlugin, ): from napari._app_model import get_app_model from napari.plugins import _initialize_plugins # we make sure 'plugin-b' is registered first tmp_plugin2 = tmp_plugin.spawn(name='plugin-b', register=True) tmp_plugin1 = tmp_plugin.spawn(name='plugin-a', register=True) @tmp_plugin1.contribute.sample_data(display_name='Sample 1') def sample1(): ... @tmp_plugin1.contribute.sample_data(display_name='Sample 2') def sample2(): ... @tmp_plugin2.contribute.sample_data(display_name='Sample 1') def sample2_1(): ... @tmp_plugin2.contribute.sample_data(display_name='Sample 2') def sample2_2(): ... _initialize_plugins() samples_menu = list(get_app_model().menus.get_menu('napari/file/samples')) submenus = [item for item in samples_menu if isinstance(item, SubmenuItem)] assert len(submenus) == 3 # mock_pm registers a sample_manifest with two sample data contributions assert submenus[0].title == 'My Plugin' assert submenus[1].title == 'plugin-a' assert submenus[2].title == 'plugin-b' def test_show_shortcuts_actions(make_napari_viewer): viewer = make_napari_viewer() assert viewer.window._pref_dialog is None action_manager.trigger('napari:show_shortcuts') assert viewer.window._pref_dialog is not None assert viewer.window._pref_dialog._list.currentItem().text() == 'Shortcuts' viewer.window._pref_dialog.close() def test_image_from_clipboard(make_napari_viewer): make_napari_viewer() app = get_app_model() # Ensure clipboard is empty QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # Check action command execution with mock.patch('napari._qt.qt_viewer.show_info') as mock_show_info: app.commands.execute_command( 'napari.window.file._image_from_clipboard' ) mock_show_info.assert_called_once_with('No image or link in clipboard.') @pytest.mark.parametrize( ('action_id', 'dialog_method', 'dialog_return', 'filename_call', 'stack'), [ ( # Open File(s)... 'napari.window.file.open_files_dialog', 'getOpenFileNames', (['my-file.tif'], ''), ['my-file.tif'], False, ), ( # Open Files as Stack... 'napari.window.file.open_files_as_stack_dialog', 'getOpenFileNames', (['my-file.tif'], ''), ['my-file.tif'], True, ), ( # Open Folder... 'napari.window.file.open_folder_dialog', 'getExistingDirectory', 'my-dir/', ['my-dir/'], False, ), ], ) def test_open( make_napari_viewer, action_id, dialog_method, dialog_return, filename_call, stack, ): """Test base `Open ...` actions can be triggered.""" make_napari_viewer() app = get_app_model() # Check action command execution 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 app.commands.execute_command(action_id) mock_read.assert_called_once_with( filename_call, stack=stack, choose_plugin=False ) @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_submenu_action( viewer.window.file_menu, 'Open with Plugin', 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 ) def test_preference_dialog(make_napari_viewer): """Test preferences action can be triggered.""" make_napari_viewer() app = get_app_model() # Check action command execution with ( mock.patch( 'napari._qt.qt_main_window.PreferencesDialog.show' ) as mock_pref_dialog_show, ): app.commands.execute_command( 'napari.window.file.show_preferences_dialog' ) mock_pref_dialog_show.assert_called_once() def test_save_layers_enablement_updated_context(make_napari_viewer, builtins): """Test that enablement status of save layer actions updated correctly.""" get_app_model() viewer = make_napari_viewer() save_layers_action = viewer.window.file_menu.findAction( 'napari.window.file.save_layers_dialog', ) save_selected_layers_action = viewer.window.file_menu.findAction( 'napari.window.file.save_layers_dialog.selected', ) # Check both save actions are not enabled when no layers assert len(viewer.layers) == 0 viewer.window._update_file_menu_state() assert not save_layers_action.isEnabled() assert not save_selected_layers_action.isEnabled() # Add selected layer and check both save actions enabled layer = Image(np.random.random((10, 10))) viewer.layers.append(layer) assert len(viewer.layers) == 1 viewer.window._update_file_menu_state() assert save_layers_action.isEnabled() assert save_selected_layers_action.isEnabled() # Remove selection and check 'Save All Layers...' is enabled but # 'Save Selected Layers...' is not viewer.layers.selection.clear() viewer.window._update_file_menu_state() assert save_layers_action.isEnabled() assert not save_selected_layers_action.isEnabled() @pytest.mark.parametrize( ('action_id', 'dialog_method', 'dialog_return'), [ ( # Save Selected Layers... 'napari.window.file.save_layers_dialog.selected', 'getSaveFileName', (None, None), ), ( # Save All Layers... 'napari.window.file.save_layers_dialog', 'getSaveFileName', (None, None), ), ], ) def test_save_layers( make_napari_viewer, action_id, dialog_method, dialog_return ): """Test save layer selected/all actions can be triggered.""" viewer = make_napari_viewer() app = get_app_model() # Add selected layer layer = Image(np.random.random((10, 10))) viewer.layers.append(layer) assert len(viewer.layers) == 1 viewer.window._update_file_menu_state() # Check action command execution with mock.patch('napari._qt.qt_viewer.QFileDialog') as mock_file: mock_file_instance = mock_file.return_value getattr(mock_file_instance, dialog_method).return_value = dialog_return app.commands.execute_command(action_id) mock_file.assert_called_once() @pytest.mark.parametrize( ('action_id', 'patch_method', 'dialog_return'), [ ( # Save Screenshot with Viewer... 'napari.window.file.save_viewer_screenshot_dialog', 'napari._qt.dialogs.screenshot_dialog.ScreenshotDialog.exec_', False, ), ], ) def test_screenshot( make_napari_viewer, action_id, patch_method, dialog_return ): """Test screenshot actions can be triggered.""" make_napari_viewer() app = get_app_model() # Check action command execution with mock.patch(patch_method) as mock_screenshot: mock_screenshot.return_value = dialog_return app.commands.execute_command(action_id) mock_screenshot.assert_called_once() @pytest.mark.parametrize( 'action_id', [ # Copy Screenshot with Viewer to Clipboard 'napari.window.file.copy_viewer_screenshot', ], ) def test_screenshot_to_clipboard(make_napari_viewer, qtbot, action_id): """Test screenshot to clipboard actions can be triggered.""" viewer = make_napari_viewer() app = get_app_model() # Add selected layer layer = Image(np.random.random((10, 10))) viewer.layers.append(layer) assert len(viewer.layers) == 1 viewer.window._update_file_menu_state() # Check action command execution # ---- Ensure clipboard is empty QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # ---- Execute action with mock.patch('napari._qt.utils.add_flash_animation') as mock_flash: app.commands.execute_command(action_id) mock_flash.assert_called_once() # ---- Ensure clipboard has image clipboard_image = QGuiApplication.clipboard().image() assert not clipboard_image.isNull() @pytest.mark.parametrize( ( 'action_id', 'patch_method', ), [ ( # Restart 'napari.window.file.restart', 'napari._qt.qt_main_window._QtMainWindow.restart', ), ], ) def test_restart(make_napari_viewer, action_id, patch_method): """Testrestart action can be triggered.""" make_napari_viewer() app = get_app_model() # Check action command execution with mock.patch(patch_method) as mock_restart: app.commands.execute_command(action_id) mock_restart.assert_called_once() @pytest.mark.parametrize( ('action_id', 'patch_method', 'method_params'), [ ( # Close Window 'napari.window.file.close_dialog', 'napari._qt.qt_main_window._QtMainWindow.close', (False, True), ), ( # Exit 'napari.window.file.quit_dialog', 'napari._qt.qt_main_window._QtMainWindow.close', (True, True), ), ], ) def test_close(make_napari_viewer, action_id, patch_method, method_params): """Test close/exit actions can be triggered.""" make_napari_viewer() app = get_app_model() quit_app, confirm_need = method_params # Check action command execution with mock.patch(patch_method) as mock_close: app.commands.execute_command(action_id) mock_close.assert_called_once_with( quit_app=quit_app, confirm_need=confirm_need ) napari-0.5.6/napari/_qt/_qapp_model/_tests/test_help_menu.py000066400000000000000000000017061474413133200242050ustar00rootroot00000000000000"""For testing the Help menu""" import sys from unittest import mock import pytest import requests from napari._app_model import get_app_model from napari._qt._qapp_model.qactions._help 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() @pytest.mark.parametrize( 'action_id', [ 'napari.window.help.info', 'napari.window.help.about_macos', ] if sys.platform == 'darwin' else ['napari.window.help.info'], ) def test_about_action(make_napari_viewer, action_id): app = get_app_model() viewer = make_napari_viewer() with mock.patch( 'napari._qt.dialogs.qt_about.QtAbout.showAbout' ) as mock_about: app.commands.execute_command(action_id) mock_about.assert_called_once_with(viewer.window._qt_window) napari-0.5.6/napari/_qt/_qapp_model/_tests/test_layerlist_context_actions.py000066400000000000000000000027711474413133200275300ustar00rootroot00000000000000import pytest from napari._app_model import get_app_model from napari._app_model.actions._layerlist_context_actions import ( LAYERLIST_CONTEXT_ACTIONS, ) @pytest.mark.parametrize('layer_action', LAYERLIST_CONTEXT_ACTIONS) def test_layer_actions_ctx_menu_execute_command( layer_action, make_napari_viewer ): """ Test layer context menu actions via app-model `execute_command`. Note: This test is here only to ensure app-model action dispatch mechanism is working for these actions (which use the `_provide_active_layer_list` provider). To check a set of functional tests related to these actions you can see: https://github.com/napari/napari/blob/main/napari/layers/_tests/test_layer_actions.py """ app = get_app_model() make_napari_viewer() command_id = layer_action.id if command_id == 'napari.layer.merge_stack': with pytest.raises(IndexError, match=r'images list is empty'): app.commands.execute_command(command_id) elif command_id == 'napari.layer.merge_rgb': with pytest.raises( ValueError, match='Merging to RGB requires exactly 3 Image' ): app.commands.execute_command(command_id) elif command_id in [ 'napari.layer.link_selected_layers', 'napari.layer.unlink_selected_layers', ]: with pytest.raises(ValueError, match=r'at least one'): app.commands.execute_command(command_id) else: app.commands.execute_command(command_id) napari-0.5.6/napari/_qt/_qapp_model/_tests/test_plugins_menu.py000066400000000000000000000235741474413133200247450ustar00rootroot00000000000000import importlib from unittest import mock import pytest from app_model.types import MenuItem, SubmenuItem from npe2 import DynamicPlugin from qtpy.QtWidgets import QWidget from napari._app_model import get_app_model from napari._app_model.constants import MenuId from napari._qt._qapp_model.qactions import _plugins, init_qactions from napari._qt._qplugins._qnpe2 import _toggle_or_get_widget from napari._tests.utils import skip_local_popups from napari.plugins._tests.test_npe2 import mock_pm # noqa: F401 class DummyWidget(QWidget): pass @pytest.mark.skipif( not _plugins._plugin_manager_dialog_avail(), reason='`napari_plugin_manager` not available', ) def test_plugin_manager_action(make_napari_viewer): """ Test manage plugins installation action. The test is skipped in case `napari_plugin_manager` is not available """ app = get_app_model() viewer = make_napari_viewer() with mock.patch( 'napari_plugin_manager.qt_plugin_dialog.QtPluginDialog' ) as mock_plugin_dialog: app.commands.execute_command( 'napari.window.plugins.plugin_install_dialog' ) mock_plugin_dialog.assert_called_once_with(viewer.window._qt_window) def test_plugin_errors_action(make_napari_viewer): """Test plugin errors action.""" make_napari_viewer() app = get_app_model() with mock.patch( 'napari._qt._qapp_model.qactions._plugins.QtPluginErrReporter.exec_' ) as mock_plugin_dialog: app.commands.execute_command( 'napari.window.plugins.plugin_err_reporter' ) mock_plugin_dialog.assert_called_once() @skip_local_popups def test_toggle_or_get_widget( make_napari_viewer, tmp_plugin: DynamicPlugin, qtbot, ): """Check `_toggle_or_get_widget` changes visibility correctly.""" widget_name = 'Widget' plugin_name = 'tmp_plugin' full_name = 'Widget (Temp Plugin)' @tmp_plugin.contribute.widget(display_name=widget_name) def widget1(): return DummyWidget() app = get_app_model() # Viewer needs to be visible viewer = make_napari_viewer(show=True) # Trigger the action, opening the widget: `Widget 1` app.commands.execute_command('tmp_plugin:Widget') widget = viewer.window._dock_widgets[full_name] # Widget takes some time to appear qtbot.waitUntil(widget.isVisible) assert widget.isVisible() # Define not visible callable def widget_not_visible(): return not widget.isVisible() # Hide widget _toggle_or_get_widget( plugin=plugin_name, widget_name=widget_name, full_name=full_name, ) qtbot.waitUntil(widget_not_visible) assert not widget.isVisible() # Make widget appear again _toggle_or_get_widget( plugin=plugin_name, widget_name=widget_name, full_name=full_name, ) qtbot.waitUntil(widget.isVisible) assert widget.isVisible() def test_plugin_single_widget_menu( make_napari_viewer, tmp_plugin: DynamicPlugin ): """Test single plugin widgets get added to the window menu correctly.""" @tmp_plugin.contribute.widget(display_name='Widget 1') def widget1(): return DummyWidget() app = get_app_model() viewer = make_napari_viewer() assert tmp_plugin.display_name == 'Temp Plugin' plugin_menu = app.menus.get_menu('napari/plugins') assert plugin_menu[0].command.title == 'Widget 1 (Temp Plugin)' assert len(viewer.window._dock_widgets) == 0 assert 'tmp_plugin:Widget 1' in app.commands # trigger the action, opening the widget: `Widget 1` app.commands.execute_command('tmp_plugin:Widget 1') assert len(viewer.window._dock_widgets) == 1 assert 'Widget 1 (Temp Plugin)' in viewer.window._dock_widgets def test_plugin_multiple_widget_menu( make_napari_viewer, tmp_plugin: DynamicPlugin, ): """Check plugin with >1 widgets added with submenu and uses 'display_name'.""" @tmp_plugin.contribute.widget(display_name='Widget 1') def widget1(): return DummyWidget() @tmp_plugin.contribute.widget(display_name='Widget 2') def widget2(): return DummyWidget() app = get_app_model() viewer = make_napari_viewer() assert tmp_plugin.display_name == 'Temp Plugin' plugin_menu = app.menus.get_menu('napari/plugins') assert plugin_menu[0].title == tmp_plugin.display_name plugin_submenu = app.menus.get_menu('napari/plugins/tmp_plugin') assert plugin_submenu[0].command.title == 'Widget 1' assert len(viewer.window._dock_widgets) == 0 assert 'tmp_plugin:Widget 1' in app.commands # Trigger the action, opening the first widget: `Widget 1` app.commands.execute_command('tmp_plugin:Widget 1') assert len(viewer.window._dock_widgets) == 1 assert 'Widget 1 (Temp Plugin)' in viewer.window._dock_widgets def test_plugin_menu_plugin_state_change( make_napari_viewer, tmp_plugin: DynamicPlugin, ): """Check plugin menu items correct after a plugin changes state.""" app = get_app_model() pm = tmp_plugin.plugin_manager # Register plugin q actions init_qactions() # Check only `Q_PLUGINS_ACTIONS` in plugin menu before any plugins registered plugins_menu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS) assert len(plugins_menu) == len(_plugins.Q_PLUGINS_ACTIONS) @tmp_plugin.contribute.widget(display_name='Widget 1') def widget1(): """Dummy widget.""" @tmp_plugin.contribute.widget(display_name='Widget 2') def widget2(): """Dummy widget.""" # Configures `app`, registers actions and initializes plugins make_napari_viewer() plugins_menu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS) assert len(plugins_menu) == len(_plugins.Q_PLUGINS_ACTIONS) + 1 assert isinstance(plugins_menu[-1], SubmenuItem) assert plugins_menu[-1].title == tmp_plugin.display_name plugin_submenu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS + '/tmp_plugin') assert len(plugin_submenu) == 2 assert isinstance(plugin_submenu[0], MenuItem) assert plugin_submenu[0].command.title == 'Widget 1' assert 'tmp_plugin:Widget 1' in app.commands # Disable plugin pm.disable(tmp_plugin.name) with pytest.raises(KeyError): app.menus.get_menu(MenuId.MENUBAR_PLUGINS + '/tmp_plugin') assert 'tmp_plugin:Widget 1' not in app.commands # Enable plugin pm.enable(tmp_plugin.name) samples_sub_menu = app.menus.get_menu( MenuId.MENUBAR_PLUGINS + '/tmp_plugin' ) assert len(samples_sub_menu) == 2 assert 'tmp_plugin:Widget 1' in app.commands def test_plugin_widget_checked( make_napari_viewer, qtbot, tmp_plugin: DynamicPlugin ): """Check widget toggling/hiding updates check mark correctly.""" @tmp_plugin.contribute.widget(display_name='Widget') def widget_contrib(): return DummyWidget() app = get_app_model() viewer = make_napari_viewer() assert 'tmp_plugin:Widget' in app.commands widget_action = viewer.window.plugins_menu.findAction('tmp_plugin:Widget') assert not widget_action.isChecked() # Trigger the action, opening the widget widget_action.trigger() assert widget_action.isChecked() assert 'Widget (Temp Plugin)' in viewer.window._dock_widgets widget = viewer.window._dock_widgets['Widget (Temp Plugin)'] # Hide widget widget.title.hide_button.click() # Run `_on_about_to_show`, which is called on `aboutToShow`` event, # to update checked status viewer.window.plugins_menu._on_about_to_show() assert not widget_action.isChecked() # Trigger the action again to open widget and test item checked widget_action.trigger() assert widget_action.isChecked() def test_import_plugin_manager(): from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog assert QtPluginDialog is not None def test_plugin_manager(make_napari_viewer): """Test that the plugin manager is accessible from the viewer""" viewer = make_napari_viewer() assert _plugins._plugin_manager_dialog_avail() # Check plugin install action is visible plugin_install_action = viewer.window.plugins_menu.findAction( 'napari.window.plugins.plugin_install_dialog', ) assert plugin_install_action.isVisible() def test_no_plugin_manager(monkeypatch, make_napari_viewer): """Test that the plugin manager menu item is hidden when not installed.""" def mockreturn(*args): return None monkeypatch.setattr('importlib.util.find_spec', mockreturn) # We need to reload `_plugins` for the monkeypatching to work importlib.reload(_plugins) assert not _plugins._plugin_manager_dialog_avail() # Check plugin install action is not visible viewer = make_napari_viewer() plugin_install_action = viewer.window.plugins_menu.findAction( 'napari.window.plugins.plugin_install_dialog', ) assert not plugin_install_action.isVisible() def test_plugins_menu_sorted( mock_pm, # noqa: F811 mock_app_model, tmp_plugin: DynamicPlugin, ): from napari._app_model import get_app_model from napari.plugins import _initialize_plugins # we make sure 'plugin-b' is registered first tmp_plugin2 = tmp_plugin.spawn( name='plugin-b', plugin_manager=mock_pm, register=True ) tmp_plugin1 = tmp_plugin.spawn( name='plugin-a', plugin_manager=mock_pm, register=True ) @tmp_plugin1.contribute.widget(display_name='Widget 1') def widget1(): ... @tmp_plugin1.contribute.widget(display_name='Widget 2') def widget2(): ... @tmp_plugin2.contribute.widget(display_name='Widget 1') def widget2_1(): ... @tmp_plugin2.contribute.widget(display_name='Widget 2') def widget2_2(): ... _initialize_plugins() plugins_menu = list(get_app_model().menus.get_menu('napari/plugins')) submenus = [item for item in plugins_menu if isinstance(item, SubmenuItem)] assert len(submenus) == 2 assert submenus[0].title == 'plugin-a' assert submenus[1].title == 'plugin-b' napari-0.5.6/napari/_qt/_qapp_model/_tests/test_processors.py000066400000000000000000000066471474413133200244440ustar00rootroot00000000000000from typing import Optional, Union from unittest.mock import MagicMock import numpy as np import pytest from qtpy.QtWidgets import QWidget from napari._qt._qapp_model.injection._qprocessors import ( _add_future_data, _add_layer_data_to_viewer, _add_layer_data_tuples_to_viewer, _add_layer_to_viewer, _add_plugin_dock_widget, ) from napari.components import ViewerModel from napari.layers import Image from napari.types import ImageData, LabelsData def test_add_plugin_dock_widget(qtbot): widget = QWidget() viewer = MagicMock() qtbot.addWidget(widget) with pytest.raises(RuntimeError, match='No current `Viewer` found.'): _add_plugin_dock_widget((widget, 'widget')) _add_plugin_dock_widget((widget, 'widget'), viewer) viewer.window.add_dock_widget.assert_called_with(widget, name='widget') def test_add_layer_data_tuples_to_viewer_invalid_data(): viewer = MagicMock() error_data = (np.zeros((10, 10)), np.zeros((10, 10))) with pytest.raises( TypeError, match='Not a valid list of layer data tuples!' ): _add_layer_data_tuples_to_viewer( data=error_data, return_type=Union[ImageData, LabelsData], viewer=viewer, ) def test_add_layer_data_tuples_to_viewer_valid_data(): viewer = ViewerModel() valid_data = [ (np.zeros((10, 10)), {'name': 'layer1'}, 'image'), (np.zeros((10, 20)), {'name': 'layer1'}, 'image'), ] _add_layer_data_tuples_to_viewer( data=valid_data, return_type=Union[ImageData, LabelsData], viewer=viewer, ) assert len(viewer.layers) == 1 assert np.array_equal(viewer.layers[0].data, np.zeros((10, 20))) def test_add_layer_data_to_viewer_return_type(): v = MagicMock() with pytest.raises(TypeError, match='napari supports only Optional'): _add_layer_data_to_viewer( data=np.zeros((10, 10)), return_type=Union[ImageData, LabelsData], viewer=v, ) _add_layer_data_to_viewer( data=np.zeros((10, 10)), return_type=Optional[ImageData], viewer=v, ) v.add_image.assert_called_once() def test_add_layer_data_to_viewer(): viewer = ViewerModel() _add_layer_data_to_viewer( data=np.zeros((10, 10)), return_type=Optional[ImageData], viewer=viewer, layer_name='layer1', ) assert len(viewer.layers) == 1 assert np.array_equal(viewer.layers[0].data, np.zeros((10, 10))) _add_layer_data_to_viewer( data=np.zeros((10, 20)), return_type=Optional[ImageData], viewer=viewer, layer_name='layer1', ) assert len(viewer.layers) == 1 assert np.array_equal(viewer.layers[0].data, np.zeros((10, 20))) def test_add_layer_to_viewer(): layer1 = Image(np.zeros((10, 10))) layer2 = Image(np.zeros((10, 10))) viewer = ViewerModel() _add_layer_to_viewer(None) assert len(viewer.layers) == 0 _add_layer_to_viewer(layer1, viewer=viewer) assert len(viewer.layers) == 1 _add_layer_to_viewer(layer2, source={'parent': layer1}, viewer=viewer) assert len(viewer.layers) == 2 assert layer2._source.parent == layer1 def test_add_future_data(): future = MagicMock() viewer = MagicMock() _add_future_data(future, Union[ImageData, LabelsData]) _add_future_data(future, Union[ImageData, LabelsData], viewer=viewer) assert future.add_done_callback.call_count == 2 napari-0.5.6/napari/_qt/_qapp_model/_tests/test_qaction_layer.py000066400000000000000000000174261474413133200250710ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import numpy.testing as npt import pytest from qtpy.QtWidgets import QApplication from napari._qt._qapp_model.qactions._layerlist_context import ( _copy_affine_to_clipboard, _copy_rotate_to_clipboard, _copy_scale_to_clipboard, _copy_shear_to_clipboard, _copy_spatial_to_clipboard, _copy_translate_to_clipboard, _paste_spatial_from_clipboard, is_valid_spatial_in_clipboard, ) from napari.components import LayerList from napari.layers.base._test_util_sample_layer import SampleLayer from napari.utils.transforms import Affine @pytest.fixture def layer_list(): layer_1 = SampleLayer( data=np.empty((10, 10)), scale=(2, 3), translate=(1, 1), rotate=90, name='l1', affine=Affine(scale=(0.5, 0.5), translate=(1, 2), rotate=45), shear=[1], ) layer_2 = SampleLayer( data=np.empty((10, 10)), scale=(1, 1), translate=(0, 0), rotate=0, name='l2', affine=Affine(), shear=[0], ) layer_3 = SampleLayer( data=np.empty((10, 10)), scale=(1, 1), translate=(0, 0), rotate=0, name='l3', affine=Affine(), shear=[0], ) ll = LayerList([layer_1, layer_2, layer_3]) ll.selection = {layer_2} return ll @pytest.fixture def layer_list_dim(): layer_1 = SampleLayer( data=np.empty((5, 10, 10)), scale=(2, 3, 4), translate=(1, 1, 2), rotate=90, name='l1', affine=Affine(scale=(0.1, 0.5, 0.5), translate=(4, 1, 2), rotate=45), shear=[1, 0.5, 1], ) layer_2 = SampleLayer( data=np.empty((10, 10)), scale=(1, 1), translate=(0, 0), rotate=0, name='l2', affine=Affine(), shear=[0], ) ll = LayerList([layer_1, layer_2]) ll.selection = {layer_2} return ll @pytest.mark.usefixtures('qtbot') def test_copy_scale_to_clipboard(layer_list): _copy_scale_to_clipboard(layer_list['l1']) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) _paste_spatial_from_clipboard(layer_list) npt.assert_array_equal(layer_list['l2'].scale, (2, 3)) npt.assert_array_equal(layer_list['l3'].scale, (1, 1)) npt.assert_array_equal(layer_list['l2'].translate, (0, 0)) @pytest.mark.usefixtures('qtbot') def test_copy_translate_to_clipboard(layer_list): _copy_translate_to_clipboard(layer_list['l1']) npt.assert_array_equal(layer_list['l2'].translate, (0, 0)) _paste_spatial_from_clipboard(layer_list) npt.assert_array_equal(layer_list['l2'].translate, (1, 1)) npt.assert_array_equal(layer_list['l3'].translate, (0, 0)) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) @pytest.mark.usefixtures('qtbot') def test_copy_rotate_to_clipboard(layer_list): _copy_rotate_to_clipboard(layer_list['l1']) npt.assert_array_almost_equal(layer_list['l2'].rotate, ([1, 0], [0, 1])) _paste_spatial_from_clipboard(layer_list) npt.assert_array_almost_equal(layer_list['l2'].rotate, ([0, -1], [1, 0])) npt.assert_array_almost_equal(layer_list['l3'].rotate, ([1, 0], [0, 1])) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) @pytest.mark.usefixtures('qtbot') def test_copy_affine_to_clipboard(layer_list): _copy_affine_to_clipboard(layer_list['l1']) npt.assert_array_almost_equal( layer_list['l2'].affine.linear_matrix, Affine().linear_matrix ) _paste_spatial_from_clipboard(layer_list) npt.assert_array_almost_equal( layer_list['l2'].affine.linear_matrix, layer_list['l1'].affine.linear_matrix, ) npt.assert_array_almost_equal( layer_list['l3'].affine.linear_matrix, Affine().linear_matrix ) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) @pytest.mark.usefixtures('qtbot') def test_copy_shear_to_clipboard(layer_list): _copy_shear_to_clipboard(layer_list['l1']) npt.assert_array_almost_equal(layer_list['l2'].shear, (0,)) _paste_spatial_from_clipboard(layer_list) npt.assert_array_almost_equal(layer_list['l2'].shear, (1,)) npt.assert_array_almost_equal(layer_list['l3'].shear, (0,)) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) @pytest.mark.usefixtures('qtbot') def test_copy_spatial_to_clipboard(layer_list): _copy_spatial_to_clipboard(layer_list['l1']) npt.assert_array_equal(layer_list['l2'].scale, (1, 1)) _paste_spatial_from_clipboard(layer_list) npt.assert_array_equal(layer_list['l2'].scale, (2, 3)) npt.assert_array_equal(layer_list['l2'].translate, (1, 1)) npt.assert_array_almost_equal(layer_list['l2'].rotate, ([0, -1], [1, 0])) npt.assert_array_almost_equal( layer_list['l2'].affine.affine_matrix, layer_list['l1'].affine.affine_matrix, ) npt.assert_array_equal(layer_list['l3'].scale, (1, 1)) @pytest.mark.usefixtures('qtbot') def test_copy_spatial_to_clipboard_different_dim(layer_list_dim): _copy_spatial_to_clipboard(layer_list_dim['l1']) npt.assert_array_equal(layer_list_dim['l2'].scale, (1, 1)) _paste_spatial_from_clipboard(layer_list_dim) npt.assert_array_equal(layer_list_dim['l2'].scale, (3, 4)) npt.assert_array_equal(layer_list_dim['l2'].translate, (1, 2)) npt.assert_array_almost_equal( layer_list_dim['l2'].rotate, ([0, -1], [1, 0]) ) npt.assert_array_almost_equal( layer_list_dim['l2'].affine.affine_matrix, layer_list_dim['l1'].affine.affine_matrix[-3:, -3:], ) def test_fail_copy_to_clipboard(monkeypatch): mock_clipboard = Mock(return_value=None) warning_mock = Mock() monkeypatch.setattr(QApplication, 'clipboard', mock_clipboard) monkeypatch.setattr( 'napari._qt._qapp_model.qactions._layerlist_context.show_warning', warning_mock, ) layer = SampleLayer(data=np.empty((10, 10))) _copy_scale_to_clipboard(layer) mock_clipboard.assert_called_once() warning_mock.assert_called_once_with('Cannot access clipboard') def test_fail_copy_data_from_clipboard(monkeypatch, layer_list): mock_clipboard = Mock(return_value=None) warning_mock = Mock() monkeypatch.setattr(QApplication, 'clipboard', mock_clipboard) monkeypatch.setattr( 'napari._qt._qapp_model.qactions._layerlist_context.show_warning', warning_mock, ) _paste_spatial_from_clipboard(layer_list) mock_clipboard.assert_called_once() warning_mock.assert_called_once_with('Cannot access clipboard') @pytest.mark.usefixtures('qtbot') def test_fail_decode_text(monkeypatch, layer_list): warning_mock = Mock() monkeypatch.setattr( 'napari._qt._qapp_model.qactions._layerlist_context.show_warning', warning_mock, ) clip = QApplication.clipboard() clip.setText('aaaaa') _paste_spatial_from_clipboard(layer_list) warning_mock.assert_called_once_with('Cannot parse clipboard data') @pytest.mark.usefixtures('qtbot') def test_is_valid_spatial_in_clipboard_simple(): layer = SampleLayer(data=np.empty((10, 10))) _copy_scale_to_clipboard(layer) assert is_valid_spatial_in_clipboard() @pytest.mark.usefixtures('qtbot') def test_is_valid_spatial_in_clipboard_json(): QApplication.clipboard().setText('{"scale": [1, 1]}') assert is_valid_spatial_in_clipboard() @pytest.mark.usefixtures('qtbot') def test_is_valid_spatial_in_clipboard_bad_json(): QApplication.clipboard().setText('[1, 1]') assert not is_valid_spatial_in_clipboard() @pytest.mark.usefixtures('qtbot') def test_is_valid_spatial_in_clipboard_invalid_str(): QApplication.clipboard().setText('aaaa') assert not is_valid_spatial_in_clipboard() @pytest.mark.usefixtures('qtbot') def test_is_valid_spatial_in_clipboard_invalid_key(): QApplication.clipboard().setText('{"scale": [1, 1], "invalid": 1}') assert not is_valid_spatial_in_clipboard() napari-0.5.6/napari/_qt/_qapp_model/_tests/test_qapp_model_menus.py000066400000000000000000000045031474413133200255570ustar00rootroot00000000000000import numpy as np import pytest from app_model.types import Action from napari._app_model import get_app_model from napari._app_model.constants import MenuId from napari._app_model.context import LayerListContextKeys as LLCK from napari._qt._qapp_model import build_qmodel_menu from napari.layers import Image # `builtins` required so there are samples registered, so samples menu exists @pytest.mark.parametrize('menu_id', list(MenuId)) def test_build_qmodel_menu(builtins, make_napari_viewer, qtbot, menu_id): """Test that we can build qmenus for all registered menu IDs.""" app = get_app_model() # Configures `app`, registers actions and initializes plugins make_napari_viewer() menu = build_qmodel_menu(menu_id) qtbot.addWidget(menu) # `>=` because separator bars count as actions # only check non-empty menus if menu_id in app.menus: assert len(menu.actions()) >= len(app.menus.get_menu(menu_id)) def test_update_menu_state_context(make_napari_viewer): """Test `_update_menu_state` correctly updates enabled/visible state.""" app = get_app_model() viewer = make_napari_viewer() action = Action( id='dummy_id', title='dummy title', callback=lambda: None, menus=[{'id': MenuId.MENUBAR_FILE, 'when': (LLCK.num_layers > 0)}], enablement=(LLCK.num_layers == 2), ) app.register_action(action) dummy_action = viewer.window.file_menu.findAction('dummy_id') assert 'dummy_id' in app.commands assert len(viewer.layers) == 0 # `dummy_action` should be disabled & not visible as num layers == 0 viewer.window._update_file_menu_state() assert not dummy_action.isVisible() assert not dummy_action.isEnabled() layer_a = Image(np.random.random((10, 10))) viewer.layers.append(layer_a) assert len(viewer.layers) == 1 viewer.window._update_file_menu_state() # `dummy_action` should be visible but not enabled after adding layer assert dummy_action.isVisible() assert not dummy_action.isEnabled() layer_b = Image(np.random.random((10, 10))) viewer.layers.append(layer_b) assert len(viewer.layers) == 2 # `dummy_action` should be enabled and visible after adding second layer viewer.window._update_file_menu_state() assert dummy_action.isVisible() assert dummy_action.isEnabled() napari-0.5.6/napari/_qt/_qapp_model/_tests/test_qproviders.py000066400000000000000000000073161474413133200244320ustar00rootroot00000000000000"""Test app-model Qt-related providers.""" import numpy as np import pytest from app_model.types import Action from napari._app_model._app import get_app_model from napari._qt._qapp_model.injection._qproviders import ( _provide_active_layer, _provide_active_layer_list, _provide_qt_viewer_or_raise, _provide_viewer, _provide_viewer_or_raise, _provide_window_or_raise, ) from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer from napari.components import LayerList from napari.layers import Image from napari.utils._proxies import PublicOnlyProxy from napari.viewer import Viewer def test_publicproxy_provide_viewer(capsys, make_napari_viewer): """Test `_provide_viewer` outputs a `PublicOnlyProxy` when appropriate. Check manual (e.g., internal) `_provide_viewer` calls can disable `PublicOnlyProxy` via `public_proxy` parameter but `PublicOnlyProxy` is always used when it is used as a provider. """ # No current viewer, `None` should be returned viewer = _provide_viewer() assert viewer is None # Create a viewer make_napari_viewer() # Ensure we can disable via `public_proxy` viewer = _provide_viewer(public_proxy=False) assert isinstance(viewer, Viewer) # Ensure we get a `PublicOnlyProxy` when used as a provider def my_viewer(viewer: Viewer) -> Viewer: # Allows us to check type when `Action` executed print(type(viewer)) # noqa: T201 action = Action( id='some.command.id', title='some title', callback=my_viewer, ) app = get_app_model() app.register_action(action) app.commands.execute_command('some.command.id') captured = capsys.readouterr() assert 'napari.utils._proxies.PublicOnlyProxy' in captured.out def test_provide_viewer_or_raise(make_napari_viewer): """Check `_provide_viewer_or_raise` raises or returns correct `Viewer`.""" # raises when no viewer with pytest.raises(RuntimeError, match='No current `Viewer` found. test'): _provide_viewer_or_raise(msg='test') # create viewer make_napari_viewer() viewer = _provide_viewer_or_raise() assert isinstance(viewer, Viewer) viewer = _provide_viewer_or_raise(public_proxy=True) assert isinstance(viewer, PublicOnlyProxy) def test_provide_qt_viewer_or_raise(make_napari_viewer): """Check `_provide_qt_viewer_or_raise` raises or returns `QtViewer`.""" # raises when no QtViewer with pytest.raises( RuntimeError, match='No current `QtViewer` found. test' ): _provide_qt_viewer_or_raise(msg='test') # create QtViewer make_napari_viewer() viewer = _provide_qt_viewer_or_raise() assert isinstance(viewer, QtViewer) def test_provide_window_or_raise(make_napari_viewer): """Check `_provide_window_or_raise` raises or returns `Window`.""" # raises when no Window with pytest.raises(RuntimeError, match='No current `Window` found. test'): _provide_window_or_raise(msg='test') # create viewer (and Window) make_napari_viewer() viewer = _provide_window_or_raise() assert isinstance(viewer, Window) def test_provide_active_layer_and_layer_list(make_napari_viewer): """Check `_provide_active_layer/_list` returns correct object.""" shape = (10, 10) viewer = make_napari_viewer() layer_a = Image(np.random.random(shape)) viewer.layers.append(layer_a) provided_layer = _provide_active_layer() assert isinstance(provided_layer, Image) assert provided_layer.data.shape == shape provided_layers = _provide_active_layer_list() assert isinstance(provided_layers, LayerList) assert isinstance(provided_layers[0], Image) assert provided_layers[0].data.shape == shape napari-0.5.6/napari/_qt/_qapp_model/_tests/test_togglers.py000066400000000000000000000035361474413133200240620ustar00rootroot00000000000000from napari import Viewer from napari._app_model._app import get_app_model from napari._qt._qapp_model.qactions._toggle_action import ( DockWidgetToggleAction, ViewerToggleAction, ) from napari._qt.qt_main_window import Window from napari.components import ViewerModel def test_viewer_toggler(mock_app_model): viewer = ViewerModel() action = ViewerToggleAction( id='some.command.id', title='Toggle Axis Visibility', viewer_attribute='axes', sub_attribute='visible', ) app = get_app_model() app.register_action(action) # Injection required as there is no current viewer, use a high weight (100) # so this provider is used over `_provide_viewer`, which would raise an error with app.injection_store.register( providers=[ (lambda: viewer, Viewer, 100), ] ): 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 def test_dock_widget_toggler(make_napari_viewer): """Tests `DockWidgetToggleAction` toggling works.""" viewer = make_napari_viewer(show=True) action = DockWidgetToggleAction( id='some.command.id', title='Toggle Dock Widget', dock_widget='dockConsole', ) app = get_app_model() app.register_action(action) with app.injection_store.register( providers={Window: lambda: viewer.window} ): assert viewer.window._qt_viewer.dockConsole.isVisible() is False app.commands.execute_command('some.command.id') assert viewer.window._qt_viewer.dockConsole.isVisible() is True app.commands.execute_command('some.command.id') assert viewer.window._qt_viewer.dockConsole.isVisible() is False napari-0.5.6/napari/_qt/_qapp_model/_tests/test_view_menu.py000066400000000000000000000245771474413133200242420ustar00rootroot00000000000000import os import sys import numpy as np import pytest from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QApplication from napari._app_model import get_app_model from napari._qt._qapp_model.qactions._view import ( _get_current_tooltip_visibility, toggle_action_details, ) from napari._tests.utils import skip_local_focus, skip_local_popups def check_windows_style(viewer): if os.name != 'nt': return import win32con import win32gui window_handle = viewer.window._qt_window.windowHandle() window_handle_id = int(window_handle.winId()) window_style = win32gui.GetWindowLong(window_handle_id, win32con.GWL_STYLE) assert window_style & win32con.WS_BORDER == win32con.WS_BORDER def check_view_menu_visibility(viewer, qtbot): if viewer.window._qt_window.menuBar().isNativeMenuBar(): return assert not viewer.window.view_menu.isVisible() qtbot.keyClick( viewer.window._qt_window.menuBar(), Qt.Key_V, modifier=Qt.AltModifier ) qtbot.waitUntil(viewer.window.view_menu.isVisible) viewer.window.view_menu.close() assert not viewer.window.view_menu.isVisible() @pytest.mark.parametrize( ('action_id', 'action_title', 'viewer_attr', 'sub_attr'), toggle_action_details, ) def test_toggle_axes_scale_bar_attr( make_napari_viewer, action_id, action_title, viewer_attr, sub_attr ): """ Test toggle actions related with viewer axes and scale bar attributes. * Viewer `axes` attributes: * `visible` * `colored` * `labels` * `dashed` * `arrows` * Viewer `scale_bar` attributes: * `visible` * `colored` * `ticks` """ app = get_app_model() viewer = make_napari_viewer() # Get viewer attribute to check (`axes` or `scale_bar`) axes_scale_bar = getattr(viewer, viewer_attr) # Get initial sub-attribute value (for example `axes.visible`) initial_value = getattr(axes_scale_bar, sub_attr) # Change sub-attribute via action command execution and check value app.commands.execute_command(action_id) changed_value = getattr(axes_scale_bar, sub_attr) assert initial_value is not changed_value @skip_local_popups @pytest.mark.qt_log_level_fail('WARNING') def test_toggle_fullscreen_from_normal(make_napari_viewer, qtbot): """ Test toggle fullscreen action from normal window state. Check that toggling from a normal state can be done without generating any type of warning and menu bar elements are visible in any window state. """ action_id = 'napari.window.view.toggle_fullscreen' app = get_app_model() viewer = make_napari_viewer(show=True) # Check initial default state (no fullscreen) assert not viewer.window._qt_window.isFullScreen() # Check `View` menu can be seen in normal window state check_view_menu_visibility(viewer, qtbot) # Check fullscreen state change app.commands.execute_command(action_id) if sys.platform == 'darwin': # On macOS, wait for the animation to complete qtbot.wait(250) assert viewer.window._qt_window.isFullScreen() check_windows_style(viewer) # Check `View` menu can be seen in fullscreen window state check_view_menu_visibility(viewer, qtbot) # Check return to non fullscreen state app.commands.execute_command(action_id) if sys.platform == 'darwin': # On macOS, wait for the animation to complete qtbot.wait(250) assert not viewer.window._qt_window.isFullScreen() check_windows_style(viewer) # Check `View` still menu can be seen in non fullscreen window state check_view_menu_visibility(viewer, qtbot) @skip_local_popups @pytest.mark.qt_log_level_fail('WARNING') def test_toggle_fullscreen_from_maximized(make_napari_viewer, qtbot): """ Test toggle fullscreen action from maximized window state. Check that toggling from a maximized state can be done without generating any type of warning and menu bar elements are visible in any window state. """ action_id = 'napari.window.view.toggle_fullscreen' app = get_app_model() viewer = make_napari_viewer(show=True) # Check fullscreen state change while maximized assert not viewer.window._qt_window.isMaximized() viewer.window._qt_window.showMaximized() # Check `View` menu can be seen in maximized window state check_view_menu_visibility(viewer, qtbot) # Check fullscreen state change app.commands.execute_command(action_id) if sys.platform == 'darwin': # On macOS, wait for the animation to complete qtbot.wait(250) assert viewer.window._qt_window.isFullScreen() check_windows_style(viewer) # Check `View` menu can be seen in fullscreen window state coming from maximized state check_view_menu_visibility(viewer, qtbot) # Check return to non fullscreen state app.commands.execute_command(action_id) if sys.platform == 'darwin': # On macOS, wait for the animation to complete qtbot.wait(250) def check_not_fullscreen(): assert not viewer.window._qt_window.isFullScreen() qtbot.waitUntil(check_not_fullscreen) check_windows_style(viewer) # Check `View` still menu can be seen in non fullscreen window state check_view_menu_visibility(viewer, qtbot) @skip_local_focus @pytest.mark.skipif( sys.platform == 'darwin', reason='Toggle menubar action not enabled on macOS', ) def test_toggle_menubar(make_napari_viewer, qtbot): """ Test menubar toggle functionality. Skipped on macOS since the menubar is the system one so the menubar toggle action doesn't exist/isn't enabled there. """ action_id = 'napari.window.view.toggle_menubar' app = get_app_model() viewer = make_napari_viewer(show=True) # Check initial state (visible menubar) assert viewer.window._qt_window.menuBar().isVisible() assert not viewer.window._qt_window._toggle_menubar_visibility # Check menubar gets hidden app.commands.execute_command(action_id) assert not viewer.window._qt_window.menuBar().isVisible() assert viewer.window._qt_window._toggle_menubar_visibility # Check menubar gets visible via mouse hovering over the window top area qtbot.mouseMove(viewer.window._qt_window, pos=QPoint(10, 10)) qtbot.mouseMove(viewer.window._qt_window, pos=QPoint(15, 15)) qtbot.waitUntil(viewer.window._qt_window.menuBar().isVisible) # Check menubar hides when the mouse no longer is hovering over the window top area qtbot.mouseMove(viewer.window._qt_window, pos=QPoint(50, 50)) qtbot.waitUntil(lambda: not viewer.window._qt_window.menuBar().isVisible()) # Check restore menubar visibility app.commands.execute_command(action_id) assert viewer.window._qt_window.menuBar().isVisible() assert not viewer.window._qt_window._toggle_menubar_visibility def test_toggle_play(make_napari_viewer, qtbot): """Test toggle play action.""" action_id = 'napari.window.view.toggle_play' app = get_app_model() viewer = make_napari_viewer() # Check action on empty viewer with pytest.warns( expected_warning=UserWarning, match='Refusing to play a hidden axis' ): app.commands.execute_command(action_id) # Check action on viewer with layer np.random.seed(0) data = np.random.random((10, 10, 15)) viewer.add_image(data) # Assert action triggers play app.commands.execute_command(action_id) qtbot.waitUntil(lambda: viewer.window._qt_viewer.dims.is_playing) # Assert action stops play with qtbot.waitSignal( viewer.window._qt_viewer.dims._animation_thread.finished ): app.commands.execute_command(action_id) QApplication.processEvents() qtbot.waitUntil(lambda: not viewer.window._qt_viewer.dims.is_playing) @skip_local_popups def test_toggle_activity_dock(make_napari_viewer): """Test toggle activity dock""" action_id = 'napari.window.view.toggle_activity_dock' app = get_app_model() viewer = make_napari_viewer(show=True) # Check initial activity dock state (hidden) assert not viewer.window._qt_window._activity_dialog.isVisible() assert ( viewer.window._status_bar._activity_item._activityBtn.arrowType() == Qt.ArrowType.UpArrow ) # Check activity dock gets visible app.commands.execute_command(action_id) assert viewer.window._qt_window._activity_dialog.isVisible() assert ( viewer.window._status_bar._activity_item._activityBtn.arrowType() == Qt.ArrowType.DownArrow ) # Restore activity dock visibility (hidden) app.commands.execute_command(action_id) assert not viewer.window._qt_window._activity_dialog.isVisible() assert ( viewer.window._status_bar._activity_item._activityBtn.arrowType() == Qt.ArrowType.UpArrow ) def test_toggle_layer_tooltips(make_napari_viewer, qtbot): """Test toggle layer tooltips""" make_napari_viewer() action_id = 'napari.window.view.toggle_layer_tooltips' app = get_app_model() # Check initial layer tooltip visibility settings state (False) assert not _get_current_tooltip_visibility() # Check layer tooltip visibility toggle app.commands.execute_command(action_id) assert _get_current_tooltip_visibility() # Restore layer tooltip visibility app.commands.execute_command(action_id) assert not _get_current_tooltip_visibility() def test_zoom_actions(make_napari_viewer): """Test zoom actions""" viewer = make_napari_viewer() app = get_app_model() viewer.add_image(np.ones((10, 10, 10))) # get initial zoom state initial_zoom = viewer.camera.zoom # Check zoom in action app.commands.execute_command('napari.viewer.camera.zoom_in') assert viewer.camera.zoom == pytest.approx(1.5 * initial_zoom) # Check zoom out action app.commands.execute_command('napari.viewer.camera.zoom_out') assert viewer.camera.zoom == pytest.approx(initial_zoom) viewer.camera.zoom = 2 # Check reset zoom action app.commands.execute_command('napari.viewer.fit_to_view') assert viewer.camera.zoom == pytest.approx(initial_zoom) # Check that angle is preserved viewer.dims.ndisplay = 3 viewer.camera.angles = (90, 0, 0) viewer.camera.zoom = 2 app.commands.execute_command('napari.viewer.fit_to_view') # Zoom should be reset, but angle unchanged assert viewer.camera.zoom == pytest.approx(initial_zoom) assert viewer.camera.angles == (90, 0, 0) napari-0.5.6/napari/_qt/_qapp_model/_tests/test_window_menu.py000066400000000000000000000032111474413133200245550ustar00rootroot00000000000000import pytest from napari._app_model import get_app_model from napari._qt._qapp_model.qactions._window import toggle_action_details from napari._tests.utils import skip_local_popups @skip_local_popups @pytest.mark.parametrize( ( 'action_id', 'action_text', 'action_dockwidget_name', 'action_status_tooltip', ), toggle_action_details, ) def test_toggle_dockwidget_actions( make_napari_viewer, action_id, action_text, action_dockwidget_name, action_status_tooltip, ): app = get_app_model() viewer = make_napari_viewer(show=True) widget = getattr(viewer.window._qt_viewer, action_dockwidget_name) widget_initial_visibility = widget.isVisible() action = viewer.window.window_menu.findAction(action_id) # ---- Check initial action checked state # Need to emit manually menu `aboutToShow` signal to ensure actions checked state is synced viewer.window.window_menu.aboutToShow.emit() # If the action is checked the widget should be visible # If the action is not checked the widget shouldn't be visible assert action.isChecked() == widget.isVisible() # ---- Check toggling dockwidget from initial visibility app.commands.execute_command(action_id) assert widget.isVisible() != widget_initial_visibility viewer.window.window_menu.aboutToShow.emit() assert action.isChecked() == widget.isVisible() # ---- Check restoring initial visibility app.commands.execute_command(action_id) assert widget.isVisible() == widget_initial_visibility viewer.window.window_menu.aboutToShow.emit() assert action.isChecked() == widget.isVisible() napari-0.5.6/napari/_qt/_qapp_model/_tests/utils.py000066400000000000000000000031641474413133200223320ustar00rootroot00000000000000import qtpy from qtpy.QtWidgets import QMenu def get_submenu_action(qmodel_menu, submenu_text, action_text): """ Get an action that belongs to a submenu inside a `QModelMenu` instance. Needed since `QModelMenu.findAction` will not search inside submenu actions Parameters ---------- qmodel_menu : app_model.backends.qt.QModelMenu One of the application menus created via `napari._qt._qapp_model.build_qmodel_menu`. submenu_text : str Text of the submenu where an action should be searched. action_text : str Text of the action to search for. Raises ------ ValueError In case no action could be found. Returns ------- tuple[QAction, QAction] Tuple with submenu action and found action. """ 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 menu method of action return ( QMenu.menuInAction(act) if getattr(qtpy, 'PYQT6', False) else act.menu() ) actions = qmodel_menu.actions() for action1 in actions: if action1.text() == submenu_text: for action2 in _get_menu(action1).actions(): if action2.text() == action_text: return action2, action1 raise ValueError( f'Could not find action "{action_text}" in "{submenu_text}"' ) # pragma: no cover napari-0.5.6/napari/_qt/_qapp_model/injection/000077500000000000000000000000001474413133200212755ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qapp_model/injection/__init__.py000066400000000000000000000000001474413133200233740ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qapp_model/injection/_qprocessors.py000066400000000000000000000155731474413133200244040ustar00rootroot00000000000000"""Qt processors. Non-Qt processors can be found in `napari/_app_model/injection/_processors.py`. """ from concurrent.futures import Future from contextlib import nullcontext, suppress from functools import partial from typing import ( Any, Callable, Optional, Union, get_origin, ) from magicgui.widgets import FunctionGui, Widget from qtpy.QtWidgets import QWidget from napari import layers, types, viewer from napari._qt._qapp_model.injection._qproviders import ( _provide_viewer, _provide_viewer_or_raise, ) from napari.layers._source import layer_source def _add_plugin_dock_widget( widget_name_tuple: tuple[Union[FunctionGui, QWidget, Widget], str], viewer: Optional[viewer.Viewer] = None, ) -> None: if viewer is None: viewer = _provide_viewer_or_raise( msg='Widgets cannot be opened in headless mode.', ) widget, full_name = widget_name_tuple viewer.window.add_dock_widget(widget, name=full_name) def _add_layer_data_tuples_to_viewer( data: Union[tuple, list[tuple]], return_type: Optional[Any] = None, viewer: Optional[viewer.Viewer] = None, source: Optional[dict] = None, ) -> 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, ) -> 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): # layerlist also allow lookup by name viewer.layers[layer_name].data = data return if get_origin(return_type) is Union: if len(return_type.__args__) != 2 or return_type.__args__[ 1 ] is not type(None): # this case should be impossible, but we'll check anyway. raise TypeError( f'napari supports only Optional[], not {return_type}' ) return_type = return_type.__args__[0] 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, ) -> 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 garbage collection of the future object while processing. _FUTURES: set[Future] = set() def _add_future_data( future: Future, return_type: Any, _from_tuple: bool = True, viewer: Optional[viewer.Viewer] = None, source: Optional[dict] = None, ) -> 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. add_kwargs = { 'return_type': return_type, 'viewer': viewer, 'source': source, } def _on_future_ready(f: Future) -> None: if _from_tuple: _add_layer_data_tuples_to_viewer(f.result(), **add_kwargs) else: _add_layer_data_to_viewer(f.result(), **add_kwargs) _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) QPROCESSORS: dict[object, Callable] = { Optional[ tuple[Union[FunctionGui, QWidget, Widget], str] ]: _add_plugin_dock_widget, types.LayerDataTuple: _add_layer_data_tuples_to_viewer, list[types.LayerDataTuple]: _add_layer_data_tuples_to_viewer, layers.Layer: _add_layer_to_viewer, } # Add future and LayerData processors for each layer type. for t in types._LayerData.__args__: # type: ignore [attr-defined] QPROCESSORS[t] = partial(_add_layer_data_to_viewer, return_type=t) QPROCESSORS[Future[t]] = partial( # type: ignore [valid-type] _add_future_data, return_type=t, _from_tuple=False ) napari-0.5.6/napari/_qt/_qapp_model/injection/_qproviders.py000066400000000000000000000051751474413133200242140ustar00rootroot00000000000000"""Qt providers. Any non-Qt providers should be added inside `napari/_app_model/injection/`. """ from __future__ import annotations from typing import TYPE_CHECKING, Optional from napari import components, layers, viewer from napari.utils._proxies import PublicOnlyProxy from napari.utils.translations import trans if TYPE_CHECKING: from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer def _provide_viewer(public_proxy: bool = True) -> Optional[viewer.Viewer]: """Provide `PublicOnlyProxy` (allows internal napari access) of current viewer.""" if current_viewer := viewer.current_viewer(): if public_proxy: return PublicOnlyProxy(current_viewer) return current_viewer return None def _provide_viewer_or_raise( msg: str = '', public_proxy: bool = False ) -> viewer.Viewer: viewer = _provide_viewer(public_proxy) if viewer: return viewer if msg: msg = ' ' + msg raise RuntimeError( trans._( 'No current `Viewer` found.{msg}', deferred=True, msg=msg, ) ) def _provide_qt_viewer() -> Optional[QtViewer]: from napari._qt.qt_main_window import _QtMainWindow if _qmainwin := _QtMainWindow.current(): return _qmainwin._qt_viewer return None def _provide_qt_viewer_or_raise(msg: str = '') -> QtViewer: qt_viewer = _provide_qt_viewer() if qt_viewer: return qt_viewer if msg: msg = ' ' + msg raise RuntimeError( trans._( 'No current `QtViewer` found.{msg}', deferred=True, msg=msg, ) ) def _provide_window() -> Optional[Window]: from napari._qt.qt_main_window import _QtMainWindow if _qmainwin := _QtMainWindow.current(): return _qmainwin._window return None def _provide_window_or_raise(msg: str = '') -> Window: window = _provide_window() if window: return window if msg: msg = ' ' + msg raise RuntimeError( trans._( 'No current `Window` found.{msg}', deferred=True, msg=msg, ) ) 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 QPROVIDERS = [ (_provide_viewer,), (_provide_qt_viewer,), (_provide_window,), (_provide_active_layer,), (_provide_active_layer_list,), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/000077500000000000000000000000001474413133200211345ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qapp_model/qactions/__init__.py000066400000000000000000000102561474413133200232510ustar00rootroot00000000000000from __future__ import annotations from functools import lru_cache, partial from itertools import chain from typing import TYPE_CHECKING from napari._qt._qapp_model.injection._qprocessors import QPROCESSORS from napari._qt._qapp_model.injection._qproviders import QPROVIDERS if TYPE_CHECKING: from app_model.types import Context # 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_model from napari._qt._qapp_model.qactions._debug import ( DEBUG_SUBMENUS, Q_DEBUG_ACTIONS, ) from napari._qt._qapp_model.qactions._file import ( FILE_SUBMENUS, Q_FILE_ACTIONS, ) from napari._qt._qapp_model.qactions._help import Q_HELP_ACTIONS from napari._qt._qapp_model.qactions._layerlist_context import ( Q_LAYERLIST_CONTEXT_ACTIONS, ) from napari._qt._qapp_model.qactions._layers_actions import ( LAYERS_ACTIONS, LAYERS_SUBMENUS, ) from napari._qt._qapp_model.qactions._plugins import Q_PLUGINS_ACTIONS from napari._qt._qapp_model.qactions._view import ( Q_VIEW_ACTIONS, VIEW_SUBMENUS, ) from napari._qt._qapp_model.qactions._window import Q_WINDOW_ACTIONS from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer # update the namespace with the Qt-specific types/providers/processors app = get_app_model() store = app.injection_store store.namespace = { **store.namespace, 'Window': Window, 'QtViewer': QtViewer, } # Qt-specific providers/processors app.injection_store.register( processors=QPROCESSORS, providers=QPROVIDERS, ) # register menubar actions app.register_actions( chain( Q_DEBUG_ACTIONS, Q_FILE_ACTIONS, Q_HELP_ACTIONS, Q_PLUGINS_ACTIONS, Q_VIEW_ACTIONS, LAYERS_ACTIONS, Q_LAYERLIST_CONTEXT_ACTIONS, Q_WINDOW_ACTIONS, ) ) # register menubar submenus app.menus.append_menu_items( chain(FILE_SUBMENUS, VIEW_SUBMENUS, DEBUG_SUBMENUS, LAYERS_SUBMENUS) ) def add_dummy_actions(context: Context) -> None: """Register dummy 'Empty' actions for all contributable menus. Each action is registered with its own `when` condition, that ensures the action is not visible once the menu is populated. The context key used in the `when` condition is also added to the given `context` and assigned to a partial function that returns True if the menu is empty, and otherwise False. Parameters ---------- context : Context context to store functional keys used in `when` conditions """ from napari._app_model import get_app_model from napari._app_model.constants._menus import MenuId from napari._app_model.utils import get_dummy_action, is_empty_menu app = get_app_model() actions = [] for menu_id in MenuId.contributables(): dummmy_action, context_key = get_dummy_action(menu_id) if dummmy_action.id not in app.commands: actions.append(dummmy_action) # NOTE: even if action is already registered, the `context` instance # may be new e.g. when closing and relaunching a viewer # in a notebook. Context key should be assigned regardless context[context_key] = partial(is_empty_menu, menu_id) app.register_actions(actions) napari-0.5.6/napari/_qt/_qapp_model/qactions/_debug.py000066400000000000000000000052641474413133200227420ustar00rootroot00000000000000"""Qt 'Debug' menu actions. The debug menu is for developer-focused functionality that we want to be easy-to-use and discoverable, but is not for the average user. """ from app_model.types import Action, KeyCode, KeyMod, SubmenuItem from qtpy.QtCore import QTimer from qtpy.QtWidgets import QFileDialog from napari._app_model.constants import MenuGroup, MenuId from napari._qt.qt_viewer import QtViewer from napari.utils import perf from napari.utils.history import get_save_history, update_save_history from napari.utils.translations import trans # Debug submenu DEBUG_SUBMENUS = [ ( MenuId.MENUBAR_DEBUG, SubmenuItem( submenu=MenuId.DEBUG_PERFORMANCE, title=trans._('Performance Trace'), ), ), ] def _start_trace_dialog(qt_viewer: QtViewer) -> None: """Open Save As dialog to start recording a trace file.""" dlg = QFileDialog() hist = get_save_history() dlg.setHistory(hist) filename, _ = dlg.getSaveFileName( qt_viewer, # parent trans._('Record performance trace file'), # caption hist[0], # directory in PyQt, dir in PySide 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: _start_trace(filename)) update_save_history(filename) def _start_trace(path: str) -> None: """Start recording a trace file.""" perf.timers.start_trace_file(path) def _stop_trace() -> None: """Stop recording a trace file.""" perf.timers.stop_trace_file() def _is_set_trace_active() -> bool: """Whether we are currently recording a set trace.""" return perf.timers.trace_file is not None Q_DEBUG_ACTIONS: list[Action] = [ Action( id='napari.window.debug.start_trace_dialog', title=trans._('Start Recording...'), callback=_start_trace_dialog, menus=[ {'id': MenuId.DEBUG_PERFORMANCE, 'group': MenuGroup.NAVIGATION} ], keybindings=[{'primary': KeyMod.Alt | KeyCode.KeyT}], enablement='not is_set_trace_active', status_tip=trans._('Start recording a trace file'), ), Action( id='napari.window.debug.stop_trace', title=trans._('Stop Recording...'), callback=_stop_trace, menus=[ {'id': MenuId.DEBUG_PERFORMANCE, 'group': MenuGroup.NAVIGATION} ], keybindings=[{'primary': KeyMod.Alt | KeyMod.Shift | KeyCode.KeyT}], enablement='is_set_trace_active', status_tip=trans._('Stop recording a trace file'), ), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/_file.py000066400000000000000000000220731474413133200225700ustar00rootroot00000000000000"""Qt 'File' menu Actions.""" import sys from pathlib import Path from app_model.types import ( Action, KeyCode, KeyMod, StandardKeyBinding, SubmenuItem, ) from napari._app_model.constants import MenuGroup, MenuId from napari._app_model.context import ( LayerListContextKeys as LLCK, LayerListSelectionContextKeys as LLSCK, ) from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer from napari._qt.widgets.qt_viewer_buttons import add_new_points, add_new_shapes from napari.utils.translations import trans # File submenus FILE_SUBMENUS = [ ( MenuId.MENUBAR_FILE, SubmenuItem( submenu=MenuId.FILE_NEW_LAYER, title=trans._('New Layer'), group=MenuGroup.NAVIGATION, order=0, ), ), ( MenuId.MENUBAR_FILE, SubmenuItem( submenu=MenuId.FILE_OPEN_WITH_PLUGIN, title=trans._('Open with Plugin'), group=MenuGroup.OPEN, order=99, ), ), ( MenuId.MENUBAR_FILE, SubmenuItem( submenu=MenuId.FILE_SAMPLES, title=trans._('Open Sample'), group=MenuGroup.OPEN, order=100, ), ), ( MenuId.MENUBAR_FILE, SubmenuItem( submenu=MenuId.FILE_IO_UTILITIES, title=trans._('IO Utilities'), group=MenuGroup.UTIL, order=101, ), ), ( MenuId.MENUBAR_FILE, SubmenuItem( submenu=MenuId.FILE_ACQUIRE, title=trans._('Acquire'), group=MenuGroup.UTIL, order=101, ), ), ] # File actions def new_labels(qt_viewer: QtViewer): viewer = qt_viewer.viewer viewer._new_labels() def new_points(qt_viewer: QtViewer): viewer = qt_viewer.viewer add_new_points(viewer) def new_shapes(qt_viewer: QtViewer): viewer = qt_viewer.viewer add_new_shapes(viewer) def _open_files_with_plugin(qt_viewer: QtViewer): qt_viewer._open_files_dialog(choose_plugin=True) def _open_files_as_stack_with_plugin(qt_viewer: QtViewer): qt_viewer._open_files_dialog_as_stack_dialog(choose_plugin=True) def _open_folder_with_plugin(qt_viewer: QtViewer): qt_viewer._open_folder_dialog(choose_plugin=True) def _save_selected_layers(qt_viewer: QtViewer): qt_viewer._save_layers_dialog(selected=True) def _restart(window: Window): window._qt_window.restart() def _close_window(window: Window): window._qt_window.close(quit_app=False, confirm_need=True) def _close_app(window: Window): window._qt_window.close(quit_app=True, confirm_need=True) Q_FILE_ACTIONS: list[Action] = [ Action( id='napari.window.file.new_layer.new_labels', title=trans._('Labels'), callback=new_labels, menus=[{'id': MenuId.FILE_NEW_LAYER, 'group': MenuGroup.NAVIGATION}], ), Action( id='napari.window.file.new_layer.new_points', title=trans._('Points'), callback=new_points, menus=[{'id': MenuId.FILE_NEW_LAYER, 'group': MenuGroup.NAVIGATION}], ), Action( id='napari.window.file.new_layer.new_shapes', title=trans._('Shapes'), callback=new_shapes, menus=[{'id': MenuId.FILE_NEW_LAYER, 'group': MenuGroup.NAVIGATION}], ), Action( id='napari.window.file._image_from_clipboard', title=trans._('New Image from Clipboard'), callback=QtViewer._image_from_clipboard, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.NAVIGATION}], keybindings=[{'primary': KeyMod.CtrlCmd | KeyCode.KeyN}], ), Action( id='napari.window.file.open_files_dialog', title=trans._('Open File(s)...'), callback=QtViewer._open_files_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.OPEN}], keybindings=[StandardKeyBinding.Open], ), Action( id='napari.window.file.open_files_as_stack_dialog', title=trans._('Open Files as Stack...'), callback=QtViewer._open_files_dialog_as_stack_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.OPEN}], keybindings=[{'primary': KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyO}], ), Action( id='napari.window.file.open_folder_dialog', title=trans._('Open Folder...'), callback=QtViewer._open_folder_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.OPEN}], keybindings=[ {'primary': KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyO} ], ), Action( id='napari.window.file._open_files_with_plugin', title=trans._('Open File(s)...'), callback=_open_files_with_plugin, menus=[{'id': MenuId.FILE_OPEN_WITH_PLUGIN, 'group': MenuGroup.OPEN}], ), Action( id='napari.window.file._open_files_as_stack_with_plugin', title=trans._('Open Files as Stack...'), callback=_open_files_as_stack_with_plugin, menus=[{'id': MenuId.FILE_OPEN_WITH_PLUGIN, 'group': MenuGroup.OPEN}], ), Action( id='napari.window.file._open_folder_with_plugin', title=trans._('Open Folder...'), callback=_open_folder_with_plugin, menus=[{'id': MenuId.FILE_OPEN_WITH_PLUGIN, 'group': MenuGroup.OPEN}], ), Action( id='napari.window.file.show_preferences_dialog', title=trans._('Preferences'), callback=Window._open_preferences_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.PREFERENCES}], # TODO: revert to `StandardKeyBinding.Preferences` after app-model>0.2.0 keybindings=[{'primary': KeyMod.CtrlCmd | KeyCode.Comma}], ), # TODO: # If app-model supports a `kwargs` field (see: # https://github.com/pyapp-kit/app-model/issues/52) # it may allow registration of the same `id` when args are different and # we can re-use `DLG_SAVE_LAYERS` below. Action( id='napari.window.file.save_layers_dialog.selected', title=trans._('Save Selected Layers...'), callback=_save_selected_layers, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[StandardKeyBinding.Save], enablement=(LLSCK.num_selected_layers > 0), ), Action( id='napari.window.file.save_layers_dialog', title=trans._('Save All Layers...'), callback=QtViewer._save_layers_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[{'primary': KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyS}], enablement=(LLCK.num_layers > 0), ), Action( id='napari.window.file.save_canvas_screenshot_dialog', title=trans._('Save Screenshot...'), callback=QtViewer._screenshot_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[{'primary': KeyMod.Alt | KeyCode.KeyS}], status_tip=trans._( 'Save screenshot of current display, default: .png' ), ), Action( id='napari.window.file.save_viewer_screenshot_dialog', title=trans._('Save Screenshot with Viewer...'), callback=Window._screenshot_dialog, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[{'primary': KeyMod.Alt | KeyMod.Shift | KeyCode.KeyS}], status_tip=trans._( 'Save screenshot of current display, default: .png' ), ), Action( id='napari.window.file.copy_canvas_screenshot', title=trans._('Copy Screenshot to Clipboard'), callback=QtViewer.clipboard, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[{'primary': KeyMod.Alt | KeyCode.KeyC}], status_tip=trans._( 'Copy screenshot of current display to the clipboard' ), ), Action( id='napari.window.file.copy_viewer_screenshot', title=trans._('Copy Screenshot with Viewer to Clipboard'), callback=Window.clipboard, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.SAVE}], keybindings=[{'primary': KeyMod.Alt | KeyMod.Shift | KeyCode.KeyC}], status_tip=trans._( 'Copy screenshot of current display with the viewer to the clipboard' ), ), Action( id='napari.window.file.close_dialog', title=trans._('Close Window'), callback=_close_window, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.CLOSE}], keybindings=[StandardKeyBinding.Close], ), Action( id='napari.window.file.restart', title=trans._('Restart'), callback=_restart, menus=[ { 'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.CLOSE, 'when': ( Path(sys.executable).parent / '.napari_is_bundled' ).exists(), } ], ), Action( id='napari.window.file.quit_dialog', title=trans._('Exit'), callback=_close_app, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.CLOSE}], keybindings=[StandardKeyBinding.Quit], ), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/_help.py000066400000000000000000000072321474413133200226010ustar00rootroot00000000000000"""Qt 'Help' menu Actions.""" import sys from functools import partial from webbrowser import open as web_open from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod from packaging.version import parse from napari import __version__ from napari._app_model.constants import MenuGroup, MenuId from napari._qt.dialogs.qt_about import QtAbout from napari._qt.qt_main_window import Window from napari.utils.translations import trans def _show_about(window: Window): QtAbout.showAbout(window._qt_window) v = parse(__version__) VERSION = 'dev' if v.is_devrelease or v.is_prerelease else str(v.base_version) HELP_URLS: dict[str, str] = { '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', } Q_HELP_ACTIONS: list[Action] = [ Action( id='napari.window.help.info', title=trans._('‎napari Info'), callback=_show_about, menus=[{'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER}], status_tip=trans._('About napari'), keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.Slash)], ), Action( id='napari.window.help.about_macos', title=trans._('About napari'), callback=_show_about, menus=[ { 'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER, 'when': sys.platform == 'darwin', } ], status_tip=trans._('About napari'), ), Action( id='napari.window.help.getting_started', title=trans._('Getting started'), callback=partial(web_open, url=HELP_URLS['getting_started']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id='napari.window.help.tutorials', title=trans._('Tutorials'), callback=partial(web_open, url=HELP_URLS['tutorials']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id='napari.window.help.layers_guide', title=trans._('Using Layers Guides'), callback=partial(web_open, url=HELP_URLS['layers_guide']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id='napari.window.help.examples', title=trans._('Examples Gallery'), callback=partial(web_open, url=HELP_URLS['examples_gallery']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id='napari.window.help.release_notes', title=trans._('Release Notes'), callback=partial(web_open, url=HELP_URLS['release_notes']), menus=[ { 'id': MenuId.MENUBAR_HELP, 'when': VERSION != 'dev', 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.window.help.github_issue', title=trans._('Report an issue on GitHub'), callback=partial(web_open, url=HELP_URLS['github_issue']), menus=[ { 'id': MenuId.MENUBAR_HELP, 'when': VERSION == 'dev', 'group': MenuGroup.NAVIGATION, } ], ), Action( id='napari.window.help.homepage', title=trans._('napari homepage'), callback=partial(web_open, url=HELP_URLS['homepage']), menus=[{'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.NAVIGATION}], ), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/_layerlist_context.py000066400000000000000000000142421474413133200254240ustar00rootroot00000000000000"""Qt 'Layer' menu Actions.""" from __future__ import annotations import json import pickle import numpy as np from app_model.expressions import parse_expression from app_model.types import Action from qtpy.QtCore import QMimeData from qtpy.QtWidgets import QApplication from napari._app_model.constants import MenuGroup, MenuId from napari._app_model.context import LayerListSelectionContextKeys as LLSCK from napari.components import LayerList from napari.layers import Layer from napari.utils.notifications import show_warning from napari.utils.translations import trans __all__ = ('Q_LAYERLIST_CONTEXT_ACTIONS', 'is_valid_spatial_in_clipboard') def _numpy_to_list(d: dict) -> dict: for k, v in list(d.items()): if isinstance(v, np.ndarray): d[k] = v.tolist() return d def _set_data_in_clipboard(data: dict) -> None: data = _numpy_to_list(data) clip = QApplication.clipboard() if clip is None: show_warning('Cannot access clipboard') return d = json.dumps(data) p = pickle.dumps(data) mime_data = QMimeData() mime_data.setText(d) mime_data.setData('application/octet-stream', p) clip.setMimeData(mime_data) def _copy_spatial_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard( { 'affine': layer.affine.affine_matrix, 'rotate': layer.rotate, 'scale': layer.scale, 'shear': layer.shear, 'translate': layer.translate, } ) def _copy_affine_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard({'affine': layer.affine.affine_matrix}) def _copy_rotate_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard({'rotate': layer.rotate}) def _copy_shear_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard({'shear': layer.shear}) def _copy_scale_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard({'scale': layer.scale}) def _copy_translate_to_clipboard(layer: Layer) -> None: _set_data_in_clipboard({'translate': layer.translate}) def _get_spatial_from_clipboard() -> dict | None: clip = QApplication.clipboard() if clip is None: return None mime_data = clip.mimeData() if mime_data is None: # pragma: no cover # we should never get here, but just in case return None if mime_data.data('application/octet-stream'): return pickle.loads(mime_data.data('application/octet-stream')) # type: ignore[arg-type] return json.loads(mime_data.text()) def _paste_spatial_from_clipboard(ll: LayerList) -> None: try: loaded = _get_spatial_from_clipboard() except (json.JSONDecodeError, pickle.UnpicklingError): show_warning('Cannot parse clipboard data') return if loaded is None: show_warning('Cannot access clipboard') return for layer in ll.selection: for key in loaded: loaded_attr_value = loaded[key] if isinstance(loaded_attr_value, list): loaded_attr_value = np.array(loaded_attr_value) if key == 'shear': loaded_attr_value = loaded_attr_value[ -(layer.ndim * (layer.ndim - 1)) // 2 : ] elif key == 'affine': loaded_attr_value = loaded_attr_value[ -(layer.ndim + 1) :, -(layer.ndim + 1) : ] elif isinstance(loaded_attr_value, np.ndarray): if loaded_attr_value.ndim == 1: loaded_attr_value = loaded_attr_value[-layer.ndim :] elif loaded_attr_value.ndim == 2: loaded_attr_value = loaded_attr_value[ -layer.ndim :, -layer.ndim : ] setattr(layer, key, loaded_attr_value) def is_valid_spatial_in_clipboard() -> bool: try: loaded = _get_spatial_from_clipboard() except (json.JSONDecodeError, pickle.UnpicklingError): return False if not isinstance(loaded, dict): return False return set(loaded).issubset( {'affine', 'rotate', 'scale', 'shear', 'translate'} ) Q_LAYERLIST_CONTEXT_ACTIONS = [ Action( id='napari.layer.copy_all_to_clipboard', title=trans._('Copy all to clipboard'), callback=_copy_spatial_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.copy_affine_to_clipboard', title=trans._('Copy affine to clipboard'), callback=_copy_affine_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.copy_rotate_to_clipboard', title=trans._('Copy rotate to clipboard'), callback=_copy_rotate_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.copy_scale_to_clipboard', title=trans._('Copy scale to clipboard'), callback=_copy_scale_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.copy_shear_to_clipboard', title=trans._('Copy shear to clipboard'), callback=_copy_shear_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.copy_translate_to_clipboard', title=trans._('Copy translate to clipboard'), callback=_copy_translate_to_clipboard, menus=[{'id': MenuId.LAYERS_CONTEXT_COPY_SPATIAL}], enablement=(LLSCK.num_selected_layers == 1), ), Action( id='napari.layer.paste_spatial_from_clipboard', title=trans._('Apply scale/transforms from Clipboard'), callback=_paste_spatial_from_clipboard, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.LAYERLIST_CONTEXT.COPY_SPATIAL, } ], enablement=parse_expression('valid_spatial_json_clipboard'), ), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/_layers_actions.py000066400000000000000000000051461474413133200246720ustar00rootroot00000000000000from app_model.types import Action, SubmenuItem from napari._app_model.constants import MenuGroup, MenuId from napari.utils.translations import trans LAYERS_SUBMENUS = [ ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_VISUALIZE, title=trans._('Visualize'), group=MenuGroup.NAVIGATION, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_ANNOTATE, title=trans._('Annotate'), group=MenuGroup.NAVIGATION, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_DATA, title=trans._('Data'), group=MenuGroup.LAYERS.CONVERT, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_LAYER_TYPE, title=trans._('Layer Type'), group=MenuGroup.LAYERS.CONVERT, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_TRANSFORM, title=trans._('Transform'), group=MenuGroup.LAYERS.GEOMETRY, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_MEASURE, title=trans._('Measure'), group=MenuGroup.LAYERS.GEOMETRY, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_FILTER, title=trans._('Filter'), group=MenuGroup.LAYERS.GENERATE, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_REGISTER, title=trans._('Register'), group=MenuGroup.LAYERS.GENERATE, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_PROJECT, title=trans._('Project'), group=MenuGroup.LAYERS.GENERATE, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_SEGMENT, title=trans._('Segment'), group=MenuGroup.LAYERS.GENERATE, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_TRACK, title=trans._('Track'), group=MenuGroup.LAYERS.GENERATE, ), ), ( MenuId.MENUBAR_LAYERS, SubmenuItem( submenu=MenuId.LAYERS_CLASSIFY, title=trans._('Classify'), group=MenuGroup.LAYERS.GENERATE, ), ), ] # placeholder, add actions here! LAYERS_ACTIONS: list[Action] = [] napari-0.5.6/napari/_qt/_qapp_model/qactions/_plugins.py000066400000000000000000000043621474413133200233330ustar00rootroot00000000000000"""Qt 'Plugins' menu Actions.""" from importlib.util import find_spec from logging import getLogger from app_model.types import Action from napari._app_model.constants import MenuGroup, MenuId from napari._qt.dialogs.qt_plugin_report import QtPluginErrReporter from napari._qt.qt_main_window import Window from napari.utils.translations import trans logger = getLogger(__name__) def _plugin_manager_dialog_avail() -> bool: """Returns whether the plugin manager class is available.""" plugin_dlg = find_spec('napari_plugin_manager') if plugin_dlg: return True # not available logger.debug('QtPluginDialog not available') return False def _show_plugin_install_dialog(window: Window) -> None: """Show dialog that allows users to install and enable/disable plugins.""" # TODO: Once menu contributions supported, `napari_plugin_manager` should be # amended to be a napari plugin and simply add this menu item itself. # This callback is only used when this package is available, thus we do not check from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog QtPluginDialog(window._qt_window).exec_() def _show_plugin_err_reporter(window: Window) -> None: """Show dialog that allows users to review and report plugin errors.""" QtPluginErrReporter(parent=window._qt_window).exec_() # type: ignore [attr-defined] Q_PLUGINS_ACTIONS: list[Action] = [ Action( id='napari.window.plugins.plugin_install_dialog', title=trans._('Install/Uninstall Plugins...'), menus=[ { 'id': MenuId.MENUBAR_PLUGINS, 'group': MenuGroup.PLUGINS, 'order': 1, 'when': _plugin_manager_dialog_avail(), } ], callback=_show_plugin_install_dialog, ), Action( id='napari.window.plugins.plugin_err_reporter', title=trans._('Plugin Errors...'), menus=[ { 'id': MenuId.MENUBAR_PLUGINS, 'group': MenuGroup.PLUGINS, 'order': 2, } ], callback=_show_plugin_err_reporter, status_tip=trans._( 'Review stack traces for plugin exceptions and notify developers' ), ), ] napari-0.5.6/napari/_qt/_qapp_model/qactions/_toggle_action.py000066400000000000000000000061201474413133200244620ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any from app_model.types import Action, ToggleRule from napari._qt.qt_main_window import Window 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, # noqa: A002 title: str, viewer_attribute: str, sub_attribute: str, **kwargs: Any, ) -> None: def get_current(viewer: Viewer) -> bool: """return the current value of the viewer attribute""" attr = getattr(viewer, viewer_attribute) return getattr(attr, sub_attribute) def toggle(viewer: Viewer) -> None: """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, ) class DockWidgetToggleAction(Action): """`Action` subclass that toggles visibility of a `QtViewerDockWidget`. Parameters ---------- id : str The command id of the action. title : str The title of the action. Prefer capital case. dock_widget: str The DockWidget to toggle. **kwargs Additional keyword arguments to pass to the `Action` constructor. Examples -------- >>> action = DockWidgetToggleAction( ... id='some.command.id', ... title='Toggle Layer List', ... dock_widget='dockConsole', ... ) """ def __init__( self, *, id: str, # noqa: A002 title: str, dock_widget: str, **kwargs: Any, ) -> None: def toggle_dock_widget(window: Window) -> None: dock_widget_prop = getattr(window._qt_viewer, dock_widget) dock_widget_prop.setVisible(not dock_widget_prop.isVisible()) def get_current(window: Window) -> bool: dock_widget_prop = getattr(window._qt_viewer, dock_widget) return dock_widget_prop.isVisible() super().__init__( id=id, title=title, toggled=ToggleRule(get_current=get_current), callback=toggle_dock_widget, **kwargs, ) napari-0.5.6/napari/_qt/_qapp_model/qactions/_view.py000066400000000000000000000160361474413133200226250ustar00rootroot00000000000000"""Qt 'View' menu Actions.""" import sys from app_model.types import ( Action, KeyCode, KeyMod, StandardKeyBinding, SubmenuItem, ToggleRule, ) from napari._app_model.constants import MenuGroup, MenuId from napari._qt._qapp_model.qactions._toggle_action import ViewerToggleAction 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 from napari.viewer import Viewer # View submenus VIEW_SUBMENUS = [ ( MenuId.MENUBAR_VIEW, SubmenuItem(submenu=MenuId.VIEW_AXES, title=trans._('Axes')), ), ( MenuId.MENUBAR_VIEW, SubmenuItem(submenu=MenuId.VIEW_SCALEBAR, title=trans._('Scale Bar')), ), ] # View actions def _toggle_activity_dock(window: Window): window._status_bar._toggle_activity_dock() def _get_current_fullscreen_status(window: Window): return window._qt_window.isFullScreen() def _get_current_menubar_status(window: Window): return window._qt_window._toggle_menubar_visibility def _get_current_play_status(qt_viewer: QtViewer): return bool(qt_viewer.dims.is_playing) def _get_current_activity_dock_status(window: Window): return window._qt_window._activity_dialog.isVisible() def _tooltip_visibility_toggle() -> None: settings = get_settings().appearance settings.layer_tooltip_visibility = not settings.layer_tooltip_visibility def _get_current_tooltip_visibility() -> bool: return get_settings().appearance.layer_tooltip_visibility def _fit_to_view(viewer: Viewer): viewer.reset_view(reset_camera_angle=False) def _zoom_in(viewer: Viewer): viewer.camera.zoom *= 1.5 def _zoom_out(viewer: Viewer): viewer.camera.zoom /= 1.5 Q_VIEW_ACTIONS: list[Action] = [ Action( id='napari.window.view.toggle_fullscreen', title=trans._('Toggle Full Screen'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.NAVIGATION, 'order': 1, } ], callback=Window._toggle_fullscreen, keybindings=[StandardKeyBinding.FullScreen], toggled=ToggleRule(get_current=_get_current_fullscreen_status), ), Action( id='napari.window.view.toggle_menubar', title=trans._('Toggle Menubar Visibility'), 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'), toggled=ToggleRule(get_current=_get_current_menubar_status), ), Action( id='napari.window.view.toggle_play', title=trans._('Toggle Play'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.NAVIGATION, 'order': 3, } ], callback=Window._toggle_play, keybindings=[{'primary': KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyP}], toggled=ToggleRule(get_current=_get_current_play_status), ), Action( id='napari.viewer.fit_to_view', title=trans._('Fit to View'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.ZOOM, 'order': 1, } ], callback=_fit_to_view, keybindings=[StandardKeyBinding.OriginalSize], ), Action( id='napari.viewer.camera.zoom_in', title=trans._('Zoom In'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.ZOOM, 'order': 1, } ], callback=_zoom_in, keybindings=[StandardKeyBinding.ZoomIn], ), Action( id='napari.viewer.camera.zoom_out', title=trans._('Zoom Out'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.ZOOM, 'order': 1, } ], callback=_zoom_out, keybindings=[StandardKeyBinding.ZoomOut], ), Action( id='napari.window.view.toggle_activity_dock', title=trans._('Toggle Activity Dock'), menus=[ {'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.RENDER, 'order': 11} ], callback=_toggle_activity_dock, toggled=ToggleRule(get_current=_get_current_activity_dock_status), ), # TODO: this could be made into a toggle setting Action subclass # using a similar pattern to the above ViewerToggleAction classes Action( id='napari.window.view.toggle_layer_tooltips', title=trans._('Toggle Layer Tooltips'), menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.RENDER, 'order': 10, } ], callback=_tooltip_visibility_toggle, toggled=ToggleRule(get_current=_get_current_tooltip_visibility), ), ] MENUID_DICT = {'axes': MenuId.VIEW_AXES, 'scale_bar': MenuId.VIEW_SCALEBAR} toggle_action_details = [ ( 'napari.window.view.toggle_viewer_axes', trans._('Axes Visible'), 'axes', 'visible', ), ( 'napari.window.view.toggle_viewer_axes_colored', trans._('Axes Colored'), 'axes', 'colored', ), ( 'napari.window.view.toggle_viewer_axes_labels', trans._('Axes Labels'), 'axes', 'labels', ), ( 'napari.window.view.toggle_viewer_axesdashed', trans._('Axes Dashed'), 'axes', 'dashed', ), ( 'napari.window.view.toggle_viewer_axes_arrows', trans._('Axes Arrows'), 'axes', 'arrows', ), ( 'napari.window.view.toggle_viewer_scale_bar', trans._('Scale Bar Visible'), 'scale_bar', 'visible', ), ( 'napari.window.view.toggle_viewer_scale_bar_colored', trans._('Scale Bar Colored'), 'scale_bar', 'colored', ), ( 'napari.window.view.toggle_viewer_scale_bar_ticks', trans._('Scale Bar Ticks'), 'scale_bar', 'ticks', ), ] # Add `Action`s that toggle various viewer `axes` and `scale_bar` sub-attributes # E.g., `toggle_viewer_scale_bar_ticks` toggles the sub-attribute `ticks` of the # viewer attribute `scale_bar` for cmd, cmd_title, viewer_attr, sub_attr in toggle_action_details: Q_VIEW_ACTIONS.append( ViewerToggleAction( id=cmd, title=cmd_title, viewer_attribute=viewer_attr, sub_attribute=sub_attr, menus=[{'id': MENUID_DICT[viewer_attr]}], ) ) napari-0.5.6/napari/_qt/_qapp_model/qactions/_window.py000066400000000000000000000023721474413133200231600ustar00rootroot00000000000000"""Qt 'Window' menu Actions.""" from app_model.types import Action from napari._app_model.constants import MenuGroup, MenuId from napari._qt._qapp_model.qactions._toggle_action import ( DockWidgetToggleAction, ) from napari.utils.translations import trans Q_WINDOW_ACTIONS: list[Action] = [] toggle_action_details = [ ( 'napari:window:window:toggle_window_console', trans._('Console'), 'dockConsole', trans._('Toggle console panel'), ), ( 'napari:window:window:toggle_layer_controls', trans._('Layer Controls'), 'dockLayerControls', trans._('Toggle layer controls panel'), ), ( 'napari:window:window:toggle_layer_list', trans._('Layer List'), 'dockLayerList', trans._('Toggle layer list panel'), ), ] for cmd_id, cmd_title, dock_widget, status_tip in toggle_action_details: Q_WINDOW_ACTIONS.append( DockWidgetToggleAction( id=cmd_id, title=cmd_title, dock_widget=dock_widget, menus=[ { 'id': MenuId.MENUBAR_WINDOW, 'group': MenuGroup.NAVIGATION, } ], status_tip=status_tip, ) ) napari-0.5.6/napari/_qt/_qplugins/000077500000000000000000000000001474413133200170345ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_qplugins/__init__.py000066400000000000000000000003651474413133200211510ustar00rootroot00000000000000from napari._qt._qplugins._qnpe2 import ( _rebuild_npe1_plugins_menu, _rebuild_npe1_samples_menu, _register_qt_actions, ) __all__ = [ '_rebuild_npe1_plugins_menu', '_rebuild_npe1_samples_menu', '_register_qt_actions', ] napari-0.5.6/napari/_qt/_qplugins/_qnpe2.py000066400000000000000000000370751474413133200206060ustar00rootroot00000000000000"""Plugin related functions that require Qt. Non-Qt plugin functions can be found in: `napari/plugins/_npe2.py` """ from __future__ import annotations import inspect from functools import partial from itertools import chain from typing import ( TYPE_CHECKING, Any, Optional, Union, cast, ) from app_model import Action from app_model.types import SubmenuItem, ToggleRule from magicgui.type_map._magicgui import MagicFactory from magicgui.widgets import FunctionGui, Widget from npe2 import plugin_manager as pm from qtpy.QtWidgets import QWidget from napari._app_model import get_app_model from napari._app_model.constants import MenuGroup, MenuId from napari._qt._qapp_model.injection._qproviders import ( _provide_viewer_or_raise, _provide_window, _provide_window_or_raise, ) from napari.errors.reader_errors import MultipleReaderError from napari.plugins import menu_item_template, plugin_manager from napari.plugins._npe2 import _when_group_order, get_widget_contribution from napari.utils.events import Event from napari.utils.translations import trans from napari.viewer import Viewer if TYPE_CHECKING: from npe2.manifest import PluginManifest from npe2.plugin_manager import PluginName from npe2.types import WidgetCreator from napari.qt import QtViewer # TODO: This is a separate function from `_build_samples_submenu_actions` so it # can be easily deleted once npe1 is no longer supported. def _rebuild_npe1_samples_menu() -> None: # pragma: no cover """Register submenu and actions for all npe1 plugins, clearing all first.""" app = get_app_model() # Unregister all existing npe1 sample menu actions and submenus if unreg := plugin_manager._unreg_sample_submenus: unreg() if unreg := plugin_manager._unreg_sample_actions: unreg() sample_actions: list[Action] = [] sample_submenus: list[Any] = [] for plugin_name, samples in plugin_manager._sample_data.items(): multiprovider = len(samples) > 1 if multiprovider: submenu_id = f'napari/file/samples/{plugin_name}' submenu = ( MenuId.FILE_SAMPLES, SubmenuItem(submenu=submenu_id, title=trans._(plugin_name)), ) sample_submenus.append(submenu) else: submenu_id = MenuId.FILE_SAMPLES for sample_name, sample_dict in samples.items(): _add_sample_partial = partial( _add_sample, plugin=plugin_name, sample=sample_name, ) display_name = sample_dict['display_name'].replace('&', '&&') if multiprovider: title = display_name else: title = menu_item_template.format(plugin_name, display_name) action: Action = Action( id=f'{plugin_name}:{display_name}', title=title, menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], callback=_add_sample_partial, ) sample_actions.append(action) if sample_submenus: unreg_sample_submenus = app.menus.append_menu_items(sample_submenus) plugin_manager._unreg_sample_submenus = unreg_sample_submenus if sample_actions: unreg_sample_actions = app.register_actions(sample_actions) plugin_manager._unreg_sample_actions = unreg_sample_actions # TODO: This should be deleted once npe1 is no longer supported. def _toggle_or_get_widget_npe1( plugin: str, widget_name: str, name: str, hook_type: str, ) -> None: # pragma: no cover """Toggle if widget already built otherwise return widget for npe1.""" window = _provide_window_or_raise( msg='Note that widgets cannot be opened in headless mode.' ) if window and (dock_widget := window._dock_widgets.get(name)): dock_widget.setVisible(not dock_widget.isVisible()) return if hook_type == 'dock': window.add_plugin_dock_widget(plugin, widget_name) else: window._add_plugin_function_widget(plugin, widget_name) def _rebuild_npe1_plugins_menu() -> None: """Register widget submenu and actions for all npe1 plugins, clearing all first.""" app = get_app_model() # Unregister all existing npe1 plugin menu actions and submenus if unreg := plugin_manager._unreg_plugin_submenus: unreg() if unreg := plugin_manager._unreg_plugin_actions: unreg() widget_actions: list[Action] = [] widget_submenus: list[Any] = [] for hook_type, (plugin_name, widgets) in chain( plugin_manager.iter_widgets() ): multiprovider = len(widgets) > 1 if multiprovider: submenu_id = f'napari/plugins/{plugin_name}' submenu = ( MenuId.MENUBAR_PLUGINS, SubmenuItem( submenu=submenu_id, title=trans._(plugin_name), group=MenuGroup.PLUGIN_MULTI_SUBMENU, ), ) widget_submenus.append(submenu) else: submenu_id = MenuId.MENUBAR_PLUGINS for widget_name in widgets: full_name = menu_item_template.format(plugin_name, widget_name) title = widget_name if multiprovider else full_name _widget_callback = partial( _toggle_or_get_widget_npe1, plugin=plugin_name, widget_name=widget_name, name=full_name, hook_type=hook_type, ) _get_current_dock_status_partial = partial( _get_current_dock_status, full_name=full_name, ) action: Action = Action( id=f'{plugin_name}:{widget_name.replace("&", "&&")}', title=title.replace('&', '&&'), menus=[ { 'id': submenu_id, 'group': MenuGroup.PLUGIN_SINGLE_CONTRIBUTIONS, } ], callback=_widget_callback, toggled=ToggleRule( get_current=_get_current_dock_status_partial ), ) widget_actions.append(action) if widget_submenus: unreg_plugin_submenus = app.menus.append_menu_items(widget_submenus) plugin_manager._unreg_plugin_submenus = unreg_plugin_submenus if widget_actions: unreg_plugin_actions = app.register_actions(widget_actions) plugin_manager._unreg_plugin_actions = unreg_plugin_actions def _get_contrib_parent_menu( multiprovider: bool, parent_menu: MenuId, mf: PluginManifest, group: Optional[str] = None, ) -> tuple[str, list[tuple[str, SubmenuItem]]]: """Get parent menu of plugin contribution (samples/widgets). If plugin provides multiple contributions, create a new submenu item. """ submenu: list[tuple[str, SubmenuItem]] = [] if multiprovider: submenu_id = f'{parent_menu}/{mf.name}' submenu = [ ( parent_menu, SubmenuItem( submenu=submenu_id, title=trans._(mf.display_name), group=group, ), ), ] else: submenu_id = parent_menu return submenu_id, submenu # Note `QtViewer` gets added to `injection_store.namespace` during # `init_qactions` so does not need to be imported for type annotation resolution def _add_sample(qt_viewer: QtViewer, plugin: str, sample: str) -> None: from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading try: qt_viewer.viewer.open_sample(plugin, sample) except MultipleReaderError as e: handle_gui_reading( [str(p) for p in e.paths], qt_viewer, stack=False, ) def _build_samples_submenu_actions( mf: PluginManifest, ) -> tuple[list[tuple[str, SubmenuItem]], list[Action]]: """Build sample data submenu and actions for a single npe2 plugin manifest.""" from napari._app_model.constants import MenuGroup, MenuId from napari.plugins import menu_item_template # If no sample data, return if not mf.contributions.sample_data: return [], [] sample_data = mf.contributions.sample_data multiprovider = len(sample_data) > 1 submenu_id, submenu = _get_contrib_parent_menu( multiprovider, MenuId.FILE_SAMPLES, mf, ) sample_actions: list[Action] = [] for sample in sample_data: _add_sample_partial = partial( _add_sample, plugin=mf.name, sample=sample.key, ) if multiprovider: title = sample.display_name else: title = menu_item_template.format( mf.display_name, sample.display_name ) # To display '&' instead of creating a shortcut title = title.replace('&', '&&') action: Action = Action( id=f'{mf.name}:{sample.key}', title=title, menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], callback=_add_sample_partial, ) sample_actions.append(action) return submenu, sample_actions def _get_widget_viewer_param( widget_callable: WidgetCreator, widget_name: str ) -> str: """Get widget parameter name for `viewer` (if any) and check type.""" if inspect.isclass(widget_callable) and issubclass( widget_callable, (QWidget, Widget), ): widget_param = '' try: sig = inspect.signature(widget_callable.__init__) except ValueError: # pragma: no cover # Inspection can fail when adding to bundled viewer as it thinks widget is # a builtin pass else: for param in sig.parameters.values(): if param.name == 'napari_viewer' or param.annotation in ( 'napari.viewer.Viewer', Viewer, ): widget_param = param.name break # For magicgui type widget contributions, `Viewer` injection is done by # `magicgui.register_type`. elif isinstance(widget_callable, MagicFactory) or inspect.isfunction( widget_callable ): widget_param = '' else: raise TypeError( trans._( "'{widget}' must be `QtWidgets.QWidget` or `magicgui.widgets.Widget` subclass, `MagicFactory` instance or function. Please raise an issue in napari GitHub with the plugin and widget you were trying to use.", deferred=True, widget=widget_name, ) ) return widget_param def _toggle_or_get_widget( plugin: str, widget_name: str, full_name: str, ) -> Optional[tuple[Union[FunctionGui, QWidget, Widget], str]]: """Toggle if widget already built otherwise return widget. Returned widget will be added to main window by a processor. Note for magicgui type widget contributions, `Viewer` injection is done by `magicgui.register_type` instead of a provider via annnotation. """ viewer = _provide_viewer_or_raise( msg='Note that widgets cannot be opened in headless mode.', ) window = viewer.window if window and (dock_widget := window._dock_widgets.get(full_name)): dock_widget.setVisible(not dock_widget.isVisible()) return None # Get widget param name (if any) and check type widget_callable, _ = get_widget_contribution(plugin, widget_name) # type: ignore [misc] widget_param = _get_widget_viewer_param(widget_callable, widget_name) kwargs = {} if widget_param: kwargs[widget_param] = viewer return widget_callable(**kwargs), full_name def _get_current_dock_status(full_name: str) -> bool: window = _provide_window_or_raise( msg='Note that widgets cannot be opened in headless mode.', ) if full_name in window._dock_widgets: return window._dock_widgets[full_name].isVisible() return False def _build_widgets_submenu_actions( mf: PluginManifest, ) -> tuple[list[tuple[str, SubmenuItem]], list[Action]]: """Build widget submenu and actions for a single npe2 plugin manifest.""" # If no widgets, return if not mf.contributions.widgets: return [], [] # if this plugin declares any menu items, its actions should have the # plugin name. # TODO: update once plugin has self menus - they shouldn't exclude it # from the shorter name declares_menu_items = any( len(pm.instance()._command_menu_map[mf.name][command.id]) for command in mf.contributions.commands or [] ) widgets = mf.contributions.widgets multiprovider = len(widgets) > 1 default_submenu_id, default_submenu = _get_contrib_parent_menu( multiprovider, MenuId.MENUBAR_PLUGINS, mf, MenuGroup.PLUGIN_MULTI_SUBMENU, ) needs_full_title = declares_menu_items or not multiprovider widget_actions: list[Action] = [] for widget in widgets: full_name = menu_item_template.format( mf.display_name, widget.display_name, ) _widget_callback = partial( _toggle_or_get_widget, plugin=mf.name, widget_name=widget.display_name, full_name=full_name, ) _get_current_dock_status_partial = partial( _get_current_dock_status, full_name=full_name, ) action_menus = [ dict({'id': menu_key}, **_when_group_order(menu_item)) for menu_key, menu_items in pm.instance() ._command_menu_map[mf.name][widget.command] .items() for menu_item in menu_items ] + [ { 'id': default_submenu_id, 'group': MenuGroup.PLUGIN_SINGLE_CONTRIBUTIONS, } ] title = full_name if needs_full_title else widget.display_name # To display '&' instead of creating a shortcut title = title.replace('&', '&&') widget_actions.append( Action( id=f'{mf.name}:{widget.display_name}', title=title, callback=_widget_callback, menus=action_menus, toggled=ToggleRule( get_current=_get_current_dock_status_partial ), ) ) return default_submenu, widget_actions def _register_qt_actions(mf: PluginManifest) -> None: """Register samples and widget actions and submenus from a manifest. This is called when a plugin is registered or enabled and it adds the plugin's sample and widget actions and submenus to the app model registry. """ app = get_app_model() samples_submenu, sample_actions = _build_samples_submenu_actions(mf) widgets_submenu, widget_actions = _build_widgets_submenu_actions(mf) context = pm.get_context(cast('PluginName', mf.name)) actions = sample_actions + widget_actions if actions: context.register_disposable(app.register_actions(actions)) submenus = samples_submenu + widgets_submenu if submenus: context.register_disposable(app.menus.append_menu_items(submenus)) # Register dispose functions to remove plugin widgets from widget dictionary # `window._dock_widgets` if window := _provide_window(): for widget in mf.contributions.widgets or (): widget_event = Event(type_name='', value=widget.display_name) def _remove_widget(event: Event = widget_event) -> None: window._remove_dock_widget(event) context.register_disposable(_remove_widget) napari-0.5.6/napari/_qt/_tests/000077500000000000000000000000001474413133200163345ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_tests/__init__.py000066400000000000000000000000001474413133200204330ustar00rootroot00000000000000napari-0.5.6/napari/_qt/_tests/test_app.py000066400000000000000000000054451474413133200205350ustar00rootroot00000000000000import os from collections import defaultdict from unittest.mock import Mock import pytest from qtpy.QtWidgets import QAction, QShortcut from napari._qt.qt_event_loop import ( _ipython_has_eventloop, get_app, get_qapp, run, set_app_id, ) def test_qapp(qapp): qapp = get_qapp() with pytest.warns( FutureWarning, match='`QApplication` instance access through `get_app` is deprecated and will be removed in 0.6.0.\nPlease use `get_qapp` instead.\n', ): deprecated_qapp = get_app() assert qapp == deprecated_qapp @pytest.mark.skipif(os.name != 'nt', 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('') # app id can't be an empty string assert get_app_id() == 'custom_string' set_app_id(' ') assert get_app_id() == ' ' def test_run_outside_ipython(make_napari_viewer, qapp, monkeypatch): """Test that we don't incorrectly give ipython the event loop.""" assert not _ipython_has_eventloop() v1 = make_napari_viewer() assert not _ipython_has_eventloop() v2 = make_napari_viewer() 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(qtbot, 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 # due to throttled mouse_move, a timer is started by the viewer, so we # need to wait for it to be done qtbot.wait(10) napari-0.5.6/napari/_qt/_tests/test_async_slicing.py000066400000000000000000000240171474413133200225760ustar00rootroot00000000000000# The tests in this module for the new style of async slicing in napari: # https://napari.org/dev/naps/4-async-slicing.html from functools import partial import numpy as np import pytest from vispy.visuals import VolumeVisual from napari import Viewer from napari._tests.utils import LockableData from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.layers.image import VispyImageLayer from napari._vispy.layers.points import VispyPointsLayer from napari._vispy.layers.vectors import ( VispyVectorsLayer, generate_vector_meshes_2D, ) from napari.layers import Image, Layer, Points, Vectors from napari.utils.events import Event @pytest.fixture def rng() -> np.random.Generator: return np.random.default_rng(0) @pytest.fixture def _enable_async(_fresh_settings, make_napari_viewer): """ This fixture depends on _fresh_settings and make_napari_viewer to enforce proper order of fixture execution. """ from napari import settings settings.get_settings().experimental.async_ = True @pytest.mark.usefixtures('_enable_async') def test_async_slice_image_on_current_step_change( make_napari_viewer, qtbot, rng ): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) image = Image(data) vispy_image = setup_viewer_for_async_slicing(viewer, image) assert viewer.dims.current_step != (2, 0, 0) viewer.dims.current_step = (2, 0, 0) wait_until_vispy_image_data_equal(qtbot, vispy_image, data[2, :, :]) @pytest.mark.usefixtures('_enable_async') def test_async_out_of_bounds_layer_loaded(make_napari_viewer, qtbot): """Check that images that are out of bounds when slicing appear loaded. See https://github.com/napari/napari/issues/7070. """ viewer = make_napari_viewer() l0 = viewer.add_image(np.random.random((5, 5, 5))) l1 = viewer.add_image(np.random.random((5, 5, 5)), translate=(5, 0, 0)) assert viewer.dims.nsteps == (10, 5, 5) def layer_loaded(ly): return ly.loaded for i in range(viewer.dims.nsteps[0]): viewer.dims.current_step = (i, 0, 0) qtbot.waitUntil(partial(layer_loaded, l0), timeout=500) qtbot.waitUntil(partial(layer_loaded, l1), timeout=500) @pytest.mark.usefixtures('_enable_async') def test_async_slice_image_on_order_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 5, 7)) image = Image(data) vispy_image = setup_viewer_for_async_slicing(viewer, image) assert viewer.dims.order != (1, 0, 2) viewer.dims.order = (1, 0, 2) wait_until_vispy_image_data_equal(qtbot, vispy_image, data[:, 2, :]) @pytest.mark.usefixtures('_enable_async') def test_async_slice_image_on_ndisplay_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) image = Image(data) vispy_image = setup_viewer_for_async_slicing(viewer, image) assert viewer.dims.ndisplay != 3 viewer.dims.ndisplay = 3 wait_until_vispy_image_data_equal(qtbot, vispy_image, data) @pytest.mark.usefixtures('_enable_async') def test_async_slice_multiscale_image_on_pan(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] image = Image(data) vispy_image = setup_viewer_for_async_slicing(viewer, image) # Check that we're initially slicing the middle of the first dimension # over the whole of lowest resolution image. assert viewer.dims.not_displayed == (0,) assert viewer.dims.current_step[0] == 1 assert image._data_level == 1 np.testing.assert_equal(image.corner_pixels, [[0, 0, 0], [0, 3, 4]]) # Simulate panning to the left by changing the corner pixels in the last # dimension, which corresponds to x/columns, then triggering a reload. image.corner_pixels = np.array([[0, 0, 0], [0, 3, 2]]) image.events.reload(Event('reload', layer=image)) wait_until_vispy_image_data_equal(qtbot, vispy_image, data[1][0, 0:4, 0:3]) @pytest.mark.usefixtures('_enable_async') def test_async_slice_multiscale_image_on_zoom(qtbot, make_napari_viewer, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] image = Image(data) vispy_image = setup_viewer_for_async_slicing(viewer, image) # Check that we're initially slicing the middle of the first dimension # over the whole of lowest resolution image. assert viewer.dims.not_displayed == (0,) assert viewer.dims.current_step[0] == 1 assert image._data_level == 1 np.testing.assert_equal(image.corner_pixels, [[0, 0, 0], [0, 3, 4]]) # Simulate zooming into the middle of the higher resolution image. image._data_level = 0 image.corner_pixels = np.array([[0, 2, 3], [0, 5, 6]]) image.events.reload(Event('reload', layer=image)) wait_until_vispy_image_data_equal(qtbot, vispy_image, data[0][1, 2:6, 3:7]) @pytest.mark.usefixtures('_enable_async') def test_async_slice_points_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( [ [0, 2, 3], [1, 3, 4], [2, 4, 5], [3, 5, 6], [4, 6, 7], ] ) points = Points(data) vispy_points = setup_viewer_for_async_slicing(viewer, points) assert viewer.dims.current_step != (3, 0, 0) viewer.dims.current_step = (3, 0, 0) wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[5, 6]])) @pytest.mark.usefixtures('_enable_async') def test_async_slice_points_on_point_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() # Define data so that slicing at 1.6 in the first dimension should match the # second point, but won't if that index is prematurely rounded as for other # layers. data = np.array( [ [0, 2, 3], [1.4, 3, 4], [2.4, 4, 5], [3.4, 5, 6], [4, 6, 7], ] ) points = Points(data) vispy_points = setup_viewer_for_async_slicing(viewer, points) assert viewer.dims.point != (1.6, 0, 0) viewer.dims.point = (1.6, 0, 0) wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[3, 4]])) @pytest.mark.usefixtures('_enable_async') def test_async_slice_image_loaded(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) lockable_data = LockableData(data) layer = Image(lockable_data, multiscale=False) vispy_layer = setup_viewer_for_async_slicing(viewer, layer) assert layer.loaded assert viewer.dims.current_step != (2, 0, 0) with lockable_data.lock: viewer.dims.current_step = (2, 0, 0) assert not layer.loaded qtbot.waitUntil(lambda: layer.loaded) np.testing.assert_allclose(vispy_layer.node._data, data[2, :, :]) @pytest.mark.usefixtures('_enable_async') def test_async_slice_vectors_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( [ [[0, 2, 3], [1, 2, 2]], [[2, 4, 5], [0, -3, 3]], [[4, 6, 7], [3, 0, -2]], ] ) vectors = Vectors(data) vispy_vectors = setup_viewer_for_async_slicing(viewer, vectors) assert viewer.dims.current_step != (2, 0, 0) viewer.dims.current_step = (2, 0, 0) wait_until_vispy_vectors_data_equal( qtbot, vispy_vectors, np.array([[[2, 4, 5], [0, -3, 3]]]) ) @pytest.mark.usefixtures('_enable_async') def test_async_slice_two_layers_shutdown(make_napari_viewer): """See https://github.com/napari/napari/issues/6685""" viewer = make_napari_viewer() # To reproduce the issue, we need two points layers where the second has # some non-zero coordinates. viewer.add_points() points = viewer.add_points() points.add([[1, 2]]) viewer.close() def setup_viewer_for_async_slicing( viewer: Viewer, layer: Layer, ) -> VispyBaseLayer: # Initially force synchronous slicing so any slicing caused # by adding the layer finishes before any other slicing starts. with viewer._layer_slicer.force_sync(): # Add the layer and get the corresponding vispy layer. layer = viewer.add_layer(layer) vispy_layer = viewer.window._qt_viewer.layer_to_visual[layer] return vispy_layer def wait_until_vispy_image_data_equal( qtbot, vispy_layer: VispyImageLayer, expected_data: np.ndarray ) -> None: def assert_vispy_image_data_equal() -> None: node = vispy_layer.node data = ( node._last_data if isinstance(node, VolumeVisual) else node._data ) # Vispy node data may have been post-processed (e.g. through a colormap), # so check that values are close rather than exactly equal. np.testing.assert_allclose(data, expected_data) qtbot.waitUntil(assert_vispy_image_data_equal) def wait_until_vispy_points_data_equal( qtbot, vispy_layer: VispyPointsLayer, expected_data: np.ndarray ) -> None: def assert_vispy_points_data_equal() -> None: positions = vispy_layer.node._subvisuals[0]._data['a_position'] # Flip the coordinates because vispy uses xy instead of rc ordering. # Also only take the number of dimensions expected since vispy points # are always 3D even when displaying 2D slices. data = positions[:, -expected_data.shape[1] :: -1] np.testing.assert_array_equal(data, expected_data) qtbot.waitUntil(assert_vispy_points_data_equal) def wait_until_vispy_vectors_data_equal( qtbot, vispy_layer: VispyVectorsLayer, expected_data: np.ndarray ) -> None: def assert_vispy_vectors_data_equal() -> None: displayed = expected_data[..., -2:] exp_vertices, exp_faces = generate_vector_meshes_2D( displayed, 1, 1, 'triangle' ) meshdata = vispy_layer.node._meshdata vertices = meshdata.get_vertices() faces = meshdata.get_faces() # invert for vispy np.testing.assert_array_equal(vertices, exp_vertices[..., ::-1]) np.testing.assert_array_equal(faces, exp_faces) qtbot.waitUntil(assert_vispy_vectors_data_equal) napari-0.5.6/napari/_qt/_tests/test_open_file.py000066400000000000000000000012121474413133200217010ustar00rootroot00000000000000from unittest import mock import pytest @pytest.mark.parametrize('stack', [True, False]) def test_open_files_dialog(make_napari_viewer, stack): """Check `QtViewer._open_files_dialog` correnct when `stack=True`.""" viewer = make_napari_viewer() with ( mock.patch( 'napari._qt.qt_viewer.QtViewer._open_file_dialog_uni' ) as mock_file, mock.patch('napari._qt.qt_viewer.QtViewer._qt_open') as mock_open, ): viewer.window._qt_viewer._open_files_dialog(stack=stack) mock_open.assert_called_once_with( mock_file.return_value, choose_plugin=False, stack=stack, ) napari-0.5.6/napari/_qt/_tests/test_plugin_widgets.py000066400000000000000000000132701474413133200227740ustar00rootroot00000000000000from unittest.mock import Mock, patch import pytest from magicgui import magic_factory, magicgui from magicgui.widgets import Container from napari_plugin_engine import napari_hook_implementation from npe2 import DynamicPlugin from qtpy.QtWidgets import QWidget import napari from napari._app_model import get_app_model from napari._qt._qplugins._qnpe2 import _get_widget_viewer_param from napari._qt.qt_main_window import _instantiate_dock_widget from napari.utils._proxies import PublicOnlyProxy from napari.viewer import Viewer class ErrorWidget: pass class QWidget_example(QWidget): def __init__(self, napari_viewer): super().__init__() class QWidget_string_annnot(QWidget): def __init__(self, test: 'napari.viewer.Viewer'): super().__init__() # pragma: no cover class Container_example(Container): def __init__(self, test: Viewer): super().__init__() @magic_factory def magic_widget_example(): """Example magic factory widget.""" def callable_example(): @magicgui def magic_widget_example(): """Example magic factory widget.""" return magic_widget_example 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': QWidget_example, 'class_tuple': (QWidget_example, {'area': 'right'}), 'tuple_list': [(QWidget_example, {'area': 'right'}), (Widg2, {})], 'tuple_list2': [(QWidget_example, {'area': 'right'}), Widg2], 'bad_class': 1, 'bad_tuple1': (QWidget_example, 1), 'bad_double_tuple': ((QWidget_example, {}), (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']['Q Widget_example'][0] == QWidget_example if 'tuple_list' in request.node.name: assert widgets['Plugin']['Widg2'][0] == Widg2 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() @pytest.mark.parametrize( ('widget_callable', 'param'), [ (QWidget_example, 'napari_viewer'), (QWidget_string_annnot, 'test'), (Container_example, 'test'), ], ) def test_get_widget_viewer_param(widget_callable, param): """Test `_get_widget_viewer_param` returns correct parameter name.""" out = _get_widget_viewer_param(widget_callable, 'widget_name') assert out == param def test_get_widget_viewer_param_error(): """Test incorrect subclass raises error in `_get_widget_viewer_param`.""" with pytest.raises(TypeError) as e: _get_widget_viewer_param(ErrorWidget, 'widget_name') assert "'widget_name' must be `QtWidgets.QWidget`" in str(e) def test_widget_hide_destroy(make_napari_viewer, qtbot): """Test that widget hide and destroy works.""" viewer = make_napari_viewer() viewer.window.add_dock_widget(QWidget_example(viewer), name='test') dock_widget = viewer.window._dock_widgets['test'] # Check widget persists after hide widget = dock_widget.widget() dock_widget.title.hide_button.click() assert widget # Check that widget removed from `_dock_widgets` dict and parent # `QtViewerDockWidget` is `None` when closed dock_widget.destroyOnClose() assert 'test' not in viewer.window._dock_widgets assert widget.parent() is None widget.deleteLater() widget.close() qtbot.wait(50) @pytest.mark.parametrize( 'Widget', [ QWidget_example, Container_example, magic_widget_example, callable_example, ], ) def test_widget_types_supported( make_napari_viewer, tmp_plugin: DynamicPlugin, Widget, ): """Test all supported widget types correctly instantiated and call processor. The 4 parametrized `Widget`s represent the varing widget constructors and signatures that we want to support. """ # Using the decorator as a function on the parametrized `Widget` # This allows `Widget` to be callable object that, when called, returns an # instance of a widget tmp_plugin.contribute.widget(display_name='Widget')(Widget) app = get_app_model() viewer = make_napari_viewer() # `side_effect` required so widget is added to window and then # cleaned up, preventing widget leaks viewer.window.add_dock_widget = Mock( side_effect=viewer.window.add_dock_widget ) app.commands.execute_command('tmp_plugin:Widget') viewer.window.add_dock_widget.assert_called_once() napari-0.5.6/napari/_qt/_tests/test_proxy_fixture.py000066400000000000000000000012131474413133200226710ustar00rootroot00000000000000import 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.6/napari/_qt/_tests/test_prune_qt_connections.py000066400000000000000000000015541474413133200242110ustar00rootroot00000000000000from 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.6/napari/_qt/_tests/test_qt_event_filters.py000066400000000000000000000023271474413133200233260ustar00rootroot00000000000000import pytest from napari._qt.qt_event_filters import QtToolTipEventFilter pytest.importorskip('qtpy', reason='Cannot test event filters without qtpy.') @pytest.mark.parametrize( ('tooltip', 'is_qt_tag_present'), [ ( '' '

A widget to test that a rich text tooltip might be detected ' 'and therefore not changed to include a qt tag

' '', False, ), ( 'A widget to test that a non-rich text tooltip might ' 'be detected and therefore changed', True, ), ], ) def test_qt_tooltip_event_filter(qtbot, tooltip, is_qt_tag_present): """ Check that the tooltip event filter only changes tooltips with non-rich text. """ from qtpy.QtCore import QEvent from qtpy.QtWidgets import QWidget # event filter object and QEvent event_filter_handler = QtToolTipEventFilter() qevent = QEvent(QEvent.ToolTipChange) # check if tooltip is changed by the event filter widget = QWidget() qtbot.addWidget(widget) widget.setToolTip(tooltip) event_filter_handler.eventFilter(widget, qevent) assert ('' in widget.toolTip()) == is_qt_tag_present napari-0.5.6/napari/_qt/_tests/test_qt_notifications.py000066400000000000000000000207111474413133200233230ustar00rootroot00000000000000import 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!', stacklevel=3) 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.usefixtures('_clean_current') @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, 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 = [] @pytest.mark.usefixtures('_clean_current') def test_show_notification_from_thread(count_show, monkeypatch, qtbot): 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.usefixtures('_clean_current') @pytest.mark.parametrize('severity', NotificationSeverity.__members__) def test_notification_display(count_show, severity, monkeypatch): """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_qapp()`, 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.usefixtures('_clean_current') def test_notifications_error_with_threading(make_napari_viewer, 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.6/napari/_qt/_tests/test_qt_provide_theme.py000066400000000000000000000054071474413133200233110ustar00rootroot00000000000000import 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).to_rgb_dict() 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.6/napari/_qt/_tests/test_qt_public_imports.py000066400000000000000000000001121474413133200234760ustar00rootroot00000000000000from napari.qt import * # noqa from napari.qt.threading import * # noqa napari-0.5.6/napari/_qt/_tests/test_qt_utils.py000066400000000000000000000071521474413133200216160ustar00rootroot00000000000000import 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(): str_to_qbytearray( '!QBYTE_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=' ) def test_str_to_qbytearray_invalid(): with pytest.raises(ValueError, match='Invalid QByte string.'): str_to_qbytearray('') with pytest.raises(ValueError, match='Invalid QByte string.'): str_to_qbytearray('FOOBAR') with pytest.raises(ValueError, match='Invalid QByte string.'): 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.6/napari/_qt/_tests/test_qt_viewer.py000066400000000000000000001266371474413133200217710ustar00rootroot00000000000000import gc import os import weakref from dataclasses import dataclass from itertools import product, takewhile from unittest import mock import numpy as np import numpy.testing as npt import pytest from imageio import imread from pytestqt.qtbot import QtBot from qtpy.QtCore import QEvent, Qt from qtpy.QtGui import QGuiApplication, QKeyEvent from qtpy.QtWidgets import QApplication, QMessageBox from scipy import ndimage as ndi 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._tests.utils import vispy_image_scene_size from napari.components.viewer_model import ViewerModel from napari.layers import Labels, Points from napari.settings import get_settings from napari.utils.colormaps import DirectLabelColormap, label_colormap from napari.utils.interactions import mouse_press_callbacks from napari.utils.theme import available_themes BUILTINS_DISP = 'napari' BUILTINS_NAME = 'builtins' NUMPY_INTEGER_TYPES = [ np.int8, np.int16, np.int32, np.int64, np.uint8, np.uint16, np.uint32, np.uint64, ] 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 @pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') 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.canvas.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): viewer.window.qt_viewer.screenshot(flash=False) screenshot = viewer.window.screenshot(flash=False, canvas_only=True) assert screenshot.ndim == 3 def test_export_figure(make_napari_viewer, tmp_path): viewer = make_napari_viewer() np.random.seed(0) # Add image data = np.random.randint(150, 250, size=(250, 250)) layer = viewer.add_image(data) camera_center = viewer.camera.center camera_zoom = viewer.camera.zoom img = viewer.export_figure(flash=False, path=str(tmp_path / 'img.png')) assert viewer.camera.center == camera_center assert viewer.camera.zoom == camera_zoom assert img.shape == (250, 250, 4) assert np.all(img != np.array([0, 0, 0, 0])) assert (tmp_path / 'img.png').exists() layer.scale = [0.12, 0.24] img = viewer.export_figure(flash=False) # allclose accounts for rounding errors when computing size in hidpi aka # retina displays np.testing.assert_allclose(img.shape, (250, 499, 4), atol=1) layer.scale = [0.12, 0.12] img = viewer.export_figure(flash=False) assert img.shape == (250, 250, 4) def test_export_rois(make_napari_viewer, tmp_path): # Create an image with a defined shape (100x100) and a square in the middle img = np.zeros((100, 100), dtype=np.uint8) img[25:75, 25:75] = 255 # Add viewer viewer = make_napari_viewer(show=True) viewer.add_image(img, colormap='gray') # Create a couple of clearly defined rectangular polygons for validation roi_shapes_data = [ np.array([[0, 0], [20, 0], [20, 20], [0, 20]]) - (0.5, 0.5), np.array([[15, 15], [35, 15], [35, 35], [15, 35]]) - (0.5, 0.5), np.array([[65, 65], [85, 65], [85, 85], [65, 85]]) - (0.5, 0.5), np.array([[15, 65], [35, 65], [35, 85], [15, 85]]) - (0.5, 0.5), np.array([[65, 15], [85, 15], [85, 35], [65, 35]]) - (0.5, 0.5), np.array([[40, 40], [60, 40], [60, 60], [40, 60]]) - (0.5, 0.5), ] paths = [ str(tmp_path / f'roi_{i}.png') for i in range(len(roi_shapes_data)) ] # Save original camera state for comparison later camera_center = viewer.camera.center camera_zoom = viewer.camera.zoom with pytest.raises(ValueError, match='The number of file'): viewer.export_rois(roi_shapes_data, paths=paths + ['fake']) # Export ROI to image path test_roi = viewer.export_rois(roi_shapes_data, paths=paths) assert all( (tmp_path / f'roi_{i}.png').exists() for i in range(len(roi_shapes_data)) ) assert all(roi.shape == (20, 20, 4) for roi in test_roi) assert viewer.camera.center == camera_center assert viewer.camera.zoom == camera_zoom test_dir = tmp_path / 'test_dir' viewer.export_rois(roi_shapes_data, paths=test_dir) assert all( (test_dir / f'roi_{i}.png').exists() for i in range(len(roi_shapes_data)) ) expected_values = [0, 100, 100, 100, 100, 400] for index, roi_img in enumerate(test_roi): gray_img = roi_img[..., 0] assert np.count_nonzero(gray_img) == expected_values[index], ( f'Wrong number of white pixels in the ROI {index}' ) # Not testing the exact content of the screenshot. It seems not to work within the test, but manual testing does. viewer.close() def test_export_rois_3d_fail(make_napari_viewer): viewer = make_napari_viewer() # create 3d ROI for testing roi_3d = [ np.array([[0, 0, 0], [0, 20, 0], [0, 20, 20], [0, 0, 20]]), np.array([[0, 15, 15], [0, 35, 15], [0, 35, 35], [0, 15, 35]]), ] # Only 2D roi supported at the moment with pytest.raises(ValueError, match='ROI found with invalid'): viewer.export_rois(roi_3d) test_data = np.zeros((4, 50, 50)) viewer.add_image(test_data) viewer.dims.ndisplay = 3 # 3D view should fail roi_data = [ np.array([[0, 0], [20, 0], [20, 20], [0, 20]]), np.array([[15, 15], [35, 15], [35, 35], [15, 35]]), ] with pytest.raises( NotImplementedError, match="'export_rois' is not implemented" ): viewer.export_rois(roi_data) viewer.close() @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) 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]) @pytest.mark.slow @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') @pytest.mark.key_bindings 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.canvas._map_canvas2world(new_pos) np.testing.assert_almost_equal(expected_position, list(event.position)) viewer.dims.ndisplay = 3 view.canvas._process_mouse_event(mouse_press_callbacks, mouse_event) def test_process_mouse_event_2d_layer_3d_viewer(make_napari_viewer): """Test that _process_mouse_events can handle 2d layers in 3D. This is a test for: https://github.com/napari/napari/issues/7299 """ # make a mock mouse event new_pos = [5, 5] mouse_event = MouseEvent( pos=new_pos, ) data = np.zeros((20, 20)) viewer = make_napari_viewer() view = viewer.window._qt_viewer image = viewer.add_image(data) @image.mouse_drag_callbacks.append def on_click(layer, event): expected_position = view.canvas._map_canvas2world(new_pos) np.testing.assert_almost_equal(expected_position, list(event.position)) assert viewer.dims.ndisplay == 2 view.canvas._process_mouse_event(mouse_press_callbacks, mouse_event) viewer.dims.ndisplay = 3 view.canvas._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], [s - 1 for s in img.shape]]) 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.canvas.on_draw(None) np.testing.assert_array_equal( img_multi_layer.corner_pixels, expected_corner_pixels ) viewer.dims.order = (2, 0, 1) viewer.window._qt_viewer.canvas.on_draw(None) np.testing.assert_array_equal( img_multi_layer.corner_pixels, expected_corner_pixels ) viewer.dims.order = (1, 2, 0) viewer.window._qt_viewer.canvas.on_draw(None) np.testing.assert_array_equal( 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.canvas.layer_to_visual[pl1].node pl2_vispy = viewer.window._qt_viewer.canvas.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() def _update_data( layer: Labels, label: int, qtbot: QtBot, qt_viewer: QtViewer, dtype: np.dtype = np.uint64, ) -> tuple[np.ndarray, np.ndarray]: """Change layer data and return color of label and middle pixel of screenshot.""" layer.data = np.full((2, 2), label, dtype=dtype) layer.selected_label = label qtbot.wait(50) # wait for .update() to be called on QtColorBox from Qt color_box_color = qt_viewer.controls.widgets[layer].colorBox.color screenshot = qt_viewer.screenshot(flash=False) shape = np.array(screenshot.shape[:2]) middle_pixel = screenshot[tuple(shape // 2)] return color_box_color, middle_pixel @pytest.fixture def qt_viewer_with_controls(qt_viewer): qt_viewer.controls.show() return qt_viewer @skip_local_popups @skip_on_win_ci @pytest.mark.parametrize( 'use_selection', [True, False], ids=['selected', 'all'] ) @pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int64]) def test_label_colors_matching_widget_auto( qtbot, qt_viewer_with_controls, use_selection, dtype ): """Make sure the rendered label colors match the QtColorBox widget.""" # XXX TODO: this unstable! Seed = 0 fails, for example. This is due to numerical # imprecision in random colormap on gpu vs cpu np.random.seed(1) data = np.ones((2, 2), dtype=dtype) layer = qt_viewer_with_controls.viewer.add_labels(data) layer.show_selected_label = use_selection layer.opacity = 1.0 # QtColorBox & single layer are blending differently n_c = len(layer.colormap) test_colors = np.concatenate( ( np.arange(1, 10, dtype=dtype), [n_c - 1, n_c, n_c + 1], np.random.randint( 1, min(2**20, np.iinfo(dtype).max), size=20, dtype=dtype ), [-1, -2, -10], ) ) for label in test_colors: # Change color & selected color to the same label color_box_color, middle_pixel = _update_data( layer, label, qtbot, qt_viewer_with_controls, dtype ) npt.assert_allclose( color_box_color, middle_pixel, atol=1, err_msg=f'label {label}' ) # there is a difference of rounding between the QtColorBox and the screenshot @skip_local_popups @skip_on_win_ci @pytest.mark.parametrize( 'use_selection', [True, False], ids=['selected', 'all'] ) @pytest.mark.parametrize('dtype', [np.uint64, np.uint16, np.uint8, np.int16]) def test_label_colors_matching_widget_direct( qtbot, qt_viewer_with_controls, use_selection, dtype ): """Make sure the rendered label colors match the QtColorBox widget.""" data = np.ones((2, 2), dtype=dtype) test_colors = (1, 2, 3, 8, 150, 50) color = { 0: 'transparent', 1: 'yellow', 3: 'blue', 8: 'red', 150: 'green', None: 'white', } if np.iinfo(dtype).min < 0: color[-1] = 'pink' color[-2] = 'orange' test_colors = test_colors + (-1, -2, -10) colormap = DirectLabelColormap(color_dict=color) layer = qt_viewer_with_controls.viewer.add_labels( data, opacity=1, colormap=colormap ) layer.show_selected_label = use_selection color_box_color, middle_pixel = _update_data( layer, 0, qtbot, qt_viewer_with_controls, dtype ) assert np.allclose([0, 0, 0, 255], middle_pixel) for label in test_colors: # Change color & selected color to the same label color_box_color, middle_pixel = _update_data( layer, label, qtbot, qt_viewer_with_controls, dtype ) npt.assert_almost_equal( color_box_color, middle_pixel, err_msg=f'{label=}' ) npt.assert_almost_equal( color_box_color, colormap.color_dict.get(label, colormap.color_dict[None]) * 255, err_msg=f'{label=}', ) def test_axis_labels(make_napari_viewer): viewer = make_napari_viewer(ndisplay=3) layer = viewer.add_image(np.zeros((2, 2, 2)), scale=(1, 2, 4)) layer_visual = viewer._window._qt_viewer.layer_to_visual[layer] axes_visual = viewer._window._qt_viewer.canvas._overlay_to_visual[ viewer._overlays['axes'] ] layer_visual_size = vispy_image_scene_size(layer_visual) assert tuple(layer_visual_size) == (8, 4, 2) assert tuple(axes_visual.node.text.text) == ('2', '1', '0') @pytest.fixture def qt_viewer(qtbot, qt_viewer_: QtViewer): qt_viewer_.show() qt_viewer_.resize(460, 460) QApplication.processEvents() return qt_viewer_ def _find_margin(data: np.ndarray, additional_margin: int) -> tuple[int, int]: """ helper function to determine margins in test_thumbnail_labels """ mid_x, mid_y = data.shape[0] // 2, data.shape[1] // 2 x_margin = len( list(takewhile(lambda x: np.all(x == 0), data[:, mid_y, :3][::-1])) ) y_margin = len( list(takewhile(lambda x: np.all(x == 0), data[mid_x, :, :3][::-1])) ) return x_margin + additional_margin, y_margin + additional_margin # @pytest.mark.xfail(reason="Fails on CI, but not locally") @skip_local_popups @pytest.mark.parametrize('direct', [True, False], ids=['direct', 'auto']) def test_thumbnail_labels(qtbot, direct, qt_viewer: QtViewer, tmp_path): # Add labels to empty viewer layer = qt_viewer.viewer.add_labels( np.array([[0, 1], [2, 3]]), opacity=1.0 ) if direct: layer.colormap = DirectLabelColormap( color_dict={ 0: 'red', 1: 'green', 2: 'blue', 3: 'yellow', None: 'black', } ) else: layer.colormap = label_colormap(49) qt_viewer.viewer.reset_view() qt_viewer.canvas.native.paintGL() QApplication.processEvents() qtbot.wait(50) canvas_screenshot_ = qt_viewer.screenshot(flash=False) import imageio imageio.imwrite(tmp_path / 'canvas_screenshot_.png', canvas_screenshot_) np.savez(tmp_path / 'canvas_screenshot_.npz', canvas_screenshot_) # cut off black border margin1, margin2 = _find_margin(canvas_screenshot_, 10) canvas_screenshot = canvas_screenshot_[margin1:-margin1, margin2:-margin2] assert canvas_screenshot.size > 0, ( f'{canvas_screenshot_.shape}, {margin1=}, {margin2=}' ) thumbnail = layer.thumbnail scaled_thumbnail = ndi.zoom( thumbnail, np.array(canvas_screenshot.shape) / np.array(thumbnail.shape), order=0, mode='nearest', ) close = np.isclose(canvas_screenshot, scaled_thumbnail) problematic_pixels_count = np.sum(~close) assert problematic_pixels_count < 0.01 * canvas_screenshot.size @pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32]) def test_background_color(qtbot, qt_viewer: QtViewer, dtype): data = np.zeros((10, 10), dtype=dtype) data[5:] = 10 layer = qt_viewer.viewer.add_labels(data, opacity=1) color = layer.colormap.map(10) * 255 backgrounds = (0, 2, -2) for background in backgrounds: data[:5] = background layer.data = data layer.colormap = label_colormap(49, background_value=background) qtbot.wait(50) canvas_screenshot = qt_viewer.screenshot(flash=False) shape = np.array(canvas_screenshot.shape[:2]) background_pixel = canvas_screenshot[tuple((shape * 0.25).astype(int))] color_pixel = canvas_screenshot[tuple((shape * 0.75).astype(int))] npt.assert_array_equal( background_pixel, [0, 0, 0, 255], err_msg=f'background {background}', ) npt.assert_array_equal( color_pixel, color, err_msg=f'background {background}' ) def test_rendering_interpolation(qtbot, qt_viewer): data = np.zeros((20, 20, 20), dtype=np.uint8) data[1:-1, 1:-1, 1:-1] = 5 layer = qt_viewer.viewer.add_labels( data, opacity=1, rendering='translucent' ) layer.selected_label = 5 qt_viewer.viewer.dims.ndisplay = 3 QApplication.processEvents() canvas_screenshot = qt_viewer.screenshot(flash=False) shape = np.array(canvas_screenshot.shape[:2]) pixel = canvas_screenshot[tuple((shape * 0.5).astype(int))] color = layer.colormap.map(5) * 255 npt.assert_array_equal(pixel, color) def test_shortcut_passing(make_napari_viewer): viewer = make_napari_viewer(ndisplay=3) layer = viewer.add_labels( np.zeros((2, 2, 2), dtype=np.uint8), scale=(1, 2, 4) ) layer.mode = 'fill' qt_window = viewer.window._qt_window qt_window.keyPressEvent( QKeyEvent( QEvent.Type.KeyPress, Qt.Key.Key_1, Qt.KeyboardModifier.NoModifier ) ) qt_window.keyReleaseEvent( QKeyEvent( QEvent.Type.KeyPress, Qt.Key.Key_1, Qt.KeyboardModifier.NoModifier ) ) assert layer.mode == 'erase' @pytest.mark.slow @pytest.mark.parametrize('mode', ['direct', 'random']) def test_selection_collision(qt_viewer: QtViewer, mode): data = np.zeros((10, 10), dtype=np.uint8) data[:5] = 10 data[5:] = 10 + 49 layer = qt_viewer.viewer.add_labels(data, opacity=1) layer.selected_label = 10 if mode == 'direct': layer.colormap = DirectLabelColormap( color_dict={10: 'red', 10 + 49: 'red', None: 'black'} ) for dtype in NUMPY_INTEGER_TYPES: layer.data = data.astype(dtype) layer.show_selected_label = False QApplication.processEvents() canvas_screenshot = qt_viewer.screenshot(flash=False) shape = np.array(canvas_screenshot.shape[:2]) pixel_10 = canvas_screenshot[tuple((shape * 0.25).astype(int))] pixel_59 = canvas_screenshot[tuple((shape * 0.75).astype(int))] npt.assert_array_equal(pixel_10, pixel_59, err_msg=f'{dtype}') assert not np.all(pixel_10 == [0, 0, 0, 255]), dtype layer.show_selected_label = True canvas_screenshot = qt_viewer.screenshot(flash=False) shape = np.array(canvas_screenshot.shape[:2]) pixel_10_2 = canvas_screenshot[tuple((shape * 0.25).astype(int))] pixel_59_2 = canvas_screenshot[tuple((shape * 0.75).astype(int))] npt.assert_array_equal(pixel_59_2, [0, 0, 0, 255], err_msg=f'{dtype}') npt.assert_array_equal(pixel_10_2, pixel_10, err_msg=f'{dtype}') def test_all_supported_dtypes(qt_viewer): data = np.zeros((10, 10), dtype=np.uint8) layer_ = qt_viewer.viewer.add_labels(data, opacity=1) for i, dtype in enumerate(NUMPY_INTEGER_TYPES, start=1): data = np.full((10, 10), i, dtype=dtype) layer_.data = data QApplication.processEvents() canvas_screenshot = qt_viewer.screenshot(flash=False) midd_pixel = canvas_screenshot[ tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( midd_pixel, layer_.colormap.map(i) * 255, err_msg=f'{dtype=} {i=}' ) layer_.colormap = DirectLabelColormap( color_dict={ 0: 'red', 1: 'green', 2: 'blue', 3: 'yellow', 4: 'magenta', 5: 'cyan', 6: 'white', 7: 'pink', 8: 'orange', 9: 'purple', 10: 'brown', 11: 'gray', None: 'black', } ) for i, dtype in enumerate(NUMPY_INTEGER_TYPES, start=1): data = np.full((10, 10), i, dtype=dtype) layer_.data = data QApplication.processEvents() canvas_screenshot = qt_viewer.screenshot(flash=False) midd_pixel = canvas_screenshot[ tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( midd_pixel, layer_.colormap.map(i) * 255, err_msg=f'{dtype} {i}' ) @pytest.mark.slow def test_more_than_uint16_colors(qt_viewer): pytest.importorskip('numba') # this test is slow (10s locally) data = np.zeros((10, 10), dtype=np.uint32) colors = { i: (x, y, z, 1) for i, (x, y, z) in zip( range(256**2 + 20), product(np.linspace(0, 1, 256, endpoint=True), repeat=3), ) } colors[None] = (0, 0, 0, 1) layer = qt_viewer.viewer.add_labels( data, opacity=1, colormap=DirectLabelColormap(color_dict=colors) ) assert layer._slice.image.view.dtype == np.float32 for i in [1, 1000, 100000]: data = np.full((10, 10), i, dtype=np.uint32) layer.data = data canvas_screenshot = qt_viewer.screenshot(flash=False) midd_pixel = canvas_screenshot[ tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( midd_pixel, layer.colormap.map(i) * 255, err_msg=f'{i}' ) def test_points_2d_to_3d(make_napari_viewer): """See https://github.com/napari/napari/issues/6925""" # this requires a full viewer cause some issues are caused only by # qt processing events viewer = make_napari_viewer(ndisplay=2, show=True) viewer.add_points() QApplication.processEvents() viewer.dims.ndisplay = 3 QApplication.processEvents() @skip_local_popups def test_scale_bar_colored(qt_viewer, qtbot): viewer = qt_viewer.viewer scale_bar = viewer.scale_bar # Add black image data = np.zeros((2, 2)) viewer.add_image(data) # Check scale bar is not visible (all the canvas is black - `[0, 0, 0, 255]`) def check_all_black(): screenshot = qt_viewer.screenshot(flash=False) assert np.all(screenshot == [0, 0, 0, 255], axis=-1).all() qtbot.waitUntil(check_all_black) # Check scale bar is visible (canvas has white `[1, 1, 1, 255]` in it) def check_white_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert not np.all(screenshot == [0, 0, 0, 255], axis=-1).all() assert np.all(screenshot == [1, 1, 1, 255], axis=-1).any() scale_bar.visible = True qtbot.waitUntil(check_white_scale_bar) # Check scale bar is colored (canvas has fuchsia `[1, 0, 1, 255]` and not white in it) def check_colored_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert not np.all(screenshot == [1, 1, 1, 255], axis=-1).any() assert np.all(screenshot == [1, 0, 1, 255], axis=-1).any() scale_bar.colored = True qtbot.waitUntil(check_colored_scale_bar) # Check scale bar is still visible but not colored (canvas has white again but not fuchsia in it) def check_only_white_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert np.all(screenshot == [1, 1, 1, 255], axis=-1).any() assert not np.all(screenshot == [1, 0, 1, 255], axis=-1).any() scale_bar.colored = False qtbot.waitUntil(check_only_white_scale_bar) @skip_local_popups def test_scale_bar_ticks(qt_viewer, qtbot): viewer = qt_viewer.viewer scale_bar = viewer.scale_bar # Add black image data = np.zeros((2, 2)) viewer.add_image(data) # Check scale bar is not visible (all the canvas is black - `[0, 0, 0, 255]`) def check_all_black(): screenshot = qt_viewer.screenshot(flash=False) assert np.all(screenshot == [0, 0, 0, 255], axis=-1).all() qtbot.waitUntil(check_all_black) # Check scale bar is visible (canvas has white `[1, 1, 1, 255]` in it) def check_white_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert not np.all(screenshot == [0, 0, 0, 255], axis=-1).all() assert np.all(screenshot == [1, 1, 1, 255], axis=-1).any() scale_bar.visible = True qtbot.waitUntil(check_white_scale_bar) # Check scale bar has ticks active and take screenshot for later comparison assert scale_bar.ticks screenshot_with_ticks = qt_viewer.screenshot(flash=False) # Check scale bar without ticks (still white present but new screenshot differs from ticks one) def check_no_ticks_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert np.all(screenshot == [1, 1, 1, 255], axis=-1).any() npt.assert_raises( AssertionError, npt.assert_array_equal, screenshot, screenshot_with_ticks, ) scale_bar.ticks = False qtbot.waitUntil(check_no_ticks_scale_bar) # Check scale bar again has ticks (still white present and new screenshot corresponds with ticks one) def check_ticks_scale_bar(): screenshot = qt_viewer.screenshot(flash=False) assert np.all(screenshot == [1, 1, 1, 255], axis=-1).any() npt.assert_array_equal(screenshot, screenshot_with_ticks) scale_bar.ticks = True qtbot.waitUntil(check_ticks_scale_bar) @skip_local_popups def test_dask_cache(qt_viewer): initial_dask_cache = get_settings().application.dask.cache # check that disabling dask cache setting calls related logic with mock.patch( 'napari._qt.qt_viewer.resize_dask_cache' ) as mock_resize_dask_cache: get_settings().application.dask.enabled = False mock_resize_dask_cache.assert_called_once_with( int(int(False) * initial_dask_cache * 1e9) ) # check that enabling dask cache setting calls related logic with mock.patch( 'napari._qt.qt_viewer.resize_dask_cache' ) as mock_resize_dask_cache: get_settings().application.dask.enabled = True mock_resize_dask_cache.assert_called_once_with( int(int(True) * initial_dask_cache * 1e9) ) napari-0.5.6/napari/_qt/_tests/test_qt_viewer_2.py000066400000000000000000000035271474413133200222020ustar00rootroot00000000000000import numpy as np import pytest from napari._vispy.utils.gl import fix_data_dtype BUILTINS_DISP = 'napari' BUILTINS_NAME = 'builtins' # Previously tests often segfaulted on CI at the 26th test of test_qt_viewer # That test (number 26) was split off to make debugging easier # See https://github.com/napari/napari/pull/5676 @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) @pytest.mark.parametrize( ('dtype', 'expected'), [ (np.bool_, np.uint8), (np.int8, np.float32), (np.uint8, np.uint8), (np.int16, np.float32), (np.uint16, np.uint16), (np.uint32, np.float32), (np.float32, np.float32), (np.float64, np.float32), ], ) def test_fix_data_dtype_big_values(dtype, expected): data = np.array([0, 2, 2**17], dtype=np.int32).astype(dtype) casted = fix_data_dtype(data) assert np.allclose(casted, data) assert casted.dtype == expected napari-0.5.6/napari/_qt/_tests/test_qt_window.py000066400000000000000000000113201474413133200217550ustar00rootroot00000000000000import platform from unittest.mock import patch import numpy as np import pytest from qtpy.QtGui import QImage from napari._qt.qt_main_window import Window, _QtMainWindow from napari._qt.utils import QImg2array from napari._tests.utils import skip_on_win_ci 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') 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() @skip_on_win_ci def test_screenshot_to_file(make_napari_viewer, tmp_path): """ Test taking a screenshot using the Window instance and saving it to a file. """ viewer = make_napari_viewer() screenshot_file_path = str(tmp_path / 'screenshot.png') 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 screenshot_array = viewer.window.screenshot( screenshot_file_path, flash=False, canvas_only=True ) screenshot_array_from_file = QImg2array(QImage(screenshot_file_path)) assert np.array_equal(screenshot_array, screenshot_array_from_file) def test_set_status_and_tooltip(make_napari_viewer): viewer = make_napari_viewer() # create active layer viewer.add_image(np.zeros((10, 10))) assert viewer.status == 'Ready' assert viewer.tooltip.text == '' viewer.window._qt_window.set_status_and_tooltip(('Text1', 'Text2')) assert viewer.status == 'Text1' assert viewer.tooltip.text == 'Text2' viewer.window._qt_window.set_status_and_tooltip(None) assert viewer.status == 'Text1' assert viewer.tooltip.text == 'Text2' napari-0.5.6/napari/_qt/_tests/test_sigint_interupt.py000066400000000000000000000014511474413133200231750ustar00rootroot00000000000000import 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) # 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), _maybe_allow_interrupt(qapp): qapp.exec_() napari-0.5.6/napari/_qt/_tests/test_threading_progress.py000066400000000000000000000066351474413133200236500ustar00rootroot00000000000000import 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.waitSignals([worker.yielded, worker.finished]): worker.start() assert worker.pbar.n == test_val[0] assert test_val[0] == 2 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.waitSignals([worker.yielded, 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.6/napari/_qt/_tests/test_threads.py000066400000000000000000000027021474413133200214000ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from napari._qt.threads.status_checker import StatusChecker from napari.components import ViewerModel @pytest.mark.usefixtures('qapp') def test_create(): StatusChecker(ViewerModel()) @pytest.mark.usefixtures('qapp') def test_no_emmit_no_ref(monkeypatch): """Calling calculate_status should not emit after viewer is deleted.""" model = ViewerModel() status_checker = StatusChecker(model) monkeypatch.setattr( status_checker, 'status_and_tooltip_changed', MagicMock(side_effect=RuntimeError('Should not emit')), ) del model status_checker.calculate_status() def test_terminate_no_ref(monkeypatch): """Test that the thread terminates when the viewer is garbage collected.""" model = ViewerModel() status_checker = StatusChecker(model) del model status_checker.run() assert not status_checker._terminate def test_waiting_on_no_request(monkeypatch, qtbot): def _check_status(value): return value == ('Ready', '') model = ViewerModel() model.mouse_over_canvas = True status_checker = StatusChecker(model) status_checker.start() with qtbot.waitSignal( status_checker.status_and_tooltip_changed, timeout=1000, check_params_cb=_check_status, ): status_checker.trigger_status_update() status_checker.terminate() qtbot.wait_until(lambda: status_checker.isFinished()) napari-0.5.6/napari/_qt/containers/000077500000000000000000000000001474413133200172005ustar00rootroot00000000000000napari-0.5.6/napari/_qt/containers/__init__.py000066400000000000000000000014101474413133200213050ustar00rootroot00000000000000from napari._qt.containers._factory import create_model, create_view from napari._qt.containers.qt_axis_model import ( AxisList, AxisModel, QtAxisListModel, ) 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__ = [ 'AxisList', 'AxisModel', 'QtAxisListModel', 'QtLayerList', 'QtLayerListModel', 'QtListModel', 'QtListView', 'QtNodeTreeModel', 'QtNodeTreeView', 'create_model', 'create_view', ] napari-0.5.6/napari/_qt/containers/_base_item_model.py000066400000000000000000000260751474413133200230330ustar00rootroot00000000000000from __future__ import annotations from collections.abc import MutableSequence from typing import TYPE_CHECKING, Any, Generic, 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 typing import Optional from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] 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: Optional[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: Optional[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: Optional[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 reimplement 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.6/napari/_qt/containers/_base_item_view.py000066400000000000000000000124101474413133200226710ustar00rootroot00000000000000from __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 None 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.6/napari/_qt/containers/_factory.py000066400000000000000000000047611474413133200213700ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Union from napari._qt.containers.qt_axis_model import AxisList, QtAxisListModel 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 typing import Optional from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] def create_view( obj: Union[SelectableEventedList, Group], parent: Optional[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: Optional[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, AxisList): return QtAxisListModel(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.6/napari/_qt/containers/_layer_delegate.py000066400000000000000000000330641474413133200226650ustar00rootroot00000000000000""" 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 weakref import WeakKeyDictionary, ref from qtpy.QtCore import QPoint, QSize, Qt, Signal from qtpy.QtGui import QMouseEvent, QMovie, 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 LoadedRole, ThumbnailRole from napari._qt.qt_resources import QColoredSVGIcon from napari.resources import LOADING_GIF_PATH 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. """ loading_frame_changed = Signal() def __init__(self, parent=None): super().__init__(parent) self._load_movie = QMovie(LOADING_GIF_PATH) self._load_movie.setScaledSize(QSize(18, 18)) self._load_movie.frameChanged.connect(self.loading_frame_changed) self._layer_visibility_states = WeakKeyDictionary() self._alt_click_layer = lambda: None 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 option.textElideMode = Qt.TextElideMode.ElideMiddle self.get_layer_icon(option, index) # paint the standard itemView (includes name, icon, and vis. checkbox) super().paint(painter, option, index) # paint loading indicator if needed self._paint_loading(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_loading( self, painter: QPainter, option: QStyleOptionViewItem, index: QtCore.QModelIndex, ): """Paint loading layer indicator.""" loaded = index.data(LoadedRole) if not loaded: self._load_movie.start() load_rect = option.rect.translated(4, 8) h = index.data(Qt.ItemDataRole.SizeHintRole).height() - 16 load_rect.setWidth(h) load_rect.setHeight(h) painter.drawPixmap(load_rect, self._load_movie.currentPixmap()) 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 loaded = index.data(LoadedRole) if loaded: # only pause the loading movie if all the layers are loaded. The # last layer that enters the loaded state will pause the load # movie. This is needed since there is only one instance of the # delegate and therefore only one instance of the load movie shared # between all the layer items. all_loaded = index.model().sourceModel().all_loaded() if all_loaded: self._load_movie.setPaused(True) 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.AlignmentFlag(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 visibility # 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 ) # catch alt-click on the vis checkbox and hide *other* layer visibility # on second alt-click, restore the visibility state of the layers if event.type() == QMouseEvent.MouseButtonRelease and ( event.button() == Qt.MouseButton.LeftButton and event.modifiers() == Qt.AltModifier ): 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()): return self._show_on_alt_click_hide_others(model, index) # on regular click of visibility icon, clear alt-click state if event.type() == QMouseEvent.MouseButtonRelease and ( event.button() == Qt.MouseButton.LeftButton ): 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()): self._alt_click_layer = lambda: None # refer all other events to the QStyledItemDelegate return super().editorEvent(event, model, option, index) def _show_on_alt_click_hide_others( self, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex, ) -> QtCore.QAbstractItemModel: """On alt/option click of a layer show the layer, hide other layers, to be restored once a layer is alt/option-clicked a second time. """ alt_clicked_layer = index.data(ItemRole) layer_list: LayerList = model.sourceModel()._root # show the alt-clicked layer state = Qt.CheckState.Checked if self._alt_click_layer() is None: # first click on visibility, so store visibility & hide others for layer in layer_list: self._layer_visibility_states[layer] = layer.visible layer.visible = layer == alt_clicked_layer # hide others # make a note that this layer was alt-clicked self._alt_click_layer = ref(alt_clicked_layer) elif self._alt_click_layer() is alt_clicked_layer: # second alt-click on same layer, so restore visibility # account for any added/deleted layers when restoring for layer in layer_list: if layer in self._layer_visibility_states: layer.visible = self._layer_visibility_states[layer] # restore clicked layer to original state if not alt_clicked_layer.visible: state = Qt.CheckState.Unchecked # reset alt-click state self._alt_click_layer = lambda: None else: # option-click on a different layer, hide others, show it for layer in layer_list: layer.visible = layer is alt_clicked_layer # make a note that this layer was alt-clicked self._alt_click_layer = ref(alt_clicked_layer) return model.setData(index, state, Qt.ItemDataRole.CheckStateRole) 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 ctx = get_context(layer_list) self._context_menu.update_from_context(ctx) self._context_menu.exec_(pos) napari-0.5.6/napari/_qt/containers/_tests/000077500000000000000000000000001474413133200205015ustar00rootroot00000000000000napari-0.5.6/napari/_qt/containers/_tests/test_factory.py000066400000000000000000000013111474413133200235550ustar00rootroot00000000000000import 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.6/napari/_qt/containers/_tests/test_qt_axis_list.py000066400000000000000000000056231474413133200246230ustar00rootroot00000000000000from qtpy.QtCore import QModelIndex, Qt from napari._qt.containers import ( AxisList, AxisModel, QtAxisListModel, QtListView, ) from napari.components import Dims FLAGS = ( Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren | Qt.ItemFlag.ItemIsDragEnabled ) def test_axismodel(): dims = Dims() axismodel = AxisModel(dims, 0) assert axismodel == 0 assert axismodel.rollable axismodel.rollable = False assert not axismodel.rollable assert not dims.rollable[0] def test_AxisList(): # from list dims = Dims() axes = [AxisModel(dims, axis) for axis in dims.order] axislist = AxisList(axes) assert all(axis == idx for idx, axis in enumerate(axislist)) # from_dims axislist = AxisList.from_dims(dims) assert len(axislist) == 2 for idx, axis in enumerate(axislist): assert axis == idx def test_QtAxisListModel_data(qtbot): dims, axislist, listview, axislistmodel = make_QtAxisListModel(qtbot) assert all( axislistmodel.data( axislistmodel.index(idx), role=Qt.ItemDataRole.DisplayRole ) == axislist[idx] for idx in dims.order ) assert all( axislistmodel.data( axislistmodel.index(idx), role=Qt.ItemDataRole.TextAlignmentRole, ) == Qt.AlignCenter for idx in dims.order ) assert all( ( axislistmodel.data( axislistmodel.index(idx), role=Qt.ItemDataRole.CheckStateRole, ), axislist[idx].rollable, ) for idx in dims.order ) # setData idx = 1 with qtbot.waitSignal(axislistmodel.dataChanged, timeout=100): assert axislistmodel.setData( axislistmodel.index(idx), Qt.CheckState.Checked, role=Qt.ItemDataRole.CheckStateRole, ) assert dims.rollable[idx] new_name = 'new_name' with qtbot.waitSignal(axislistmodel.dataChanged, timeout=100): assert axislistmodel.setData( axislistmodel.index(idx), new_name, role=Qt.ItemDataRole.EditRole ) assert dims.axis_labels[idx] == new_name def test_QtAxisListModel_flags(qtbot): dims, axislist, listview, axislistmodel = make_QtAxisListModel(qtbot) assert axislistmodel.flags(QModelIndex()) == Qt.ItemFlag.ItemIsDropEnabled flags = [ axislistmodel.flags(axislistmodel.index(idx)) for idx in dims.order ] ref_flags = [FLAGS for idx in dims.order] assert flags == ref_flags def make_QtAxisListModel(qtbot) -> tuple[Dims, AxisList, QtAxisListModel]: dims = Dims() dims.rollable = [True, False] axislist = AxisList.from_dims(dims) view = QtListView(axislist) axislistmodel = view.model() return dims, axislist, view, axislistmodel napari-0.5.6/napari/_qt/containers/_tests/test_qt_layer_list.py000066400000000000000000000225151474413133200247720ustar00rootroot00000000000000import threading import numpy as np from qtpy.QtCore import QModelIndex, QPoint, Qt from qtpy.QtWidgets import QLineEdit, QStyleOptionViewItem from napari._qt.containers import QtLayerList from napari._qt.containers._layer_delegate import LayerDelegate from napari._tests.utils import skip_local_focus from napari.components import LayerList from napari.layers import Image, Shapes 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 test_alt_click_to_show_single_layer(qtbot): ( image1, image2, image3, layers, view, delegate, ) = make_qt_layer_list_with_delegate(qtbot) # hide the middle-layer, image2 and ensure it's unchecked image2.visible = False assert check_state_at_layer_index(view, 1) == Qt.CheckState.Unchecked # ensure the other layers are visible & checked assert image3.visible assert check_state_at_layer_index(view, 0) == Qt.CheckState.Checked assert image1.visible assert check_state_at_layer_index(view, 2) == Qt.CheckState.Checked # alt-click state should be None assert delegate._alt_click_layer() is None # mock an alt-click on bottom-most layer, image1 index = layer_to_model_index(view, 2) delegate._show_on_alt_click_hide_others(view.model(), index) # alt-click state should be set to image1 assert delegate._alt_click_layer() == image1 # only image1 should be shown, while image3, image2 be hidden assert not image3.visible assert check_state_at_layer_index(view, 0) == Qt.CheckState.Unchecked assert not image2.visible assert check_state_at_layer_index(view, 1) == Qt.CheckState.Unchecked assert image1.visible assert check_state_at_layer_index(view, 2) == Qt.CheckState.Checked def test_second_alt_click_to_show_different_layer(qtbot): ( image1, image2, image3, layers, view, delegate, ) = make_qt_layer_list_with_delegate(qtbot) # mock an alt-click on bottom-most layer, image1 index = layer_to_model_index(view, 2) delegate._show_on_alt_click_hide_others(view.model(), index) # alt-click state should be set to image1 assert delegate._alt_click_layer() == image1 # image2 should be hidden assert not image2.visible assert check_state_at_layer_index(view, 1) == Qt.CheckState.Unchecked # mock an alt-click on middle layer, image2 index2 = layer_to_model_index(view, 1) delegate._show_on_alt_click_hide_others(view.model(), index2) # alt-click state should be set to image2 assert delegate._alt_click_layer() == image2 # only image2 should be shown, while image3, image1 be hidden assert not image3.visible assert check_state_at_layer_index(view, 0) == Qt.CheckState.Unchecked assert not image1.visible assert check_state_at_layer_index(view, 2) == Qt.CheckState.Unchecked assert image2.visible assert check_state_at_layer_index(view, 1) == Qt.CheckState.Checked def test_second_alt_click_to_restore_layer_state(qtbot): ( image1, image2, image3, layers, view, delegate, ) = make_qt_layer_list_with_delegate(qtbot) # mock an alt-click on bottom-most layer, image1 index = layer_to_model_index(view, 2) delegate._show_on_alt_click_hide_others(view.model(), index) # add a layer (will be at position 0) image4 = Image(np.zeros((4, 3))) layers.append(image4) assert image4.visible # remove a layer (image3, which has been pushed down to position 1 layers.pop(1) # mock second alt-click on image1, which should restore initial state delegate._show_on_alt_click_hide_others(view.model(), index) # image4 should remain visible (not part of initial state) # image2 should be not visible--that was the initial state assert image4.visible assert image1.visible assert not image2.visible # alt-click state should be cleared assert delegate._alt_click_layer() is None def test_contextual_menu_updates_selection_ctx_keys(monkeypatch, qtbot): shapes_layer = Shapes() layer_list = LayerList() layer_list._create_contexts() layer_list.append(shapes_layer) view = QtLayerList(layer_list) qtbot.addWidget(view) delegate = view.itemDelegate() assert not layer_list[0].data layer_list.selection.add(shapes_layer) index = layer_to_model_index(view, 0) assert layer_list._selection_ctx_keys.num_selected_shapes_layers == 1 assert layer_list._selection_ctx_keys.selected_empty_shapes_layer monkeypatch.setattr( 'app_model.backends.qt.QModelMenu.exec_', lambda self, x: x ) delegate.show_context_menu( index, view.model(), QPoint(10, 10), parent=view ) assert not delegate._context_menu.findAction( 'napari.layer.convert_to_labels' ).isEnabled() layer_list[0].add(np.array(([0, 0], [0, 10], [10, 10], [10, 0]))) assert layer_list[0].data delegate.show_context_menu( index, view.model(), QPoint(10, 10), parent=view ) assert delegate._context_menu.findAction( 'napari.layer.convert_to_labels' ).isEnabled() def make_qt_layer_list_with_delegate(qtbot): image1 = Image(np.zeros((4, 3))) image2 = Image(np.zeros((4, 3))) image3 = Image(np.zeros((4, 3))) layers = LayerList([image1, image2, image3]) # this will make the list have image2 on top of image1 view = QtLayerList(layers) qtbot.addWidget(view) delegate = LayerDelegate() return image1, image2, image3, layers, view, delegate @skip_local_focus def test_drag_and_drop_layers(qtbot): """ Test drag and drop actions with pyautogui to change layer list order. Notes: * For this test to pass locally on macOS, you need to give the Terminal/iTerm application accessibility permissions: `System Settings > Privacy & Security > Accessibility` See https://github.com/asweigart/pyautogui/issues/247 and https://github.com/asweigart/pyautogui/issues/247#issuecomment-437668855. """ view, images = make_qt_layer_list_with_layers(qtbot) with qtbot.waitExposed(view): view.show() # check initial element is the one expected (last element in the layerlist) name = view.model().data( layer_to_model_index(view, 0), Qt.ItemDataRole.DisplayRole ) assert name == images[-1].name # drag and drop event simulation base_pos = view.mapToGlobal(view.rect().topLeft()) start_pos = base_pos + QPoint(50, 10) start_x = start_pos.x() start_y = start_pos.y() end_pos = base_pos + QPoint(100, 100) end_x = end_pos.x() end_y = end_pos.y() drag_drop = threading.Thread( target=drag_and_drop, args=(start_x, start_y, end_x, end_y) ) drag_drop.start() def check_drag_and_drop(): # check layerlist first element corresponds with first layer in the GUI name = view.model().data( layer_to_model_index(view, 0), Qt.ItemDataRole.DisplayRole ) return name == images[0].name qtbot.waitUntil(check_drag_and_drop) def drag_and_drop(start_x, start_y, end_x, end_y): # simulate a drag and drop action with pyautogui import pyautogui pyautogui.moveTo(start_x, start_y, duration=0.2) pyautogui.mouseDown() pyautogui.moveTo(end_x, end_y, duration=0.2) pyautogui.mouseUp() 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 make_qt_layer_list_with_layers(qtbot) -> tuple[QtLayerList, list[Image]]: image1 = Image(np.zeros((4, 3)), name='image1') image2 = Image(np.zeros((4, 3)), name='image2') layers = LayerList([image1, image2]) view = QtLayerList(layers) qtbot.addWidget(view) return view, [image1, image2] 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) def test_createEditor(qtbot): view, image = make_qt_layer_list_with_layer(qtbot) model_index = layer_to_model_index(view, 0) delegate = view.itemDelegate() editor = delegate.createEditor(view, QStyleOptionViewItem(), model_index) assert isinstance(editor, QLineEdit) delegate.setEditorData(editor, model_index) assert editor.text() == image.name napari-0.5.6/napari/_qt/containers/_tests/test_qt_list.py000066400000000000000000000071071474413133200235760ustar00rootroot00000000000000from 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.6/napari/_qt/containers/_tests/test_qt_tree.py000066400000000000000000000122001474413133200235500ustar00rootroot00000000000000import 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.6/napari/_qt/containers/qt_axis_model.py000066400000000000000000000122621474413133200224050ustar00rootroot00000000000000from collections.abc import Iterable from typing import Any from qtpy.QtCore import QModelIndex, Qt from typing_extensions import Self from napari._qt.containers.qt_list_model import QtListModel from napari.components import Dims from napari.utils.events import SelectableEventedList class AxisModel: """View of an axis within a dims model. The model keeps track of axis names and allows read / write access on the corresponding rollable state of a Dims object. Parameters ---------- dims : napari.components.dims.Dims Parent Dims object. axis : int Axis index. Attributes ---------- dims : napari.components.dims.Dims Dimensions object modeling slicing and displaying. axis : int Axis index. """ 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: object) -> bool: # to allow comparisons between a list of AxisModels and the current dims order # we need to overload the int and str equality check, this is necessary as the # comparison will either be against a list of ints (Dims.order) or a list of # strings (Dims.axis_labels) if isinstance(other, int): return self.axis == other if isinstance(other, str): return repr(self) == other if isinstance(other, AxisModel): return (self.dims is other.dims) and (self.axis == other.axis) return NotImplemented @property def rollable(self) -> bool: """ If the axis should be rollable. """ return self.dims.rollable[self.axis] @rollable.setter def rollable(self, value: bool) -> None: rollable = list(self.dims.rollable) rollable[self.axis] = value self.dims.rollable = tuple(rollable) class AxisList(SelectableEventedList[AxisModel]): def __init__(self, axes: Iterable[AxisModel]): super().__init__(axes) @classmethod def from_dims(cls, dims: Dims) -> Self: """Create AxisList instance from Dims object. The AxisList is filled with a number of AxisModels based on the number of dimensions in the Dims object. Parameters ---------- dims : napari.components.dims.Dims Dims object to be used for creation. Returns ------- AxisList A selectable evented list of the viewer axes. """ return cls(AxisModel(dims, d) for d in dims.order) class QtAxisListModel(QtListModel[AxisModel]): def data(self, index: QModelIndex, role: Qt.ItemDataRole): if not index.isValid(): return None axis = self.getItem(index) if role == Qt.ItemDataRole.DisplayRole: return str(axis) if role == Qt.ItemDataRole.TextAlignmentRole: return Qt.AlignCenter if role == Qt.ItemDataRole.CheckStateRole: return ( Qt.CheckState.Checked if axis.rollable else Qt.CheckState.Unchecked ) return super().data(index, role) def setData( self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: axis = self.getItem(index) if role == Qt.ItemDataRole.CheckStateRole: axis.rollable = Qt.CheckState(value) == Qt.CheckState.Checked elif role == Qt.ItemDataRole.EditRole: axis_labels = list(axis.dims.axis_labels) axis_labels[axis.axis] = value axis.dims.axis_labels = tuple(axis_labels) else: return super().setData(index, value, role=role) self.dataChanged.emit(index, index, [role]) return True 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, draggable, 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 Parameters ---------- index : qtpy.QtCore.QModelIndex Index to return flags for. Returns ------- qtpy.QtCore.Qt.ItemFlags ItemFlags specific to the given index. """ if not index.isValid(): # We only allow drops outside and in between the items # (and not inside them), in which case the index is not valid. return Qt.ItemFlag.ItemIsDropEnabled flags = ( Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren | Qt.ItemFlag.ItemIsDragEnabled ) return flags napari-0.5.6/napari/_qt/containers/qt_layer_list.py000066400000000000000000000065651474413133200224410ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import QSortFilterProxyModel, Qt # type: ignore[attr-defined] from napari._qt._qapp_model.qactions._layerlist_context import ( is_valid_spatial_in_clipboard, ) 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 typing import Optional from qtpy.QtGui import QKeyEvent # type: ignore[attr-defined] from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] 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: Optional[QWidget] = None ) -> None: root._ctx['valid_spatial_json_clipboard'] = ( is_valid_spatial_in_clipboard ) super().__init__(root, parent) layer_delegate = LayerDelegate() self.setItemDelegate(layer_delegate) # To be able to update the loading indicator frame in the item delegate # smoothly and also be able to leave the item painted in a coherent # state (showing the loading indicator or the thumbnail) viewport = self.viewport() assert viewport is not None layer_delegate.loading_frame_changed.connect(viewport.update) self.setToolTip(trans._('Layer list')) # 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: Optional[QKeyEvent]) -> None: """Override Qt event to pass events to the viewer.""" if e is None: return # capture arrows with modifiers so they are handled by Viewer keybindings if (e.key() == Qt.Key.Key_Up or e.key() == Qt.Key.Key_Down) and ( e.modifiers() & Qt.KeyboardModifier.AltModifier or e.modifiers() & Qt.KeyboardModifier.ControlModifier or e.modifiers() & Qt.KeyboardModifier.MetaModifier or e.modifiers() & Qt.KeyboardModifier.ShiftModifier ): e.ignore() elif e.key() != Qt.Key.Key_Space: super().keyPressEvent(e) if e.key() not in ( Qt.Key.Key_Backspace, Qt.Key.Key_Delete, Qt.Key.Key_Return, ): e.ignore() # pass key events up to viewer napari-0.5.6/napari/_qt/containers/qt_layer_model.py000066400000000000000000000105571474413133200225620ustar00rootroot00000000000000import typing from qtpy.QtCore import QModelIndex, QSize, Qt from qtpy.QtGui import QImage from napari import current_viewer from napari._qt.containers.qt_list_model import QtListModel from napari.layers import Layer from napari.settings import get_settings from napari.utils.translations import trans ThumbnailRole = Qt.UserRole + 2 LoadedRole = Qt.UserRole + 3 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) viewer = current_viewer() layer_loaded = layer.loaded # Playback with async slicing causes flickering between the thumbnail # and loading animation in some cases due quick changes in the loaded # state, so report as unloaded in that case to avoid that. if get_settings().experimental.async_ and (viewer := current_viewer()): viewer_playing = viewer.window._qt_viewer.dims.is_playing layer_loaded = layer.loaded and not viewer_playing 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 layer_source_info = layer.get_source_str() if layer_loaded: return layer_source_info return trans._('{source} (loading)', source=layer_source_info) 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, ) if role == LoadedRole: return layer_loaded # 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 = ( Qt.CheckState(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 all_loaded(self): """Return if all the layers are loaded.""" return all( self.index(row, 0).data(LoadedRole) for row in range(self.rowCount()) ) 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, 'loaded': LoadedRole, }.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.6/napari/_qt/containers/qt_list_model.py000066400000000000000000000063661474413133200224240ustar00rootroot00000000000000import logging import pickle from collections.abc import Sequence from typing import Optional, 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) 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.6/napari/_qt/containers/qt_list_view.py000066400000000000000000000032301474413133200222610ustar00rootroot00000000000000from __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 typing import Optional from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] 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: Optional[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.6/napari/_qt/containers/qt_tree_model.py000066400000000000000000000206321474413133200224000ustar00rootroot00000000000000import logging import pickle from typing import Optional, 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: Optional[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( 'dropMimeData: indices {ind} ➡ {idx}', extra={'ind': moving_indices, 'idx': 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.6/napari/_qt/containers/qt_tree_view.py000066400000000000000000000046311474413133200222530ustar00rootroot00000000000000from __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 typing import Optional from qtpy.QtCore import QModelIndex from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] 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: Optional[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.6/napari/_qt/dialogs/000077500000000000000000000000001474413133200164555ustar00rootroot00000000000000napari-0.5.6/napari/_qt/dialogs/__init__.py000066400000000000000000000000601474413133200205620ustar00rootroot00000000000000"""Custom dialogs that inherit from QDialog.""" napari-0.5.6/napari/_qt/dialogs/_tests/000077500000000000000000000000001474413133200177565ustar00rootroot00000000000000napari-0.5.6/napari/_qt/dialogs/_tests/__init__.py000066400000000000000000000000001474413133200220550ustar00rootroot00000000000000napari-0.5.6/napari/_qt/dialogs/_tests/test_about.py000066400000000000000000000002731474413133200225030ustar00rootroot00000000000000from 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.6/napari/_qt/dialogs/_tests/test_activity_dialog.py000066400000000000000000000100261474413133200245410ustar00rootroot00000000000000import 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), 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.6/napari/_qt/dialogs/_tests/test_confirm_close_dialog.py000066400000000000000000000032731474413133200255350ustar00rootroot00000000000000from 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.6/napari/_qt/dialogs/_tests/test_preferences_dialog.py000066400000000000000000000277141474413133200252220ustar00rootroot00000000000000import sys import numpy.testing as npt import pyautogui import pytest from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QApplication from napari._pydantic_compat import BaseModel from napari._qt.dialogs.preferences_dialog import ( PreferencesDialog, QMessageBox, ) from napari._tests.utils import skip_local_focus, skip_on_mac_ci from napari._vendor.qt_json_builder.qt_jsonschema_form.widgets import ( EnumSchemaWidget, FontSizeSchemaWidget, HighlightPreviewWidget, HorizontalObjectSchemaWidget, ) from napari.settings import NapariSettings, get_settings from napari.settings._constants import BrushSizeOnMouseModifiers, LabelDTypes from napari.utils.interactions import Shortcut from napari.utils.key_bindings import KeyBinding @pytest.fixture def pref(qtbot): dlg = PreferencesDialog() qtbot.addWidget(dlg) # check settings default values and change them for later checks settings = get_settings() # change theme setting (default `dark`) assert settings.appearance.theme == 'dark' dlg._settings.appearance.theme = 'light' assert get_settings().appearance.theme == 'light' # change highlight setting related value (default thickness `1`) assert get_settings().appearance.highlight.highlight_thickness == 1 dlg._settings.appearance.highlight.highlight_thickness = 5 assert get_settings().appearance.highlight.highlight_thickness == 5 # change `napari:reset_scroll_progress` shortcut/keybinding (default keybinding `Ctrl`/`Control`) # a copy of the initial `shortcuts` dictionary needs to be done since, to trigger an # event update from the `ShortcutsSettings` model, the whole `shortcuts` dictionary # needs to be reassigned. assert dlg._settings.shortcuts.shortcuts[ 'napari:reset_scroll_progress' ] == [KeyBinding.from_str('Ctrl')] shortcuts = dlg._settings.shortcuts.shortcuts.copy() shortcuts['napari:reset_scroll_progress'] = [KeyBinding.from_str('U')] dlg._settings.shortcuts.shortcuts = shortcuts assert dlg._settings.shortcuts.shortcuts[ 'napari:reset_scroll_progress' ] == [KeyBinding.from_str('U')] return 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_dask_widget(qtbot, pref): dask_widget = pref._stack.currentWidget().widget().widget.widgets['dask'] def_dask_enabled = True settings = pref._settings # check custom widget definition and default value for dask cache `enabled` setting assert isinstance(dask_widget, HorizontalObjectSchemaWidget) assert settings.application.dask.enabled == def_dask_enabled assert dask_widget.state['enabled'] == def_dask_enabled # check changing dask cache `enabled` setting via widget new_dask_enabled = False dask_widget.state = { 'enabled': new_dask_enabled, 'cache': dask_widget.state['cache'], } assert settings.application.dask.enabled == new_dask_enabled assert dask_widget.state['enabled'] == new_dask_enabled assert dask_widget.widgets['enabled'].state == new_dask_enabled # check changing dask `enabled` setting via settings object (to default value) settings.application.dask.enabled = def_dask_enabled assert dask_widget.state['enabled'] == def_dask_enabled assert dask_widget.widgets['enabled'].state == def_dask_enabled def test_font_size_widget(qtbot, pref): font_size_widget = ( pref._stack.widget(1).widget().widget.widgets['font_size'] ) def_font_size = 12 if sys.platform == 'darwin' else 9 # check custom widget definition usage for the font size setting # and default values assert isinstance(font_size_widget, FontSizeSchemaWidget) assert get_settings().appearance.font_size == def_font_size assert font_size_widget.state == def_font_size # check setting a new font size value via widget new_font_size = 14 font_size_widget.state = new_font_size assert get_settings().appearance.font_size == new_font_size # verify that a theme change preserves the font size value assert get_settings().appearance.theme == 'light' get_settings().appearance.theme = 'dark' assert get_settings().appearance.font_size == new_font_size assert font_size_widget.state == new_font_size # check reset button works font_size_widget._reset_button.click() assert get_settings().appearance.font_size == def_font_size assert font_size_widget.state == def_font_size @pytest.mark.parametrize( ('enum_setting_name', 'enum_setting_class'), [ ('new_labels_dtype', LabelDTypes), ('brush_size_on_mouse_move_modifiers', BrushSizeOnMouseModifiers), ], ) def test_StrEnum_widgets(qtbot, pref, enum_setting_name, enum_setting_class): enum_widget = ( pref._stack.currentWidget().widget().widget.widgets[enum_setting_name] ) settings = pref._settings # check custom widget definition and widget value follows setting assert isinstance(enum_widget, EnumSchemaWidget) assert enum_widget.state == getattr( settings.application, enum_setting_name ) # check changing setting via widget for idx in range(enum_widget.count()): item_text = enum_widget.itemText(idx) item_data = enum_widget.itemData(idx) enum_widget.setCurrentText(item_text) assert getattr(settings.application, enum_setting_name) == item_data assert enum_widget.state == item_data # check changing setting updates widget for enum_value in enum_setting_class: setattr(settings.application, enum_setting_name, enum_value) assert enum_widget.state == enum_value def test_highlight_widget(qtbot, pref): highlight_widget = ( pref._stack.widget(1).widget().widget.widgets['highlight'] ) settings = pref._settings # check custom widget definition and widget follows settings values assert isinstance(highlight_widget, HighlightPreviewWidget) assert ( highlight_widget.state['highlight_color'] == settings.appearance.highlight.highlight_color ) assert ( highlight_widget.state['highlight_thickness'] == settings.appearance.highlight.highlight_thickness ) # check changing setting via widget new_widget_values = { 'highlight_thickness': 5, 'highlight_color': [0.6, 0.6, 1.0, 1.0], } highlight_widget.setValue(new_widget_values) npt.assert_allclose( settings.appearance.highlight.highlight_color, new_widget_values['highlight_color'], ) assert ( settings.appearance.highlight.highlight_thickness == new_widget_values['highlight_thickness'] ) # check changing setting updates widget new_setting_values = { 'highlight_thickness': 1, 'highlight_color': [0.5, 0.6, 1.0, 1.0], } settings.appearance.highlight.highlight_color = new_setting_values[ 'highlight_color' ] npt.assert_allclose( highlight_widget.state['highlight_color'], new_setting_values['highlight_color'], ) settings.appearance.highlight.highlight_thickness = new_setting_values[ 'highlight_thickness' ] assert ( highlight_widget.state['highlight_thickness'] == new_setting_values['highlight_thickness'] ) 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' @pytest.mark.key_bindings def test_preferences_dialog_cancel(qtbot, pref): with qtbot.waitSignal(pref.finished): pref._button_cancel.click() assert get_settings().appearance.theme == 'dark' assert get_settings().shortcuts.shortcuts[ 'napari:reset_scroll_progress' ] == [KeyBinding.from_str('Ctrl')] @pytest.mark.key_bindings def test_preferences_dialog_restore(qtbot, pref, monkeypatch): theme_widget = pref._stack.widget(1).widget().widget.widgets['theme'] highlight_widget = ( pref._stack.widget(1).widget().widget.widgets['highlight'] ) shortcut_widget = ( pref._stack.widget(3).widget().widget.widgets['shortcuts'] ) assert get_settings().appearance.theme == 'light' assert theme_widget.state == 'light' assert get_settings().appearance.highlight.highlight_thickness == 5 assert highlight_widget.state['highlight_thickness'] == 5 assert get_settings().shortcuts.shortcuts[ 'napari:reset_scroll_progress' ] == [KeyBinding.from_str('U')] assert KeyBinding.from_str( Shortcut.parse_platform( shortcut_widget._table.item( 0, shortcut_widget._shortcut_col ).text() ) ) == KeyBinding.from_str('U') monkeypatch.setattr( QMessageBox, 'question', lambda *a: QMessageBox.RestoreDefaults ) pref._restore_default_dialog() assert get_settings().appearance.theme == 'dark' assert theme_widget.state == 'dark' assert get_settings().appearance.highlight.highlight_thickness == 1 assert highlight_widget.state['highlight_thickness'] == 1 assert get_settings().shortcuts.shortcuts[ 'napari:reset_scroll_progress' ] == [KeyBinding.from_str('Ctrl')] assert KeyBinding.from_str( Shortcut.parse_platform( shortcut_widget._table.item( 0, shortcut_widget._shortcut_col ).text() ) ) == KeyBinding.from_str('Ctrl') @skip_local_focus @skip_on_mac_ci @pytest.mark.key_bindings @pytest.mark.parametrize( 'confirm_key', ['enter', 'return', 'tab'], ) def test_preferences_dialog_not_dismissed_by_keybind_confirm( qtbot, pref, confirm_key ): """This test ensures that when confirming a keybinding change, the dialog is not dismissed. Notes: * Skipped on macOS CI due to accessibility permissions not being settable on macOS GitHub Actions runners. * For this test to pass locally, you need to give the Terminal/iTerm/VSCode application accessibility permissions: `System Settings > Privacy & Security > Accessibility` See https://github.com/asweigart/pyautogui/issues/247 and https://github.com/asweigart/pyautogui/issues/247#issuecomment-437668855 """ shortcut_widget = ( pref._stack.widget(3).widget().widget.widgets['shortcuts'] ) pref._stack.setCurrentIndex(3) # ensure the dialog is showing pref.show() qtbot.waitExposed(pref) assert pref.isVisible() shortcut = shortcut_widget._table.item( 0, shortcut_widget._shortcut_col ).text() assert shortcut == 'U' x = shortcut_widget._table.columnViewportPosition( shortcut_widget._shortcut_col ) y = shortcut_widget._table.rowViewportPosition(0) item_pos = QPoint(x, y) qtbot.mouseClick( shortcut_widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos, ) qtbot.mouseDClick( shortcut_widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos, ) qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) pyautogui.press('delete') qtbot.wait(100) pyautogui.press(confirm_key) qtbot.wait(100) # ensure the dialog is still open assert pref.isVisible() # verify that the keybind is changed shortcut = shortcut_widget._table.item( 0, shortcut_widget._shortcut_col ).text() assert shortcut == '' napari-0.5.6/napari/_qt/dialogs/_tests/test_qt_modal.py000066400000000000000000000041201474413133200231640ustar00rootroot00000000000000from 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, match='Specifying position as a string' ): 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, match='position must be one of'): popup.move_to('dummy_text') with pytest.raises(TypeError, match='Wrong type of position'): 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.6/napari/_qt/dialogs/_tests/test_qt_plugin_report.py000066400000000000000000000041341474413133200247660ustar00rootroot00000000000000import 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): # noqa: PT011 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.6/napari/_qt/dialogs/_tests/test_reader_dialog.py000066400000000000000000000156601474413133200241600ustar00rootroot00000000000000import os from unittest import mock import numpy as np import pytest import zarr from npe2 import DynamicPlugin from npe2.manifest.contributions import SampleDataURI from qtpy.QtWidgets import QLabel, QRadioButton from napari._app_model import get_app_model from napari._qt.dialogs.qt_reader_dialog import ( QtReaderDialog, open_with_dialog_choices, prepare_remaining_readers, ) from napari._qt.qt_viewer import QtViewer from napari.components import ViewerModel 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 not 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_name = tmpdir.mkdir('my_dir.zarr') widg = reader_dialog(pth=dir_name, 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_name = tmpdir.mkdir('my_dir') widg = reader_dialog(pth=dir_name, readers={'p1': 'p1', 'p2': 'p2'}) assert ( widg._persist_text == f'Remember this choice for folders labeled as {dir_name}{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 not widg._get_persist_choice() widg.persist_checkbox.toggle() assert 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_sample_data_shows_all_readers( make_napari_viewer, tmp_plugin: DynamicPlugin, ): """Checks that sample data callback `_add_sample` shows all readers.""" # Test for bug fixed in #6058 tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def _(path): ... @tmp2.contribute.reader(filename_patterns=['*.fake']) def _(path): ... my_sample = SampleDataURI( key='tmp-sample', display_name='Temp Sample', uri='some-path/some-file.fake', ) tmp_plugin.manifest.contributions.sample_data = [my_sample] app = get_app_model() # required so setup steps run in init of `Viewer` and `Window` viewer = make_napari_viewer() # Ensure that `handle_gui_reading`` is not passed the sample plugin name with mock.patch( 'napari._qt.dialogs.qt_reader_dialog.handle_gui_reading' ) as mock_read: app.commands.execute_command('tmp_plugin:tmp-sample') mock_read.assert_called_once_with( ['some-path/some-file.fake'], viewer.window._qt_viewer, stack=False, ) def test_open_with_dialog_choices_persist(builtins, tmp_path, qtbot): pth = tmp_path / 'my-file.npy' np.save(pth, np.random.random((10, 10))) viewer = ViewerModel() qt_viewer = QtViewer(viewer) qtbot.addWidget(qt_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=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_persist_dir(builtins, tmp_path, qtbot): pth = tmp_path / 'data.zarr' z = zarr.open( store=str(pth), mode='w', shape=(10, 10), chunks=(5, 5), dtype='f4' ) z[:] = np.random.random((10, 10)) viewer = ViewerModel() qt_viewer = QtViewer(viewer) qtbot.addWidget(qt_viewer) open_with_dialog_choices( display_name=builtins.display_name, persist=True, extension=str(pth), readers={builtins.name: builtins.display_name}, paths=[str(pth)], stack=False, qt_viewer=qt_viewer, ) assert len(viewer.layers) == 1 # make sure extension was saved without * and with trailing slash assert ( get_settings().plugins.extension2reader[f'{pth}{os.sep}'] == builtins.name ) def test_open_with_dialog_choices_raises(make_napari_viewer): viewer = make_napari_viewer() get_settings().plugins.extension2reader = {} with pytest.raises(ValueError, match='does not exist'): 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.6/napari/_qt/dialogs/_tests/test_screenshot_dialog.py000066400000000000000000000063131474413133200250660ustar00rootroot00000000000000import pytest from qtpy.QtCore import QTimer from qtpy.QtWidgets import QApplication, QFileDialog, QLineEdit, QMessageBox from napari._qt.dialogs.screenshot_dialog import ScreenshotDialog @pytest.mark.parametrize('filename', ['test', 'test.png', 'test.tif']) def test_screenshot_save(qtbot, tmp_path, filename): """Check passing different extensions with the filename.""" def save_function(path): # check incoming path has extension event when a filename without one # was provided assert filename in path assert '.' in filename or '.png' in path # create a file with the given path to check for # non-native qt overwrite message with open(path, 'w') as mock_img: mock_img.write('') qt_overwrite_shown = False def qt_overwrite_qmessagebox_warning(): for widget in QApplication.topLevelWidgets(): if isinstance(widget, QMessageBox): # pragma: no cover # test should not enter here! widget.accept() nonlocal qt_overwrite_shown qt_overwrite_shown = True break # setup dialog dialog = ScreenshotDialog( save_function, directory=str(tmp_path), history=[] ) qtbot.addWidget(dialog) dialog.setOptions(QFileDialog.DontUseNativeDialog) dialog.show() # check dialog and set filename assert dialog.windowTitle() == 'Save screenshot' line_edit = dialog.findChild(QLineEdit) line_edit.setText(filename) # check that no warning message related with overwriting is shown QTimer.singleShot(100, qt_overwrite_qmessagebox_warning) dialog.accept() qtbot.wait(120) assert not qt_overwrite_shown, 'Qt non-native overwrite message was shown!' # check the file was created save_filename = filename if '.' in filename else f'{filename}.png' qtbot.waitUntil((tmp_path / save_filename).exists) def test_screenshot_overwrite_save(qtbot, tmp_path, monkeypatch): """Check overwriting file validation.""" (tmp_path / 'test.png').write_text('') def save_function(path): assert 'test.png' in path (tmp_path / 'test.png').write_text('overwritten') def overwrite_qmessagebox_warning(*args): box, parent, title, text, buttons, default = args assert parent == dialog assert title == 'Confirm overwrite' assert 'test.png' in text assert 'already exists. Do you want to replace it?' in text assert buttons == QMessageBox.Yes | QMessageBox.No assert default == QMessageBox.No return QMessageBox.Yes # monkeypath custom overwrite QMessageBox usage monkeypatch.setattr(QMessageBox, 'warning', overwrite_qmessagebox_warning) dialog = ScreenshotDialog( save_function, directory=str(tmp_path), history=[] ) qtbot.addWidget(dialog) dialog.setOptions(QFileDialog.DontUseNativeDialog) dialog.show() # check dialog, set filename and trigger accept logic assert dialog.windowTitle() == 'Save screenshot' line_edit = dialog.findChild(QLineEdit) line_edit.setText('test') dialog.accept() # check the file was overwritten assert (tmp_path / 'test.png').read_text() == 'overwritten' napari-0.5.6/napari/_qt/dialogs/confirm_close_dialog.py000066400000000000000000000050631474413133200231740ustar00rootroot00000000000000from 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.6/napari/_qt/dialogs/preferences_dialog.py000066400000000000000000000241111474413133200226460ustar00rootroot00000000000000import json from enum import EnumMeta from typing import TYPE_CHECKING, ClassVar, cast from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QListWidget, QMessageBox, QPushButton, QScrollArea, QStackedWidget, QVBoxLayout, ) from napari._pydantic_compat import BaseModel, ModelField, ModelMetaclass from napari.utils.compat import StrEnum from napari.utils.translations import trans if TYPE_CHECKING: from qtpy.QtGui import QCloseEvent, QKeyEvent class PreferencesDialog(QDialog): """Preferences Dialog for Napari user settings.""" ui_schema: ClassVar[dict[str, dict[str, str]]] = { 'call_order': {'ui:widget': 'plugins'}, 'highlight': {'ui:widget': 'highlight'}, 'shortcuts': {'ui:widget': 'shortcuts'}, 'extension2reader': {'ui:widget': 'extension2reader'}, 'dask': {'ui:widget': 'horizontal_object'}, 'font_size': {'ui:widget': 'font_size'}, } resized = Signal(QSize) def __init__(self, parent=None) -> None: from napari.settings import get_settings super().__init__(parent) self.setWindowTitle(trans._('Preferences')) self.setMinimumSize(QSize(1065, 470)) 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, 4) # 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) ) # make widgets follow values of the settings settings_category = getattr(self._settings, name.lower()) excluded = set( getattr( getattr(settings_category, 'NapariConfig', None), 'preferences_exclude', {}, ) ) nested_settings = ['dask', 'highlight'] for name_, emitter in settings_category.events.emitters.items(): if name_ not in excluded and name_ not in nested_settings: emitter.connect(update_widget_state(name_, form.widget)) elif name_ in nested_settings: # Needed to handle nested event model settings (i.e `DaskSettings` and `HighlightSettings`) for subname_, subemitter in getattr( settings_category, name_ ).events.emitters.items(): subemitter.connect( update_widget_state( subname_, form.widget.widgets[name_] ) ) page_scrollarea = QScrollArea() page_scrollarea.setWidgetResizable(True) page_scrollarea.setWidget(form) self._list.addItem(field.field_info.title or field.name) self._stack.addWidget(page_scrollarea) 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': field.type_.__fields__[ 'shortcuts' ].field_info.title, 'description': field.type_.__fields__[ 'shortcuts' ].field_info.description, 'type': 'object', } }, } else: schema = json.loads(ftype.schema_json()) if field.field_info.title: schema['title'] = field.field_info.title if field.field_info.description: schema['description'] = field.field_info.description # 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' if isinstance(subfield.type_, ModelMetaclass): local_schema = json.loads(subfield.type_.schema_json()) schema['properties'][name]['type'] = 'object' schema['properties'][name]['properties'] = local_schema[ 'properties' ] # 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.""" prev = QApplication.instance().testAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs ) QApplication.instance().setAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs, True ) response = QMessageBox.question( self, trans._('Restore Settings'), trans._('Are you sure you want to restore default settings?'), QMessageBox.StandardButton.RestoreDefaults | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.RestoreDefaults, ) QApplication.instance().setAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs, prev ) if response == QMessageBox.RestoreDefaults: self._settings.reset() 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() def update_widget_state(name, widget): def _update_widget_state(event): value = event.value if isinstance(value, StrEnum): value = value.value widget.state = {name: value} return _update_widget_state napari-0.5.6/napari/_qt/dialogs/qt_about.py000066400000000000000000000107311474413133200206470ustar00rootroot00000000000000from 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.6/napari/_qt/dialogs/qt_activity_dialog.py000066400000000000000000000246021474413133200227120ustar00rootroot00000000000000from 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, ) from napari._qt.widgets.qt_progress_bar import ( QtLabeledProgressBar, QtProgressBarGroup, ) from napari.resources import LOADING_GIF_PATH 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) mov = QMovie(LOADING_GIF_PATH) 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 return None 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.6/napari/_qt/dialogs/qt_modal.py000066400000000000000000000133351474413133200206340ustar00rootroot00000000000000from 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 flags = Qt.Popup | Qt.FramelessWindowHint self.setWindowFlags(flags) 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 TypeError( 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.6/napari/_qt/dialogs/qt_notification.py000066400000000000000000000407671474413133200222370ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Sequence from typing import Callable, Optional, Union, cast 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 superqt.utils import CodeSyntaxHighlight 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).icon.as_hex() # 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.parent() is not None and not self.parent().isActiveWindow(): return if self.parent() is not None: notifications = cast( list[NapariQtNotification], self.parent().findChildren(NapariQtNotification), ) for notification in notifications: notification.timer_stop() 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 enterEvent(self, event): """On hover, stop the self-destruct timer""" self.timer_stop() def leaveEvent(self, event): """On hover exit, restart the self-destruct timer""" self.timer_start() def timer_start(self): """Start the self-destruct timer""" if self.DISMISS_AFTER > 0: self.timer.start() def timer_stop(self): """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() if self.parent() is not None: notifications = cast( list[NapariQtNotification], self.parent().findChildren(NapariQtNotification), ) if len(notifications) > 1 and notifications[-1] == self: notifications[-2].timer_start() 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( # noqa: T201 "Entering debugger. Type 'q' to return to napari.\n" ) pdb.post_mortem(tb) print('\nDebugging finished. Napari active again.') # noqa: T201 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) _highlight = CodeSyntaxHighlight( 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.6/napari/_qt/dialogs/qt_plugin_report.py000066400000000000000000000174271474413133200224370ustar00rootroot00000000000000"""Provides a QtPluginErrReporter that allows the user report plugin errors.""" import contextlib 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 superqt.utils import CodeSyntaxHighlight 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) self._highlight = CodeSyntaxHighlight( 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(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() with contextlib.suppress(RuntimeError, TypeError): self.github_button.clicked.disconnect() # when disconnecting a non-existent signal # PySide2 raises runtimeError, PyQt5 raises TypeError 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.6/napari/_qt/dialogs/qt_reader_dialog.py000066400000000000000000000237141474413133200223230ustar00rootroot00000000000000import os from typing import Optional, 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: Optional[dict[str, str]] = None, error_message: str = '', persist_checked: bool = False, ) -> 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() return None 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=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. preferred 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 = next( p_name for p_name, d_name in readers.items() if d_name == display_name ) # may throw error, but we let it this time qt_viewer.viewer.open(paths, stack=stack, plugin=plugin_name, **kwargs) if persist: if not os.path.isabs(extension): extension = f'*{extension}' elif os.path.isdir(extension) and not extension.endswith(os.sep): extension += os.sep get_settings().plugins.extension2reader = { **get_settings().plugins.extension2reader, extension: plugin_name, } napari-0.5.6/napari/_qt/dialogs/screenshot_dialog.py000066400000000000000000000042261474413133200225270ustar00rootroot00000000000000import 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: # return in this case since a valid name for the # file is needed so the dialog needs to be visible return super().accept() if self.result(): self.save_function(save_path) napari-0.5.6/napari/_qt/experimental/000077500000000000000000000000001474413133200175305ustar00rootroot00000000000000napari-0.5.6/napari/_qt/experimental/__init__.py000066400000000000000000000000001474413133200216270ustar00rootroot00000000000000napari-0.5.6/napari/_qt/experimental/qt_poll.py000066400000000000000000000106101474413133200215520ustar00rootroot00000000000000"""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.6/napari/_qt/layer_controls/000077500000000000000000000000001474413133200200725ustar00rootroot00000000000000napari-0.5.6/napari/_qt/layer_controls/__init__.py000066400000000000000000000002141474413133200222000ustar00rootroot00000000000000from napari._qt.layer_controls.qt_layer_controls_container import ( QtLayerControlsContainer, ) __all__ = ['QtLayerControlsContainer'] napari-0.5.6/napari/_qt/layer_controls/_tests/000077500000000000000000000000001474413133200213735ustar00rootroot00000000000000napari-0.5.6/napari/_qt/layer_controls/_tests/__init__.py000066400000000000000000000000001474413133200234720ustar00rootroot00000000000000napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py000066400000000000000000000141011474413133200271140ustar00rootroot00000000000000import 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.components.dims import Dims 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""" # this test relies implicitly on ndisplay=3 which is now a broken assumption? layer._slice_dims(Dims(ndim=3, ndisplay=3)) 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(qtbot): """Regression to test, makes sure it works with tensorstore dtype""" ts = pytest.importorskip('tensorstore') layer = Image(ts.array(np.random.rand(20, 20))) qtbot.addWidget(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.6/napari/_qt/layer_controls/_tests/test_qt_image_layer.py000066400000000000000000000113301474413133200257640ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_image_controls import QtImageControls from napari.components.dims import Dims 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 dims = Dims( ndim=3, range=((0, 4, 1), (0, 8, 1), (0, 8, 1)), point=(1, 8, 8) ) layer._slice_dims(dims) # 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] dims.point = (2, 8, 8) layer._slice_dims(dims) assert layer.contrast_limits == [128, 191] dims.point = (3, 8, 8) layer._slice_dims(dims) assert layer.contrast_limits == [192, 255] # once button turns off continuous qtctrl.autoScaleBar._once_btn.click() dims.point = (4, 8, 8) layer._slice_dims(dims) assert layer.contrast_limits == [192, 255] napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py000066400000000000000000000151271474413133200261540ustar00rootroot00000000000000import numpy as np import pytest from napari._qt.layer_controls.qt_labels_controls import QtLabelsControls from napari.layers import Labels from napari.layers.labels._labels_constants import ( IsoCategoricalGradientMode, LabelsRendering, ) from napari.utils.colormaps import DirectLabelColormap, colormap_utils np.random.seed(0) _LABELS = np.random.randint(5, size=(10, 15), dtype=np.uint8) _COLOR = DirectLabelColormap( color_dict={ 1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow', None: 'black', } ) @pytest.fixture def make_labels_controls(qtbot, colormap=None): def _make_labels_controls(colormap=colormap): layer = Labels(_LABELS, colormap=colormap) qtctrl = QtLabelsControls(layer) qtbot.add_widget(qtctrl) return layer, qtctrl return _make_labels_controls def test_changing_layer_color_mode_updates_combo_box(make_labels_controls): """Updating layer color mode changes the combo box selection""" layer, qtctrl = make_labels_controls(colormap=_COLOR) assert qtctrl.colorModeComboBox.currentText() == 'direct' layer.colormap = layer._random_colormap assert qtctrl.colorModeComboBox.currentText() == 'auto' def test_changing_layer_show_selected_label_updates_check_box( make_labels_controls, ): """See https://github.com/napari/napari/issues/5371""" layer, qtctrl = make_labels_controls() assert not qtctrl.selectedColorCheckbox.isChecked() assert not layer.show_selected_label layer.show_selected_label = True assert qtctrl.selectedColorCheckbox.isChecked() def test_rendering_combobox(make_labels_controls): """Changing the model attribute should update the view""" layer, qtctrl = make_labels_controls() 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(make_labels_controls): """Test that changing the colormap on a layer will update color swatch in the combo box""" layer, qtctrl = make_labels_controls(colormap=_COLOR) 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.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), ) def test_selected_color_checkbox(make_labels_controls): """Tests that the 'selected color' checkbox sets the 'show_selected_label' property properly.""" layer, qtctrl = make_labels_controls() qtctrl.selectedColorCheckbox.setChecked(True) assert layer.show_selected_label qtctrl.selectedColorCheckbox.setChecked(False) assert not layer.show_selected_label qtctrl.selectedColorCheckbox.setChecked(True) assert layer.show_selected_label def test_contiguous_labels_checkbox(make_labels_controls): """Tests that the 'contiguous' checkbox sets the 'contiguous' property properly.""" layer, qtctrl = make_labels_controls() qtctrl.contigCheckBox.setChecked(True) assert layer.contiguous qtctrl.contigCheckBox.setChecked(False) assert not layer.contiguous qtctrl.contigCheckBox.setChecked(True) assert layer.contiguous def test_preserve_labels_checkbox(make_labels_controls): """Tests that the 'preserve labels' checkbox sets the 'preserve_labels' property properly.""" layer, qtctrl = make_labels_controls() qtctrl.preserveLabelsCheckBox.setChecked(True) assert layer.preserve_labels qtctrl.preserveLabelsCheckBox.setChecked(False) assert not layer.preserve_labels qtctrl.preserveLabelsCheckBox.setChecked(True) assert layer.preserve_labels def test_change_label_selector_range(make_labels_controls): """Changing the label layer dtype should update label selector range.""" layer, qtctrl = make_labels_controls() assert layer.data.dtype == np.uint8 assert qtctrl.selectionSpinBox.minimum() == 0 assert qtctrl.selectionSpinBox.maximum() == 255 layer.data = layer.data.astype(np.int8) assert qtctrl.selectionSpinBox.minimum() == -128 assert qtctrl.selectionSpinBox.maximum() == 127 def test_change_iso_gradient_mode(make_labels_controls): """Changing the iso gradient mode should update the layer and vice versa.""" layer, qtctrl = make_labels_controls() qtctrl.ndisplay = 3 assert layer.rendering == LabelsRendering.ISO_CATEGORICAL assert layer.iso_gradient_mode == IsoCategoricalGradientMode.FAST # Change the iso gradient mode via the control, check the layer qtctrl.isoGradientComboBox.setCurrentEnum( IsoCategoricalGradientMode.SMOOTH ) assert layer.iso_gradient_mode == IsoCategoricalGradientMode.SMOOTH # Change the iso gradient mode via the layer, check the control layer.iso_gradient_mode = IsoCategoricalGradientMode.FAST assert ( qtctrl.isoGradientComboBox.currentEnum() == IsoCategoricalGradientMode.FAST ) def test_iso_gradient_mode_hidden_for_2d(make_labels_controls): """Test that the iso gradient mode control is hidden with 2D view.""" layer, qtctrl = make_labels_controls() assert qtctrl.isoGradientComboBox.isHidden() layer.data = np.random.randint(5, size=(10, 15), dtype=np.uint8) assert qtctrl.isoGradientComboBox.isHidden() qtctrl.ndisplay = 3 assert not qtctrl.isoGradientComboBox.isHidden() qtctrl.ndisplay = 2 assert qtctrl.isoGradientComboBox.isHidden() def test_iso_gradient_mode_with_rendering(make_labels_controls): """Test the iso gradeint mode control is enabled for iso_categorical rendering.""" layer, qtctrl = make_labels_controls() qtctrl.ndisplay = 3 assert layer.rendering == LabelsRendering.ISO_CATEGORICAL assert ( qtctrl.isoGradientComboBox.currentText() == IsoCategoricalGradientMode.FAST ) assert qtctrl.isoGradientComboBox.isEnabled() layer.rendering = LabelsRendering.TRANSLUCENT assert not qtctrl.isoGradientComboBox.isEnabled() layer.rendering = LabelsRendering.ISO_CATEGORICAL assert qtctrl.isoGradientComboBox.isEnabled() napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py000066400000000000000000000703521474413133200265560ustar00rootroot00000000000000import os import random import sys from typing import NamedTuple, Optional from unittest.mock import Mock import numpy as np import pytest import qtpy from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QAbstractButton, QAbstractSlider, QAbstractSpinBox, QCheckBox, QComboBox, QMessageBox, QPushButton, QRadioButton, ) 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_layer_controls_container import ( QtLayerControlsContainer, create_qt_layer_controls, layer_to_controls, ) 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._qt.widgets.qt_color_swatch import QColorSwatchEdit from napari.components import ViewerModel from napari.layers import ( Image, Labels, Layer, Points, Shapes, Surface, Tracks, Vectors, ) from napari.utils.colormaps import DirectLabelColormap from napari.utils.events.event import Event class LayerTypeWithData(NamedTuple): type: type[Layer] data: np.ndarray colormap: Optional[DirectLabelColormap] properties: Optional[dict] expected_isinstance: type[QtLayerControlsContainer] np.random.seed(0) _IMAGE = LayerTypeWithData( type=Image, data=np.random.rand(8, 8), colormap=None, properties=None, expected_isinstance=QtImageControls, ) _LABELS_WITH_DIRECT_COLORMAP = LayerTypeWithData( type=Labels, data=np.random.randint(5, size=(10, 15)), colormap=DirectLabelColormap( color_dict={ 1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow', None: 'black', } ), properties=None, expected_isinstance=QtLabelsControls, ) _LABELS = LayerTypeWithData( type=Labels, data=np.random.randint(5, size=(10, 15)), colormap=None, properties=None, expected_isinstance=QtLabelsControls, ) _POINTS = LayerTypeWithData( type=Points, data=np.random.random((5, 2)), colormap=None, properties=None, expected_isinstance=QtPointsControls, ) _SHAPES = LayerTypeWithData( type=Shapes, data=np.random.random((10, 4, 2)), colormap=None, properties=None, expected_isinstance=QtShapesControls, ) _SURFACE = LayerTypeWithData( type=Surface, data=( np.random.random((10, 2)), np.random.randint(10, size=(6, 3)), np.random.random(10), ), colormap=None, properties=None, expected_isinstance=QtSurfaceControls, ) _TRACKS = LayerTypeWithData( type=Tracks, data=np.zeros((2, 4)), colormap=None, properties={ 'track_id': [0, 0], 'time': [0, 0], 'speed': [50, 30], }, expected_isinstance=QtTracksControls, ) _VECTORS = LayerTypeWithData( type=Vectors, data=np.zeros((2, 2, 2)), colormap=None, properties=None, expected_isinstance=QtVectorsControls, ) _LINES_DATA = np.random.random((6, 2, 2)) @pytest.fixture def create_layer_controls(qtbot): def _create_layer_controls(layer_type_with_data): if layer_type_with_data.colormap: layer = layer_type_with_data.type( layer_type_with_data.data, colormap=layer_type_with_data.colormap, ) elif layer_type_with_data.properties: layer = layer_type_with_data.type( layer_type_with_data.data, properties=layer_type_with_data.properties, ) else: layer = layer_type_with_data.type(layer_type_with_data.data) ctrl = create_qt_layer_controls(layer) qtbot.addWidget(ctrl) return ctrl return _create_layer_controls @pytest.mark.parametrize( 'layer_type_with_data', [ _LABELS_WITH_DIRECT_COLORMAP, _LABELS, _IMAGE, _POINTS, _SHAPES, _SURFACE, _TRACKS, _VECTORS, ], ids=[ 'labels_with_direct_colormap', 'labels_with_auto_colormap', 'image', 'points', 'shapes', 'surface', 'tracks', 'vectors', ], ) @pytest.mark.qt_no_exception_capture @pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls( qtbot, create_layer_controls, layer_type_with_data, capsys ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check QComboBox by changing current index for qcombobox in ctrl.findChildren(QComboBox): if qcombobox.isVisible(): qcombobox_count = qcombobox.count() qcombobox_initial_idx = qcombobox.currentIndex() if qcombobox_count: qcombobox.setCurrentIndex(0) for idx in range(qcombobox_count): previous_qcombobox_text = qcombobox.currentText() qcombobox.setCurrentIndex(idx) # If a value for the QComboBox is an invalid selection check if # it fallbacks to the previous value captured = capsys.readouterr() if captured.err: assert qcombobox.currentText() == previous_qcombobox_text qcombobox.setCurrentIndex(qcombobox_initial_idx) skip_predicate = sys.version_info >= (3, 11) and ( qtpy.API == 'pyqt5' or qtpy.API == 'pyqt6' ) @pytest.mark.parametrize( 'layer_type_with_data', [ # those 2 fail on 3.11 + pyqt5 and pyqt6 with a segfault that can't be caught by # pytest in qspinbox.setValue(value) # See: https://github.com/napari/napari/pull/5439 pytest.param( _LABELS_WITH_DIRECT_COLORMAP, marks=pytest.mark.skipif( skip_predicate, reason='segfault on Python 3.11+ and pyqt5 or Pyqt6', ), ), pytest.param( _LABELS, marks=pytest.mark.skipif( skip_predicate, reason='segfault on Python 3.11+ and pyqt5 or Pyqt6', ), ), _IMAGE, _POINTS, _SHAPES, _SURFACE, _TRACKS, _VECTORS, ], ) @pytest.mark.qt_no_exception_capture @pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_spin( qtbot, create_layer_controls, layer_type_with_data, capsys ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) qtbot.addWidget(ctrl) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check QAbstractSpinBox by changing value with `setValue` from minimum value to maximum for qspinbox in ctrl.findChildren(QAbstractSpinBox): qspinbox_initial_value = qspinbox.value() qspinbox_min = qspinbox.minimum() qspinbox_max = qspinbox.maximum() if isinstance(qspinbox_min, float): if np.isinf(qspinbox_max): qspinbox_max = sys.float_info.max value_range = np.linspace(qspinbox_min, qspinbox_max) else: # use + 1 to include maximum value value_range = range(qspinbox_min, qspinbox_max + 1) try: value_range_length = len(value_range) except OverflowError: # range too big for even trying to get how big it is. value_range_length = 100 value_range = [ random.randrange(qspinbox_min, qspinbox_max) for _ in range(value_range_length) ] value_range.append(qspinbox_max) if value_range_length > 100: # prevent iterating over a big range of values random.seed(0) value_range = random.sample(value_range, 100) value_range = np.insert(value_range, 0, qspinbox_min) value_range = np.append(value_range, qspinbox_max - 1) for value in value_range: qspinbox.setValue(value) # capture any output done to sys.stdout or sys.stderr. captured = capsys.readouterr() assert not captured.out if captured.err: # since an error was found check if it is associated with a known issue still open expected_errors = [ 'MemoryError: Unable to allocate', # See https://github.com/napari/napari/issues/5798 'ValueError: array is too big; `arr.size * arr.dtype.itemsize` is larger than the maximum possible size.', # See https://github.com/napari/napari/issues/5798 'ValueError: Maximum allowed dimension exceeded', # See https://github.com/napari/napari/issues/5798 'IndexError: index ', # See https://github.com/napari/napari/issues/4864 'RuntimeWarning: overflow encountered', # See https://github.com/napari/napari/issues/4864 ] assert any( expected_error in captured.err for expected_error in expected_errors ), f'value: {value}, range {value_range}\nerr: {captured.err}' assert qspinbox.value() in [qspinbox_max, qspinbox_max - 1] qspinbox.setValue(qspinbox_initial_value) @pytest.mark.parametrize( 'layer_type_with_data', [ _LABELS_WITH_DIRECT_COLORMAP, _LABELS, _IMAGE, _POINTS, _SHAPES, _SURFACE, _TRACKS, _VECTORS, ], ) @pytest.mark.qt_no_exception_capture @pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_qslider( qtbot, create_layer_controls, layer_type_with_data, capsys ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check QAbstractSlider by changing value with `setValue` from minimum value to maximum for qslider in ctrl.findChildren(QAbstractSlider): if isinstance(qslider.minimum(), float): if getattr(qslider, '_valuesChanged', None): # create a list of tuples in the case the slider is ranged # from (minimum, minimum) to (maximum, maximum) + # from (minimum, maximum) to (minimum, minimum) # (minimum, minimum) and (maximum, maximum) values are excluded # to prevent the sequence not being monotonically increasing base_value_range = np.linspace( qslider.minimum(), qslider.maximum() ) num_values = base_value_range.size max_value = np.full(num_values, qslider.maximum()) min_value = np.full(num_values, qslider.minimum()) value_range_to_max = list(zip(base_value_range, max_value)) value_range_to_min = list( zip(min_value, np.flip(base_value_range)) ) value_range = value_range_to_max[:-1] + value_range_to_min[:-1] else: value_range = np.linspace(qslider.minimum(), qslider.maximum()) else: if getattr(qslider, '_valuesChanged', None): # create a list of tuples in the case the slider is ranged # from (minimum, minimum) to (maximum, maximum) + # from (minimum, maximum) to (minimum, minimum) # base list created with + 1 to include maximum value # (minimum, minimum) and (maximum, maximum) values are excluded # to prevent the sequence not being monotonically increasing base_value_range = range( qslider.minimum(), qslider.maximum() + 1 ) num_values = len(base_value_range) max_value = [qslider.maximum()] * num_values min_value = [qslider.minimum()] * num_values value_range_to_max = list(zip(base_value_range, max_value)) base_value_range_copy = base_value_range.copy() base_value_range_copy.reverse() value_range_to_min = list( zip(min_value, base_value_range_copy) ) value_range = value_range_to_max[:-1] + value_range_to_min[:-1] else: # use + 1 to include maximum value value_range = range(qslider.minimum(), qslider.maximum() + 1) for value in value_range: qslider.setValue(value) # capture any output done to sys.stdout or sys.stderr. captured = capsys.readouterr() assert not captured.out assert not captured.err if getattr(qslider, '_valuesChanged', None): assert qslider.value()[0] == qslider.minimum() else: assert qslider.value() == qslider.maximum() @pytest.mark.parametrize( 'layer_type_with_data', [ _LABELS_WITH_DIRECT_COLORMAP, _LABELS, _IMAGE, _POINTS, _SHAPES, _SURFACE, _TRACKS, _VECTORS, ], ) @pytest.mark.qt_no_exception_capture @pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_qcolorswatchedit( qtbot, create_layer_controls, layer_type_with_data, capsys ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check QColorSwatchEdit by changing line edit text with a range of predefined values for qcolorswatchedit in ctrl.findChildren(QColorSwatchEdit): lineedit = qcolorswatchedit.line_edit colorswatch = qcolorswatchedit.color_swatch colors = [ ('white', 'white', np.array([1.0, 1.0, 1.0, 1.0])), ('black', 'black', np.array([0.0, 0.0, 0.0, 1.0])), # check autocompletion `bla` -> `black` ('bla', 'black', np.array([0.0, 0.0, 0.0, 1.0])), # check that setting an invalid color makes it fallback to the previous value ('invalid_value', 'black', np.array([0.0, 0.0, 0.0, 1.0])), ] for color, expected_color, expected_array in colors: lineedit.clear() qtbot.keyClicks(lineedit, color) qtbot.keyClick(lineedit, Qt.Key_Enter) assert lineedit.text() == expected_color assert (colorswatch.color == expected_array).all() # capture any output done to sys.stdout or sys.stderr. captured = capsys.readouterr() assert not captured.out assert not captured.err # check QCheckBox by clicking with mouse click for qcheckbox in ctrl.findChildren(QCheckBox): if qcheckbox.isVisible(): qcheckbox_checked = qcheckbox.isChecked() qtbot.mouseClick(qcheckbox, Qt.LeftButton) assert qcheckbox.isChecked() != qcheckbox_checked # capture any output done to sys.stdout or sys.stderr. captured = capsys.readouterr() assert not captured.out assert not captured.err # check QPushButton and QRadioButton by clicking with mouse click for button in ctrl.findChildren(QPushButton) + ctrl.findChildren( QRadioButton ): if button.isVisible(): qtbot.mouseClick(button, Qt.LeftButton) # capture any output done to sys.stdout or sys.stderr. captured = capsys.readouterr() assert not captured.out assert not captured.err @pytest.mark.parametrize( ( 'layer_type_with_data', 'action_manager_trigger', ), [ ( _LABELS_WITH_DIRECT_COLORMAP, 'napari:activate_labels_transform_mode', ), ( _LABELS, 'napari:activate_labels_transform_mode', ), ( _IMAGE, 'napari:activate_image_transform_mode', ), ( _POINTS, 'napari:activate_points_transform_mode', ), ( _SHAPES, 'napari:activate_shapes_transform_mode', ), ( _SURFACE, 'napari:activate_surface_transform_mode', ), ( _TRACKS, 'napari:activate_tracks_transform_mode', ), ( _VECTORS, 'napari:activate_vectors_transform_mode', ), ], ) def test_create_layer_controls_transform_mode_button( qtbot, create_layer_controls, layer_type_with_data, action_manager_trigger, monkeypatch, ): action_manager_mock = Mock(trigger=Mock()) # Monkeypatch the action_manager instance to prevent `KeyError: 'layer'` # over `napari.layers.utils.layer_utils.register_layer_attr_action._handle._wrapper` monkeypatch.setattr( 'napari._qt.layer_controls.qt_layer_controls_base.action_manager', action_manager_mock, ) # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check transform mode button existence assert ctrl.transform_button # check layer mode change assert ctrl.layer.mode == 'pan_zoom' ctrl.transform_button.click() assert ctrl.layer.mode == 'transform' # check reset transform behavior ctrl.layer.affine = None assert ctrl.layer.affine != ctrl.layer._initial_affine def reset_transform_warning_dialog(*args): return QMessageBox.Yes monkeypatch.setattr( 'qtpy.QtWidgets.QMessageBox.warning', reset_transform_warning_dialog ) qtbot.mouseClick( ctrl.transform_button, Qt.LeftButton, Qt.KeyboardModifier.AltModifier, ) assert ctrl.layer.affine == ctrl.layer._initial_affine @pytest.mark.parametrize( 'layer_type_with_data', [ _LABELS_WITH_DIRECT_COLORMAP, _LABELS, _IMAGE, _POINTS, _SHAPES, _SURFACE, _TRACKS, _VECTORS, ], ) def test_layer_controls_invalid_mode( qtbot, create_layer_controls, layer_type_with_data, ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) # check layer mode and corresponding mode button assert ctrl.layer.mode == 'pan_zoom' assert ctrl.panzoom_button.isChecked() # check setting invalid mode with pytest.raises(ValueError, match='not recognized'): ctrl._on_mode_change(Event('mode', mode='invalid_mode')) # check panzoom_button is still checked assert ctrl.panzoom_button.isChecked() 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() @pytest.mark.parametrize(('ndim', 'editable_after'), [(2, False), (3, True)]) def test_set_3d_display_with_points(qtbot, ndim, editable_after): """Interactivity only works for 2D points layers rendered in 2D and not in 3D. Verify that layer.editable is set appropriately upon switching to 3D rendering mode. See: https://github.com/napari/napari/pull/4184 """ viewer = ViewerModel() container = QtLayerControlsContainer(viewer) qtbot.addWidget(container) layer = viewer.add_points(np.zeros((0, ndim)), ndim=ndim) assert viewer.dims.ndisplay == 2 assert layer.editable viewer.dims.ndisplay = 3 assert layer.editable == editable_after def test_set_3d_display_with_shapes(qtbot): """Interactivity only works for shapes layers rendered in 2D and not in 3D. Verify that layer.editable is set appropriately upon switching to 3D rendering mode. See: https://github.com/napari/napari/pull/4184 """ viewer = ViewerModel() container = QtLayerControlsContainer(viewer) qtbot.addWidget(container) layer = viewer.add_shapes(np.zeros((0, 2, 4))) assert viewer.dims.ndisplay == 2 assert layer.editable viewer.dims.ndisplay = 3 assert not layer.editable def test_set_3d_display_with_labels(qtbot): """Some modes only work for labels layers rendered in 2D and not in 3D. Verify that the related mode buttons are disabled upon switching to 3D rendering mode while the layer is still editable. """ viewer = ViewerModel() container = QtLayerControlsContainer(viewer) qtbot.addWidget(container) layer = viewer.add_labels(np.zeros((3, 4), dtype=int)) assert viewer.dims.ndisplay == 2 assert container.currentWidget().polygon_button.isEnabled() assert container.currentWidget().transform_button.isEnabled() assert layer.editable viewer.dims.ndisplay = 3 assert not container.currentWidget().polygon_button.isEnabled() assert not container.currentWidget().transform_button.isEnabled() assert layer.editable @pytest.mark.parametrize( 'add_layer_with_data', [ ('add_labels', np.zeros((3, 4), dtype=int)), ('add_points', np.empty((0, 2))), ('add_shapes', np.empty((0, 2, 4))), ('add_image', np.random.rand(8, 8)), ( 'add_surface', ( np.random.random((10, 2)), np.random.randint(10, size=(6, 3)), np.random.random(10), ), ), ('add_tracks', np.zeros((2, 4))), ('add_vectors', np.zeros((2, 2, 2))), ], ) def test_set_3d_display_and_layer_visibility(qtbot, add_layer_with_data): """Some modes only work for layers rendered in 2D and not in 3D. Verify that the related mode buttons are disabled upon switching to 3D rendering mode and the disable state is kept even when changing layer visibility. For the labels layer the specific polygon mode button should be disabled in 3D regardless of the layer being visible or not. For all the layers the same applies for the transform mode button. """ viewer = ViewerModel() container = QtLayerControlsContainer(viewer) qtbot.addWidget(container) add_layer_method, data = add_layer_with_data layer = getattr(viewer, add_layer_method)(data) # 2D mode assert viewer.dims.ndisplay == 2 if add_layer_method == 'add_labels': assert container.currentWidget().polygon_button.isEnabled() assert container.currentWidget().transform_button.isEnabled() # 2D mode + layer not visible layer.visible = False if add_layer_method == 'add_labels': assert not container.currentWidget().polygon_button.isEnabled() assert not container.currentWidget().transform_button.isEnabled() # 2D mode + layer visible layer.visible = True if add_layer_method == 'add_labels': assert container.currentWidget().polygon_button.isEnabled() assert container.currentWidget().transform_button.isEnabled() # 3D mode viewer.dims.ndisplay = 3 if add_layer_method == 'add_labels': assert not container.currentWidget().polygon_button.isEnabled() assert not container.currentWidget().transform_button.isEnabled() # 3D mode + layer not visible layer.visible = False if add_layer_method == 'add_labels': assert not container.currentWidget().polygon_button.isEnabled() assert not container.currentWidget().transform_button.isEnabled() # 3D mode + layer visible layer.visible = True if add_layer_method == 'add_labels': assert not container.currentWidget().polygon_button.isEnabled() assert not container.currentWidget().transform_button.isEnabled() # 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 # Updated due to the addition of a transform mode button for all the layers, # For more context see: # https://github.com/napari/napari/pull/6794 @pytest.fixture( params=( (Labels, np.zeros((3, 4), dtype=int)), (Points, np.empty((0, 2))), (Shapes, np.empty((0, 2, 4))), (Image, np.random.rand(8, 8)), ( Surface, ( np.random.random((10, 2)), np.random.randint(10, size=(6, 3)), np.random.random(10), ), ), (Tracks, np.zeros((2, 4))), (Vectors, np.zeros((2, 2, 2))), ) ) 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.6/napari/_qt/layer_controls/_tests/test_qt_points_layer.py000066400000000000000000000074121474413133200262240ustar00rootroot00000000000000import 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.raises(ValueError, match='must be positive'): 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 layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 20 assert layer.current_size == 20 with pytest.warns(DeprecationWarning): layer.current_size = [10, 10] layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 10 assert layer.current_size == 10 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 def test_size_slider_represents_current_size(qtbot): """Changing the current_size attribute should update the slider""" layer = Points(np.random.rand(10, 2)) qtctrl = QtPointsControls(layer) qtbot.addWidget(qtctrl) slider = qtctrl.sizeSlider slider.setValue(10) # Initial value 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.current_size() assert slider.value() == 5 assert layer.current_size == 5 napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_shapes_layer.py000066400000000000000000000032561474413133200261750ustar00rootroot00000000000000import 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) def test_text_visible_checkbox(qtbot): layer = Shapes(_SHAPES) qtctrl = QtShapesControls(layer) qtbot.addWidget(qtctrl) qtctrl.textDispCheckBox.setChecked(True) assert layer.text.visible qtctrl.textDispCheckBox.setChecked(False) assert not layer.text.visible qtctrl.textDispCheckBox.setChecked(True) assert layer.text.visible napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_surface_layer.py000066400000000000000000000015511474413133200263360ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_surface_controls import QtSurfaceControls from napari.layers import Surface from napari.layers.surface._surface_constants import SHADING_TRANSLATION 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)) _SURFACE = (data, faces, values) def test_shading_combobox(qtbot): layer = Surface(_SURFACE) qtctrl = QtSurfaceControls(layer) qtbot.addWidget(qtctrl) assert qtctrl.shadingComboBox.currentText() == layer.shading for display, shading in SHADING_TRANSLATION.items(): qtctrl.shadingComboBox.setCurrentText(display) assert layer.shading == shading for display, shading in SHADING_TRANSLATION.items(): layer.shading = shading assert qtctrl.shadingComboBox.currentText() == display napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py000066400000000000000000000061061474413133200261760ustar00rootroot00000000000000import 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 @pytest.fixture def null_data() -> np.ndarray: return np.zeros((2, 4)) @pytest.fixture def properties() -> dict[str, list]: return { 'track_id': [0, 0], 'time': [0, 0], 'speed': [50, 30], } def test_tracks_controls_color_by(null_data, properties, qtbot): """Check updating of the color_by combobox.""" inital_color_by = 'time' with pytest.warns(UserWarning) as wrn: layer = Tracks( null_data, 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 @pytest.mark.parametrize('color_by', ['track_id', 'speed']) def test_color_by_same_after_properties_change( null_data, properties, color_by, qtbot ): """See https://github.com/napari/napari/issues/5330""" layer = Tracks(null_data, properties=properties) layer.color_by = color_by controls = QtTracksControls(layer) qtbot.addWidget(controls) assert controls.color_by_combobox.currentText() == color_by # Change the properties value by removing the time column. layer.properties = { 'track_id': properties['track_id'], 'speed': properties['speed'], } assert layer.color_by == color_by assert controls.color_by_combobox.currentText() == color_by def test_color_by_missing_after_properties_change( null_data, properties, qtbot ): """See https://github.com/napari/napari/issues/5330""" layer = Tracks(null_data, properties=properties) layer.color_by = 'time' controls = QtTracksControls(layer) qtbot.addWidget(controls) assert controls.color_by_combobox.currentText() == 'time' # Change the properties value by removing the time column. with pytest.warns( UserWarning, match="Previous color_by key 'time' not present in features. Falling back to track_id", ): layer.properties = { 'track_id': properties['track_id'], 'speed': properties['speed'], } assert layer.color_by == 'track_id' assert controls.color_by_combobox.currentText() == 'track_id' napari-0.5.6/napari/_qt/layer_controls/_tests/test_qt_vectors_layer.py000066400000000000000000000007421474413133200263740ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_vectors_controls import QtVectorsControls from napari.layers import Vectors _VECTORS = np.zeros((2, 2, 2)) def test_out_of_slice_display_checkbox(qtbot): layer = Vectors(_VECTORS) qtctrl = QtVectorsControls(layer) qtbot.addWidget(qtctrl) qtctrl.outOfSliceCheckBox.setChecked(True) assert layer.out_of_slice_display qtctrl.outOfSliceCheckBox.setChecked(False) assert not layer.out_of_slice_display napari-0.5.6/napari/_qt/layer_controls/qt_colormap_combobox.py000066400000000000000000000045111474413133200246550ustar00rootroot00000000000000from 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.6/napari/_qt/layer_controls/qt_image_controls.py000066400000000000000000000415161474413133200241640ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QPushButton, 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.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 ---------- layer : napari.layers.Image An instance of a napari Image layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group for image based layer modes (PAN_ZOOM TRANSFORM). button_grid : qtpy.QtWidgets.QGridLayout GridLayout for the layer mode buttons panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to transform shapes layer. 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. 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' PAN_ZOOM_ACTION_NAME = 'activate_image_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_image_transform_mode' 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.interpComboBox.setToolTip( trans._( 'Texture interpolation for display.\nnearest and linear are most performant.' ) ) 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_no_gen', 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 = QLabeledDoubleSlider(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 = QLabeledDoubleSlider(Qt.Orientation.Horizontal, parent=self) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(0) sld.setMaximum(0.5) sld.setSingleStep(0.001) sld.setValue(self.layer.attenuation) sld.setDecimals(3) 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.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) 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(self.interpLabel, self.interpComboBox) self.layout().addRow(self.depictionLabel, self.depictionComboBox) self.layout().addRow(self.planeNormalLabel, self.planeNormalButtons) self.layout().addRow( self.planeThicknessLabel, self.planeThicknessSlider ) self.layout().addRow(self.renderLabel, self.renderComboBox) self.layout().addRow(self.isoThresholdLabel, self.isoThresholdSlider) self.layout().addRow(self.attenuationLabel, self.attenuationSlider) 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 def _on_attenuation_change(self): """Receive layer model attenuation change event and update the slider.""" with self.layer.events.attenuation.blocker(): self.attenuationSlider.setValue(self.layer.attenuation) 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 and self.layer.ndim >= 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() super()._on_ndisplay_changed() 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.6/napari/_qt/layer_controls/qt_image_controls_base.py000066400000000000000000000303521474413133200251520ustar00rootroot00000000000000from __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, QLabeledDoubleSlider 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.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 ---------- layer : napari.layers.Layer An instance of a napari layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group for image based layer modes (PAN_ZOOM TRANSFORM). button_grid : qtpy.QtWidgets.QGridLayout GridLayout for the layer mode buttons panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to transform shapes layer. 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. """ 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 = QLabeledDoubleSlider(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() if self.__class__ == QtBaseImageControls: # This base class is only instantiated in tests. When it's not a # concrete subclass, we need to parent the button_grid to the # layout so that qtbot will correctly clean up all instantiated # widgets. self.layout().addRow(self.button_grid) 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 and ( 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 decimals_ = range_to_decimals( layer.contrast_limits_range, layer.dtype ) self.slider.setDecimals(decimals_) self.slider.setSingleStep(10**-decimals_) 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. """ dtype = normalize_dtype(dtype) if np.issubdtype(dtype, np.integer): return 0 # 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.6/napari/_qt/layer_controls/qt_labels_controls.py000066400000000000000000000553121474413133200243430ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( QCheckBox, QComboBox, QHBoxLayout, QLabel, QSpinBox, QWidget, ) from superqt import QEnumComboBox, QLabeledSlider, 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.qt_mode_buttons import QtModePushButton from napari.layers.labels._labels_constants import ( LABEL_COLOR_MODE_TRANSLATIONS, IsoCategoricalGradientMode, LabelColorMode, LabelsRendering, Mode, ) from napari.layers.labels._labels_utils import get_dtype from napari.utils import CyclicLabelColormap from napari.utils._dtype import get_dtype_limits 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 ---------- layer : napari.layers.Labels An instance of a napari Labels layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. 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 : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select FILL mode on Labels layer. ndimSpinBox : qtpy.QtWidgets.QSpinBox Spinbox to control the number of editable dimensions of label layer. paint_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PAINT mode on Labels layer. panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PAN_ZOOM mode on Labels layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select TRANSFORM mode on Labels layer. pick_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PICKER mode on Labels layer. preserveLabelsCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if existing labels are preserved erase_button : napari._qt.widgets.qt_mode_button.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. selectedColorCheckbox : qtpy.QtWidgets.QCheckBox Checkbox to control if only currently selected label is shown. Raises ------ ValueError Raise error if label mode is not PAN_ZOOM, PICKER, PAINT, ERASE, or FILL. """ layer: 'napari.layers.Labels' MODE = Mode PAN_ZOOM_ACTION_NAME = 'activate_labels_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_labels_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.rendering.connect(self._on_rendering_change) self.layer.events.iso_gradient_mode.connect( self._on_iso_gradient_mode_change ) self.layer.events.colormap.connect(self._on_colormap_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.preserve_labels.connect( self._on_preserve_labels_change ) self.layer.events.show_selected_label.connect( self._on_show_selected_label_change ) self.layer.events.data.connect(self._on_data_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 = QLabeledSlider(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() color_mode_comboBox = QComboBox(self) for data, text in LABEL_COLOR_MODE_TRANSLATIONS.items(): data = data.value color_mode_comboBox.addItem(text, data) self.colorModeComboBox = color_mode_comboBox self._on_colormap_change() color_mode_comboBox.activated.connect(self.change_color_mode) 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(0, dtype_lims[1]) self.contourSpinBox.setToolTip( trans._('Set width of displayed label contours') ) 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 self._on_show_selected_label_change() # shuffle colormap button self.colormapUpdate = QtModePushButton( layer, 'shuffle', slot=self.changeColor, tooltip=trans._('Shuffle colors'), ) self.pick_button = self._radio_button( layer, 'picker', Mode.PICK, True, 'activate_labels_picker_mode', ) self.paint_button = self._radio_button( layer, 'paint', Mode.PAINT, True, 'activate_labels_paint_mode', ) self.polygon_button = self._radio_button( layer, 'labels_polygon', Mode.POLYGON, True, 'activate_labels_polygon_mode', ) self.fill_button = self._radio_button( layer, 'fill', Mode.FILL, True, 'activate_labels_fill_mode', ) self.erase_button = self._radio_button( layer, 'erase', Mode.ERASE, True, 'activate_labels_erase_mode', ) # don't bind with action manager as this would remove "Toggle with {shortcut}" self._on_editable_or_visible_change() self.button_grid.addWidget(self.colormapUpdate, 0, 0) self.button_grid.addWidget(self.erase_button, 0, 1) self.button_grid.addWidget(self.paint_button, 0, 2) self.button_grid.addWidget(self.polygon_button, 0, 3) self.button_grid.addWidget(self.fill_button, 0, 4) self.button_grid.addWidget(self.pick_button, 0, 5) renderComboBox = QEnumComboBox(enum_class=LabelsRendering) renderComboBox.setCurrentEnum(LabelsRendering(self.layer.rendering)) renderComboBox.currentEnumChanged.connect(self.changeRendering) self.renderComboBox = renderComboBox self.renderLabel = QLabel(trans._('rendering:')) isoGradientComboBox = QEnumComboBox( enum_class=IsoCategoricalGradientMode ) isoGradientComboBox.setCurrentEnum( IsoCategoricalGradientMode(self.layer.iso_gradient_mode) ) isoGradientComboBox.currentEnumChanged.connect( self.changeIsoGradientMode ) isoGradientComboBox.setEnabled( self.layer.rendering == LabelsRendering.ISO_CATEGORICAL ) self.isoGradientComboBox = isoGradientComboBox self.isoGradientLabel = QLabel(trans._('gradient\nmode:')) self._on_ndisplay_changed() color_layout = QHBoxLayout() self.colorBox = QtColorBox(layer) color_layout.addWidget(self.colorBox) color_layout.addWidget(self.selectionSpinBox) self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('label:'), color_layout) self.layout().addRow(trans._('brush size:'), self.brushSizeSlider) self.layout().addRow(self.renderLabel, self.renderComboBox) self.layout().addRow(self.isoGradientLabel, self.isoGradientComboBox) 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 change_color_mode(self): """Change color mode of label layer""" if self.colorModeComboBox.currentData() == LabelColorMode.AUTO.value: self.layer.colormap = self.layer._original_random_colormap else: self.layer.colormap = self.layer._direct_colormap 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, FILL or TRANSFORM """ super()._on_mode_change(event) def _on_colormap_change(self): enable_combobox = not self.layer._is_default_colors( self.layer._direct_colormap.color_dict ) self.colorModeComboBox.setEnabled(enable_combobox) if not enable_combobox: self.colorModeComboBox.setToolTip( 'Layer needs a user-set DirectLabelColormap to enable direct ' 'mode.' ) if isinstance(self.layer.colormap, CyclicLabelColormap): self.colorModeComboBox.setCurrentIndex( self.colorModeComboBox.findData(LabelColorMode.AUTO.value) ) else: self.colorModeComboBox.setCurrentIndex( self.colorModeComboBox.findData(LabelColorMode.DIRECT.value) ) def _on_data_change(self): """Update label selection spinbox min/max when data changes.""" dtype_lims = get_dtype_limits(get_dtype(self.layer)) self.selectionSpinBox.setRange(*dtype_lims) def changeRendering(self, rendering_mode: LabelsRendering): """Change rendering mode for image display. Parameters ---------- rendering_mode : LabelsRendering 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.isoGradientComboBox.setEnabled( rendering_mode == LabelsRendering.ISO_CATEGORICAL ) self.layer.rendering = rendering_mode def changeIsoGradientMode(self, gradient_mode: IsoCategoricalGradientMode): """Change gradient mode for isosurface rendering. Parameters ---------- gradient_mode : IsoCategoricalGradientMode Gradient mode for the isosurface rendering method. Selects the finite-difference gradient method for the isosurface shader: * fast: simple finite difference gradient along each axis * smooth: isotropic Sobel gradient, smoother but more computationally expensive """ self.layer.iso_gradient_mode = gradient_mode 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): """Toggle display of selected label only. Parameters ---------- state : int Integer value of Qt.CheckState that indicates the check state of selectedColorCheckbox """ self.layer.show_selected_label = ( Qt.CheckState(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 : int Integer value of Qt.CheckState that indicates the check state of contigCheckBox """ self.layer.contiguous = Qt.CheckState(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 : int Integer value of Qt.CheckState that indicates the check state of preserveLabelsCheckBox """ self.layer.preserve_labels = ( Qt.CheckState(state) == Qt.CheckState.Checked ) 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)) self._set_polygon_tool_state() 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_show_selected_label_change(self): """Receive layer model show_selected_labels event and update the checkbox.""" with self.layer.events.show_selected_label.blocker(): self.selectedColorCheckbox.setChecked( self.layer.show_selected_label ) def _on_rendering_change(self): """Receive layer model rendering change event and update dropdown menu.""" with self.layer.events.rendering.blocker(): self.renderComboBox.setCurrentEnum( LabelsRendering(self.layer.rendering) ) def _on_iso_gradient_mode_change(self): """Receive layer model iso_gradient_mode change event and update dropdown menu.""" with self.layer.events.iso_gradient_mode.blocker(): self.isoGradientComboBox.setCurrentEnum( IsoCategoricalGradientMode(self.layer.iso_gradient_mode) ) def _on_editable_or_visible_change(self): super()._on_editable_or_visible_change() self._set_polygon_tool_state() def _on_ndisplay_changed(self): show_3d_widgets = self.ndisplay == 3 self.renderComboBox.setVisible(show_3d_widgets) self.renderLabel.setVisible(show_3d_widgets) self.isoGradientComboBox.setVisible(show_3d_widgets) self.isoGradientLabel.setVisible(show_3d_widgets) self._on_editable_or_visible_change() self._set_polygon_tool_state() super()._on_ndisplay_changed() def _set_polygon_tool_state(self): if hasattr(self, 'polygon_button'): set_widgets_enabled_with_opacity( self, [self.polygon_button], self._is_polygon_tool_enabled() ) def _is_polygon_tool_enabled(self): return ( self.layer.editable and self.layer.visible and self.layer.n_edit_dimensions == 2 and self.ndisplay == 2 ) 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.Labels 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.round(255 * self.layer._selected_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.6/napari/_qt/layer_controls/qt_layer_controls_base.py000066400000000000000000000312261474413133200252050ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtGui import QMouseEvent from qtpy.QtWidgets import ( QButtonGroup, QComboBox, QFormLayout, QFrame, QGridLayout, QLabel, QMessageBox, ) from superqt import QLabeledDoubleSlider from napari._qt.utils import set_widgets_enabled_with_opacity from napari._qt.widgets.qt_mode_buttons import QtModeRadioButton from napari.layers.base._base_constants import ( BLENDING_TRANSLATIONS, Blending, Mode, ) from napari.layers.base.base import Layer from napari.utils.action_manager import action_manager 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 ---------- layer : napari.layers.Layer An instance of a napari layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group for image based layer modes (PAN_ZOOM TRANSFORM). button_grid : qtpy.QtWidgets.QGridLayout GridLayout for the layer mode buttons panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to transform shapes layer. 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. """ MODE = Mode PAN_ZOOM_ACTION_NAME = '' TRANSFORM_ACTION_NAME = '' def __init__(self, layer: Layer) -> None: super().__init__() self._ndisplay: int = 2 self._EDIT_BUTTONS: tuple = () self._MODE_BUTTONS: dict = {} self.layer = layer self.layer.events.mode.connect(self._on_mode_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.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)) # Buttons self.button_group = QButtonGroup(self) self.panzoom_button = self._radio_button( layer, 'pan', self.MODE.PAN_ZOOM, False, self.PAN_ZOOM_ACTION_NAME, extra_tooltip_text=trans._('(or hold Space)'), checked=True, ) self.transform_button = self._radio_button( layer, 'transform', self.MODE.TRANSFORM, True, self.TRANSFORM_ACTION_NAME, extra_tooltip_text=trans._( '\nAlt + Left mouse click over this button to reset' ), ) self.transform_button.installEventFilter(self) self._on_editable_or_visible_change() self.button_grid = QGridLayout() self.button_grid.addWidget(self.panzoom_button, 0, 6) self.button_grid.addWidget(self.transform_button, 0, 7) self.button_grid.setContentsMargins(5, 0, 0, 5) self.button_grid.setColumnStretch(0, 1) self.button_grid.setSpacing(4) # Control widgets sld = QLabeledDoubleSlider(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 ) if self.__class__ == QtLayerControls: # This base class is only instantiated in tests. When it's not a # concrete subclass, we need to parent the button_grid to the # layout so that qtbot will correctly clean up all instantiated # widgets. self.layout().addRow(self.button_grid) 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 _radio_button( self, layer, btn_name, mode, edit_button, action_name, extra_tooltip_text='', **kwargs, ): """ Convenience local function to create a RadioButton and bind it to an action at the same time. Parameters ---------- layer : napari.layers.Layer The layer instance that this button controls.n btn_name : str name fo the button mode : Enum Value Associated to current button edit_button: bool True if the button corresponds to edition operations. False otherwise. 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 napari._qt.widgets.qt_mode_button.QtModeRadioButton Returns ------- button: napari._qt.widgets.qt_mode_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(layer, btn_name, mode, **kwargs) action_manager.bind_button( action_name, btn, extra_tooltip_text=extra_tooltip_text, ) self._MODE_BUTTONS[mode] = btn self.button_group.addButton(btn) if edit_button: self._EDIT_BUTTONS += (btn,) return btn def _on_mode_change(self, event): """ Update ticks in checkbox widgets when image based layer mode changed. Available modes for base layer are: * PAN_ZOOM * TRANSFORM Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. Raises ------ ValueError Raise error if event.mode is not PAN_ZOOM or TRANSFORM. """ if event.mode in self._MODE_BUTTONS: self._MODE_BUTTONS[event.mode].setChecked(True) else: raise ValueError( trans._("Mode '{mode}' not recognized", mode=event.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, ) self._set_transform_tool_state() 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 like the transform mode button. """ self._set_transform_tool_state() def _set_transform_tool_state(self): """ Enable/disable transform button taking into account: * Layer visibility. * Layer editability. * Number of dimensions being displayed. """ set_widgets_enabled_with_opacity( self, [self.transform_button], self.layer.editable and self.layer.visible and self.ndisplay == 2, ) def eventFilter(self, qobject, event): """ Event filter implementation to handle the Alt + Left mouse click interaction to reset the layer transform. For more info about Qt Event Filters you can check: https://doc.qt.io/qt-6/eventsandfilters.html#event-filters """ if ( qobject == self.transform_button and event.type() == QMouseEvent.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton and event.modifiers() == Qt.AltModifier ): result = QMessageBox.warning( self, trans._('Reset transform'), trans._('Are you sure you want to reset transforms?'), QMessageBox.Yes | QMessageBox.No, ) if result == QMessageBox.Yes: self.layer._reset_affine() return True return super().eventFilter(qobject, event) 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.6/napari/_qt/layer_controls/qt_layer_controls_container.py000066400000000000000000000117161474413133200262570ustar00rootroot00000000000000from 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.translations import trans layer_to_controls = { Labels: QtLabelsControls, Image: QtImageControls, Points: QtPointsControls, Shapes: QtShapesControls, Surface: QtSurfaceControls, Vectors: QtVectorsControls, Tracks: QtTracksControls, } 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 = self.viewer.dims.ndisplay 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.6/napari/_qt/layer_controls/qt_points_controls.py000066400000000000000000000316041474413133200244130ustar00rootroot00000000000000import contextlib from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QCheckBox, QComboBox, ) from superqt import QLabeledSlider 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._qt.widgets.qt_mode_buttons import QtModePushButton 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 ---------- layer : napari.layers.Points An instance of a napari Points layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. addition_button : napari._qt.widgets.qt_mode_button.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. borderColorEdit : QColorSwatchEdit Widget to select display color for points borders. faceColorEdit : QColorSwatchEdit Widget to select display color for points faces. outOfSliceCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to indicate whether to render out of slice. panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button for pan/zoom mode. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select transform mode. select_button : napari._qt.widgets.qt_mode_button.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' MODE = Mode PAN_ZOOM_ACTION_NAME = 'activate_points_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_points_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.out_of_slice_display.connect( self._on_out_of_slice_display_change ) self.layer.events.symbol.connect(self._on_current_symbol_change) self.layer.events.size.connect(self._on_current_size_change) self.layer.events.current_size.connect(self._on_current_size_change) self.layer.events.current_border_color.connect( self._on_current_border_color_change ) self.layer._border.events.current_color.connect( self._on_current_border_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.current_symbol.connect( self._on_current_symbol_change ) self.layer.text.events.visible.connect(self._on_text_visibility_change) sld = QLabeledSlider(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.changeCurrentSize) self.sizeSlider = sld self.faceColorEdit = QColorSwatchEdit( initial_color=self.layer.current_face_color, tooltip=trans._( 'Click to set the face color of currently selected points and any added afterwards.' ), ) self.borderColorEdit = QColorSwatchEdit( initial_color=self.layer.current_border_color, tooltip=trans._( 'Click to set the border color of currently selected points and any added afterwards.' ), ) self.faceColorEdit.color_changed.connect(self.changeCurrentFaceColor) self.borderColorEdit.color_changed.connect( self.changeCurrentBorderColor ) 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.changeCurrentSymbol) 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.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.select_button = self._radio_button( layer, 'select_points', Mode.SELECT, True, 'activate_points_select_mode', ) self.addition_button = self._radio_button( layer, 'add_points', Mode.ADD, True, 'activate_points_add_mode', ) self.delete_button = QtModePushButton( layer, 'delete_shape', ) action_manager.bind_button( 'napari:delete_selected_points', self.delete_button ) self._EDIT_BUTTONS += (self.delete_button,) self._on_editable_or_visible_change() self.button_grid.addWidget(self.delete_button, 0, 3) self.button_grid.addWidget(self.addition_button, 0, 4) self.button_grid.addWidget(self.select_button, 0, 5) self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('point size:'), self.sizeSlider) self.layout().addRow(trans._('symbol:'), self.symbolComboBox) self.layout().addRow(trans._('face color:'), self.faceColorEdit) self.layout().addRow(trans._('border color:'), self.borderColorEdit) self.layout().addRow(trans._('display text:'), self.textDispCheckBox) self.layout().addRow(trans._('out of slice:'), self.outOfSliceCheckBox) def changeCurrentSymbol(self, text): """Change marker symbol of the points on the layer model. Parameters ---------- text : int Index of current marker symbol of points, eg: '+', '.', etc. """ with self.layer.events.symbol.blocker(self._on_current_symbol_change): self.layer.current_symbol = SYMBOL_TRANSLATION_INVERTED[text] def changeCurrentSize(self, value): """Change size of points on the layer model. Parameters ---------- value : float Size of points. """ with self.layer.events.current_size.blocker( self._on_current_size_change ): 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 with self.layer.events.out_of_slice_display.blocker( self._on_out_of_slice_display_change ): 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. """ with self.layer.text.events.visible.blocker( self._on_text_visibility_change ): # needs cast to bool for Qt6 self.layer.text.visible = bool(state) 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 * TRANSFORM 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, TRANSFORM or SELECT. """ super()._on_mode_change(event) def _on_text_visibility_change(self): """Receive layer model text visibiltiy change event and update checkbox.""" with qt_signals_blocked(self.textDispCheckBox): 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 qt_signals_blocked(self.outOfSliceCheckBox): self.outOfSliceCheckBox.setChecked(self.layer.out_of_slice_display) def _on_current_symbol_change(self): """Receive marker symbol change event and update the dropdown menu.""" with qt_signals_blocked(self.symbolComboBox): self.symbolComboBox.setCurrentIndex( self.symbolComboBox.findData(self.layer.current_symbol.value) ) def _on_current_size_change(self): """Receive layer model size change event and update point size slider.""" with qt_signals_blocked(self.sizeSlider): 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)) with contextlib.suppress(TypeError): self.sizeSlider.setValue(int(value)) @Slot(np.ndarray) def changeCurrentFaceColor(self, color: np.ndarray): """Update face color of layer model from color picker user input.""" with self.layer.events.current_face_color.blocker( self._on_current_face_color_change ): self.layer.current_face_color = color @Slot(np.ndarray) def changeCurrentBorderColor(self, color: np.ndarray): """Update border color of layer model from color picker user input.""" with self.layer.events.current_border_color.blocker( self._on_current_border_color_change ): self.layer.current_border_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_border_color_change(self): """Receive layer.current_border_color() change event and update view.""" with qt_signals_blocked(self.borderColorEdit): self.borderColorEdit.setColor(self.layer.current_border_color) def _on_ndisplay_changed(self): self.layer.editable = not (self.layer.ndim == 2 and self.ndisplay == 3) super()._on_ndisplay_changed() def close(self): """Disconnect events when widget is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.6/napari/_qt/layer_controls/qt_shapes_controls.py000066400000000000000000000340241474413133200243610ustar00rootroot00000000000000from collections.abc import Iterable from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtWidgets import QCheckBox from superqt import QLabeledSlider 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._qt.widgets.qt_mode_buttons import QtModePushButton 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 ---------- layer : napari.layers.Shapes An instance of a napari Shapes layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group for shapes layer modes (SELECT, DIRECT, PAN_ZOOM, ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, ADD_POLYLINE, ADD_PATH, ADD_POLYGON, VERTEX_INSERT, VERTEX_REMOVE, TRANSFORM). delete_button : qtpy.QtWidgets.QtModePushButton Button to delete selected shapes direct_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select individual vertices in shapes. edgeColorEdit : QColorSwatchEdit Widget allowing user to set edge color of points. ellipse_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add ellipses to shapes layer. faceColorEdit : QColorSwatchEdit Widget allowing user to set face color of points. line_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add lines to shapes layer. polyline_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add polylines 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 : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to transform shapes layer. path_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add paths to shapes layer. polygon_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add polygons to shapes layer. polygon_lasso_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add polygons to shapes layer with a lasso tool. rectangle_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add rectangles to shapes layer. select_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select shapes. textDispCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if text should be displayed vertex_insert_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to insert vertex into shape. vertex_remove_button : napari._qt.widgets.qt_mode_button.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' MODE = Mode PAN_ZOOM_ACTION_NAME = 'activate_shapes_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_shapes_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) 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.text.events.visible.connect(self._on_text_visibility_change) sld = QLabeledSlider(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 self.widthSlider.setToolTip( trans._( 'Set the edge width of currently selected shapes and any added afterwards.' ) ) self.select_button = self._radio_button( layer, 'select', Mode.SELECT, True, 'activate_select_mode' ) self.direct_button = self._radio_button( layer, 'direct', Mode.DIRECT, True, 'activate_direct_mode' ) self.rectangle_button = self._radio_button( layer, 'rectangle', Mode.ADD_RECTANGLE, True, 'activate_add_rectangle_mode', ) self.ellipse_button = self._radio_button( layer, 'ellipse', Mode.ADD_ELLIPSE, True, 'activate_add_ellipse_mode', ) self.line_button = self._radio_button( layer, 'line', Mode.ADD_LINE, True, 'activate_add_line_mode' ) self.polyline_button = self._radio_button( layer, 'polyline', Mode.ADD_POLYLINE, True, 'activate_add_polyline_mode', ) self.path_button = self._radio_button( layer, 'path', Mode.ADD_PATH, True, 'activate_add_path_mode' ) self.polygon_button = self._radio_button( layer, 'polygon', Mode.ADD_POLYGON, True, 'activate_add_polygon_mode', ) self.polygon_lasso_button = self._radio_button( layer, 'polygon_lasso', Mode.ADD_POLYGON_LASSO, True, 'activate_add_polygon_lasso_mode', ) self.vertex_insert_button = self._radio_button( layer, 'vertex_insert', Mode.VERTEX_INSERT, True, 'activate_vertex_insert_mode', ) self.vertex_remove_button = self._radio_button( layer, 'vertex_remove', Mode.VERTEX_REMOVE, True, '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.delete_button, self.move_back_button, self.move_front_button, ) self._on_editable_or_visible_change() self.button_grid.addWidget(self.move_back_button, 0, 0) self.button_grid.addWidget(self.vertex_remove_button, 0, 1) self.button_grid.addWidget(self.vertex_insert_button, 0, 2) self.button_grid.addWidget(self.delete_button, 0, 3) self.button_grid.addWidget(self.direct_button, 0, 4) self.button_grid.addWidget(self.select_button, 0, 5) self.button_grid.addWidget(self.move_front_button, 1, 0) self.button_grid.addWidget(self.ellipse_button, 1, 1) self.button_grid.addWidget(self.rectangle_button, 1, 2) self.button_grid.addWidget(self.polygon_button, 1, 3) self.button_grid.addWidget(self.polygon_lasso_button, 1, 4) self.button_grid.addWidget(self.line_button, 1, 5) self.button_grid.addWidget(self.polyline_button, 1, 6) self.button_grid.addWidget(self.path_button, 1, 7) self.button_grid.setContentsMargins(5, 0, 0, 5) self.button_grid.setColumnStretch(0, 1) self.button_grid.setSpacing(4) self.faceColorEdit = QColorSwatchEdit( initial_color=self.layer.current_face_color, tooltip=trans._( 'Click to set the face color of currently selected shapes and any added afterwards.' ), ) self._on_current_face_color_change() self.edgeColorEdit = QColorSwatchEdit( initial_color=self.layer.current_edge_color, tooltip=trans._( 'Click to set the edge color of currently selected shapes and any added afterwards' ), ) 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(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('edge width:'), self.widthSlider) 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 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 : int Integer value of Qt.CheckState that indicates the check state of textDispCheckBox """ self.layer.text.visible = Qt.CheckState(state) == Qt.CheckState.Checked 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_POLYLINE * ADD_PATH * ADD_POLYGON * ADD_POLYGON_LASSO * VERTEX_INSERT * VERTEX_REMOVE * TRANSFORM Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. Raises ------ ValueError Raise error if event.mode is not one of the available modes. """ super()._on_mode_change(event) 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_ndisplay_changed(self): self.layer.editable = self.ndisplay == 2 super()._on_ndisplay_changed() def close(self): """Disconnect events when widget is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.6/napari/_qt/layer_controls/qt_surface_controls.py000066400000000000000000000067201474413133200245300ustar00rootroot00000000000000from 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. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group for image based layer modes (PAN_ZOOM TRANSFORM). button_grid : qtpy.QtWidgets.QGridLayout GridLayout for the layer mode buttons panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to transform shapes layer. """ layer: 'napari.layers.Surface' PAN_ZOOM_ACTION_NAME = 'activate_surface_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_surface_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.shading.connect(self._on_shading_change) 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.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) 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._('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() def _on_shading_change(self): """Receive layer model shading change event and update combobox.""" with self.layer.events.shading.blocker(): self.shadingComboBox.setCurrentIndex( self.shadingComboBox.findData( SHADING_TRANSLATION[self.layer.shading] ) ) napari-0.5.6/napari/_qt/layer_controls/qt_tracks_controls.py000066400000000000000000000200421474413133200243600ustar00rootroot00000000000000from 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.layers.base._base_constants import Mode 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. button_group : qtpy.QtWidgets.QButtonGroup Button group of points layer modes (ADD, PAN_ZOOM, SELECT). panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button for pan/zoom mode. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select transform mode. """ layer: 'napari.layers.Tracks' MODE = Mode PAN_ZOOM_ACTION_NAME = 'activate_tracks_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_tracks_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) # NOTE(arl): there are no events fired for changing checkboxes self.layer.events.color_by.connect(self._on_color_by_change) 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) # 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(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('color by:'), self.color_by_combobox) self.layout().addRow(trans._('colormap:'), self.colormap_combobox) 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 qt_signals_blocked(self.color_by_combobox): self.color_by_combobox.clear() self.color_by_combobox.addItems(self.layer.properties_to_color_by) self._on_color_by_change() 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.6/napari/_qt/layer_controls/qt_vectors_controls.py000066400000000000000000000332271474413133200245670ustar00rootroot00000000000000from 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.base._base_constants import Mode from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.vectors._vectors_constants import VECTORSTYLE_TRANSLATIONS 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 ---------- layer : napari.layers.Vectors An instance of a napari Vectors layer. MODE : Enum Available modes in the associated layer. PAN_ZOOM_ACTION_NAME : str String id for the pan-zoom action to bind to the pan_zoom button. TRANSFORM_ACTION_NAME : str String id for the transform action to bind to the transform button. button_group : qtpy.QtWidgets.QButtonGroup Button group of points layer modes (ADD, PAN_ZOOM, SELECT). panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button for pan/zoom mode. transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select transform mode. edge_color_label : qtpy.QtWidgets.QLabel Label for edgeColorSwatch edgeColorEdit : QColorSwatchEdit Widget to select display color for vectors. vector_style_comboBox : qtpy.QtWidgets.QComboBox Dropdown widget to select vector_style for the 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. vector_style_comboBox : qtpy.QtWidgets.QComboBox Dropdown widget to select vector_style for the vectors. """ layer: 'napari.layers.Vectors' MODE = Mode PAN_ZOOM_ACTION_NAME = 'activate_tracks_pan_zoom_mode' TRANSFORM_ACTION_NAME = 'activate_tracks_transform_mode' 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 display vector_style vector_style_comboBox = QComboBox(self) for index, (data, text) in enumerate(VECTORSTYLE_TRANSLATIONS.items()): data = data.value vector_style_comboBox.addItem(text, data) if data == self.layer.vector_style: vector_style_comboBox.setCurrentIndex(index) self.vector_style_comboBox = vector_style_comboBox self.vector_style_comboBox.currentTextChanged.connect( self.change_vector_style ) # 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.01) 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.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('width:'), self.widthSpinBox) self.layout().addRow(trans._('length:'), self.lengthSpinBox) self.layout().addRow( trans._('vector style:'), self.vector_style_comboBox ) 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.vector_style.connect(self._on_vector_style_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_name: str): """Change edge_color_property of vectors on the layer model. This property is the property the edge color is mapped to. Parameters ---------- property_name : str property to map the edge color to """ mode = self.layer.edge_color_mode try: self.layer.edge_color = property_name 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_vector_style(self, vector_style: str): """Change vector style of vectors on the layer model. Parameters ---------- vector_style : str Name of vectors style, eg: 'line', 'triangle' or 'arrow'. """ with self.layer.events.vector_style.blocker(): self.layer.vector_style = vector_style 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 (layer and GUI) self.layer.edge_color_mode = old_mode self.color_mode_comboBox.setCurrentText(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 : int Integer value of Qt.CheckState that indicates the check state of outOfSliceCheckBox """ self.layer.out_of_slice_display = ( Qt.CheckState(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_vector_style_change(self): """Receive layer model vector style change event & update dropdown.""" with self.layer.events.vector_style.blocker(): vector_style = self.layer.vector_style index = self.vector_style_comboBox.findText( vector_style, Qt.MatchFixedString ) self.vector_style_comboBox.setCurrentIndex(index) 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.6/napari/_qt/perf/000077500000000000000000000000001474413133200157675ustar00rootroot00000000000000napari-0.5.6/napari/_qt/perf/__init__.py000066400000000000000000000000001474413133200200660ustar00rootroot00000000000000napari-0.5.6/napari/_qt/perf/_tests/000077500000000000000000000000001474413133200172705ustar00rootroot00000000000000napari-0.5.6/napari/_qt/perf/_tests/__init__.py000066400000000000000000000000001474413133200213670ustar00rootroot00000000000000napari-0.5.6/napari/_qt/perf/_tests/test_perf.py000066400000000000000000000062041474413133200216370ustar00rootroot00000000000000import dataclasses import json import os import subprocess import sys from pathlib import Path from unittest.mock import MagicMock import pytest from pretend import stub 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', ] }, } @pytest.fixture def perf_config(tmp_path: Path): 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)) return stub(path=config_path, trace_path=trace_path) @pytest.fixture def perfmon_script(tmp_path): script = PERFMON_SCRIPT if 'coverage' in sys.modules: script_path = tmp_path / 'script.py' with script_path.open('w') as f: f.write(script) return '-m', 'coverage', 'run', str(script_path) return '-c', script @skip_on_win_ci @skip_local_popups @pytest.mark.usefixtures('qapp') def test_trace_on_start(tmp_path: Path, perf_config, perfmon_script): """Make sure napari can write a perfmon trace file.""" env = os.environ.copy() env.update({'NAPARI_PERFMON': str(perf_config.path), 'NAPARI_CONFIG': ''}) subprocess.run([sys.executable, *perfmon_script], env=env, check=True) # Make sure file exists and is not empty. assert perf_config.trace_path.exists(), 'Trace file not written' assert perf_config.trace_path.stat().st_size > 0, 'Trace file is empty' # Assert every event contains every important field. with perf_config.trace_path.open() 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.6/napari/_qt/perf/qt_event_tracing.py000066400000000000000000000071601474413133200217010ustar00rootroot00000000000000"""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) is 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 not object_name: # use class for object without set name. try: object_name = str(receiver.__class__.__name__) except AttributeError: # do not crash in using some strange class. object_name = None if object_name: return f'{event_str}:{object_name}' # There was no object (pretty common). return event_str napari-0.5.6/napari/_qt/perf/qt_performance.py000066400000000000000000000136201474413133200213500ustar00rootroot00000000000000"""QtPerformance widget to show performance information.""" import time from typing import ClassVar 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: ClassVar[list[str]] = [ '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.startswith('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.6/napari/_qt/qt_event_filters.py000066400000000000000000000014071474413133200207640ustar00rootroot00000000000000"""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.6/napari/_qt/qt_event_loop.py000066400000000000000000000401061474413133200202640ustar00rootroot00000000000000from __future__ import annotations import os import sys from contextlib import contextmanager from typing import TYPE_CHECKING, Optional, cast from warnings import warn from qtpy import PYQT5, PYSIDE2 from qtpy.QtCore import QDir, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication, QWidget 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 _focus_changed(old: Optional[QWidget], new: Optional[QWidget]): if old is not None and new is not None: return # ignore focus changes between two widgets if old is None and new is None: return # this should not happen if old is None: start_timer = True window = new.window() else: start_timer = False window = old.window() from napari._qt.qt_main_window import _QtMainWindow if not isinstance(window, _QtMainWindow): return notifications = cast( list[NapariQtNotification], window.findChildren(NapariQtNotification) ) if not notifications: return if start_timer: notifications[-1].timer_start() else: for notification in notifications: notification.timer_stop() # TODO: Remove in napari 0.6.0 def get_app(*args, **kwargs) -> QApplication: """Get or create the Qt QApplication. Now deprecated, use `get_qapp`.""" warn( trans._( '`QApplication` instance access through `get_app` is deprecated and will be removed in 0.6.0.\n' 'Please use `get_qapp` instead.\n', deferred=True, ), category=FutureWarning, stacklevel=2, ) return get_qapp(*args, **kwargs) def get_qapp( *, app_name: Optional[str] = None, app_version: Optional[str] = None, icon: Optional[str] = None, org_name: Optional[str] = None, org_domain: Optional[str] = None, app_id: Optional[str] = None, ipy_interactive: Optional[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, ), stacklevel=2, ) 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, ), stacklevel=2, ) else: # automatically determine monitor DPI. # Note: this MUST be set before the QApplication is instantiated. Also, this # attributes need to be applied only to Qt5 bindings (PyQt5 and PySide2) # since the High DPI scaling attributes are deactivated by default while on Qt6 # they are deprecated and activated by default. For more info see: # https://doc.qt.io/qtforpython-6/gettingstarted/porting_from2.html#class-function-deprecations if PYQT5 or PYSIDE2: 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 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() notification_manager.notification_ready.connect( NapariQtNotification.show_notification ) notification_manager.notification_ready.connect( show_console_notification ) app.focusChanged.connect(_focus_changed) _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.perf_config is not None: # 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() @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, stacklevel=2, ) 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, ), stacklevel=2, ) 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, ), stacklevel=2, ) return with notification_manager, _maybe_allow_interrupt(app): app.exec_() napari-0.5.6/napari/_qt/qt_main_window.py000066400000000000000000002142751474413133200204370ustar00rootroot00000000000000""" 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 collections.abc import MutableMapping, Sequence from pathlib import Path from typing import ( TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, cast, ) from weakref import WeakValueDictionary import numpy as np from qtpy.QtCore import ( QEvent, QEventLoop, QPoint, QProcess, QRect, QSize, Qt, Slot, ) from qtpy.QtGui import QHideEvent, QIcon, QShowEvent from qtpy.QtWidgets import ( QApplication, QDialog, QDockWidget, QHBoxLayout, QMainWindow, QMenu, QShortcut, QToolTip, QWidget, ) from napari._app_model.constants import MenuId from napari._app_model.context import create_context, get_context from napari._qt._qapp_model import build_qmodel_menu from napari._qt._qapp_model.qactions import add_dummy_actions, init_qactions from napari._qt._qapp_model.qactions._debug import _is_set_trace_active from napari._qt._qplugins import ( _rebuild_npe1_plugins_menu, _rebuild_npe1_samples_menu, ) from napari._qt.dialogs.confirm_close_dialog import ConfirmCloseDialog from napari._qt.dialogs.preferences_dialog import PreferencesDialog 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_qapp, quit_app as quit_app_, ) from napari._qt.qt_resources import get_stylesheet from napari._qt.qt_viewer import QtViewer from napari._qt.threads.status_checker import StatusChecker 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, plugin_manager, ) from napari.plugins._npe2 import index_npe1_adapters from napari.settings import get_settings from napari.utils import perf from napari.utils._proxies import PublicOnlyProxy from napari.utils.events import Event from napari.utils.geometry import get_center_bbox from napari.utils.io import imsave from napari.utils.misc import ( in_ipython, in_jupyter, in_python_repl, running_as_constructor_app, ) from napari.utils.notifications import Notification from napari.utils.theme import _themes, get_system_theme from napari.utils.translations import trans if TYPE_CHECKING: from magicgui.widgets import Widget from qtpy.QtGui import QImage from napari.viewer import Viewer _sentinel = object() MenuStr = Literal[ 'file_menu', 'view_menu', 'layers_menu', 'plugins_menu', 'window_menu', 'help_menu', ] 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) 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._normal_geometry = QRect() self._window_size = None self._window_pos = None self._old_size = None self._positions = [] self._toggle_menubar_visibility = False 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)) # Prevent QLineEdit based widgets to keep focus even when clicks are # done outside the widget. See #1571 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Ideally this would be in `NapariApplication` but that is outside of Qt self._viewer_context = create_context(self) self._viewer_context['is_set_trace_active'] = _is_set_trace_active 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 the window, # we need to manually connect them again. handle = self.windowHandle() if handle is not None: handle.screenChanged.connect(self._qt_viewer.canvas.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() with contextlib.suppress(IndexError): viewer.cursor.events.position.disconnect( viewer.update_status_from_cursor ) self.status_thread = StatusChecker(viewer, parent=self) self.status_thread.status_and_tooltip_changed.connect( self.set_status_and_tooltip ) viewer.cursor.events.position.connect( self.status_thread.trigger_status_update ) settings.appearance.events.update_status_based_on_layer.connect( self._toggle_status_thread ) def _toggle_status_thread(self, event: Event): if event.value: self.status_thread.start() else: self.status_thread.terminate() def showEvent(self, event: QShowEvent): """Override to handle window state changes.""" settings = get_settings() # if event loop is not running, we don't want to start the thread # If event loop is running, the loopLevel will be above 0 if ( settings.appearance.update_status_based_on_layer and QApplication.instance().thread().loopLevel() ): self.status_thread.start() super().showEvent(event) def enterEvent(self, a0): # as we call show in Viewer constructor, we need to start the thread # when the mouse enters the window # as first call of showEvent is before the event loop is running if ( get_settings().appearance.update_status_based_on_layer and not self.status_thread.isRunning() ): self.status_thread.start() super().enterEvent(a0) def hideEvent(self, event: QHideEvent): self.status_thread.terminate() super().hideEvent(event) def set_status_and_tooltip( self, status_and_tooltip: Optional[tuple[Union[str, dict], str]] ): if status_and_tooltip is None: return self._qt_viewer.viewer.status = status_and_tooltip[0] self._qt_viewer.viewer.tooltip.text = status_and_tooltip[1] if ( active := self._qt_viewer.viewer.layers.selection.active ) is not None: self._qt_viewer.viewer.help = active.help 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() 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))) res = super().event(e) if e.type() == QEvent.Type.Close and e.isAccepted(): # when we close the MainWindow, remove it from the instance list with contextlib.suppress(ValueError): _QtMainWindow._instances.remove(self) return res def showFullScreen(self): super().showFullScreen() # Handle OpenGL based windows fullscreen issue on Windows. # For more info see: # * https://doc.qt.io/qt-6/windows-issues.html#fullscreen-opengl-based-windows # * https://bugreports.qt.io/browse/QTBUG-41309 # * https://bugreports.qt.io/browse/QTBUG-104511 if os.name != 'nt': return import win32con import win32gui if self.windowHandle(): handle = int(self.windowHandle().winId()) win32gui.SetWindowLong( handle, win32con.GWL_STYLE, win32gui.GetWindowLong(handle, win32con.GWL_STYLE) | win32con.WS_BORDER, ) def eventFilter(self, source, event): # Handle showing hidden menubar on mouse move event. # We do not hide menubar when a menu is being shown or # we are not in menubar toggled state if ( QApplication.activePopupWidget() is None and hasattr(self, '_toggle_menubar_visibility') and self._toggle_menubar_visibility ): if event.type() == QEvent.Type.MouseMove: if self.menuBar().isHidden(): rect = self.geometry() # set mouse-sensitive zone to trigger showing the menubar rect.setHeight(25) if rect.contains(event.globalPos()): self.menuBar().show() else: rect = QRect( self.menuBar().mapToGlobal(QPoint(0, 0)), self.menuBar().size(), ) if not rect.contains(event.globalPos()): self.menuBar().hide() elif event.type() == QEvent.Type.Leave and source is self: self.menuBar().hide() return super().eventFilter(source, event) 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: origin_x, origin_y = window_position screen = QApplication.screenAt(QPoint(origin_x, origin_y)) screen_geo = screen.geometry() if screen else None if not screen_geo: 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 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 return None # 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 keyPressEvent(self, event): """Called whenever a key is pressed. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self._qt_viewer.canvas._scene_canvas._backend._keyEvent( self._qt_viewer.canvas._scene_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._qt_viewer.canvas._scene_canvas._backend._keyEvent( self._qt_viewer.canvas._scene_canvas.events.key_release, event ) event.accept() def resizeEvent(self, event): """Override to handle original size before maximizing.""" # the first resize event will have nonsense positions that we don't # 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 self.status_thread.terminate() self.status_thread.wait() 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_constructor_app(): process.setArguments(sys.argv) process.startDetached() self.close(quit_app=True) def toggle_menubar_visibility(self): """ Change menubar to be shown or to be hidden and shown on mouse movement. For the mouse movement functionality see the `eventFilter` implementation. """ self._toggle_menubar_visibility = not self._toggle_menubar_visibility self.menuBar().setVisible(not self._toggle_menubar_visibility) return self._toggle_menubar_visibility @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 qapp = get_qapp() # Dictionary holding dock widgets self._dock_widgets: MutableMapping[str, QtViewerDockWidget] = ( WeakValueDictionary() ) self._unnamed_dockwidget_count = 1 self._pref_dialog = None # Connect the Viewer and create the Main Window self._qt_window = _QtMainWindow(viewer, self) qapp.installEventFilter(self._qt_window) # 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() # import and index all discovered shimmed npe1 plugins index_npe1_adapters() self._add_menus() # TODO: the dummy actions should **not** live on the layerlist context # as they are unrelated. However, we do not currently have a suitable # enclosing context where we could store these keys, such that they # **and** the layerlist context key are available when we update # menus. We need a single context to contain all keys required for # menu update, so we add them to the layerlist context for now. add_dummy_actions(self._qt_viewer.viewer.layers._ctx) self._update_theme() self._update_theme_font_size() get_settings().appearance.events.theme.connect(self._update_theme) get_settings().appearance.events.font_size.connect( self._update_theme_font_size ) self._add_viewer_dock_widget(self._qt_viewer.dockConsole, tabify=False) self._add_viewer_dock_widget( self._qt_viewer.dockLayerControls, tabify=False, ) self._add_viewer_dock_widget( self._qt_viewer.dockLayerList, tabify=False ) if perf.perf_config is not None: 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.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.font_size.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.font_size.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\n' 'v0.6.0. It is considered an "implementation detail" of the napari\napplication, ' 'not part of the napari viewer model. If your use case\n' 'requires 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 _update_menu_state(self, menu: MenuStr): """Update enabled/visible state of menu item with context.""" layerlist = self._qt_viewer.viewer.layers menu_model = getattr(self, menu) menu_model.update_from_context(get_context(layerlist)) def _update_file_menu_state(self): self._update_menu_state('file_menu') def _update_view_menu_state(self): self._update_menu_state('view_menu') def _update_layers_menu_state(self): self._update_menu_state('layers_menu') def _update_window_menu_state(self): self._update_menu_state('window_menu') def _update_plugins_menu_state(self): self._update_menu_state('plugins_menu') def _update_help_menu_state(self): self._update_menu_state('help_menu') def _update_debug_menu_state(self): viewer_ctx = get_context(self._qt_window) self._debug_menu.update_from_context(viewer_ctx) # TODO: Remove once npe1 deprecated def _setup_npe1_samples_menu(self): """Register npe1 sample data, build menu and connect to events.""" plugin_manager.discover_sample_data() plugin_manager.events.enabled.connect(_rebuild_npe1_samples_menu) plugin_manager.events.disabled.connect(_rebuild_npe1_samples_menu) plugin_manager.events.registered.connect(_rebuild_npe1_samples_menu) plugin_manager.events.unregistered.connect(_rebuild_npe1_samples_menu) _rebuild_npe1_samples_menu() # TODO: Remove once npe1 deprecated def _setup_npe1_plugins_menu(self): """Register npe1 widgets, build menu and connect to events""" plugin_manager.discover_widgets() plugin_manager.events.registered.connect(_rebuild_npe1_plugins_menu) plugin_manager.events.disabled.connect(_rebuild_npe1_plugins_menu) plugin_manager.events.unregistered.connect(_rebuild_npe1_plugins_menu) _rebuild_npe1_plugins_menu() def _handle_trace_file_on_start(self): """Start trace of `trace_file_on_start` config set.""" from napari._qt._qapp_model.qactions._debug import _start_trace 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. _start_trace(path) 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 ) # file menu self.file_menu = build_qmodel_menu( MenuId.MENUBAR_FILE, title=trans._('&File'), parent=self._qt_window ) self._setup_npe1_samples_menu() self.file_menu.aboutToShow.connect( self._update_file_menu_state, ) self.main_menu.addMenu(self.file_menu) # view menu self.view_menu = build_qmodel_menu( MenuId.MENUBAR_VIEW, title=trans._('&View'), parent=self._qt_window ) self.view_menu.aboutToShow.connect( self._update_view_menu_state, ) self.main_menu.addMenu(self.view_menu) # layers menu self.layers_menu = build_qmodel_menu( MenuId.MENUBAR_LAYERS, title=trans._('&Layers'), parent=self._qt_window, ) self.layers_menu.aboutToShow.connect( self._update_layers_menu_state, ) self.main_menu.addMenu(self.layers_menu) # plugins menu self.plugins_menu = build_qmodel_menu( MenuId.MENUBAR_PLUGINS, title=trans._('&Plugins'), parent=self._qt_window, ) self._setup_npe1_plugins_menu() self.plugins_menu.aboutToShow.connect( self._update_plugins_menu_state, ) self.main_menu.addMenu(self.plugins_menu) # debug menu (optional) if perf.perf_config is not None: self._debug_menu = build_qmodel_menu( MenuId.MENUBAR_DEBUG, title=trans._('&Debug'), parent=self._qt_window, ) self._handle_trace_file_on_start() self._debug_menu.aboutToShow.connect( self._update_debug_menu_state, ) self.main_menu.addMenu(self._debug_menu) # window menu self.window_menu = build_qmodel_menu( MenuId.MENUBAR_WINDOW, title=trans._('&Window'), parent=self._qt_window, ) self.plugins_menu.aboutToShow.connect( self._update_window_menu_state, ) self.main_menu.addMenu(self.window_menu) # help menu self.help_menu = build_qmodel_menu( MenuId.MENUBAR_HELP, title=trans._('&Help'), parent=self._qt_window ) self.help_menu.aboutToShow.connect( self._update_help_menu_state, ) self.main_menu.addMenu(self.help_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. """ toggle_menubar_visibility = self._qt_window.toggle_menubar_visibility() self._main_menu_shortcut.setEnabled(toggle_menubar_visibility) 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: Optional[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 tabify : bool Flag to tabify dock widget or not. Returns ------- tuple A 2-tuple containing (the DockWidget instance, the plugin widget instance). """ from napari.plugins import _npe2 widget_class = None dock_kwargs = {} if result := _npe2.get_widget_contribution(plugin_name, widget_name): widget_class, widget_name = result if widget_class is None: widget_class, 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 = next(iter(plugin_manager._dock_widgets[plugin_name])) 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_class, 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 None 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: Optional[str] = None, 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 the 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 localization. tabify : bool Flag to tabify dock widget 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 area is None: settings = get_settings() area = settings.application.plugin_widget_positions.get( name, 'right' ) 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 other 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. menu : QMenu, optional Menu bar to remove toggle action from. If `None` nothing removed from menu. """ 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, ) 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_qapp(). 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_font_size(self, event=None): settings = get_settings() font_size = event.value if event else settings.appearance.font_size extra_variables = {'font_size': f'{font_size}pt'} self._update_theme(extra_variables=extra_variables) def _update_theme(self, event=None, extra_variables=None): """Update widget color theme.""" if extra_variables is None: extra_variables = {} settings = get_settings() with contextlib.suppress(AttributeError, RuntimeError): value = event.value if event else settings.appearance.theme self._qt_viewer.viewer.theme = value actual_theme_name = value if value == 'system': # system isn't a theme, so get the name actual_theme_name = get_system_theme() # check `font_size` value is always passed when updating style if 'font_size' not in extra_variables: extra_variables.update( {'font_size': f'{settings.appearance.font_size}pt'} ) # set the style sheet with the theme name and extra_variables style_sheet = get_stylesheet( actual_theme_name, extra_variables=extra_variables ) self._qt_window.setStyleSheet(style_sheet) self._qt_viewer.setStyleSheet(style_sheet) if self._qt_viewer._console: self._qt_viewer._console._update_theme(style_sheet=style_sheet) 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: Optional[tuple[int, int]] = None, scale: Optional[float] = None, flash: bool = True, canvas_only: bool = False, fit_to_data_extent: bool = 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 of two ints, optional Size (resolution height x width) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. This argument is ignored if fit_to_data_extent is set to True. scale : float, optional 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. fit_to_data_extent: bool Tightly fit the canvas around the data to prevent margins from showing in the screenshot. If False, a screenshot of the currently visible canvas will be generated. Returns ------- img : QImage """ from napari._qt.utils import add_flash_animation canvas = self._qt_viewer.canvas prev_size = canvas.size camera = self._qt_viewer.viewer.camera old_center = camera.center old_zoom = camera.zoom ndisplay = self._qt_viewer.viewer.dims.ndisplay # Part 1: validate incompatible parameters if not canvas_only and ( fit_to_data_extent or size is not None or scale is not None ): raise ValueError( trans._( 'scale, size, and fit_to_data_extent can only be set for ' 'canvas_only screenshots.', deferred=True, ) ) if fit_to_data_extent and ndisplay > 2: raise NotImplementedError( trans._( 'fit_to_data_extent is not yet implemented for 3D view.', deferred=True, ) ) if size is not None and len(size) != 2: raise ValueError( trans._( 'screenshot size must be 2 values, got {len_size}', deferred=True, len_size=len(size), ) ) # Part 2: compute canvas size and view based on parameters if fit_to_data_extent: extent_world = self._qt_viewer.viewer.layers.extent.world[1][ -ndisplay: ] extent_step = min( self._qt_viewer.viewer.layers.extent.step[-ndisplay:] ) size = extent_world / extent_step + 1 if size is not None: size = np.asarray(size) / self._qt_window.devicePixelRatio() else: size = np.asarray(prev_size) if scale is not None: # multiply canvas dimensions by the scale factor to get new size size *= scale # Part 3: take the screenshot if canvas_only: canvas.size = tuple(size.astype(int)) if fit_to_data_extent: # tight view around data self._qt_viewer.viewer.reset_view(margin=0) try: img = canvas.screenshot() if flash: add_flash_animation(self._qt_viewer._welcome_widget) finally: # make sure we always go back to the right canvas size canvas.size = prev_size camera.center = old_center camera.zoom = old_zoom else: img = self._qt_window.grab().toImage() if flash: add_flash_animation(self._qt_window) return img def export_figure( self, path: Optional[str] = None, scale: float = 1, flash=True, ) -> np.ndarray: """Export an image of the full extent of the displayed layer data. This function finds a tight boundary around the data, resets the view around that boundary (and, when scale=1, such that 1 captured pixel is equivalent to one data pixel), takes a screenshot, then restores the previous zoom and canvas sizes. Currently, only works when 2 dimensions are displayed. Parameters ---------- path : str, optional Filename for saving screenshot image. scale : float Scale factor used to increase resolution of canvas for the screenshot. By default, a scale of 1. 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. """ if not isinstance(scale, (float, int)): raise TypeError( trans._( 'Scale must be a float or an int.', deferred=True, ) ) img = QImg2array( self._screenshot( scale=scale, flash=flash, canvas_only=True, fit_to_data_extent=True, ) ) if path is not None: imsave(path, img) return img def export_rois( self, rois: list[np.ndarray], paths: Optional[Union[str, Path, list[Union[str, Path]]]] = None, scale: Optional[float] = None, ): """Export the given rectangular rois to specified file paths. For each shape, moves the camera to the center of the shape and adjust the canvas size to fit the shape. Note: The shape height and width can be of type float. However, the canvas size only accepts a tuple of integers. This can result in slight misalignment. Parameters ---------- rois: list[np.ndarray] A list of arrays with each being of shape (4, 2) representing a rectangular roi. paths: str, Path, list[str, Path], optional Where to save the rois. If a string or a Path, a directory will be created if it does not exist yet and screenshots will be saved with filename `roi_{n}.png` where n is the nth roi. If paths is a list of either string or paths, these need to be the full paths of where to store each individual roi. In this case the length of the list and the number of rois must match. If None, the screenshots will only be returned and not saved to disk. scale: float, optional Scale factor used to increase resolution of canvas for the screenshot. By default, uses the displayed scale. Returns ------- screenshot_list: list The list with roi screenshots. """ if ( paths is not None and isinstance(paths, list) and len(paths) != len(rois) ): raise ValueError( trans._( 'The number of file paths does not match the number of ROI shapes', deferred=True, ) ) if isinstance(paths, (str, Path)): storage_dir = Path(paths).expanduser() storage_dir.mkdir(parents=True, exist_ok=True) paths = [storage_dir / f'roi_{n}.png' for n in range(len(rois))] if self._qt_viewer.viewer.dims.ndisplay > 2: raise NotImplementedError( "'export_rois' is not implemented for 3D view." ) screenshot_list = [] camera = self._qt_viewer.viewer.camera start_camera_center = camera.center start_camera_zoom = camera.zoom canvas = self._qt_viewer.canvas prev_size = canvas.size visible_dims = list(self._qt_viewer.viewer.dims.displayed) step = min(self._qt_viewer.viewer.layers.extent.step[visible_dims]) for index, roi in enumerate(rois): center_coord, height, width = get_center_bbox(roi) camera.center = center_coord canvas.size = (int(height / step), int(width / step)) camera.zoom = 1 / step path = paths[index] if paths is not None else None screenshot_list.append( self.screenshot(path=path, canvas_only=True, scale=scale) ) canvas.size = prev_size camera.center = start_camera_center camera.zoom = start_camera_zoom return screenshot_list 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, Path 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 includes 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) 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) 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 _open_preferences_dialog(self) -> PreferencesDialog: """Edit preferences from the menubar.""" if self._pref_dialog is None: win = PreferencesDialog(parent=self._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()) def _clean_pref_dialog(): self._pref_dialog = None win.finished.connect(_clean_pref_dialog) win.show() else: self._pref_dialog.raise_() return self._pref_dialog def _screenshot_dialog(self): """Save screenshot of current display with viewer, default .png""" from napari._qt.dialogs.screenshot_dialog import ScreenshotDialog from napari.utils.history import get_save_history, update_save_history hist = get_save_history() dial = ScreenshotDialog( self.screenshot, self._qt_viewer, hist[0], hist ) if dial.exec_(): update_save_history(dial.selectedFiles()[0]) 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__) # Inspection can fail when adding to bundle as it thinks widget is a builtin 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.6/napari/_qt/qt_resources/000077500000000000000000000000001474413133200175515ustar00rootroot00000000000000napari-0.5.6/napari/_qt/qt_resources/__init__.py000066400000000000000000000044601474413133200216660ustar00rootroot00000000000000from pathlib import Path from typing import Optional from napari._qt.qt_resources._svg import QColoredSVGIcon from napari.settings import get_settings __all__ = ['QColoredSVGIcon', 'get_stylesheet'] 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: Optional[str] = None, extra: Optional[list[str]] = None, extra_variables: Optional[dict[str, str]] = None, ) -> str: """Combine all qss files into single, possibly pre-themed, style string. Parameters ---------- theme_id : 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 extra_variables : dict, optional Dictionary of variables values that replace default theme values. For example: `{ 'font_size': '14pt'}` 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 theme_dict = get_theme(theme_id).to_rgb_dict() if extra_variables: theme_dict.update(extra_variables) return template(stylesheet, **theme_dict) 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.6/napari/_qt/qt_resources/_svg.py000066400000000000000000000114531474413133200210650ustar00rootroot00000000000000""" 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), 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.6/napari/_qt/qt_resources/_tests/000077500000000000000000000000001474413133200210525ustar00rootroot00000000000000napari-0.5.6/napari/_qt/qt_resources/_tests/test_icons.py000066400000000000000000000005701474413133200236000ustar00rootroot00000000000000import 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.6/napari/_qt/qt_resources/_tests/test_svg.py000066400000000000000000000012561474413133200232660ustar00rootroot00000000000000from 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.6/napari/_qt/qt_resources/styles/000077500000000000000000000000001474413133200210745ustar00rootroot00000000000000napari-0.5.6/napari/_qt/qt_resources/styles/00_base.qss000066400000000000000000000357711474413133200230520ustar00rootroot00000000000000/* 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:disabled { color: {{ opacity(text, 90) }}; } 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; font-size: {{ increase(font_size, 1) }}; } /* the area behind the scrollbar */ QTextEdit > QWidget { background-color: {{ console }}; } /* ----------------- QPushButton ------------------ */ QPushButton { background-color: {{ foreground }}; border-radius: 2px; padding: 4px; border: 0px; font-size: {{ increase(font_size, 1) }}; } 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 */ font-size: {{ increase(font_size, 1) }}; } 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; font-size: {{ font_size }}; } 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; font-size: {{ decrease(font_size, 1) }}; } 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; font-size: {{ font_size }}; } QLabeledSlider > QAbstractSpinBox { min-width: 10px; padding: 0px; font-size: {{ decrease(font_size, 1) }}; } QLabeledRangeSlider > QAbstractSpinBox { min-width: 5px; 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"); } /* ----------------- QCheckBox ------------------ */ QCheckBox { spacing: 5px; color: {{ text }}; background-color: none; font-size: {{ increase(font_size, 1) }}; } 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; font-size: {{ increase(font_size, 1) }}; } 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; font-size: {{ font_size }}; } 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 */ font-size: {{ increase(font_size, 1) }}; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 3px; background-color: {{ background }}; font-size: {{ increase(font_size, 1) }}; } QGroupBox::indicator { width: 12px; height: 12px; background-color: {{ foreground }}; border: 0px; padding: 1px; border-radius: 2px } QGroupBox::indicator:hover { background-color: {{ lighten(foreground, 5) }}; } QGroupBox::indicator:unchecked { image: none; } QGroupBox::indicator:checked { image: url("theme_{{ id }}:/check.svg"); } QGroupBox::indicator:indeterminate { image: url("theme_{{ id }}:/minus.svg"); padding: 2px; width: 14px; height: 14px; } QWidget[emphasized="true"] QGroupBox::indicator { background-color: {{ primary }}; border-color: {{ primary }}; } QWidget[emphasized="true"] QGroupBox::indicator:hover { background-color: {{ lighten(primary, 5) }}; } QWidget[emphasized="true"] QGroupBox::indicator:unchecked:hover { background-color: {{ lighten(primary, 5) }}; border-color: {{ lighten(primary, 5) }}; } /* ----------------- 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, 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 }}); font-size: {{ font_size }}; } 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; font-size: {{ increase(font_size, 1) }}; } /* ----------------- QMenuBar ------------------ */ QMenuBar::item:selected { background-color: {{ secondary }}; } QLCDNumber { background: none; } /* ----------------- QMenu ------------------ QMenu::item { font-size: {{ font_size }}; padding: 3px; } QMenu::item:selected { font-size: {{ font_size }}; background-color: {{ secondary }}; } */ /* ----------------- QStatusBar ------------------ */ QStatusBar::item{ border: None; } /* ----------------- QHeaderView ----------------- */ QHeaderView::section { background-color: {{ background }}; padding: 2px; } napari-0.5.6/napari/_qt/qt_resources/styles/01_buttons.qss000066400000000000000000000123631474413133200236270ustar00rootroot00000000000000 /* ----------------- Buttons -------------------- */ QtViewerPushButton{ min-width : 28px; max-width : 28px; min-height : 28px; max-height : 28px; padding: 0px; } QtViewerPushButton[mode="delete_button"] { image: url("theme_{{ id }}:/delete.svg"); } 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="pan"]::indicator { image: url("theme_{{ id }}:/pan_arrows.svg"); } QtModeRadioButton[mode="transform"]::indicator { image: url("theme_{{ id }}:/transform.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="polyline"]::indicator { image: url("theme_{{ id }}:/polyline.svg"); } QtModeRadioButton[mode="path"]::indicator { image: url("theme_{{ id }}:/path.svg"); } QtModeRadioButton[mode="polygon"]::indicator { image: url("theme_{{ id }}:/polygon.svg"); } QtModeRadioButton[mode="labels_polygon"]::indicator { image: url("theme_{{ id }}:/polygon.svg"); } QtModeRadioButton[mode="polygon_lasso"]::indicator { image: url("theme_{{ id }}:/polygon_lasso.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.6/napari/_qt/qt_resources/styles/02_custom.qss000066400000000000000000000425311474413133200234440ustar00rootroot00000000000000QLabel#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: {{ decrease(font_size, 1) }}; } #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; font-family: Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace; font-size: {{ font_size }}; } .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: {{ increase(font_size, 2) }}; 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 > QComboBox, QtLayerControls > QLabel, QtLayerControls, QtPlaneControls > QLabeledSlider > QAbstractSpinBox { font-size: {{ decrease(font_size, 1) }}; color: {{ text }}; } QLabeledRangeSlider > QAbstractSpinBox { font-size: {{ font_size }}; 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: {{ decrease(font_size, 3) }}; padding: 4; } PlaneNormalButtons QPushButton { font-size: {{ decrease(font_size, 3) }}; 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: {{ font_size }}; } #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: {{ decrease(font_size, 1) }}; 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: {{ decrease(font_size, 1) }}; } NapariQtNotification #severity_icon { padding: 0; margin: 0 0 -3px 0; min-width: 20px; min-height: 18px; font-size: {{ increase(font_size, 3) }}; color: {{ icon }}; } /* ------------ Activity Dock ------------ */ #QtCustomTitleLabel { color: {{ primary }}; font-size: {{ decrease(font_size, 1) }}; } #QtActivityButton:hover { background-color: {{ lighten(background, 10) }}; } /* ------------ Plugin Dialog ------------ */ QCollapsible#install_info_button { background-color: {{ darken(foreground, 20) }}; color: {{ darken(text, 15) }}; } QWidget#info_widget { background-color: {{ darken(foreground, 20) }}; margin: 0px; padding: 0px; font: 11px; } QLabel#author_text { color: {{ darken(text, 35) }}; } QPushButton#install_choice { background-color: {{ current }}; color: {{ darken(text, 35) }}; } QPushButton#plugin_name_web { background-color: {{ darken(foreground, 20) }}; } QPushButton#plugin_name_web:hover { background-color: {{ foreground }} } QPushButton#plugin_name { background-color: {{ darken(foreground, 20) }}; } QPushButton#plugin_name:hover { background-color: {{ darken(foreground, 20) }}; } QWidget#install_choice_widget { background-color: {{ darken(foreground, 20) }}; color: {{ darken(text, 35) }}; margin: 0px; padding: 0px; font: 11px; } 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: {{ decrease(font_size, 2) }}; } #small_italic_text { color: {{ opacity(text, 150) }}; font-size: {{ font_size }}; 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: {{ increase(font_size, 8) }}; } QtShortcutLabel { color: {{ foreground }}; font-size: {{ increase(font_size, 4) }}; } /* ------------- 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: {{ font_size }}; } 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; } /* ------------- Set size for dims sorter --------- */ QtDimsSorter > QtListView { max-height: 100px; max-width: 150px; } /* ------------- Lock check buttons for dims sorter --------- */ QtDimsSorter > QtListView::indicator::unchecked { image: url("theme_{{ id }}:/lock.svg"); } QtDimsSorter > QtListView::indicator::checked { image: url("theme_{{ id }}:/lock_open.svg"); } /* --------------- 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.6/napari/_qt/qt_viewer.py000066400000000000000000001323621474413133200174210ustar00rootroot00000000000000from __future__ import annotations import logging import sys import traceback import warnings import weakref from collections.abc import Sequence from pathlib import Path from types import FrameType from typing import ( TYPE_CHECKING, Any, Optional, Union, ) from weakref import WeakSet, ref import numpy as np from qtpy.QtCore import QCoreApplication, QObject, Qt, QUrl from qtpy.QtGui import QGuiApplication from qtpy.QtWidgets import QFileDialog, QSplitter, QVBoxLayout, QWidget from superqt import ensure_main_thread 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 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.errors import MultipleReaderError, ReaderPluginError from napari.layers.base.base import Layer from napari.plugins import _npe2 from napari.settings import get_settings from napari.settings._application import DaskSettings from napari.utils import config, perf, resize_dask_cache from napari.utils.action_manager import action_manager from napari.utils.history import ( get_open_history, get_save_history, update_open_history, update_save_history, ) 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.naming import CallerFrame from napari.utils.notifications import show_info from napari.utils.translations import trans from napari_builtins.io import imsave_extensions from napari._vispy import VispyCanvas, create_vispy_layer # isort:skip if TYPE_CHECKING: from napari_console import QtConsole from npe2.manifest.contributions import WriterContribution from napari._qt.layer_controls import QtLayerControlsContainer from napari.components import ViewerModel from napari.utils.events import Event 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`. canvas_class : napari._vispy.canvas.VispyCanvas The VispyCanvas class providing the Vispy SceneCanvas. Users can also have a custom canvas here. Attributes ---------- canvas : napari._vispy.canvas.VispyCanvas The VispyCanvas class providing the Vispy SceneCanvas. Users can also have a custom canvas here. dims : napari.qt_dims.QtDims Dimension sliders; Qt View for Dims model. show_welcome_screen : bool Boolean indicating whether to show the welcome screen. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. _key_map_handler : napari.utils.key_bindings.KeymapHandler KeymapHandler handling the calling functionality when keys are pressed that have a callback function mapped _qt_poll : Optional[napari._qt.experimental.qt_poll.QtPoll] A QtPoll object required for the monitor. _remote_manager : napari.components.experimental.remote.RemoteManager A remote manager processing commands from remote clients and sending out messages when polled. _welcome_widget : napari._qt.widgets.qt_welcome.QtWidgetOverlay QtWidgetOverlay providing the stacked widgets for the welcome page. """ _instances = WeakSet() def __init__( self, viewer: ViewerModel, show_welcome_screen: bool = False, canvas_class: type[VispyCanvas] = VispyCanvas, ) -> 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_backlog = [] 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.canvas = canvas_class( viewer=viewer, parent=self, key_map_handler=self._key_map_handler, size=self.viewer._canvas_size, autoswap=get_settings().experimental.autoswap_buffers, # see #5734 ) # 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.viewer._layer_slicer.events.ready.connect(self._on_slice_ready) self._on_active_change() self.viewer.layers.events.inserted.connect(self._update_camera_depth) self.viewer.layers.events.removed.connect(self._update_camera_depth) self.viewer.dims.events.ndisplay.connect(self._update_camera_depth) 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.layers.events.inserted.connect(self._on_add_layer_change) self.setAcceptDrops(True) # Create the experimental QtPool for the 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 ) # bind shortcuts stored in settings last. self._bind_shortcuts() settings = get_settings() self._update_dask_cache_settings(settings.application.dask) settings.application.dask.events.connect( self._update_dask_cache_settings ) for layer in self.viewer.layers: self._add_layer(layer) @property def view(self): """ Rectangular vispy viewbox widget in which a subscene is rendered. Access directly within the QtViewer will become deprecated. """ warnings.warn( trans._( 'Access to QtViewer.view is deprecated since 0.5.0 and will be removed in the napari 0.6.0. Change to QtViewer.canvas.view instead.' ), FutureWarning, stacklevel=2, ) return self.canvas.view @property def camera(self): """ The Vispy camera class which contains both the 2d and 3d camera used to describe the perspective by which a scene is viewed and interacted with. Access directly within the QtViewer will become deprecated. """ warnings.warn( trans._( 'Access to QtViewer.camera will become deprecated in the 0.6.0. Change to QtViewer.canvas.camera instead.' ), FutureWarning, stacklevel=2, ) return self.canvas.camera @property def chunk_receiver(self) -> None: warnings.warn( trans._( 'QtViewer.chunk_receiver is deprecated in version 0.5 and will be removed in a later version. ' 'More generally the old approach to async loading was removed in version 0.5 so this value is always None. ' 'If you need to specifically use the old approach, continue to use the latest 0.4 release.' ), DeprecationWarning, ) @staticmethod def _update_dask_cache_settings( dask_setting: Union[DaskSettings, Event] = None, ): """Update dask cache to match settings.""" if not dask_setting: return if not isinstance(dask_setting, DaskSettings): dask_setting = get_settings().application.dask enabled = dask_setting.enabled size = dask_setting.cache resize_dask_cache(int(int(enabled) * size * 1e9)) @property def controls(self) -> QtLayerControlsContainer: """Qt view for GUI controls.""" 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: """Qt view for LayerList controls.""" if self._layers is None: self._layers = QtLayerList(self.viewer.layers) return self._layers @property def layerButtons(self) -> QtLayerButtons: """Button controls for napari layers.""" if self._layersButtons is None: self._layersButtons = QtLayerButtons(self.viewer) return self._layersButtons @property def viewerButtons(self) -> QtViewerButtons: """Button controls for the napari viewer.""" if self._viewerButtons is None: self._viewerButtons = QtViewerButtons(self.viewer) return self._viewerButtons @property def dockLayerList(self) -> QtViewerDockWidget: """QWidget wrapped in a QDockWidget with forwarded viewer events.""" 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: """QWidget wrapped in a QDockWidget with forwarded viewer events.""" 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: """QWidget wrapped in a QDockWidget with forwarded viewer events.""" 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 @property def layer_to_visual(self): """Mapping of Napari layer to Vispy layer. Added for backward compatibility""" return self.canvas.layer_to_visual 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_performance_dock_widget(self): """Create the dock widget that shows performance metrics.""" if perf.perf_config is not None: return QtViewerDockWidget( self, QtPerformance(), name=trans._('performance'), area='bottom', ) return None def _weakref_if_possible(self, obj): """Create a weakref to obj. Parameters ---------- obj : object Cannot create weakrefs to many Python built-in datatypes such as list, dict, str. From https://docs.python.org/3/library/weakref.html: "Objects which support weak references include class instances, functions written in Python (but not in C), instance methods, sets, frozensets, some file objects, generators, type objects, sockets, arrays, deques, regular expression pattern objects, and code objects." Returns ------- weakref or object Returns a weakref if possible. """ try: newref = ref(obj) except TypeError: newref = obj return newref def _unwrap_if_weakref(self, value): """Return value or if that is weakref the object referenced by value. Parameters ---------- value : object or weakref No-op for types other than weakref. Returns ------- unwrapped: object or None Returns referenced object, or None if weakref is dead. """ unwrapped = value() if isinstance(value, ref) else value return unwrapped def add_to_console_backlog(self, variables): """Save variables for pushing to console when it is instantiated. This function will create weakrefs when possible to avoid holding on to too much memory unnecessarily. 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 isinstance(variables, (str, list, tuple)): if isinstance(variables, str): vlist = variables.split() else: vlist = variables vdict = {} cf = sys._getframe(2) for name in vlist: try: vdict[name] = eval(name, cf.f_globals, cf.f_locals) except NameError: logging.warning( 'Could not get variable %s from %s', name, cf.f_code.co_name, ) elif isinstance(variables, dict): vdict = variables else: raise TypeError('variables must be a dict/str/list/tuple') # weakly reference values if possible new_dict = {k: self._weakref_if_possible(v) for k, v in vdict.items()} self.console_backlog.append(new_dict) @property def console_backlog(self): """List: items to push to console when instantiated.""" return self._console_backlog def _get_console(self) -> Optional[QtConsole]: """Function to setup console. Returns ------- console : QtConsole or None The napari console. Notes _____ _get_console extracted to separate function to simplify testing. """ try: import numpy as np # QtConsole imports debugpy that overwrites default breakpoint. # It makes problems with debugging if you do not know this. # So we do not want to overwrite it if it is already set. breakpoint_handler = sys.breakpointhook from napari_console import QtConsole sys.breakpointhook = breakpoint_handler import napari with warnings.catch_warnings(): warnings.filterwarnings('ignore') console = QtConsole(self.viewer, style_sheet=self.styleSheet()) console.push( {'napari': napari, 'action_manager': action_manager} ) with CallerFrame(_in_napari) as c: if c.frame.f_globals.get('__name__', '') == '__main__': console.push({'np': np}) for i in self.console_backlog: # recover weak refs console.push( { k: self._unwrap_if_weakref(v) for k, v in i.items() if self._unwrap_if_weakref(v) is not None } ) return console except ModuleNotFoundError: warnings.warn( trans._( 'napari-console not found. It can be installed with' ' "pip install napari_console"' ), stacklevel=1, ) return None except ImportError: traceback.print_exc() warnings.warn( trans._( 'error importing napari-console. See console for full error.' ), stacklevel=1, ) return None @property def console(self): """QtConsole: iPython console terminal integrated into the napari GUI.""" if self._console is None: self.console = self._get_console() self._console_backlog = [] 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) @ensure_main_thread def _on_slice_ready(self, event): """Callback connected to `viewer._layer_slicer.events.ready`. Provides updates after slicing using the slice response data. This only gets triggered on the async slicing path. """ responses: dict[weakref.ReferenceType[Layer], Any] = event.value logging.debug('QtViewer._on_slice_ready: %s', responses) for weak_layer, response in responses.items(): if layer := weak_layer(): # Update the layer slice state to temporarily support behavior # that depends on it. layer._update_slice_response(response) # Update the layer's loaded state before everything else, # because they may rely on its updated value. layer._update_loaded_slice_id(response.request_id) # The rest of `Layer.refresh` after `set_view_slice`, where # `set_data` notifies the corresponding vispy layer of the new # slice. layer.events.set_data() layer._update_thumbnail() layer._set_highlight(force=True) 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 _update_camera_depth(self): """When the layer extents change, update the camera depth. The camera depth is the difference between the near clipping plane and the far clipping plane in a scene. If they are set too high relative to the actual depth of a scene, precision issues can arise in the depth of objects in the scene, with objects at the back seeming to pop to the front. See: https://github.com/napari/napari/issues/2138 """ if self.viewer.dims.ndisplay == 2: # don't bother updating 3D camera if we're not using it return # otherwise, set depth to diameter of displayed dimensions extent = self.viewer.layers.extent # we add a step because the difference is *right* at the point # coordinates, not accounting for voxel size: # >>> viewer.add_image(np.random.random((2, 3, 4, 5))) # >>> viewer.layers.extent # Extent( # data=None, # world=array([[0., 0., 0., 0.], # [1., 2., 3., 4.]]), # step=array([1., 1., 1., 1.]), # ) extent_all = extent.world[1] - extent.world[0] + extent.step extent_displayed = extent_all[list(self.viewer.dims.displayed)] diameter = np.linalg.norm(extent_displayed) # Use 128x the diameter to avoid aggressive near- and far-plane # clipping in perspective projection, while still preserving enough # bit depth in the depth buffer to avoid artifacts. See discussion at: # https://github.com/napari/napari/pull/7529#issuecomment-2594203871 self.canvas.camera._3D_camera.depth_value = 128 * diameter 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) self.canvas.add_layer_visual_mapping(layer, vispy_layer) def _remove_invalid_chars(self, selected_layer_name): """Removes invalid characters from selected layer name to suggest a filename. Parameters ---------- selected_layer_name : str The selected napari layer name. Returns ------- suggested_name : str Suggested name from input selected layer name, without invalid characters. """ unprintable_ascii_chars = ( '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\x0e', '\x0f', '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f', ) invalid_characters = ( ''.join(unprintable_ascii_chars) + '/' + '\\' # invalid Windows filename character + ':*?"<>|\t\n\r\x0b\x0c' # invalid Windows path characters ) translation_table = dict.fromkeys(map(ord, invalid_characters), None) # Remove invalid characters suggested_name = selected_layer_name.translate(translation_table) return suggested_name 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) # get the layer's name to use for a default name if only one layer is selected selected_layer_name = '' if self.viewer.layers.selection.active is not None: selected_layer_name = self.viewer.layers.selection.active.name selected_layer_name = self._remove_invalid_chars( selected_layer_name ) filename, selected_filter = dlg.getSaveFileName( self, # parent trans._('Save {msg} layers', msg=msg), # caption # home dir by default if selected all, home dir and file name if only 1 layer str( Path(hist[0]) / selected_layer_name ), # directory in PyQt, dir in PySide 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, ) ) 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. """ img = self.canvas.screenshot() 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) -> np.ndarray: """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) -> 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, stack=False): """Add files from the menubar.""" filenames = self._open_file_dialog_uni(trans._('Select file(s)...')) if filenames: self._qt_open(filenames, choose_plugin=choose_plugin, stack=stack) update_open_history(filenames[0]) def _open_files_dialog_as_stack_dialog(self, choose_plugin=False): """Add files as a stack, from the menubar.""" return self._open_files_dialog(choose_plugin=choose_plugin, stack=True) 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: Optional[str] = None, layer_type: Optional[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_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 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._scene_canvas._backend._keyEvent( self.canvas._scene_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._scene_canvas._backend._keyEvent( self.canvas._scene_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 _image_from_clipboard(self): """Insert image from clipboard as a new layer if clipboard contains an image or link.""" cb = QGuiApplication.clipboard() if cb.mimeData().hasImage(): image = cb.image() if image.isNull(): return arr = QImg2array(image) self.viewer.add_image(arr) return if cb.mimeData().hasUrls(): show_info('No image in clipboard, trying to open link instead.') self._open_from_list_of_urls_data( cb.mimeData().urls(), stack=False, choose_plugin=False ) return if cb.mimeData().hasText(): show_info( 'No image in clipboard, trying to parse text in clipboard as a link.' ) url_list = [] for line in cb.mimeData().text().split('\n'): url = QUrl(line.strip()) if url.isEmpty(): continue if url.scheme() == '': url.setScheme('file') if url.isLocalFile() and not Path(url.toLocalFile()).exists(): break url_list.append(url) else: self._open_from_list_of_urls_data( url_list, stack=False, choose_plugin=False ) return show_info('No image or link in clipboard.') 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 ) self._open_from_list_of_urls_data( event.mimeData().urls(), stack=bool(shift_down), choose_plugin=bool(alt_down), ) def _open_from_list_of_urls_data( self, urls_list: list[QUrl], stack: bool, choose_plugin: bool ): filenames = [] for url in urls_list: 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=stack, choose_plugin=choose_plugin, ) def closeEvent(self, event): """Cleanup and close. Parameters ---------- event : qtpy.QtCore.QCloseEvent Event from the Qt context. """ if self._layers is not None: # do not create layerlist if it does not exist yet. 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.delete() 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 the monitor. 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.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 def _in_napari(n: int, frame: FrameType): """ Determines whether we are in napari by looking at: 1) the frames modules names: 2) the min_depth """ if n < 2: return True # in-n-out is used in napari for dependency injection. for pref in {'napari.', 'in_n_out.'}: if frame.f_globals.get('__name__', '').startswith(pref): return True return False napari-0.5.6/napari/_qt/qthreading.py000066400000000000000000000316171474413133200175430ustar00rootroot00000000000000import inspect import warnings from collections.abc import Sequence from functools import partial, wraps from types import FunctionType, GeneratorType from typing import ( Callable, Optional, TypeVar, Union, ) from superqt.utils import _qthreading from napari.utils.progress import progress from napari.utils.translations import trans __all__ = [ 'FunctionWorker', 'GeneratorWorker', 'create_worker', 'register_threadworker_processors', 'thread_worker', ] 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') 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._qt._qapp_model.injection._qprocessors import ( _add_layer_data_to_viewer, ) cb = _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._qt._qapp_model.injection._qprocessors import ( _add_layer_data_tuples_to_viewer, ) cb = _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_model from napari.types import LayerDataTuple from napari.utils import _magicgui as _mgui app = get_app_model() 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.6/napari/_qt/threads/000077500000000000000000000000001474413133200164655ustar00rootroot00000000000000napari-0.5.6/napari/_qt/threads/__init__.py000066400000000000000000000000001474413133200205640ustar00rootroot00000000000000napari-0.5.6/napari/_qt/threads/status_checker.py000066400000000000000000000122011474413133200220420ustar00rootroot00000000000000"""A performant, dedicated thread to compute cursor status and signal updates to a viewer.""" from __future__ import annotations import os from threading import Event from typing import TYPE_CHECKING from weakref import ref from qtpy.QtCore import QObject, QThread, Signal from napari.utils.notifications import Notification, notification_manager if TYPE_CHECKING: from napari.components import ViewerModel class StatusChecker(QThread): """A dedicated thread for performant updating of the status bar. This class offloads the job of computing the cursor status into a separate thread, Qt Signals are used to update the main viewer with the status string. Prior to https://github.com/napari/napari/pull/7146, status bar updates happened on the main thread in the viewer model, which could be expensive in some layers and resulted in bad performance when some layers were selected. Because the thread runs a single infinite while loop, the updates are naturally throttled since they can only be sent at the rate which updates can be computed, but no faster. Attributes ---------- _need_status_update : threading.Event An Event (fancy thread-safe bool-like to synchronize threads) for keeping track of when the status needs updating (because the cursor has moved). _terminate : bool If set to True, the status checker thread needs to be terminated. When the QtViewer is being closed, it sets this flag to terminate the status checker thread. After _terminate is set to True, no more status updates are sent. Default: False. viewer_ref : weakref.ref[napari.viewer.ViewerModel] A weak reference to the viewer which is providing status updates. We keep a weak reference to the viewer so the status checker thread will not prevent the viewer from being garbage collected. We proactively check the viewer to determine if a new status update needs to be computed and emitted. """ # Create a Signal to establish a lightweight communication mechanism between the # viewer and the status checker thread for cursor events and related status status_and_tooltip_changed = Signal(object) def __init__(self, viewer: ViewerModel, parent: QObject | None = None): super().__init__(parent=parent) self.viewer_ref = ref(viewer) self._need_status_update = Event() self._need_status_update.clear() self._terminate = False def trigger_status_update(self) -> None: """Trigger a status update computation. When the cursor moves, the viewer will call this to instruct the status checker to update the viewer with the present status. """ self._need_status_update.set() def terminate(self) -> None: """Terminate the status checker thread. For proper cleanup,it's important to set _terminate to True before calling _needs_status_update.set. """ self._terminate = True self._need_status_update.set() def start( self, priority: QThread.Priority = QThread.Priority.InheritPriority ) -> None: """Start the status checker thread. Make sure to set the _terminate attribute to False prior to start. """ self._terminate = False super().start(priority) def run(self) -> None: while not self._terminate: if self.viewer_ref() is None: # Stop thread when viewer is closed return if self._need_status_update.is_set(): self._need_status_update.clear() self.calculate_status() else: self._need_status_update.wait() def calculate_status(self) -> None: """Calculate the status and emit the signal. If the viewer is not available, do nothing. Otherwise, emit the signal that the status has changed. """ viewer = self.viewer_ref() if viewer is None: return try: # Calculate the status change from cursor's movement res = viewer._calc_status_from_cursor() except Exception as e: # pragma: no cover # noqa: BLE001 # Our codebase is not threadsafe. It is possible that an # ViewerModel or Layer state is changed while we are trying to # calculate the status, which may cause an exception. # All exceptions are caught and handled to keep updates # from crashing the thread. The exception is logged # and a notification is sent. notification_manager.dispatch(Notification.from_exception(e)) return # Emit the signal with the updated status self.status_and_tooltip_changed.emit(res) if os.environ.get('ASV') == 'true': # This is a hack to make sure that the StatusChecker thread is not # running when the benchmark is running. This is because the # StatusChecker thread may introduce some noise in the benchmark # results from waiting on its termination. StatusChecker.start = lambda self, priority=0: None # type: ignore[assignment] napari-0.5.6/napari/_qt/utils.py000066400000000000000000000300751474413133200165520ustar00rootroot00000000000000from __future__ import annotations import re import signal import socket import weakref from collections.abc import Iterable, Sequence from contextlib import contextmanager from functools import partial from typing import Union import numpy as np import qtpy from qtpy.QtCore import ( QByteArray, QCoreApplication, QPropertyAnimation, QSocketNotifier, Qt, QThread, ) from qtpy.QtGui import QColor, QCursor, QDrag, QImage, QPainter, 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: op = QGraphicsOpacityEffect(parent) op.setOpacity(0.5) # Only enable opacity effect when needed. That prevents layout changes # when setting the color effect for the whole window with the flash # animation option. # See https://github.com/napari/napari/issues/6147 op.setEnabled(not enabled) widget.setEnabled(enabled) widget.setGraphicsEffect(op) 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 if isinstance(widgets, QWidget): return widgets if 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._( '"widgets" must be a QWidget, a magicgui Widget or a sequence of ' 'such types' ) ) 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() -> bool: """ 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.6/napari/_qt/widgets/000077500000000000000000000000001474413133200165015ustar00rootroot00000000000000napari-0.5.6/napari/_qt/widgets/__init__.py000066400000000000000000000000601474413133200206060ustar00rootroot00000000000000"""Custom widgets that inherit from QWidget.""" napari-0.5.6/napari/_qt/widgets/_tests/000077500000000000000000000000001474413133200200025ustar00rootroot00000000000000napari-0.5.6/napari/_qt/widgets/_tests/__init__.py000066400000000000000000000000001474413133200221010ustar00rootroot00000000000000napari-0.5.6/napari/_qt/widgets/_tests/test_qt_buttons.py000066400000000000000000000024601474413133200236170ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/_tests/test_qt_color_swatch.py000066400000000000000000000034161474413133200246120ustar00rootroot00000000000000import numpy as np import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from napari._qt.widgets.qt_color_swatch import ( TRANSPARENT, QColorPopup, 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' # check widget creation and base values assert widget.color_swatch.toolTip() == test_tooltip np.testing.assert_array_equal(widget.color, test_color) # check widget popup qtbot.mouseRelease(widget.color_swatch, Qt.MouseButton.LeftButton) color_popup = None for widget in QApplication.topLevelWidgets(): if isinstance(widget, QColorPopup): color_popup = widget assert color_popup @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' # check widget creation and base values assert widget.toolTip() == test_tooltip np.testing.assert_array_equal(widget.color, test_color) # check widget popup qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton) color_popup = None for widget in QApplication.topLevelWidgets(): if isinstance(widget, QColorPopup): color_popup = widget assert color_popup napari-0.5.6/napari/_qt/widgets/_tests/test_qt_dims.py000066400000000000000000000243211474413133200230550ustar00rootroot00000000000000import 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, 0, 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 with eliding capabilites. """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) # set initial widget width and show it to be able to trigger `resizeEvent` view.setFixedWidth(100) view.show() view.dims.axis_labels = list('TZYX') assert [w.axis_label.text() for w in view.slider_widgets] == list('TZYX') observed_axis_labels_event = False def on_axis_labels_changed(): nonlocal observed_axis_labels_event observed_axis_labels_event = True view.dims.events.axis_labels.connect(on_axis_labels_changed) first_label = view.slider_widgets[0].axis_label assert first_label.text() == view.dims.axis_labels[0] # check that the label text corresponds with the dims model # while being elided on the GUI first_label.setText('napari') assert first_label.text() == view.dims.axis_labels[0] assert '…' in first_label._elidedText() assert observed_axis_labels_event # increase width to check the full text is shown view.setFixedWidth(250) assert first_label.text() == view.dims.axis_labels[0] assert first_label._elidedText() == 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() if i in [0, 1, 2]: # only the first three dims should have visible sliders assert widg.isVisibleTo(view) assert view.dims.last_used == i else: # sliders should not be visible for the following dims and the # last_used should fallback to the first available dim with a # visible slider (dim 0) assert not widg.isVisibleTo(view) assert view.dims.last_used == 0 @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) with patch.object(button.popup, 'show_above_mouse') as mock_popup: qtbot.mouseClick(button, Qt.RightButton) mock_popup.assert_called_once() # Check popup updates widget properties (fps, play mode and loop mode) button.fpsspin.clear() qtbot.keyClicks(button.fpsspin, '11') qtbot.keyClick(button.fpsspin, Qt.Key_Enter) assert slider.fps == button.fpsspin.value() == 11 button.reverse_check.setChecked(True) assert slider.fps == -button.fpsspin.value() == -11 button.mode_combo.setCurrentText('once') assert slider.loop_mode == button.mode_combo.currentText() == 'once' qtbot.waitUntil(view._animation_thread.isFinished) napari-0.5.6/napari/_qt/widgets/_tests/test_qt_dims_2.py000066400000000000000000000046071474413133200233030ustar00rootroot00000000000000""" Suspecting Segfaulting test. The test on this file have been put in their own file to try to narrow down a Sega fluting test. As when test segfault, pytest output get corrupted, it is hard to know which of the test the previous file is segfaulting. Moving the test here, at least allow us to know in which file the segfaulting happens as _at least_ the file name will be printed. """ from napari._qt.widgets.qt_dims import QtDims from napari.components import Dims 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()) == 20 # 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) 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) 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.6/napari/_qt/widgets/_tests/test_qt_dims_sorter.py000066400000000000000000000022571474413133200244570ustar00rootroot00000000000000from qtpy.QtWidgets import QWidget from napari._qt.widgets.qt_dims_sorter import QtDimsSorter from napari.components.dims import Dims def test_dims_sorter(qtbot): dims = Dims() parent = QWidget() dim_sorter = QtDimsSorter(dims, parent) qtbot.addWidget(dim_sorter) assert tuple(dim_sorter.axis_list) == (0, 1) dims.axis_labels = ('y', 'x') assert tuple(dim_sorter.axis_list) == ('y', 'x') dim_sorter.axis_list.move(1, 0) assert tuple(dim_sorter.axis_list) == ('x', 'y') assert tuple(dims.order) == (1, 0) def test_dims_sorter_callback_management(qtbot): dims = Dims() parent = QWidget() base_callback_count = len(dims.events.order.callbacks) dim_sorter = QtDimsSorter(dims, parent) qtbot.addWidget(dim_sorter) # assert callback hook up assert len(dims.events.order.callbacks) == base_callback_count + 1 assert len(dim_sorter.axis_list.events.reordered.callbacks) == 2 def test_dims_sorter_with_reordered_init(qtbot): dims = Dims() parent = QWidget() dims.order = (1, 0) dim_sorter = QtDimsSorter(dims, parent) qtbot.addWidget(dim_sorter) assert tuple(dim_sorter.axis_list) == tuple(dims.order) napari-0.5.6/napari/_qt/widgets/_tests/test_qt_dock_widget.py000066400000000000000000000122471474413133200244100ustar00rootroot00000000000000import pytest from qtpy.QtWidgets import ( QDockWidget, QHBoxLayout, QPushButton, QTextEdit, QVBoxLayout, QWidget, ) from napari._qt.utils import combine_widgets 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, match='area argument must be'): viewer.window.add_dock_widget(widg2, name='test2', area='under') with pytest.raises(ValueError, match='all allowed_areas argument must be'): viewer.window.add_dock_widget( widg2, name='test2', allowed_areas=['under'] ) with pytest.raises(TypeError, match='`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, qtbot): viewer = make_napari_viewer() widg = QPushButton('button') qtbot.addWidget(widg) 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, qtbot): viewer = make_napari_viewer() widg = QPushButton('button') qtbot.addWidget(widg) 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() def test_combine_widgets_error(): """Check error raised when combining widgets with invalid types.""" with pytest.raises( TypeError, match='"widgets" must be a QWidget, a magicgui' ): combine_widgets(['string']) napari-0.5.6/napari/_qt/widgets/_tests/test_qt_extension2reader.py000066400000000000000000000165441474413133200254120ustar00rootroot00000000000000import 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 ) @pytest.mark.parametrize('pattern', ['.', '', '/']) def test_filtering_readers_problematic_patterns( extension2reader_widget, builtins, tif_reader, npy_reader, pattern ): widget = extension2reader_widget( npe1_readers={builtins.display_name: builtins.display_name} ) widget._filter_compatible_readers(pattern) assert widget._new_reader_dropdown.count() == 1 assert widget._new_reader_dropdown.itemText(0) == 'None available' 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.6/napari/_qt/widgets/_tests/test_qt_highlight_preview.py000066400000000000000000000166141474413133200256370ustar00rootroot00000000000000import numpy as np import pytest from napari._qt.widgets.qt_highlight_preview import ( QtHighlightPreviewWidget, 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_preview_widget(qtbot): def _highlight_preview_widget(**kwargs): widget = QtHighlightPreviewWidget(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _highlight_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) # QtHighlightPreviewWidget # ---------------------------------------------------------------------------- def test_qt_highlight_preview_widget_defaults( highlight_preview_widget, ): highlight_preview_widget() def test_qt_highlight_preview_widget_description( highlight_preview_widget, ): description = 'Some text' widget = highlight_preview_widget(description=description) assert widget.description() == description widget = highlight_preview_widget() widget.setDescription(description) assert widget.description() == description def test_qt_highlight_preview_widget_unit(highlight_preview_widget): unit = 'CM' widget = highlight_preview_widget(unit=unit) assert widget.unit() == unit widget = highlight_preview_widget() widget.setUnit(unit) assert widget.unit() == unit def test_qt_highlight_preview_widget_minimum( highlight_preview_widget, ): minimum = 5 widget = highlight_preview_widget(min_value=minimum) assert widget.minimum() == minimum assert widget._thickness_value >= minimum assert widget.value()['highlight_thickness'] >= minimum widget = highlight_preview_widget() widget.setMinimum(3) assert widget.minimum() == 3 assert widget._thickness_value == 3 assert widget.value()['highlight_thickness'] == 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_preview_widget_minimum_invalid( highlight_preview_widget, ): widget = highlight_preview_widget() with pytest.raises(ValueError, match='must be smaller than'): widget.setMinimum(60) def test_qt_highlight_preview_widget_maximum( highlight_preview_widget, ): maximum = 10 widget = highlight_preview_widget(max_value=maximum) assert widget.maximum() == maximum assert widget._thickness_value <= maximum assert widget.value()['highlight_thickness'] <= maximum widget = highlight_preview_widget( value={ 'highlight_thickness': 6, 'highlight_color': [0.0, 0.6, 1.0, 1.0], } ) widget.setMaximum(20) assert widget.maximum() == 20 assert widget._slider.maximum() == 20 assert widget._triangle.maximum() == 20 assert widget._slider_max_label.text() == '20' assert widget._thickness_value == 6 assert widget.value()['highlight_thickness'] == 6 widget.setMaximum(5) assert widget.maximum() == 5 assert widget._thickness_value == 5 assert widget.value()['highlight_thickness'] == 5 assert widget._slider.maximum() == 5 assert widget._triangle.maximum() == 5 assert widget._lineedit.text() == '5' assert widget._slider_max_label.text() == '5' def test_qt_highlight_preview_widget_maximum_invalid( highlight_preview_widget, ): widget = highlight_preview_widget() with pytest.raises(ValueError, match='must be larger than'): widget.setMaximum(-5) def test_qt_highlight_preview_widget_value(highlight_preview_widget): widget = highlight_preview_widget( value={ 'highlight_thickness': 5, 'highlight_color': [0.0, 0.6, 1.0, 1.0], } ) assert widget._thickness_value <= 5 assert widget.value()['highlight_thickness'] <= 5 assert widget._color_value == [0.0, 0.6, 1.0, 1.0] assert widget.value()['highlight_color'] == [0.0, 0.6, 1.0, 1.0] widget = highlight_preview_widget() widget.setValue( {'highlight_thickness': 5, 'highlight_color': [0.6, 0.6, 1.0, 1.0]} ) assert widget._thickness_value == 5 assert widget.value()['highlight_thickness'] == 5 assert np.array_equal( np.array(widget._color_value, dtype=np.float32), np.array([0.6, 0.6, 1.0, 1.0], dtype=np.float32), ) assert np.array_equal( np.array(widget.value()['highlight_color'], dtype=np.float32), np.array([0.6, 0.6, 1.0, 1.0], dtype=np.float32), ) def test_qt_highlight_preview_widget_value_invalid( qtbot, highlight_preview_widget ): widget = highlight_preview_widget() widget.setMaximum(50) widget.setValue( {'highlight_thickness': 51, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} ) assert widget.value()['highlight_thickness'] == 50 assert widget._lineedit.text() == '50' widget.setMinimum(5) widget.setValue( {'highlight_thickness': 1, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} ) assert widget.value()['highlight_thickness'] == 5 assert widget._lineedit.text() == '5' def test_qt_highlight_preview_widget_signal(qtbot, highlight_preview_widget): widget = highlight_preview_widget() with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue( {'highlight_thickness': 7, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} ) with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue( { 'highlight_thickness': -5, 'highlight_color': [0.0, 0.6, 1.0, 1.0], } ) napari-0.5.6/napari/_qt/widgets/_tests/test_qt_play.py000066400000000000000000000143761474413133200230770ustar00rootroot00000000000000from 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 AnimationThread 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 - 1, step)) slider_widget = qtdims.slider_widgets[0] slider_widget.loop_mode = loop_mode slider_widget.fps = fps slider_widget.frame_range = frame_range worker = AnimationThread() worker.set_slider(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.slow @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.start() 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((2, 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(5, 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() with pytest.raises(ValueError, match='must be <='): view.dims.play(0, 20, frame_range=[2, 2]) with pytest.raises(ValueError, match='loop_mode must be one of'): 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 def test_change_play_axis(ref_view, qtbot): """Make sure changing the play axis stops the current animation. Prior to https://github.com/napari/napari/pull/7158, starting a new play animation resulted in QThread warnings and could crash Python in some environments. In the future, we may want to allow multiple multiple simultaneous play axes [1]_, so this test should be changed or removed when we do that. ..[1] https://github.com/napari/napari/pull/6300#issuecomment-1757696072 """ view = ref_view() with qtbot.waitSignal(view.dims._animation_thread.started): view.dims.play(0, 20) qtbot.waitUntil(lambda: view.dims.is_playing) assert view.dims._animation_thread.slider.axis == 0 view.dims.play(1, 20) assert view.dims._animation_thread.slider.axis == 1 assert view.dims.is_playing with qtbot.waitSignal(view.dims._animation_thread.finished): view.dims.stop() def test_change_play_fps(ref_view, qtbot): """Make sure changing the play fps stops the current animation""" view = ref_view() with qtbot.waitSignal(view.dims._animation_thread.started): view.dims.play(0, 20) qtbot.waitUntil(lambda: view.dims.is_playing) assert view.dims._animation_thread.slider.fps == 20 view.dims.play(0, 30) assert view.dims._animation_thread.slider.fps == 30 assert view.dims.is_playing with qtbot.waitSignal(view.dims._animation_thread.finished): view.dims.stop() napari-0.5.6/napari/_qt/widgets/_tests/test_qt_plugin_sorter.py000066400000000000000000000172071474413133200250220ustar00rootroot00000000000000import re import sys import pytest from napari._qt.widgets.qt_plugin_sorter import QtPluginSorter, rst2html @pytest.mark.parametrize( ('text', 'expected_text'), [ ('', ''), ( """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.""", '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 Viewer.open(), via the
' '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 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
' '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.', ), ], ) def test_rst2html(text, expected_text): assert rst2html(text) == expected_text def test_create_qt_plugin_sorter(qtbot): plugin_sorter = QtPluginSorter() qtbot.addWidget(plugin_sorter) # Check initial hook combobox items hook_combo_box = plugin_sorter.hook_combo_box combobox_items = [ hook_combo_box.itemText(idx) for idx in range(hook_combo_box.count()) ] assert combobox_items == [ 'select hook... ', 'get_reader', 'get_writer', 'write_image', 'write_labels', 'write_points', 'write_shapes', 'write_surface', 'write_vectors', ] @pytest.mark.parametrize( ('hook_name', 'help_info'), [ ('select hook... ', ''), ( 'get_reader', '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.
', ), ( 'get_writer', 'This function will be called whenever the user attempts to save multiple
layers (e.g. via File -> Save Layers, or
save_layers).
', ), ( 'write_labels', 'It is the responsibility of the implementation to check any extension on
path and return None if it is an unsupported extension.', ), ( 'write_points', 'It is the responsibility of the implementation to check any extension on
path and return None if it is an unsupported extension.', ), ( 'write_shapes', 'It is the responsibility of the implementation to check any extension on
path and return None if it is an unsupported extension.', ), ( 'write_surface', 'It is the responsibility of the implementation to check any extension on
path and return None if it is an unsupported extension.', ), ( 'write_vectors', 'It is the responsibility of the implementation to check any extension on
path and return None if it is an unsupported extension.', ), ], ) def test_qt_plugin_sorter_help_info(qtbot, hook_name, help_info): plugin_sorter = QtPluginSorter() qtbot.addWidget(plugin_sorter) # Check hook combobox items help tooltip in the info widget info_widget = plugin_sorter.info hook_combo_box = plugin_sorter.hook_combo_box hook_combo_box.setCurrentText(hook_name) if sys.version_info >= (3, 13): help_info = re.sub(r'
+', r'
', help_info) assert help_info in info_widget.toolTip() napari-0.5.6/napari/_qt/widgets/_tests/test_qt_progress_bar.py000066400000000000000000000034361474413133200246150ustar00rootroot00000000000000from argparse import Namespace from napari._qt.widgets.qt_progress_bar import ( QtLabeledProgressBar, QtProgressBarGroup, ) from napari.utils.progress import cancelable_progress 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_qt_labeled_progress_bar_cancel(qtbot): prog = cancelable_progress(total=10) progress = QtLabeledProgressBar(prog=prog) progress.cancel_button.clicked.emit() qtbot.waitUntil(lambda: prog.is_canceled, timeout=500) def test_create_qt_progress_bar_group(qtbot): group = QtProgressBarGroup(QtLabeledProgressBar()) qtbot.addWidget(group) napari-0.5.6/napari/_qt/widgets/_tests/test_qt_range_slider_popup.py000066400000000000000000000015131474413133200260000ustar00rootroot00000000000000import 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.6/napari/_qt/widgets/_tests/test_qt_scrollbar.py000066400000000000000000000005611474413133200241040ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/_tests/test_qt_size_preview.py000066400000000000000000000113741474413133200246400ustar00rootroot00000000000000import 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, match='must be smaller than'): 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, match='must be larger than'): 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.6/napari/_qt/widgets/_tests/test_qt_tooltip.py000066400000000000000000000016731474413133200236200ustar00rootroot00000000000000import os import sys from unittest.mock import patch import pytest from qtpy.QtCore import QPointF from qtpy.QtGui import QEnterEvent from qtpy.QtWidgets import QToolTip from napari._qt.widgets.qt_tooltip import QtToolTipLabel @pytest.mark.skipif( os.environ.get('CI', False) and sys.platform == 'darwin', reason='Timeouts when running on macOS CI', ) @patch.object(QToolTip, 'showText') def test_qt_tooltip_label(show_text, qtbot): tooltip_text = 'Test QtToolTipLabel showing a tooltip' widget = QtToolTipLabel('Label with a tooltip') widget.setToolTip(tooltip_text) qtbot.addWidget(widget) widget.show() assert QToolTip.text() == '' # simulate movement mouse from outside the widget to the center pos = QPointF(widget.rect().center()) event = QEnterEvent(pos, pos, QPointF(widget.pos()) + pos) widget.enterEvent(event) assert show_text.called assert show_text.call_args[0][1] == tooltip_text napari-0.5.6/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py000066400000000000000000000144711474413133200252050ustar00rootroot00000000000000from unittest.mock import Mock import pytest from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QApplication from napari._app_model._app import get_app_model from napari._qt.dialogs.qt_modal import QtPopup from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons from napari.components.viewer_model import ViewerModel from napari.viewer import Viewer @pytest.fixture def qt_viewer_buttons(qtbot): # create viewer model and buttons viewer = ViewerModel() viewer_buttons = QtViewerButtons(viewer) qtbot.addWidget(viewer_buttons) yield viewer, viewer_buttons # close still open popup widgets for widget in QApplication.topLevelWidgets(): if isinstance(widget, QtPopup): widget.close() viewer_buttons.close() def test_roll_dims_button_popup(qt_viewer_buttons, qtbot): """ Make sure the QtViewerButtons.rollDimsButton popup works. """ # get viewer model and buttons viewer, viewer_buttons = qt_viewer_buttons assert viewer_buttons.rollDimsButton # make dims order settings popup viewer_buttons.rollDimsButton.customContextMenuRequested.emit(QPoint()) # check that the popup widget is available dims_sorter_popup = None for widget in QApplication.topLevelWidgets(): if isinstance(widget, QtPopup): dims_sorter_popup = widget assert dims_sorter_popup def test_grid_view_button_popup(qt_viewer_buttons, qtbot): """ Make sure the QtViewerButtons.gridViewbutton popup works. The popup widget should be able to show/change viewer grid settings. """ # get viewer model and buttons viewer, viewer_buttons = qt_viewer_buttons assert viewer_buttons.gridViewButton # make grid settings popup viewer_buttons.gridViewButton.customContextMenuRequested.emit(QPoint()) # check popup widgets were created assert viewer_buttons.grid_stride_box assert viewer_buttons.grid_stride_box.value() == viewer.grid.stride assert viewer_buttons.grid_width_box assert viewer_buttons.grid_width_box.value() == viewer.grid.shape[1] assert viewer_buttons.grid_height_box assert viewer_buttons.grid_height_box.value() == viewer.grid.shape[0] # check that widget controls value changes update viewer grid values viewer_buttons.grid_stride_box.setValue(2) assert viewer_buttons.grid_stride_box.value() == viewer.grid.stride viewer_buttons.grid_width_box.setValue(2) assert viewer_buttons.grid_width_box.value() == viewer.grid.shape[1] viewer_buttons.grid_height_box.setValue(2) assert viewer_buttons.grid_height_box.value() == viewer.grid.shape[0] # check viewer grid values changes update popup widget controls values viewer.grid.stride = 1 viewer.grid.shape = (-1, -1) # popup needs to be relaunched to get widget controls with the new values for widget in QApplication.topLevelWidgets(): if isinstance(widget, QtPopup): widget.close() viewer_buttons.gridViewButton.customContextMenuRequested.emit(QPoint()) assert viewer_buttons.grid_stride_box.value() == viewer.grid.stride viewer_buttons.grid_width_box.setValue(2) assert viewer_buttons.grid_width_box.value() == viewer.grid.shape[1] assert viewer_buttons.grid_height_box.value() == viewer.grid.shape[0] def test_ndisplay_button_popup(qt_viewer_buttons, qtbot): """ Make sure the QtViewerButtons.ndisplayButton popup works. """ # get viewer model and buttons viewer, viewer_buttons = qt_viewer_buttons assert viewer_buttons.ndisplayButton # toggle ndisplay to be able to trigger popup viewer.dims.ndisplay = 2 + (viewer.dims.ndisplay == 2) # make ndisplay perspective setting popup viewer_buttons.ndisplayButton.customContextMenuRequested.emit(QPoint()) perspective_popup = None for widget in QApplication.topLevelWidgets(): if isinstance(widget, QtPopup): perspective_popup = widget assert perspective_popup # check perspective slider change affects viewer camera perspective assert viewer_buttons.perspective_slider viewer_buttons.perspective_slider.setValue(5) assert ( viewer.camera.perspective == viewer_buttons.perspective_slider.value() == 5 ) # popup needs to be relaunched to get widget controls with the new values perspective_popup.close() perspective_popup = None # check viewer camera perspective value affects perspective popup slider # initial value viewer.camera.perspective = 10 viewer_buttons.ndisplayButton.customContextMenuRequested.emit(QPoint()) for widget in QApplication.topLevelWidgets(): if isinstance(widget, QtPopup): perspective_popup = widget assert perspective_popup assert viewer_buttons.perspective_slider assert ( viewer.camera.perspective == viewer_buttons.perspective_slider.value() == 10 ) def test_toggle_ndisplay(mock_app_model, qt_viewer_buttons, qtbot): """Check `toggle_ndisplay` works via `mouseClick`.""" viewer, viewer_buttons = qt_viewer_buttons assert viewer_buttons.ndisplayButton app = get_app_model() assert viewer.dims.ndisplay == 2 with app.injection_store.register( providers=[ (lambda: viewer, Viewer, 100), ] ): qtbot.mouseClick(viewer_buttons.ndisplayButton, Qt.LeftButton) assert viewer.dims.ndisplay == 3 def test_transpose_rotate_button(monkeypatch, qt_viewer_buttons, qtbot): """ Click should trigger `transpose_axes`. Alt/Option-click should trigger `rotate_layers.` """ _, viewer_buttons = qt_viewer_buttons assert viewer_buttons.transposeDimsButton action_manager_mock = Mock(trigger=Mock()) # Monkeypatch the action_manager instance to prevent viewer error monkeypatch.setattr( 'napari._qt.widgets.qt_viewer_buttons.action_manager', action_manager_mock, ) modifiers = Qt.AltModifier qtbot.mouseClick( viewer_buttons.transposeDimsButton, Qt.LeftButton, modifiers ) action_manager_mock.trigger.assert_called_with('napari:rotate_layers') trigger_mock = Mock() monkeypatch.setattr( 'napari.utils.action_manager.ActionManager.trigger', trigger_mock ) qtbot.mouseClick(viewer_buttons.transposeDimsButton, Qt.LeftButton) trigger_mock.assert_called_with('napari:transpose_axes') napari-0.5.6/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py000066400000000000000000000277531474413133200262150ustar00rootroot00000000000000import itertools import sys from unittest.mock import patch import pyautogui import pytest from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QAbstractItemDelegate, QApplication, QMessageBox from napari._qt.widgets.qt_keyboard_settings import ShortcutEditor, WarnPopup from napari._tests.utils import skip_local_focus, skip_on_mac_ci from napari.settings import get_settings from napari.utils.action_manager import action_manager from napari.utils.interactions import KEY_SYMBOLS from napari.utils.key_bindings import KeyBinding META_CONTROL_KEY = Qt.KeyboardModifier.ControlModifier if sys.platform == 'darwin': META_CONTROL_KEY = Qt.KeyboardModifier.MetaModifier @pytest.fixture def shortcut_editor_widget(qtbot): # Always reset shortcuts (settings and action manager) 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) def _shortcut_editor_widget(**kwargs): widget = ShortcutEditor(**kwargs) widget._reset_shortcuts() widget.show() qtbot.addWidget(widget) return widget return _shortcut_editor_widget def test_shortcut_editor_defaults( shortcut_editor_widget, ): shortcut_editor_widget() @pytest.mark.key_bindings def test_potentially_conflicting_actions(shortcut_editor_widget): widget = shortcut_editor_widget() assert widget.layer_combo_box.currentText() == widget.VIEWER_KEYBINDINGS actions1 = widget._get_potential_conflicting_actions() expected_actions1 = [] for group, keybindings in widget.key_bindings_strs.items(): expected_actions1.extend( zip(itertools.repeat(group), keybindings.items()) ) assert actions1 == expected_actions1 widget.layer_combo_box.setCurrentText('Labels layer') actions2 = widget._get_potential_conflicting_actions() expected_actions2 = list( zip( itertools.repeat('Labels layer'), widget.key_bindings_strs['Labels layer'].items(), ) ) expected_actions2.extend( zip( itertools.repeat(widget.VIEWER_KEYBINDINGS), widget.key_bindings_strs[widget.VIEWER_KEYBINDINGS].items(), ) ) assert actions2 == expected_actions2 @pytest.mark.key_bindings def test_mark_conflicts(shortcut_editor_widget, qtbot): widget = shortcut_editor_widget() ctrl_keybinding = KeyBinding.from_str('Ctrl') u_keybinding = KeyBinding.from_str('U') act = widget._table.item(0, widget._action_col).text() # Add check for initial/default keybinding (first shortcuts column) and # added one (second shortcuts column) assert action_manager._shortcuts[act][0] == ctrl_keybinding widget._table.item(0, widget._shortcut_col2).setText(str(u_keybinding)) assert action_manager._shortcuts[act][1] == str(u_keybinding) # Check conflicts detection using `KeyBindingLike` params # (`KeyBinding`, `str` and `int` representations of a shortcut) with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(ctrl_keybinding, 1) assert mock.called with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(str(ctrl_keybinding), 1) assert mock.called with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(int(ctrl_keybinding), 1) assert mock.called with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(u_keybinding, 1) assert mock.called with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(str(u_keybinding), 1) assert mock.called # Check no conflicts are found using `KeyBindingLike` params # (`KeyBinding`, `str` and `int` representations of a shortcut) # "H" is arbitrary chosen and on conflict with existing shortcut should be changed h_keybinding = KeyBinding.from_str('H') assert widget._mark_conflicts(h_keybinding, 1) assert widget._mark_conflicts(str(h_keybinding), 1) assert widget._mark_conflicts(int(h_keybinding), 1) qtbot.add_widget(widget._warn_dialog) def test_restore_defaults(shortcut_editor_widget): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] widget._table.item(0, widget._shortcut_col).setText('H') shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == 'H' with patch( 'napari._qt.widgets.qt_keyboard_settings.QMessageBox.question' ) as mock: mock.return_value = QMessageBox.RestoreDefaults widget._restore_button.click() assert mock.called shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] @pytest.mark.key_bindings @skip_local_focus @pytest.mark.parametrize( ('key', 'modifier', 'key_symbols'), [ ( Qt.Key.Key_U, META_CONTROL_KEY, [KEY_SYMBOLS['Ctrl'], 'U'], ), ( Qt.Key.Key_Y, META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], 'Y'], ), ( Qt.Key.Key_Escape, META_CONTROL_KEY, [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Escape']], ), ( Qt.Key.Key_Delete, META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], KEY_SYMBOLS['Delete']], ), ( Qt.Key.Key_Backspace, META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, [ KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], KEY_SYMBOLS['Backspace'], ], ), ], ) def test_keybinding_with_modifiers( shortcut_editor_widget, qtbot, recwarn, key, modifier, key_symbols ): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) item_pos = QPoint(x, y) index = widget._table.indexAt(item_pos) widget._table.setCurrentIndex(index) widget._table.edit(index) qtbot.waitUntil(lambda: widget._table.focusWidget() is not None) editor = widget._table.focusWidget() qtbot.keyPress(editor, key, modifier=modifier) widget._table.commitData(editor) widget._table.closeEditor(editor, QAbstractItemDelegate.NoHint) assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0 shortcut = widget._table.item(0, widget._shortcut_col).text() for key_symbol in key_symbols: assert key_symbol in shortcut @skip_local_focus @pytest.mark.parametrize( ('modifiers', 'key_symbols', 'valid'), [ ( Qt.KeyboardModifier.ShiftModifier, [KEY_SYMBOLS['Shift']], True, ), ( Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, [KEY_SYMBOLS['Ctrl']], False, ), ], ) def test_keybinding_with_only_modifiers( shortcut_editor_widget, qtbot, recwarn, modifiers, key_symbols, valid ): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) item_pos = QPoint(x, y) index = widget._table.indexAt(item_pos) widget._table.setCurrentIndex(index) widget._table.edit(index) qtbot.waitUntil(lambda: widget._table.focusWidget() is not None) editor = widget._table.focusWidget() with patch.object(WarnPopup, 'exec_') as mock: qtbot.keyPress(editor, Qt.Key_Enter, modifier=modifiers) widget._table.commitData(editor) widget._table.closeEditor(editor, QAbstractItemDelegate.NoHint) if valid: assert not mock.called else: assert mock.called assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0 shortcut = widget._table.item(0, widget._shortcut_col).text() for key_symbol in key_symbols: assert key_symbol in shortcut @skip_local_focus @pytest.mark.parametrize( 'removal_trigger_key', [ Qt.Key.Key_Delete, Qt.Key.Key_Backspace, ], ) @pytest.mark.parametrize( 'confirm_key', [Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Tab], ) def test_remove_shortcut( shortcut_editor_widget, qtbot, removal_trigger_key, confirm_key ): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) item_pos = QPoint(x, y) index = widget._table.indexAt(item_pos) widget._table.setCurrentIndex(index) widget._table.edit(index) qtbot.waitUntil(lambda: widget._table.focusWidget() is not None) editor = widget._table.focusWidget() qtbot.keyClick(editor, removal_trigger_key) qtbot.keyClick(editor, confirm_key) widget._table.commitData(editor) widget._table.closeEditor(editor, QAbstractItemDelegate.NoHint) shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == '' @skip_local_focus @skip_on_mac_ci @pytest.mark.parametrize( ('modifier_key', 'modifiers', 'key_symbols'), [ ( 'shift', None, [KEY_SYMBOLS['Shift']], ), ( 'ctrl', 'shift', [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift']], ), ], ) def test_keybinding_editor_modifier_key_detection( shortcut_editor_widget, qtbot, recwarn, modifier_key, modifiers, key_symbols, ): """ Test modifier keys detection with pyautogui to trigger keyboard events from the OS. Notes: * Skipped on macOS CI due to accessibility permissions not being settable on macOS GitHub Actions runners. * For this test to pass locally, you need to give the Terminal/iTerm application accessibility permissions: `System Settings > Privacy & Security > Accessibility` See https://github.com/asweigart/pyautogui/issues/247 and https://github.com/asweigart/pyautogui/issues/247#issuecomment-437668855 """ widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) item_pos = QPoint(x, y) qtbot.mouseClick( widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos ) qtbot.mouseDClick( widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos ) qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) line_edit = QApplication.focusWidget() with pyautogui.hold(modifier_key): if modifiers: pyautogui.keyDown(modifiers) def press_check(): line_edit.selectAll() shortcut = line_edit.selectedText() all_pressed = True for key_symbol in key_symbols: all_pressed &= key_symbol in shortcut return all_pressed qtbot.waitUntil(lambda: press_check()) if modifiers: pyautogui.keyUp(modifiers) def release_check(): line_edit.selectAll() shortcut = line_edit.selectedText() return shortcut == '' qtbot.waitUntil(lambda: release_check()) qtbot.keyClick(line_edit, Qt.Key_Escape) shortcut = widget._table.item(0, widget._shortcut_col).text() assert shortcut == KEY_SYMBOLS['Ctrl'] napari-0.5.6/napari/_qt/widgets/_tests/test_theme_sample.py000066400000000000000000000003771474413133200240650ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/qt_color_swatch.py000066400000000000000000000237051474413133200222550ustar00rootroot00000000000000import 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(str(int(x * 255)) for x in 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.array_equiv(_color, TRANSPARENT): self.color_changed.emit(_color) return None return None 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([*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) return None napari-0.5.6/napari/_qt/widgets/qt_dict_table.py000066400000000000000000000121471474413133200216560ustar00rootroot00000000000000import re from typing import 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: Optional[list[dict]] = None, *, headers: Optional[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.6/napari/_qt/widgets/qt_dims.py000066400000000000000000000330431474413133200205160ustar00rootroot00000000000000import warnings from typing import Optional import numpy as np from qtpy.QtCore import Slot from qtpy.QtGui import QFont, QFontMetrics from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget from napari._qt.widgets.qt_dims_slider import ( AnimationThread, 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._animation_thread = AnimationThread(self) # 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.""" 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() self._resize_axis_labels() self.stop() def _update_nsliders(self): """Updates the number of sliders based on the number of dimensions.""" 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() self.stop() def _resize_axis_labels(self): """When any of the labels get updated, this method updates all label widths to a minimum size. This allows the full label to be visible at all times, with minimal space, without setting stretch on the layout. """ displayed_labels = [ self.slider_widgets[idx].axis_label for idx, displayed in enumerate(self._displayed_sliders) if displayed ] if displayed_labels: fm = self.fontMetrics() # set maximum width to no more than 20% of slider width maxwidth = int(self.slider_widgets[0].width() * 0.2) # set new width to the width of the longest label being displayed newwidth = max( [ int(fm.boundingRect(dlab.text()).width()) for dlab in displayed_labels ] ) for slider in self.slider_widgets: labl = slider.axis_label # here the average width of a character is used as base measure # to add some extra width. We use 4 to take into account a # space and the possible 3 dots (`...`) for elided text margin_width = int(fm.averageCharWidth() * 4) base_labl_width = min([newwidth, maxwidth]) labl_width = base_labl_width + margin_width labl.setFixedWidth(labl_width) 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.textChanged.connect( self._resize_axis_labels ) slider_widget.size_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 and self._animation_thread.axis == axis: self.slider_widgets[axis]._update_play_settings( fps, loop_mode, frame_range ) return # 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]: if self._animation_thread.isRunning(): self._animation_thread.slider.play_button._handle_stop() self.slider_widgets[axis]._update_play_settings( fps, loop_mode, frame_range ) self._animation_thread.set_slider(self.slider_widgets[axis]) self._animation_thread.frame_requested.connect(self._set_frame) if not self._animation_thread.isRunning(): self._animation_thread.start() else: self._animation_thread.slider.play_button._handle_start() else: warnings.warn( trans._( 'Refusing to play a hidden axis', deferred=True, ) ) @Slot() def stop(self): """Stop axis animation""" self._animation_thread._stop() @property def is_playing(self): """Return True if any axis is currently animated.""" try: return not self._animation_thread._waiter.is_set() 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.dims._play_ready: # disable additional point advance requests until this one draws self.dims._play_ready = False self.dims.set_current_step(axis, frame) def closeEvent(self, event): [w.deleteLater() for w in self.slider_widgets] self.deleteLater() event.accept() napari-0.5.6/napari/_qt/widgets/qt_dims_slider.py000066400000000000000000000627701474413133200220710ustar00rootroot00000000000000import threading from typing import TYPE_CHECKING, Optional from weakref import ref import numpy as np from qtpy.QtCore import QObject, Qt, QThread, 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 QElidingLineEdit from napari._qt.dialogs.qt_modal import QtPopup 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 if TYPE_CHECKING: from napari._qt.widgets.qt_dims import QtDims 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. """ fps_changed = Signal(float) mode_changed = Signal(str) range_changed = Signal(tuple) size_changed = Signal() play_started = Signal() play_stopped = Signal() def __init__(self, parent: QWidget, axis: int) -> None: super().__init__(parent=parent) self.axis = axis self.qt_dims: QtDims = 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 = QElidingLineEdit(self) label.setObjectName('axis_label') # needed for _update_label fm = label.fontMetrics() label.setEllipsesWidth(int(fm.averageCharWidth() * 3)) 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 _on_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._on_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) def _update_label(self): """Update dimension slider label.""" self.dims.set_axis_label(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. """ if self._fps == value: return 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 resizeEvent(self, event): """Emit a signal to inform about a size change.""" self.size_changed.emit() super().resizeEvent(event) 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 None if self.property('playing') == 'True': return qt_dims.stop() self.play_requested.emit(self.axis) return None 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 AnimationThread(QThread): """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 def __init__(self, parent: Optional[QObject] = None) -> None: # FIXME there are attributes defined outside of __init__. super().__init__(parent=parent) self._interval = 1 self.slider = None self._waiter = threading.Event() def run(self): self.work() def set_slider(self, slider): prev_slider = self.slider self.slider = slider self.set_fps(self.slider.fps) self.set_frame_range(slider.frame_range) if prev_slider is not None: prev_slider.fps_changed.disconnect(self.set_fps) prev_slider.range_changed.disconnect(self.set_frame_range) prev_slider.dims.events.current_step.disconnect( self._on_axis_changed ) self.finished.disconnect(prev_slider.play_button._handle_stop) self.started.disconnect(prev_slider.play_button._handle_start) slider.fps_changed.connect(self.set_fps) slider.range_changed.connect(self.set_frame_range) slider.dims.events.current_step.connect(self._on_axis_changed) self.finished.connect(slider.play_button._handle_stop) self.started.connect(slider.play_button._handle_start) self.current = max( slider.dims.current_step[slider.axis], self.min_point ) self.current = min(self.current, self.max_point) @property def interval(self): return self._interval @interval.setter def interval(self, value): self._interval = value @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) else: # immediately advance one frame self.advance() self._waiter.clear() self._waiter.wait(self.interval / 1000) while not self._waiter.is_set(): self.advance() self._waiter.wait(self.interval / 1000) def _stop(self): """Stop the animation.""" self._waiter.set() @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) return None @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() 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() return None @property def loop_mode(self): return self.slider.loop_mode @property def axis(self): return self.slider.axis @property def dims(self): return self.slider.dims def finish(self): """Emit the finished event signal.""" self._stop() 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] napari-0.5.6/napari/_qt/widgets/qt_dims_sorter.py000066400000000000000000000053251474413133200221160ustar00rootroot00000000000000from qtpy.QtWidgets import QGridLayout, QLabel, QWidget from napari._qt.containers import QtListView from napari._qt.containers.qt_axis_model import AxisList, AxisModel from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari.components import Dims from napari.utils.translations import trans def set_dims_order(dims: Dims, order: tuple[int, ...]): """Set dimension order of Dims object to order. Parameters ---------- dims : napari.components.dims.Dims Dims object. order : tuple of int New dimension order. """ if type(order[0]) is AxisModel: order = [a.axis for a in order] dims.order = order class QtDimsSorter(QWidget): """Qt widget for dimension / axis reordering and locking. Modified from: https://github.com/jni/zarpaint/blob/main/zarpaint/_dims_chooser.py Parameters ---------- viewer : napari.Viewer Main napari viewer instance. parent : QWidget QWidget that holds this widget. Attributes ---------- dims : napari.components.Dims Dimensions object of the current viewer, modeling slicing and displaying. axis_list : napari._qt.containers.qt_axis_model.AxisList Selectable evented list representing the viewer axes. """ def __init__(self, dims: Dims, parent: QWidget) -> None: super().__init__(parent=parent) self.dims = dims self.axis_list = AxisList.from_dims(self.dims) self.view = QtListView(self.axis_list) if len(self.axis_list) <= 2: # prevent excess space in popup self.view.setSizeAdjustPolicy(QtListView.AdjustToContents) layout = QGridLayout() self.setLayout(layout) widget_tooltip = QtToolTipLabel(self) widget_tooltip.setObjectName('help_label') widget_tooltip.setToolTip( trans._( 'Drag dimensions to reorder, click lock icon to lock dimension in place.' ) ) widget_title = QLabel(trans._('Dims. Ordering'), self) self.layout().addWidget(widget_title, 0, 0) self.layout().addWidget(widget_tooltip, 0, 1) self.layout().addWidget(self.view, 1, 0, 1, 2) # connect axis_list and dims self.axis_list.events.reordered.connect( self._axis_list_reorder_callback, ) self.dims.events.order.connect( self._dims_order_callback, ) def _axis_list_reorder_callback(self, event): set_dims_order(self.dims, event.value) def _dims_order_callback(self, event): # Regenerate AxisList upon Dims side order changes for easy cleanup self.axis_list = AxisList.from_dims(self.dims) self.view.setRoot(self.axis_list) napari-0.5.6/napari/_qt/widgets/qt_extension2reader.py000066400000000000000000000251451474413133200230470ustar00rootroot00000000000000import 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(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 = [] try: compatible_readers = get_potential_readers(new_pattern) except ValueError as e: if 'empty name' not in str(e): raise compatible_readers = {} 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) for i, (plugin_name, display_name) in enumerate( sorted(readers.items()) ): self._add_reader_choice(i, plugin_name, display_name) if self._new_reader_dropdown.count() == 0: self._new_reader_dropdown.addItem(trans._('None available')) 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.6/napari/_qt/widgets/qt_font_size.py000066400000000000000000000043671474413133200215710ustar00rootroot00000000000000from qtpy.QtCore import Signal from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget from napari._qt.widgets.qt_spinbox import QtSpinBox from napari.settings import get_settings from napari.utils.theme import get_system_theme, get_theme from napari.utils.translations import trans class QtFontSizeWidget(QWidget): """ Widget to change `font_size` and enable to reset is value to the current selected theme default `font_size` value. """ valueChanged = Signal(int) def __init__(self, parent: QWidget = None) -> None: super().__init__(parent=parent) self._spinbox = QtSpinBox() self._reset_button = QPushButton(trans._('Reset font size')) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._spinbox) layout.addWidget(self._reset_button) self.setLayout(layout) self._spinbox.valueChanged.connect(self.valueChanged) self._reset_button.clicked.connect(self._reset) def _reset(self) -> None: """ Reset the widget value to the current selected theme font size value. """ current_theme_name = get_settings().appearance.theme if current_theme_name == 'system': # system isn't a theme, so get the name current_theme_name = get_system_theme() current_theme = get_theme(current_theme_name) self.setValue(int(current_theme.font_size[:-2])) def value(self) -> int: """ Return the current widget value. Returns ------- int The current value. """ return self._spinbox.value() def setValue(self, value: int) -> None: """ Set the current widget value. Parameters ---------- value : int The current value. """ self._spinbox.setValue(value) def setRange(self, min_value: int, max_value: int) -> None: """ Value range that the spinbox widget will use. Parameters ---------- min_value : int Minimum value the font_size could be set. max_value : int Maximum value the font_size could be set. """ self._spinbox.setRange(min_value, max_value) napari-0.5.6/napari/_qt/widgets/qt_highlight_preview.py000066400000000000000000000431401474413133200232710ustar00rootroot00000000000000from typing import Optional import 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._qt.widgets.qt_color_swatch import QColorSwatchEdit 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: Optional[int] = None, ) -> None: super().__init__(parent) self._value = value self._color = QColor(135, 206, 235) 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, color: QColor = None): """Set line width value of star widget. Parameters ---------- value : int line width value for star """ self._value = value if color is not None: self._color = color 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 = self._color 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 self._color = QColor(135, 206, 235) 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 = self._color 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, color=None): """Set value for triangle widget. Parameters ---------- value : int Value to use for line in triangle widget. """ self._value = value if color is not None: self._color = color 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 QtHighlightPreviewWidget(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(dict) def __init__( self, parent: QWidget = None, description: str = '', value: Optional[dict] = None, min_value: int = 1, max_value: int = 10, unit: str = 'px', ) -> None: super().__init__(parent) self.setGeometry(300, 300, 125, 110) if value is None: value = { 'highlight_thickness': 1, 'highlight_color': [0.0, 0.6, 1.0, 1.0], } self._value = value self._thickness_value = ( value['highlight_thickness'] or self.fontMetrics().height() ) self._color_value = value['highlight_color'] or [0.0, 0.6, 1.0, 1.0] self._min_value = min_value self._max_value = max_value # Widget self._color_swatch_edit = QColorSwatchEdit( self, initial_color=self._color_value ) 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(self._thickness_value) self._triangle.setValue(self._thickness_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_thickness_value) self._lineedit.textChanged.connect(self._update_thickness_value) self._triangle.valueChanged.connect(self._update_thickness_value) self._color_swatch_edit.color_changed.connect(self._update_color_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 = QVBoxLayout() lineedit_layout.addWidget(self._color_swatch_edit) 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_thickness_value(self, thickness_value): """Update highlight thickness value. Parameters ---------- thickness_value : int Highlight thickness value. """ if thickness_value == '': return thickness_value = int(thickness_value) thickness_value = max( min(thickness_value, self._max_value), self._min_value ) if thickness_value == self._thickness_value: return self._thickness_value = thickness_value self._value['highlight_thickness'] = self._thickness_value self.valueChanged.emit(self._value) self._refresh() def _update_color_value(self, color_value): """Update highlight color value. Parameters ---------- color_value : List[float] Highlight color value as a list of floats. """ if isinstance(color_value, np.ndarray): color_value = color_value.tolist() if color_value == self._color_value: return if color_value == '': return self._color_value = color_value self._value['highlight_color'] = self._color_value self.valueChanged.emit(self._value) self._refresh() def _refresh(self): """Set every widget value to the new set value.""" self.blockSignals(True) # Transform color value from a float representation # (values from 0.0 to 1.0) to RGB values (values from 0 to 255) # to set widgets color. color = QColor(*[int(np.ceil(v * 255)) for v in self._color_value[:3]]) self._lineedit.setText(str(self._thickness_value)) self._slider.setValue(self._thickness_value) self._triangle.setValue(self._thickness_value, color=color) self._preview.setValue(self._thickness_value, color=color) self._color_swatch_edit.setColor(self._color_value) self.blockSignals(False) def value(self): """Return current value. Returns ------- dict Current value of highlight widget (thickness and color). """ return self._value def setValue(self, value: dict): """Set new value and update widget. Parameters ---------- value : dict Highlight value (thickness and color). """ self._update_thickness_value(value.get('highlight_thickness', '')) self._update_color_value(value.get('highlight_color', '')) 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._thickness_value = max( self._value['highlight_thickness'], self._min_value ) self._value['highlight_thickness'] = self._thickness_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._thickness_value = min( self._value['highlight_thickness'], self._max_value ) self._value['highlight_thickness'] = self._thickness_value self._refresh() def maximum(self): """Return maximum highlight value. Returns ------- int Maximum value of highlight widget. """ return self._max_value napari-0.5.6/napari/_qt/widgets/qt_keyboard_settings.py000066400000000000000000000666751474413133200233230ustar00rootroot00000000000000import contextlib import itertools import sys from collections import OrderedDict from typing import Optional from app_model.backends.qt import ( qkeysequence2modelkeybinding, ) from qtpy.QtCore import QEvent, QPoint, Qt, Signal from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, QHBoxLayout, QItemDelegate, QKeySequenceEdit, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from napari._qt.widgets.qt_message_popup import WarnPopup from napari.layers import ( Image, Labels, Points, Shapes, Surface, Tracks, 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 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: Optional[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, Tracks, 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: all_actions.pop(name) self.key_bindings_strs[f'{layer.__name__} layer'] = actions # Don't include actions without keymapproviders for action_name in all_actions.copy(): if all_actions[action_name].keymapprovider is None: all_actions.pop(action_name) # 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.""" prev = QApplication.instance().testAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs ) QApplication.instance().setAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs, True ) response = QMessageBox.question( self, trans._('Restore Shortcuts'), trans._('Are you sure you want to restore default shortcuts?'), QMessageBox.StandardButton.RestoreDefaults | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.RestoreDefaults, ) QApplication.instance().setAttribute( Qt.ApplicationAttribute.AA_DontUseNativeDialogs, prev ) 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.horizontalHeader().setDefaultAlignment( Qt.AlignmentFlag.AlignLeft ) 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, 370) self._table.setColumnWidth(self._shortcut_col, 190) self._table.setColumnWidth(self._shortcut_col2, 145) self._table.setColumnWidth(self._icon_col, 35) self._table.setWordWrap(True) # Add some padding to rows self._table.setStyleSheet('QTableView::item { padding: 6px; }') # 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.ItemIsEnabled) self._table.setItem(row, self._action_name_col, item) # Ensure long descriptions can be wrapped in cells self._table.resizeRowToContents(row) # 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(next(iter(shortcuts))).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_potential_conflicting_actions(self): """ Get all actions we want to avoid keybinding conflicts with. If current selected keybinding group is a layer, return the selected layer actions and viewer actions. If the current selected keybinding group is viewer, return viewer actions and actions from all layers. Returns ------- actions_all: list[tuple[str, tuple[str, Action]]] Tuple of group names and actions, to avoid keybinding conflicts with. Format: [ ('keybinding_group', ('action_name', Action)), ... ] """ current_layer_text = self.layer_combo_box.currentText() actions_all = list(self._get_group_actions(current_layer_text)) if current_layer_text != self.VIEWER_KEYBINDINGS: actions_all = list(self._get_group_actions(current_layer_text)) actions_all.extend( self._get_group_actions(self.VIEWER_KEYBINDINGS) ) else: actions_all = [] for group in self.key_bindings_strs: actions_all.extend(self._get_group_actions(group)) return actions_all def _get_group_actions(self, group_name): group_actions = self.key_bindings_strs[group_name] return zip(itertools.repeat(group_name), group_actions.items()) 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(next(iter(shortcuts))).platform if shortcuts else '' ) self._table.item(row, self._shortcut_col2).setText( Shortcut(list(shortcuts)[1]).platform if len(shortcuts) > 1 else '' ) def _show_conflicts_warning( self, new_shortcut, conflicting_actions, conflicting_rows ): # create string listing info of all the conflicts found conflicting_actions_string = '
    ' for group, action_description in conflicting_actions: conflicting_actions_string += trans._( '
  • {action_description} in the {group} group
  • ', action_description=action_description, group=group, ) conflicting_actions_string += '
' # show warning symbols self._show_warning_icons(conflicting_rows) # show warning message message = trans._( 'The keybinding {new_shortcut} is already assigned to:' '{conflicting_actions_string}' 'Change or clear conflicting shortcuts before assigning {new_shortcut} to this one.', new_shortcut=new_shortcut, conflicting_actions_string=conflicting_actions_string, ) self._show_warning(conflicting_rows[0], message) self._restore_shortcuts(conflicting_rows[0]) self._cleanup_warning_icons(conflicting_rows) 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_potential_conflicting_actions() current_item = self._table.currentItem() conflicting_rows = [row] conflicting_actions = [] for conflicting_row, (group, (action_name, action)) in enumerate( actions_all ): shortcuts = action_manager._shortcuts.get(action_name, []) if Shortcut(new_shortcut).qt not in [ Shortcut(shortcut).qt for shortcut 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, save conflicting shortcut info if conflicting_row < self._table.rowCount(): # only save row number for conflicts that are inside the current table conflicting_rows.append(conflicting_row) conflicting_actions.append((group, action.description)) # This shortcut was here. Reformat and reset text. format_shortcut = Shortcut(new_shortcut).platform with lock_keybind_update(self): current_item.setText(format_shortcut) if len(conflicting_actions) > 0: # show conflicts message and mark conflicting rows as necessary self._show_conflicts_warning( new_shortcut, conflicting_actions, conflicting_rows ) return False 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=Shortcut(new_shortcut).platform, ) self._show_warning(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 the current item from shortcuts column current_item = self._table.currentItem() new_shortcut = Shortcut.parse_platform(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, []) ) for mod in {'Shift', 'Ctrl', 'Alt', 'Cmd', 'Super', 'Meta'}: # we want to prevent multiple modifiers but still allow single modifiers. if new_shortcut.endswith('-' + mod): self._show_bind_shortcut_error( current_action, current_shortcuts, row, new_shortcut, ) return # 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, row: int, message: str) -> None: """Creates and displays warning message when shortcut is already assigned. Parameters ---------- 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) 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: shortcuts = action_manager._shortcuts.get(action_name, []) value[action_name] = list(shortcuts) return value def setValue(self, state): for action, shortcuts in state.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()) 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 if event.type() == QEvent.Type.Shortcut: return True if event.type() == QEvent.Type.KeyPress and event.key() in ( Qt.Key.Key_Delete, Qt.Key.Key_Backspace, ): # If there is a shortcut set already, two events are being emitted when # pressing `Delete` or `Backspace`. First a `ShortcutOverride` event and # then a `KeyPress` event. We need to mark the second event (`KeyPress`) # as handled for those keys to not end up processing it multiple times. # Without that, pressing `Backspace` or `Delete` will set those keys as shortcuts # instead of just cleaning/removing the previous shortcut that was set. return True return super().event(event) def _handleEditModifiersOnly(self, event) -> None: """ Shared handler between keyPressEvent and keyReleaseEvent for modifiers. This is valid both on keyPress and Keyrelease events during edition as we are sure to not have received any real keys events yet. If that was the case the shortcut would have been validated and we would not be in edition mode. """ event_key = event.key() if event_key not in ( Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta, ): return if sys.platform == 'darwin': # On macOS, the modifiers are not the same as on other platforms. # we also use pairs instead of a dict to keep the order. modmap = ( (Qt.ControlModifier, 'Meta+'), (Qt.AltModifier, 'Alt+'), (Qt.ShiftModifier, 'Shift+'), (Qt.MetaModifier, 'Ctrl+'), ) else: modmap = ( (Qt.ControlModifier, 'Ctrl+'), (Qt.AltModifier, 'Alt+'), (Qt.ShiftModifier, 'Shift+'), (Qt.MetaModifier, 'Meta+'), ) modifiers = event.modifiers() seq = '' for mod, s in modmap: if modifiers & mod: seq += s seq = seq[:-1] # in current pyappkit this will have weird effects on the order of modifiers # see https://github.com/pyapp-kit/app-model/issues/110 self.setText(Shortcut(seq).platform) def keyReleaseEvent(self, event) -> None: self._handleEditModifiersOnly(event) def keyPressEvent(self, event) -> None: """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() != '' and self.hasSelectedText() ): # Allow user to delete shortcut. self.setText('') return if event_key in ( Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta, ): self._handleEditModifiersOnly(event) return if event_key in { Qt.Key.Key_Return, Qt.Key.Key_Tab, Qt.Key.Key_Enter, }: # Do not allow user to set these keys as shortcut. # Use them as a save trigger for modifier only shortcuts. return # Translate key value to key string. translator = ShortcutTranslator() event_keyseq = translator.keyevent_to_keyseq(event) kb = qkeysequence2modelkeybinding(event_keyseq) short = Shortcut(kb) self.setText(short.platform) self.clearFocus() class ShortcutTranslator(QKeySequenceEdit): """ Convert QKeyEvent into QKeySequence. """ def __init__(self) -> None: super().__init__() self.hide() def keyevent_to_keyseq(self, event) -> QKeySequence: """Return a QKeySequence representation of the provided QKeyEvent. This only works for complete key sequence that do not contain only modifiers. If the event is only pressing modifiers, this will return an empty sequence as QKeySequence does not support only modifiers """ 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.6/napari/_qt/widgets/qt_message_popup.py000066400000000000000000000021531474413133200224270ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/qt_mode_buttons.py000066400000000000000000000054511474413133200222660ustar00rootroot00000000000000import 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, mode_selected): """Toggle the mode associated with the layer. Parameters ---------- mode_selected : 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 mode_selected: 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.6/napari/_qt/widgets/qt_plugin_sorter.py000066400000000000000000000351131474413133200224560ustar00rootroot00000000000000"""Provides a QtPluginSorter that allows the user to change plugin call order.""" from __future__ import annotations import re from typing import TYPE_CHECKING, 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, flags=re.DOTALL) text = re.sub(r'`([^`]+)`_', link, text, flags=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.6/napari/_qt/widgets/qt_progress_bar.py000066400000000000000000000067031474413133200222550ustar00rootroot00000000000000from typing import Optional from qtpy import QtCore from qtpy.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout, QWidget, ) from napari.utils.migrations import rename_argument from napari.utils.progress import cancelable_progress, progress from napari.utils.translations import trans 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() self.cancel_button = QPushButton(trans._('Cancel')) self.cancel_button.clicked.connect(self._cancel) self.cancel_button.setVisible(isinstance(prog, cancelable_progress)) 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) pbar_layout.addWidget(self.cancel_button) base_layout.addLayout(pbar_layout) line = QFrame(self) line.setObjectName('QtCustomTitleBarLine') line.setFixedHeight(1) base_layout.addWidget(line) self.setLayout(base_layout) @rename_argument( from_name='min', to_name='min_val', version='0.6.0', since_version='0.4.18', ) @rename_argument( from_name='max', to_name='max_val', version='0.6.0', since_version='0.4.18', ) def setRange(self, min_val, max_val): self.qt_progress_bar.setRange(min_val, max_val) 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) def _cancel(self): self.cancel_button.setText(trans._('Cancelling...')) self.progress.cancel() self.cancel_button.setText(trans._('Canceled')) 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.6/napari/_qt/widgets/qt_range_slider_popup.py000066400000000000000000000032401474413133200234370ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/qt_scrollbar.py000066400000000000000000000055411474413133200215470ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/qt_size_preview.py000066400000000000000000000235621474413133200223020ustar00rootroot00000000000000import 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: typing.Optional[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: typing.Optional[str] = None, preview_text: typing.Optional[str] = None, value: typing.Optional[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: typing.Optional[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.6/napari/_qt/widgets/qt_spinbox.py000066400000000000000000000014421474413133200212420ustar00rootroot00000000000000from 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.6/napari/_qt/widgets/qt_splash_screen.py000066400000000000000000000010051474413133200224040ustar00rootroot00000000000000from 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_qapp class NapariSplashScreen(QSplashScreen): def __init__(self, width=360) -> None: get_qapp() pm = QPixmap(NAPARI_ICON_PATH).scaled( width, width, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) super().__init__(pm) self.show() napari-0.5.6/napari/_qt/widgets/qt_theme_sample.py000066400000000000000000000123021474413133200222200ustar00rootroot00000000000000"""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 logging import sys from napari._qt.qt_event_loop import get_qapp from napari.utils.theme import available_themes themes = [sys.argv[1]] if len(sys.argv) > 1 else available_themes() app = get_qapp() widgets = [] for n, theme in enumerate(themes): try: w = SampleWidget(theme) except KeyError: logging.warning('%s is not a recognized theme', theme) continue w.setGeometry(10 + 430 * n, 0, 425, 600) w.show() widgets.append(w) if widgets: app.exec_() napari-0.5.6/napari/_qt/widgets/qt_tooltip.py000066400000000000000000000007001474413133200212460ustar00rootroot00000000000000from __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.6/napari/_qt/widgets/qt_viewer_buttons.py000066400000000000000000000343151474413133200226440ustar00rootroot00000000000000import warnings from functools import partial, wraps from typing import TYPE_CHECKING from qtpy.QtCore import QEvent, QPoint, Qt from qtpy.QtWidgets import ( QApplication, 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.misc import in_ipython, in_jupyter, in_python_repl from napari.utils.translations import trans if TYPE_CHECKING: from napari.viewer import ViewerModel def add_new_points(viewer): viewer.add_points( ndim=max(viewer.dims.ndim, 2), scale=viewer.layers.extent.step, ) def add_new_shapes(viewer): viewer.add_shapes( ndim=max(viewer.dims.ndim, 2), scale=viewer.layers.extent.step, ) 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 = QtViewerPushButton( 'delete_button', action='napari:delete_selected_layers' ) self.newPointsButton = QtViewerPushButton( 'new_points', trans._('New points layer'), partial(add_new_points, self.viewer), ) self.newShapesButton = QtViewerPushButton( 'new_shapes', trans._('New shapes layer'), partial(add_new_shapes, self.viewer), ) self.newLabelsButton = QtViewerPushButton( 'new_labels', trans._('New labels layer'), 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', extra_tooltip_text=trans._( '\nAlt/option-click to rotate visible axes' ), ) self.transposeDimsButton.installEventFilter(self) 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 eventFilter(self, qobject, event): """Have Alt/Option key rotate layers with the transpose button.""" modifiers = QApplication.keyboardModifiers() if ( modifiers == Qt.AltModifier and qobject == self.transposeDimsButton and event.type() == QEvent.MouseButtonPress ): action_manager.trigger('napari:rotate_layers') return True return False 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(int(self.viewer.camera.perspective)) sld.valueChanged.connect( lambda v: setattr(self.viewer.camera, 'perspective', v) ) self.perspective_slider = sld # 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 # popup pop = QtPopup(self) # dims sorter widget dim_sorter = QtDimsSorter(self.viewer.dims, pop) dim_sorter.setObjectName('dim_sorter') # make layout layout = QHBoxLayout() layout.addWidget(dim_sorter) pop.frame.setLayout(layout) # show popup 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]) 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 = '', extra_tooltip_text: 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, extra_tooltip_text=extra_tooltip_text ) napari-0.5.6/napari/_qt/widgets/qt_viewer_dock_widget.py000066400000000000000000000330561474413133200234320ustar00rootroot00000000000000import warnings from functools import reduce from itertools import count from operator import ior from typing import TYPE_CHECKING, 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.settings import get_settings 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}', ) dock_area_to_str = { Qt.DockWidgetArea.LeftDockWidgetArea: 'left', Qt.DockWidgetArea.RightDockWidgetArea: 'right', Qt.DockWidgetArea.TopDockWidgetArea: 'top', Qt.DockWidgetArea.BottomDockWidgetArea: 'bottom', } 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) self.dockLocationChanged.connect(self._update_default_dock_area) def _update_default_dock_area(self, value): if value not in dock_area_to_str: return settings = get_settings() settings.application.plugin_widget_positions[self.name] = ( dock_area_to_str[value] ) settings._maybe_save() @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 keyReleaseEvent(self, event): # if you subclass QtViewerDockWidget and override the keyReleaseEvent # method, be sure to call super().keyReleaseEvent(event) at the end of # your method to pass uncaught key-combinations to the viewer. return self._ref_qt_viewer().keyReleaseEvent(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): 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.6/napari/_qt/widgets/qt_viewer_status_bar.py000066400000000000000000000171231474413133200233130ustar00rootroot00000000000000"""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.6/napari/_qt/widgets/qt_welcome.py000066400000000000000000000141121474413133200212110ustar00rootroot00000000000000from __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+N', QKeySequence.PortableText).toString( QKeySequence.NativeText ) shortcut_layout.addRow( QtShortcutLabel(sc), QtShortcutLabel(trans._('New Image from Clipboard')), ) 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.6/napari/_tests/000077500000000000000000000000001474413133200155515ustar00rootroot00000000000000napari-0.5.6/napari/_tests/__init__.py000066400000000000000000000000001474413133200176500ustar00rootroot00000000000000napari-0.5.6/napari/_tests/test_adding_removing.py000066400000000000000000000104421474413133200223170ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import ( count_warning_events, 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', 'ndim'), layer_test_data) def test_add_all_layers(make_napari_viewer, Layer, data, ndim): """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 # multiple 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) np.testing.assert_array_equal(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) == count_warning_events(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) == count_warning_events(em.callbacks) + 1 napari-0.5.6/napari/_tests/test_advanced.py000066400000000000000000000245771474413133200207460ustar00rootroot00000000000000import 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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(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.enable_console @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: del viewer.window._qt_viewer.console.shell.user_ns[k] @pytest.mark.enable_console @pytest.mark.filterwarnings('ignore::DeprecationWarning:jupyter_client') def test_update_lazy_console(make_napari_viewer, caplog): """Test updating the console with local variables, before console is instantiated.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer a = 4 b = 5 viewer.update_console(['a', 'b']) x = np.arange(5) viewer.update_console('x') viewer.update_console('missing') assert 'Could not get' in caplog.text with pytest.raises(TypeError): viewer.update_console(x) # Create class objects that will have weakrefs class Foo: pass obj1 = Foo() obj2 = Foo() viewer.update_console({'obj1': obj1, 'obj2': obj2}) del obj1 # 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 # Check backlog is cleared assert len(view.console_backlog) == 0 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 assert 'x' in view.console.shell.user_ns assert view.console.shell.user_ns['x'] is x assert 'obj1' not in view.console.shell.user_ns assert 'obj2' in view.console.shell.user_ns assert view.console.shell.user_ns['obj2'] == obj2 del viewer.window._qt_viewer.console.shell.user_ns['obj2'] del viewer.window._qt_viewer.console.shell.user_ns['x'] 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.array_equal(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.6/napari/_tests/test_cli.py000066400000000000000000000141371474413133200177370ustar00rootroot00000000000000import 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 argument: --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: # pragma: no cover 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.6/napari/_tests/test_conftest_fixtures.py000066400000000000000000000035071474413133200227450ustar00rootroot00000000000000from unittest.mock import Mock, patch import pytest from qtpy.QtCore import QMutex, QThread, QTimer from superqt.utils import qdebounced 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() @pytest.mark.usefixtures('_disable_throttling') @patch('qtpy.QtCore.QTimer.start') def test_disable_throttle(start_mock): mock = Mock() @qdebounced(timeout=50) def f() -> str: mock() f() start_mock.assert_not_called() mock.assert_called_once() def test_lack_disable_throttle(monkeypatch): """This is test showing that if we do not use disable_throttling then timer is started""" mock = Mock() start_mock = Mock() active_mock = Mock(return_value=True) monkeypatch.setattr('qtpy.QtCore.QTimer.start', start_mock) monkeypatch.setattr('qtpy.QtCore.QTimer.isActive', active_mock) @qdebounced(timeout=50) def f() -> str: mock() f() start_mock.assert_called_once() mock.assert_not_called() napari-0.5.6/napari/_tests/test_draw.py000066400000000000000000000020071474413133200201160ustar00rootroot00000000000000import 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._scene_canvas.events.draw() # Add layer data = np.random.random((15, 10, 5)) layer = viewer.add_image(data) assert len(viewer.layers) == 1 view.canvas._scene_canvas.events.draw() # Remove layer viewer.layers.remove(layer) assert len(viewer.layers) == 0 view.canvas._scene_canvas.events.draw() napari-0.5.6/napari/_tests/test_dtypes.py000066400000000000000000000025741474413133200205020ustar00rootroot00000000000000import 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), pytest.param( np.dtype(np.uint64), # np.clip(np.array([0, 1]), 0, np.iinfo(np.uint64).max) fails because # np.array([0, 1]) defaults to int64, and numpy 2.0.0 does not allow # clip values outside the range of the dtype of the input array. marks=pytest.mark.skipif( not np.__version__.startswith('1'), reason='Expected failure in numpy v2+', ), ), 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) np.testing.assert_array_equal(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.6/napari/_tests/test_examples.py000066400000000000000000000073741474413133200210130ustar00rootroot00000000000000import 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 = [ '3d_kymograph_.py', # needs tqdm, omero-py and can take some time downloading data 'live_tiffs_.py', # requires files 'tiled-rendering-2d_.py', # too slow 'live_tiffs_generator_.py', # to generate files for live_tiffs_.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 ] # To skip examples during docs build end name with `_.py` # these are more interactive tools than proper examples, so skip them # cause they are hard to adapt for testing skip_dev = ['leaking_check.py', 'demo_shape_creation.py'] EXAMPLE_DIR = Path(napari.__file__).parent.parent / 'examples/' DEV_EXAMPLE_DIR = Path(napari.__file__).parent.parent / 'examples/dev' # 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] dev_examples = [f.name for f in DEV_EXAMPLE_DIR.glob('*.py') if f.name not in skip_dev] # 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.fixture def _example_monkeypatch(monkeypatch): # 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) def _run_example(example_path): try: runpy.run_path(example_path) except SystemExit as e: # we use sys.exit(0) to gracefully exit from examples if e.code != 0: raise finally: napari.Viewer.close_all() @pytest.mark.usefixtures('_example_monkeypatch') @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.""" example_path = str(EXAMPLE_DIR / fname) monkeypatch.setattr(sys, 'argv', [fname]) _run_example(example_path) @pytest.mark.usefixtures('_example_monkeypatch') @pytest.mark.filterwarnings('ignore') @pytest.mark.skipif(not dev_examples, reason='No dev examples were found.') @pytest.mark.parametrize('fname', dev_examples) def test_dev_examples(fname, monkeypatch): """Test that all of our dev examples are still working without warnings.""" example_path = str(DEV_EXAMPLE_DIR / fname) monkeypatch.setattr(sys, 'argv', [fname]) _run_example(example_path) napari-0.5.6/napari/_tests/test_function_widgets.py000066400000000000000000000017201474413133200225350ustar00rootroot00000000000000import 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) return None 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.6/napari/_tests/test_key_bindings.py000066400000000000000000000112701474413133200216300ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import pytest from vispy import keys @pytest.mark.key_bindings def test_viewer_key_bindings(make_napari_viewer): """Test adding key bindings to the viewer""" np.random.seed(0) viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas 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 canvas._scene_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 canvas._scene_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 canvas._scene_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 canvas._scene_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() @pytest.mark.key_bindings def test_layer_key_bindings(make_napari_viewer): """Test adding key bindings to a layer""" np.random.seed(0) viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas 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 canvas._scene_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 canvas._scene_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 canvas._scene_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 canvas._scene_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() canvas = viewer.window._qt_viewer.canvas assert viewer.dims._scroll_progress == 0 canvas._scene_canvas.events.key_press(key=keys.Key('Control')) viewer.dims._scroll_progress = 10 assert viewer.dims._scroll_progress == 10 canvas._scene_canvas.events.key_release(key=keys.Key('Control')) assert viewer.dims._scroll_progress == 0 napari-0.5.6/napari/_tests/test_layer_utils_with_qt.py000066400000000000000000000020421474413133200232530ustar00rootroot00000000000000import 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.6/napari/_tests/test_magicgui.py000066400000000000000000000275331474413133200207610ustar00rootroot00000000000000import contextlib import sys import time from typing import TYPE_CHECKING 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.migrations import _DeprecatingDict from napari.utils.misc import all_subclasses if TYPE_CHECKING: import typing import napari.types try: import qtpy # noqa: F401 need to be ignored as qtpy may be available but Qt bindings may not be 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 def test_add_layer_data_to_viewer_optional(make_napari_viewer): viewer = make_napari_viewer() @magicgui def func_optional(a: bool) -> 'typing.Optional[napari.types.ImageData]': if a: return np.zeros((10, 10)) return None viewer.window.add_dock_widget(func_optional) assert not viewer.layers func_optional(a=True) assert len(viewer.layers) == 1 func_optional(a=False) assert len(viewer.layers) == 1 @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() 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') monkeypatch.setattr( 'napari.utils.action_manager.action_manager._actions', {} ) # need to clear all of these submodules too, otherwise 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 assert '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 def test_from_layer_data_tuple_accept_deprecating_dict(make_napari_viewer): """Test that a function returning a layer data tuple runs without error.""" viewer = make_napari_viewer() @magicgui def from_layer_data_tuple() -> types.LayerDataTuple: data = np.zeros((10, 10)) meta = _DeprecatingDict({'name': 'test_image'}) layer_type = 'image' return data, meta, layer_type viewer.window.add_dock_widget(from_layer_data_tuple) from_layer_data_tuple() assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], Image) assert viewer.layers[0].name == 'test_image' napari-0.5.6/napari/_tests/test_mouse_bindings.py000066400000000000000000000211441474413133200221710ustar00rootroot00000000000000import os from unittest.mock import Mock import numpy as np import pytest from napari._tests.utils import skip_on_win_ci from napari.layers import Image from napari.layers.base._base_constants import InteractionBoxHandle from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) @skip_on_win_ci def test_viewer_mouse_bindings(qtbot, make_napari_viewer): """Test adding mouse bindings to the viewer""" np.random.seed(0) viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas 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 canvas._scene_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 canvas._scene_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 canvas._scene_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 canvas._scene_canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) qtbot.wait(10) canvas._scene_canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) qtbot.wait(10) canvas._scene_canvas.events.mouse_release( pos=(0, 0), modifiers=(), button=0 ) qtbot.wait(10) 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(qtbot, make_napari_viewer): """Test adding mouse bindings to a layer that is selected""" np.random.seed(0) viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas 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 canvas._scene_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 canvas._scene_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 canvas._scene_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 canvas._scene_canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) qtbot.wait(10) canvas._scene_canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) qtbot.wait(10) canvas._scene_canvas.events.mouse_release( pos=(0, 0), modifiers=(), button=0 ) qtbot.wait(10) 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(qtbot, make_napari_viewer): """Test adding mouse bindings to a layer that is not selected""" np.random.seed(0) viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas 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 canvas._scene_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 canvas._scene_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 canvas._scene_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 canvas._scene_canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) qtbot.wait(10) canvas._scene_canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) qtbot.wait(10) canvas._scene_canvas.events.mouse_release( pos=(0, 0), modifiers=(), button=0 ) qtbot.wait(10) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() @pytest.mark.parametrize( ('position', 'dims_displayed', 'nearby_handle'), [ # Postion inside the transform box space so the inside value should be set as selected ([0, 3], [0, 1], InteractionBoxHandle.INSIDE), ([0, 3, 3], [1, 2], InteractionBoxHandle.INSIDE), # Postion outside the transform box space so no handle should be set as selected ([0, 11], [0, 1], None), ([0, 11, 11], [1, 2], None), # When 3 dimensions are being displayed no `highlight_box_handles` logic should be run ([0, 3, 3], [0, 1, 2], None), ], ) def test_highlight_box_handles(position, dims_displayed, nearby_handle): layer = Image(np.empty((10, 10))) event = Mock( position=position, dims_displayed=dims_displayed, modifiers=[None] ) highlight_box_handles( layer, event, ) # mouse event should be detected over the expected handle assert layer._overlays['transform_box'].selected_handle == nearby_handle def test_transform_box(): layer = Image(np.empty((10, 10))) event = Mock(position=[0, 3], dims_displayed=[0, 1], modifiers=[None]) next(transform_with_box(layer, event)) # no interaction has been done so affine should be the same as the initial assert layer.affine == layer._initial_affine napari-0.5.6/napari/_tests/test_multiple_viewers.py000066400000000000000000000012351474413133200225620ustar00rootroot00000000000000import gc from unittest.mock import patch def test_multi_viewers_dont_clash(make_napari_viewer, qtbot): v1 = make_napari_viewer(title='v1') v2 = make_napari_viewer(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.6/napari/_tests/test_notebook_display.py000066400000000000000000000061201474413133200225260ustar00rootroot00000000000000import html from unittest.mock import Mock import numpy as np import pytest from napari._tests.utils import skip_on_win_ci from napari._version import __version__ 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 png_bytes = rich_display_object._repr_png_() assert rich_display_object.image is not None # Test digital watermark is included in bytes of .png file version_byte_string = __version__.encode('utf-8') assert b'napari version' in png_bytes assert version_byte_string in png_bytes @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",./;'[]\-=", ',./;'[]\\-='), # 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 # # Special characters ( '田中さんにあげて下さい', '田中さんにあげて下さい', ), # two-byte characters ( '表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀', # noqa: RUF001 '表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀', # noqa: RUF001 ), # 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.6/napari/_tests/test_numpy_like.py�������������������������������������������������������0000664�0000000�0000000�00000004637�14744131332�0021350�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dask.array as da import numpy as np import xarray as xr import zarr from numpy.testing import assert_array_equal 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_array_equal(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_array_equal(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_array_equal(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_array_equal(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_array_equal(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_array_equal(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_array_equal(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_array_equal(viewer.layers[0].data, xdata) �������������������������������������������������������������������������������������������������napari-0.5.6/napari/_tests/test_pytest_plugin.py����������������������������������������������������0000664�0000000�0000000�00000001743�14744131332�0022075�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(pytester_pretty): """Make sure that our make_napari_viewer plugin works.""" # create a temporary pytest test file pytester_pretty.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 = pytester_pretty.runpytest() # check that all 1 test passed result.assert_outcomes(passed=1) �����������������������������napari-0.5.6/napari/_tests/test_sys_info.py���������������������������������������������������������0000664�0000000�0000000�00000000714�14744131332�0021015�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.6/napari/_tests/test_top_level_availability.py000066400000000000000000000003331474413133200237040ustar00rootroot00000000000000import 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.6/napari/_tests/test_view_layers.py000066400000000000000000000165111474413133200215170ustar00rootroot00000000000000""" 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, 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 if name == 'Image': summary_format = 'Add one or more Image layers to the layer list.' else: summary_format = 'Add an? .+? layers? 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.', ), 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: assert class_param in method_parameters, 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]): np.testing.assert_array_equal( 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]): np.testing.assert_array_equal(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).astype(np.float32) 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.6/napari/_tests/test_viewer.py000066400000000000000000000330011474413133200204600ustar00rootroot00000000000000import os from unittest.mock import Mock import numpy as np import pytest from napari import Viewer, layers from napari._pydantic_compat import ValidationError 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 # Test that flash animation does not occur screenshot = viewer.screenshot(canvas_only=True) assert not hasattr( viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) assert screenshot.ndim == 3 # Take screenshot with the viewer included screenshot = viewer.screenshot(canvas_only=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) # test flash animation works screenshot = viewer.screenshot(canvas_only=True, flash=True) assert hasattr( viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) # Here we wait until the flash animation will be over for teardown. # 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( ValidationError, match="Theme 'nonexistent_theme' not found" ): viewer.theme = 'nonexistent_theme' @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]) viewer._calc_status_from_cursor() 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 @pytest.mark.parametrize('n_viewers', [1, 2, 3, 4]) def test_close_all(n_viewers, make_napari_viewer): """Test that close all closes multiple viewers""" # Make several viewers viewers = [make_napari_viewer() for _ in range(n_viewers)] last_viewer = viewers[-1] # Ensure the last viewer closes all assert last_viewer.close_all() == n_viewers 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() def test_running_status_thread(make_napari_viewer, qtbot, monkeypatch): viewer = make_napari_viewer() settings = get_settings() start_mock, stop_mock = Mock(), Mock() monkeypatch.setattr( viewer.window._qt_window.status_thread, 'start', start_mock ) monkeypatch.setattr( viewer.window._qt_window.status_thread, 'terminate', stop_mock ) assert settings.appearance.update_status_based_on_layer settings.appearance.update_status_based_on_layer = False stop_mock.assert_called_once() start_mock.assert_not_called() settings.appearance.update_status_based_on_layer = True start_mock.assert_called_once() def test_negative_translate(make_napari_viewer, qtbot): """Check that negative translation behaves as expected. See https://github.com/napari/napari/issues/7248 """ data = np.random.random((1, 3, 10, 12, 12)) viewer = make_napari_viewer() _ = viewer.add_image(data, translate=(-1, 0, 0)) assert viewer.dims.range[2].start == -1 napari-0.5.6/napari/_tests/test_viewer_layer_parity.py000066400000000000000000000021341474413133200232470ustar00rootroot00000000000000""" 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'] del viewer_parameters['kwargs'] # 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.6/napari/_tests/test_windowsettings.py000066400000000000000000000026261474413133200222600ustar00rootroot00000000000000from qtpy.QtCore import QRect from napari.settings import get_settings class ScreenMock: def __init__(self): self._geometry = QRect(0, 0, 1000, 1000) def geometry(self): return self._geometry def screen_at(point): if point.x() < 0 or point.y() < 0 or point.x() > 1000 or point.y() > 1000: return None return ScreenMock() def test_singlescreen_window_settings(make_napari_viewer, monkeypatch): """Test whether valid screen position is returned even after disconnected secondary screen.""" monkeypatch.setattr( 'napari._qt.qt_main_window.QApplication.screenAt', screen_at ) settings = get_settings() viewer = make_napari_viewer() default_window_position = ( viewer.window._qt_window.x(), viewer.window._qt_window.y(), ) # Valid position settings.application.window_position = (60, 50) window_position = viewer.window._qt_window._load_window_settings()[2] assert window_position == (60, 50) # Invalid left of screen settings.application.window_position = (0, -400) window_position = viewer.window._qt_window._load_window_settings()[2] assert window_position == default_window_position # Invalid right of screen settings.application.window_position = (0, 40000) window_position = viewer.window._qt_window._load_window_settings()[2] assert window_position == default_window_position napari-0.5.6/napari/_tests/test_with_screenshot.py000066400000000000000000000521041474413133200223740ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import skip_local_popups, skip_on_win_ci from napari.layers import Shapes from napari.utils._test_utils import read_only_mouse_event from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) @pytest.fixture def qt_viewer(qt_viewer_): # show the qt_viewer and hide its welcome widget qt_viewer_.show() qt_viewer_.set_welcome_visible(False) return qt_viewer_ @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 = read_only_mouse_event( type='mouse_press', is_dragging=False, position=viewer.cursor.position, ) mouse_press_callbacks(layer, event) viewer.cursor.position = (100, 100) # Simulate drag event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=viewer.cursor.position, ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=False, position=viewer.cursor.position, ) mouse_release_callbacks(layer, event) event = read_only_mouse_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) @skip_on_win_ci @skip_local_popups def test_blending_modes_with_canvas(make_napari_viewer): shape = (60, 80) viewer = make_napari_viewer(show=True) # add two images with different values img1 = np.full(shape, 20, np.uint8) img2 = np.full(shape, 50, np.uint8) img1_layer = viewer.add_image(img1) img2_layer = viewer.add_image(img2) viewer.window._qt_viewer.canvas.size = shape viewer.camera.zoom = 1 # check that additive behaves correctly with black canvas img1_layer.blending = 'additive' img2_layer.blending = 'additive' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], img1 + img2) # minimum should not result in black background if canvas is black img1_layer.blending = 'minimum' img2_layer.blending = 'minimum' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], np.minimum(img1, img2)) # toggle visibility of bottom layer img1_layer.visible = False screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], img2) # and canvas should not affect the above results viewer.window._qt_viewer.canvas.bgcolor = 'white' # check that additive behaves correctly, despite white canvas img1_layer.visible = True img1_layer.blending = 'additive' img2_layer.blending = 'additive' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], img1 + img2) # toggle visibility of bottom layer img1_layer.visible = False screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], img2) # minimum should always work with white canvas bgcolor img1_layer.visible = True img1_layer.blending = 'minimum' img2_layer.blending = 'minimum' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_array_equal(screenshot[:, :, 0], np.minimum(img1, img2)) @skip_local_popups def test_active_layer_highlight_visibility(qt_viewer): viewer = qt_viewer.viewer # take initial screenshot (full black/empty screenshot since welcome message is hidden) launch_screenshot = qt_viewer.screenshot(flash=False) # check screenshot ignoring alpha assert launch_screenshot[..., :-1].max() == 0 # add shapes layer setting edge and face color to `black` (so shapes aren't # visible unless they're selected), create a rectangle and select the created shape shapes_layer: Shapes = viewer.add_shapes( edge_color='black', face_color='black' ) shapes_layer.add_rectangles([[0, 0], [1, 1]]) shapes_layer.selected_data = {0} # there should be a highlight so a screenshot should have something visible highlight_screenshot = qt_viewer.screenshot(flash=False) # check screenshot ignoring alpha assert highlight_screenshot[..., :-1].max() > 0 # clear viewer layer selection viewer.layers.selection.clear() # there shouldn't be a highlight so a new screenshot shouldn't have something visible no_highlight_screenshot = qt_viewer.screenshot(flash=False) # check screenshot ignoring alpha assert no_highlight_screenshot[..., :-1].max() == 0 # select again the layer with the rectangle shape viewer.layers.selection.add(shapes_layer) # there should be a highlight so a screenshot should have something visible reselection_highlight_screenshot = qt_viewer.screenshot(flash=False) # check screenshot ignoring alpha assert reselection_highlight_screenshot[..., :-1].max() > 0 napari-0.5.6/napari/_tests/utils.py000066400000000000000000000252121474413133200172650ustar00rootroot00000000000000import os import sys from collections import abc from contextlib import suppress from threading import RLock from typing import Any, Union import numpy as np import pandas as pd import pytest from numpy.typing import DTypeLike from napari import Viewer from napari.layers import ( Image, Labels, Points, Shapes, Surface, Tracks, Vectors, ) from napari.layers._data_protocols import Index, LayerDataProtocol from napari.utils.color import ColorArray from napari.utils.events.event import WarningEmitter 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_on_mac_ci = pytest.mark.skipif( sys.platform.startswith('darwin') and os.getenv('CI', '0') != '0', reason='Unsupported test on macOS 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.', ) skip_local_focus = pytest.mark.skipif( not os.getenv('CI') and os.getenv('NAPARI_FOCUS_TESTS', '0') == '0', reason='Tests requiring GUI windows focus are skipped locally by default.' ' Set NAPARI_FOCUS_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)).astype(np.float32), 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, ), ] with suppress(ModuleNotFoundError): 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)]) 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)), {'opacity': 0.9}, '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', ), ] 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 @property def ndim(self) -> int: # LayerDataProtocol does not have ndim, but this should be equivalent. return len(self.data.shape) def __getitem__( self, key: Union[Index, tuple[Index, ...], LayerDataProtocol] ) -> LayerDataProtocol: with self.lock: return self.data[key] def __len__(self) -> int: return len(self.data) 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 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 # Get an handle on visual layer: vis_lyr = viewer.window._qt_viewer.canvas.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): """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) np.testing.assert_almost_equal(layer.extent.world, extent) # Apply scale transformation layer.scale = scale scaled_world_extent = np.multiply(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) def count_warning_events(callbacks) -> int: """ Counts the number of WarningEmitter in the callback list. Useful to filter out deprecated events' callbacks. """ return len( list(filter(lambda x: isinstance(x, WarningEmitter), callbacks)) ) napari-0.5.6/napari/_vendor/000077500000000000000000000000001474413133200157045ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/__init__.py000066400000000000000000000000001474413133200200030ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/darkdetect/000077500000000000000000000000001474413133200200165ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/darkdetect/LICENSE000066400000000000000000000027271474413133200210330ustar00rootroot00000000000000Copyright (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.6/napari/_vendor/darkdetect/__init__.py000066400000000000000000000026041474413133200221310ustar00rootroot00000000000000#----------------------------------------------------------------------------- # 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.6/napari/_vendor/darkdetect/__main__.py000066400000000000000000000005141474413133200221100ustar00rootroot00000000000000#----------------------------------------------------------------------------- # 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.6/napari/_vendor/darkdetect/_dummy.py000066400000000000000000000007311474413133200216630ustar00rootroot00000000000000#----------------------------------------------------------------------------- # 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.6/napari/_vendor/darkdetect/_linux_detect.py000066400000000000000000000030601474413133200232150ustar00rootroot00000000000000#----------------------------------------------------------------------------- # 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.6/napari/_vendor/darkdetect/_mac_detect.py000066400000000000000000000072411474413133200226230ustar00rootroot00000000000000#----------------------------------------------------------------------------- # 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.6/napari/_vendor/darkdetect/_windows_detect.py000066400000000000000000000101141474413133200235460ustar00rootroot00000000000000from 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.6/napari/_vendor/qt_json_builder/000077500000000000000000000000001474413133200210675ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/qt_json_builder/LICENSE000066400000000000000000000020571474413133200221000ustar00rootroot00000000000000MIT 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.6/napari/_vendor/qt_json_builder/__init__.py000066400000000000000000000000001474413133200231660ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/000077500000000000000000000000001474413133200247505ustar00rootroot00000000000000napari-0.5.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/__init__.py000066400000000000000000000000401474413133200270530ustar00rootroot00000000000000from .form import WidgetBuilder napari-0.5.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/defaults.py000066400000000000000000000015211474413133200271300ustar00rootroot00000000000000def 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.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py000066400000000000000000000100531474413133200262640ustar00rootroot00000000000000from 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, "horizontal_object": widgets.HorizontalObjectSchemaWidget, "enum": widgets.EnumSchemaWidget, "plugins": widgets.PluginWidget, "shortcuts": widgets.ShortcutsWidget, "extension2reader": widgets.Extension2ReaderWidget, "highlight": widgets.HighlightPreviewWidget, }, "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, "font_size": widgets.FontSizeSchemaWidget, }, "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" if schema.get("description"): description = schema["description"] 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.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/signal.py000066400000000000000000000010651474413133200266010ustar00rootroot00000000000000class 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.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/utils.py000066400000000000000000000016651474413133200264720ustar00rootroot00000000000000from 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.6/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py000066400000000000000000000616211474413133200267760ustar00rootroot00000000000000from 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 QtHighlightPreviewWidget from ...._qt.widgets.qt_keyboard_settings import ShortcutEditor from ...._qt.widgets.qt_font_size import QtFontSizeWidget 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) if 'minimum' in self.schema: self.setMinimum(self.schema['minimum']) if 'maximum' in self.schema: self.setMaximum(self.schema['maximum']) 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 HighlightPreviewWidget( SchemaWidgetMixin, QtHighlightPreviewWidget ): @state_property def state(self) -> dict: return self.value() def setDescription(self, description: str): self._description.setText(description) @state.setter def state(self, state: dict): 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) 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 FontSizeSchemaWidget(SchemaWidgetMixin, QtFontSizeWidget): @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 = 1 if "minimum" in self.schema: minimum = self.schema["minimum"] if self.schema.get("exclusiveMinimum"): minimum += 1 maximum = 100 if "maximum" in self.schema: maximum = self.schema["maximum"] if self.schema.get("exclusiveMaximum"): maximum -= 1 self.setRange(minimum, maximum) def setDescription(self, description: str): self.description = description class ObjectSchemaWidgetMinix(SchemaWidgetMixin): 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]: raise NotImplementedError def _prepare_widget(self, name: str, sub_schema: dict, widget_builder: 'WidgetBuilder', ui_schema: dict): description = sub_schema.get('description', "") label = QtWidgets.QLabel(sub_schema.get("title", name)) sub_ui_schema = ui_schema.get(name, {}) widget = widget_builder.create_widget( sub_schema, sub_ui_schema, description=description ) # TODO on changed widget._name = name widget.on_changed.connect(partial(self.widget_on_changed, name)) return label, widget class HorizontalObjectSchemaWidget(ObjectSchemaWidgetMinix, QtWidgets.QWidget): def populate_from_schema( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', ) -> Dict[str, QtWidgets.QWidget]: layout = QtWidgets.QHBoxLayout() self.setLayout(layout) widgets = {} for name, sub_schema in schema['properties'].items(): label, widget = self._prepare_widget(name, sub_schema, widget_builder, ui_schema) layout.addWidget(label) layout.addWidget(widget) widgets[name] = widget return widgets class ObjectSchemaWidget(ObjectSchemaWidgetMinix, QtWidgets.QGroupBox): 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(): label, widget = self._prepare_widget(name, sub_schema, widget_builder, ui_schema) if len(schema['properties']) == 1: layout.addRow(widget) else: 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): value = str(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.6/napari/_vispy/000077500000000000000000000000001474413133200155615ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/__init__.py000066400000000000000000000022651474413133200176770ustar00rootroot00000000000000import 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.labels_polygon import VispyLabelsPolygonOverlay from napari._vispy.overlays.scale_bar import VispyScaleBarOverlay from napari._vispy.overlays.text import VispyTextOverlay from napari._vispy.utils.quaternion import quaternion2euler_degrees from napari._vispy.utils.visual import create_vispy_layer, create_vispy_overlay __all__ = [ 'VispyAxesOverlay', 'VispyCamera', 'VispyCanvas', 'VispyLabelsPolygonOverlay', 'VispyScaleBarOverlay', 'VispySelectionBoxOverlay', 'VispyTextOverlay', 'VispyTransformBoxOverlay', 'create_vispy_layer', 'create_vispy_overlay', 'quaternion2euler_degrees', ] napari-0.5.6/napari/_vispy/_tests/000077500000000000000000000000001474413133200170625ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/_tests/test_image_rendering.py000066400000000000000000000055361474413133200236230ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import skip_on_win_ci from napari._vispy.layers.image import VispyImageLayer from napari.components.dims import Dims 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) vispy_layer = viewer.window._qt_viewer.layer_to_visual[layer] 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' # check custom interpolation works on the 2D node with pytest.raises(NotImplementedError): layer.interpolation3d = 'custom' viewer.dims.ndisplay = 2 layer.interpolation2d = 'custom' assert vispy_layer.node.interpolation == 'custom' @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(Dims(ndim=3, ndisplay=3)) vispy_clip = vispy_layer.node.clipping_planes np.testing.assert_array_equal(napari_clip, vispy_clip[..., ::-1]) napari-0.5.6/napari/_vispy/_tests/test_utils.py000066400000000000000000000064261474413133200216430ustar00rootroot00000000000000import numpy as np import pytest from qtpy.QtCore import Qt from vispy.util.quaternion import Quaternion from napari._pydantic_compat import ValidationError from napari._vispy.utils.cursor import QtCursorVisual from napari._vispy.utils.quaternion import quaternion2euler_degrees from napari._vispy.utils.visual import get_view_direction_in_scene_coordinates from napari.components._viewer_constants import CursorStyle # 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 @pytest.mark.parametrize('angles', angles) def test_quaternion2euler_degrees(angles): """Test quaternion to euler angle conversion.""" # Test for degrees q = Quaternion.create_from_euler_angles(*angles, degrees=True) ea = quaternion2euler_degrees(q) q_p = Quaternion.create_from_euler_angles(*ea, degrees=True) # 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() # Note: as of 0.5.6, setting the dims ndim to 3 with no layers leaves the # viewer in an inconsistent state, because the dims are 3 but the layers # extent is only 2D. Therefore, instead of setting dims to 3 we add a 3D # dataset to the viewer _ = viewer.add_image(np.random.random((2, 3, 4))) # reset view sets the camera angles to (0, 0, 90) viewer.dims.ndisplay = 3 # get the viewbox view_box = viewer.window._qt_viewer.canvas.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.canvas.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 def test_set_cursor(make_napari_viewer): viewer = make_napari_viewer() viewer.cursor.style = CursorStyle.SQUARE.value viewer.cursor.size = 10 assert ( viewer.window._qt_viewer.canvas.cursor.shape() == Qt.CursorShape.BitmapCursor ) viewer.cursor.size = 5 assert ( viewer.window._qt_viewer.canvas.cursor.shape() == QtCursorVisual['cross'].value ) viewer.cursor.style = CursorStyle.CIRCLE.value viewer.cursor.size = 100 assert viewer._brush_circle_overlay.visible assert viewer._brush_circle_overlay.size == viewer.cursor.size with pytest.raises(ValidationError): viewer.cursor.style = 'invalid' napari-0.5.6/napari/_vispy/_tests/test_vispy_axes_overlay.py000066400000000000000000000024431474413133200244310ustar00rootroot00000000000000from napari._vispy.overlays.axes import VispyAxesOverlay from napari.components import ViewerModel from napari.components.overlays import AxesOverlay def test_init_with_2d_display_of_2_dimensions(): axes_model = AxesOverlay() viewer = ViewerModel() viewer.dims.ndim = 2 viewer.dims.ndisplay = 3 axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model) assert tuple(axes_view.node.text.text) == ('1', '0') def test_init_with_2d_display_of_3_dimensions(): axes_model = AxesOverlay() viewer = ViewerModel() viewer.dims.ndim = 3 viewer.dims.ndisplay = 2 axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model) assert tuple(axes_view.node.text.text) == ('2', '1') def test_init_with_3d_display_of_2_dimensions(): axes_model = AxesOverlay() viewer = ViewerModel() viewer.dims.ndim = 2 viewer.dims.ndisplay = 3 axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model) assert tuple(axes_view.node.text.text) == ('1', '0') def test_init_with_3d_display_of_3_dimensions(): axes_model = AxesOverlay() viewer = ViewerModel() viewer.dims.ndim = 3 viewer.dims.ndisplay = 3 axes_view = VispyAxesOverlay(viewer=viewer, overlay=axes_model) assert tuple(axes_view.node.text.text) == ('2', '1', '0') napari-0.5.6/napari/_vispy/_tests/test_vispy_big_images.py000066400000000000000000000040311474413133200240110ustar00rootroot00000000000000import 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.canvas.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) np.testing.assert_array_equal(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.canvas.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) np.testing.assert_array_equal(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.6/napari/_vispy/_tests/test_vispy_brush_circle_overlay.py000066400000000000000000000011071474413133200261310ustar00rootroot00000000000000from napari._vispy.overlays.brush_circle import VispyBrushCircleOverlay from napari.components import ViewerModel from napari.components.overlays import BrushCircleOverlay def test_vispy_brush_circle_overlay(): brush_circle_model = BrushCircleOverlay() viewer = ViewerModel() vispy_brush_circle = VispyBrushCircleOverlay( viewer=viewer, overlay=brush_circle_model ) brush_circle_model.size = 100 brush_circle_model.position = 10, 20 assert vispy_brush_circle._white_circle.radius == 50 assert vispy_brush_circle._black_circle.radius == 49 napari-0.5.6/napari/_vispy/_tests/test_vispy_calls.py000066400000000000000000000103511474413133200230230ustar00rootroot00000000000000from 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.canvas.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.canvas.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.canvas.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.canvas.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.canvas.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.canvas.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.6/napari/_vispy/_tests/test_vispy_camera.py000066400000000000000000000123051474413133200231560ustar00rootroot00000000000000import 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.canvas.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.canvas.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.canvas.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.canvas.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.canvas.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.canvas.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.6/napari/_vispy/_tests/test_vispy_image_layer.py000066400000000000000000000170161474413133200242100ustar00rootroot00000000000000from itertools import permutations import numpy as np import numpy.testing as npt import pytest from napari._vispy._tests.utils import vispy_image_scene_size from napari._vispy.layers.image import VispyImageLayer from napari.components.dims import Dims from napari.layers import Image @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(Dims(ndim=3, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) 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(Dims(ndim=3, ndisplay=2, order=order)) scene_size = vispy_image_scene_size(vispy_image) 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(Dims(ndim=3, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) 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(Dims(ndim=4, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((16, 16, 16), scene_size) def test_no_float32_texture_support(monkeypatch): """Ensure Image node can be created if OpenGL driver lacks float textures. See #3988, #3990, #6652. """ monkeypatch.setattr( 'napari._vispy.layers.image.get_gl_extensions', lambda: '' ) image = Image(np.zeros((16, 8, 4, 2), dtype='uint8'), scale=(1, 2, 4, 8)) VispyImageLayer(image) @pytest.fixture def im_layer() -> Image: return Image(np.zeros((10, 10))) @pytest.fixture def pyramid_layer() -> Image: return Image([np.zeros((20, 20)), np.zeros((10, 10))]) def test_base_create(im_layer): VispyImageLayer(im_layer) def set_translate(layer): layer.translate = (10, 10) def set_affine_translate(layer): layer.affine.translate = (10, 10) layer.events.affine() def set_rotate(layer): layer.rotate = 90 def set_affine_rotate(layer): layer.affine.rotate = 90 layer.events.affine() def no_op(layer): pass @pytest.mark.parametrize( ('translate', 'exp_translate'), [ (set_translate, (10, 10)), (set_affine_translate, (10, 10)), (no_op, (0, 0)), ], ids=('translate', 'affine_translate', 'no_op'), ) @pytest.mark.parametrize( ('rotate', 'exp_rotate'), [ (set_rotate, ((0, -1), (1, 0))), (set_affine_rotate, ((0, -1), (1, 0))), (no_op, ((1, 0), (0, 1))), ], ids=('rotate', 'affine_rotate', 'no_op'), ) def test_transforming_child_node( im_layer, translate, exp_translate, rotate, exp_rotate ): layer = VispyImageLayer(im_layer) npt.assert_array_almost_equal( layer.node.transform.matrix[-1][:2], (-0.5, -0.5) ) npt.assert_array_almost_equal( layer.node.transform.matrix[:2, :2], ((1, 0), (0, 1)) ) rotate(im_layer) translate(im_layer) npt.assert_array_almost_equal( layer.node.children[0].transform.matrix[:2, :2], ((1, 0), (0, 1)) ) npt.assert_array_almost_equal( layer.node.children[0].transform.matrix[-1][:2], (0.5, 0.5) ) npt.assert_array_almost_equal( layer.node.transform.matrix[:2, :2], exp_rotate ) if translate == set_translate and rotate == set_affine_rotate: npt.assert_array_almost_equal( layer.node.transform.matrix[-1][:2], np.dot( np.linalg.inv(exp_rotate), np.array([-0.5, -0.5]) + exp_translate, ), ) else: npt.assert_array_almost_equal( layer.node.transform.matrix[-1][:2], np.dot(np.linalg.inv(exp_rotate), (-0.5, -0.5)) + exp_translate, # np.dot(np.linalg.inv(im_layer.affine.rotate), exp_translate) ) def test_transforming_child_node_pyramid(pyramid_layer): layer = VispyImageLayer(pyramid_layer) corner_pixels_world = np.array([[0, 0], [20, 20]]) npt.assert_array_almost_equal( layer.node.transform.matrix[-1][:2], (-0.5, -0.5) ) npt.assert_array_almost_equal( layer.node.children[0].transform.matrix[-1][:2], (0.5, 0.5) ) pyramid_layer.translate = (-10, -10) pyramid_layer._update_draw( scale_factor=1, corner_pixels_displayed=corner_pixels_world, shape_threshold=(10, 10), ) npt.assert_array_almost_equal( layer.node.transform.matrix[-1][:2], (-0.5, -0.5) ) npt.assert_array_almost_equal( layer.node.children[0].transform.matrix[-1][:2], (-9.5, -9.5) ) @pytest.mark.parametrize('scale', [1, 2]) @pytest.mark.parametrize('ndim', [3, 4]) @pytest.mark.parametrize('ndisplay', [2, 3]) def test_node_origin_is_consistent_with_multiscale( scale: int, ndim: int, ndisplay: int ): """See https://github.com/napari/napari/issues/6320""" scales = (scale,) * ndim # Define multi-scale image data with two levels where the # higher resolution is twice as high as the lower resolution. image = Image( data=[np.zeros((8,) * ndim), np.zeros((4,) * ndim)], scale=scales ) vispy_image = VispyImageLayer(image) # Take a full slice at the highest resolution. image.corner_pixels = np.array([[0] * ndim, [8] * ndim]) image._data_level = 0 # Use a slice point of (1, 0, 0, ...) to have some non-zero slice coordinates. point = (1,) + (0,) * (ndim - 1) image._slice_dims(Dims(ndim=ndim, ndisplay=ndisplay, point=point)) # Map the node's data origin to a vispy scene coordinate. high_res_origin = vispy_image.node.transform.map((0,) * ndisplay) # Take a full slice at the lowest resolution and map the origin again. image.corner_pixels = np.array([[0] * ndim, [4] * ndim]) image._data_level = 1 image._slice_dims(Dims(ndim=ndim, ndisplay=ndisplay, point=point)) low_res_origin = vispy_image.node.transform.map((0,) * ndisplay) # The exact origin may depend on certain parameter values, but the # full high and low resolution slices should always map to the same # scene origin, since this defines the start of the visible extent. np.testing.assert_array_equal(high_res_origin, low_res_origin) napari-0.5.6/napari/_vispy/_tests/test_vispy_labels.py000066400000000000000000000012251474413133200231670ustar00rootroot00000000000000import numpy as np import pytest from napari._vispy.layers.labels import ( build_textures_from_dict, ) def test_build_textures_from_dict(): values = build_textures_from_dict( {0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}, max_size=10, ) assert values.shape == (3, 1, 4) assert np.array_equiv(values[1], (1, 1, 1, 1)) assert np.array_equiv(values[2], (2, 2, 2, 2)) def test_build_textures_from_dict_exc(): with pytest.raises(ValueError, match='Cannot create a 2D texture'): build_textures_from_dict( {0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}, max_size=1, ) napari-0.5.6/napari/_vispy/_tests/test_vispy_labels_layer.py000066400000000000000000000106501474413133200243650ustar00rootroot00000000000000import numpy as np import pytest import zarr from qtpy.QtCore import QCoreApplication from napari._tests.utils import skip_local_popups, skip_on_win_ci from napari._vispy.visuals.volume import Volume as VolumeNode from napari.layers.labels._labels_constants import IsoCategoricalGradientMode from napari.utils.interactions import mouse_press_callbacks def make_labels_layer(array_type, shape): """Make a labels layer, either NumPy, zarr, or tensorstore.""" chunks = tuple(s // 2 for s in shape) if array_type == 'numpy': labels = np.zeros(shape, dtype=np.uint32) elif array_type == 'zarr': labels = zarr.zeros(shape=shape, dtype=np.uint32, chunks=chunks) elif array_type == 'tensorstore': ts = pytest.importorskip('tensorstore') spec = { 'driver': 'zarr', 'kvstore': {'driver': 'memory'}, 'metadata': {'chunks': chunks}, } labels = ts.open( spec, create=True, dtype='uint32', shape=shape ).result() else: pytest.fail("array_type must be 'numpy', 'zarr', or 'tensorstore'") return labels @skip_local_popups @pytest.mark.parametrize('array_type', ['numpy', 'zarr', 'tensorstore']) def test_labels_painting(qtbot, array_type, qt_viewer): """Check that painting labels paints on the canvas. This should work regardless of array type. See: https://github.com/napari/napari/issues/6079 """ viewer = qt_viewer.viewer labels = make_labels_layer(array_type, shape=(20, 20)) layer = viewer.add_labels(labels) QCoreApplication.instance().processEvents() layer.paint((10, 10), 1, refresh=True) visual = qt_viewer.layer_to_visual[layer] assert np.any(visual.node._data) @skip_local_popups @pytest.mark.parametrize('array_type', ['numpy', 'zarr', 'tensorstore']) def test_labels_fill_slice(qtbot, array_type, qt_viewer): """Check that painting labels paints only on current slice. This should work regardless of array type. See: https://github.com/napari/napari/issues/6079 """ labels = make_labels_layer(array_type, shape=(3, 20, 20)) labels[0, :, :] = 1 labels[1, 10, 10] = 1 labels[2, :, :] = 1 viewer = qt_viewer.viewer layer = viewer.add_labels(labels) layer.n_edit_dimensions = 3 QCoreApplication.instance().processEvents() layer.fill((1, 10, 10), 13, refresh=True) visual = qt_viewer.layer_to_visual[layer] assert np.sum(visual.node._data) == 13 @skip_local_popups @pytest.mark.parametrize('array_type', ['numpy', 'zarr', 'tensorstore']) def test_labels_painting_with_mouse(MouseEvent, qtbot, array_type, qt_viewer): """Check that painting labels paints on the canvas when using mouse. This should work regardless of array type. See: https://github.com/napari/napari/issues/6079 """ labels = make_labels_layer(array_type, shape=(20, 20)) viewer = qt_viewer.viewer layer = viewer.add_labels(labels) QCoreApplication.instance().processEvents() layer.mode = 'paint' event = MouseEvent( type='mouse_press', button=1, position=(0, 10, 10), dims_displayed=(0, 1), ) visual = qt_viewer.layer_to_visual[layer] assert not np.any(visual.node._data) mouse_press_callbacks(layer, event) assert np.any(visual.node._data) @skip_local_popups @skip_on_win_ci def test_labels_iso_gradient_modes(qtbot, qt_viewer): """Check that we can set `iso_gradient_mode` with `iso_categorical` rendering (test shader).""" # NOTE: this test currently segfaults on Windows CI, but confirmed working locally # because it's a segfault, we have to skip instead of xfail qt_viewer.show() viewer = qt_viewer.viewer labels = make_labels_layer('numpy', shape=(32, 32, 32)) labels[14:18, 14:18, 14:18] = 1 layer = viewer.add_labels(labels) visual = qt_viewer.layer_to_visual[layer] viewer.dims.ndisplay = 3 QCoreApplication.instance().processEvents() assert layer.rendering == 'iso_categorical' assert isinstance(visual.node, VolumeNode) layer.iso_gradient_mode = IsoCategoricalGradientMode.SMOOTH QCoreApplication.instance().processEvents() assert layer.iso_gradient_mode == 'smooth' assert visual.node.iso_gradient_mode == 'smooth' layer.iso_gradient_mode = IsoCategoricalGradientMode.FAST QCoreApplication.instance().processEvents() assert layer.iso_gradient_mode == 'fast' assert visual.node.iso_gradient_mode == 'fast' napari-0.5.6/napari/_vispy/_tests/test_vispy_labels_polygon_overlay.py000066400000000000000000000075631474413133200265120ustar00rootroot00000000000000import numpy as np from napari._vispy.overlays.labels_polygon import VispyLabelsPolygonOverlay from napari.components.overlays import LabelsPolygonOverlay from napari.layers.labels._labels_key_bindings import complete_polygon from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, ) def test_vispy_labels_polygon_overlay(make_napari_viewer): viewer = make_napari_viewer() labels_polygon = LabelsPolygonOverlay() data = np.zeros((50, 50), dtype=int) layer = viewer.add_labels(data, opacity=0.5) vispy_labels_polygon = VispyLabelsPolygonOverlay( layer=layer, overlay=labels_polygon ) assert vispy_labels_polygon._polygon.color.alpha == 0.5 labels_polygon.points = [] assert not vispy_labels_polygon._line.visible assert not vispy_labels_polygon._polygon.visible labels_polygon.points = [(0, 0), (1, 1)] assert vispy_labels_polygon._line.visible assert not vispy_labels_polygon._polygon.visible assert np.allclose( vispy_labels_polygon._line.color[:3], layer._selected_color[:3] ) labels_polygon.points = [(0, 0), (1, 1), (0, 3)] assert not vispy_labels_polygon._line.visible assert vispy_labels_polygon._polygon.visible layer.selected_label = layer.colormap.background_value assert vispy_labels_polygon._polygon.color.is_blank def test_labels_drawing_with_polygons(MouseEvent, make_napari_viewer): """Test polygon painting.""" np.random.seed(0) data = np.zeros((3, 15, 15), dtype=np.int32) viewer = make_napari_viewer() layer = viewer.add_labels(data) layer.mode = 'polygon' layer.selected_label = 1 # Place some random points and then cancel them all for _ in range(5): position = (0,) + tuple(np.random.randint(20, size=2)) event = MouseEvent( type='mouse_press', button=1, position=position, dims_displayed=[1, 2], ) mouse_press_callbacks(layer, event) # Cancel all the points for _ in range(5): event = MouseEvent( type='mouse_press', button=2, position=(0, 0, 0), dims_displayed=(1, 2), ) mouse_press_callbacks(layer, event) assert np.array_equiv(data[0, :], 0) # Draw a rectangle (the latest two points will be cancelled) points = [ (1, 1, 1), (1, 1, 10), (1, 10, 10), (1, 10, 1), (1, 12, 0), (1, 0, 0), ] for position in points: event = MouseEvent( type='mouse_move', button=None, position=(1,) + tuple(np.random.randint(20, size=2)), dims_displayed=(1, 2), ) mouse_move_callbacks(layer, event) event = MouseEvent( type='mouse_press', button=1, position=position, dims_displayed=[1, 2], ) mouse_press_callbacks(layer, event) # Cancel the latest two points for _ in range(2): event = MouseEvent( type='mouse_press', button=2, position=(1, 5, 1), dims_displayed=(1, 2), ) mouse_press_callbacks(layer, event) # Finish drawing complete_polygon(layer) assert np.array_equiv(data[[0, 2], :], 0) assert np.array_equiv(data[1, 1:11, 1:11], 1) assert np.array_equiv(data[1, 0, :], 0) assert np.array_equiv(data[1, :, 0], 0) assert np.array_equiv(data[1, 11:, :], 0) assert np.array_equiv(data[1, :, 11:], 0) # Try to finish with an incomplete polygon for position in [(0, 1, 1)]: event = MouseEvent( type='mouse_press', button=1, position=position, dims_displayed=(1, 2), ) mouse_press_callbacks(layer, event) # Finish drawing complete_polygon(layer) assert np.array_equiv(data[0, :], 0) napari-0.5.6/napari/_vispy/_tests/test_vispy_multiscale.py000066400000000000000000000213301474413133200240660ustar00rootroot00000000000000import 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.canvas.size = (800, 600) viewer.window._qt_viewer.canvas.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.canvas.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.canvas.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.canvas.view.canvas.size = (800, 600) # Set zoom of camera to show highest resolution tile view.canvas.view.camera.rect = [1000, 1000, 200, 150] viewer.window._qt_viewer.canvas.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.canvas.view.canvas.size = (800, 600) # Set zoom of camera to show highest resolution tile view.canvas.view.camera.rect = [1000, 1000, 200, 150] viewer.window._qt_viewer.canvas.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.canvas.view.camera.rect = ( camera_rect_start, camera_rect_start, camera_rect_size, camera_rect_size, ) qt_viewer.canvas.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.6/napari/_vispy/_tests/test_vispy_points_layer.py000066400000000000000000000104101474413133200244310ustar00rootroot00000000000000import 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.6/napari/_vispy/_tests/test_vispy_scale_bar_visual.py000066400000000000000000000026501474413133200252260ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from 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() vispy_scale_bar = VispyScaleBarOverlay(overlay=model, viewer=viewer) assert vispy_scale_bar.overlay.length is None model.length = 50 assert vispy_scale_bar.overlay.length == 50 def test_scale_bar_positioning(make_napari_viewer): viewer = make_napari_viewer() # set devicePixelRatio to 2 so testing works on CI and local viewer.window._qt_window.devicePixelRatio = MagicMock(return_value=2) model = ScaleBarOverlay() scale_bar = VispyScaleBarOverlay(overlay=model, viewer=viewer) assert model.position == 'bottom_right' assert model.font_size == 10 assert scale_bar.y_offset == 20 model.position = 'top_right' assert scale_bar.y_offset == pytest.approx(20.333, abs=0.1) # increasing size while at top should increase y_offset to 7 + font_size*1.33 model.font_size = 30 assert scale_bar.y_offset == pytest.approx(47, abs=0.1) # moving scale bar back to bottom should reset y_offset to 20 model.position = 'bottom_right' assert scale_bar.y_offset == 20 # changing font_size at bottom should have no effect model.font_size = 50 assert scale_bar.y_offset == 20 napari-0.5.6/napari/_vispy/_tests/test_vispy_shapes_layer.py000066400000000000000000000070531474413133200244110ustar00rootroot00000000000000import 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.6/napari/_vispy/_tests/test_vispy_surface_layer.py000066400000000000000000000074441474413133200245620ustar00rootroot00000000000000import numpy as np import pytest from vispy.geometry import create_cube from napari._tests.utils import skip_local_popups from napari._vispy.layers.surface import VispySurfaceLayer from napari.components.dims import Dims from napari.layers import Surface @pytest.fixture def cube_layer(): vertices, faces, _ = create_cube() return Surface((vertices['position'] * 100, faces)) @pytest.mark.parametrize('opacity', [0, 0.3, 0.7, 1]) def test_VispySurfaceLayer(cube_layer, opacity): cube_layer.opacity = opacity visual = VispySurfaceLayer(cube_layer) assert visual.node.opacity == opacity def test_shading(cube_layer): cube_layer._slice_dims(Dims(ndim=3, ndisplay=3)) cube_layer.shading = 'flat' visual = VispySurfaceLayer(cube_layer) assert visual.node.shading_filter.attached assert visual.node.shading_filter.shading == 'flat' cube_layer.shading = 'smooth' assert visual.node.shading_filter.shading == 'smooth' @pytest.mark.parametrize( 'texture_shape', [ (32, 32), (32, 32, 1), (32, 32, 3), (32, 32, 4), ], ids=('2D', '1Ch', 'RGB', 'RGBA'), ) def test_add_texture(cube_layer, texture_shape): np.random.seed(0) visual = VispySurfaceLayer(cube_layer) assert visual._texture_filter is None texture = np.random.random(texture_shape).astype(np.float32) cube_layer.texture = texture # no texture filter initially assert visual._texture_filter is None # the texture filter is created when texture + texcoords are added texcoords = create_cube()[0]['texcoord'] cube_layer.texcoords = texcoords assert visual._texture_filter.attached assert visual._texture_filter.enabled # texture is flipped for openGL when setting up TextureFilter np.testing.assert_allclose( visual._texture_filter.texture, np.flipud(texture), ) # setting texture or texcoords to None disables the filter cube_layer.texture = None assert not visual._texture_filter.enabled def test_change_texture(cube_layer): np.random.seed(0) visual = VispySurfaceLayer(cube_layer) texcoords = create_cube()[0]['texcoord'] cube_layer.texcoords = texcoords texture0 = np.random.random((32, 32, 3)).astype(np.float32) cube_layer.texture = texture0 np.testing.assert_allclose( visual._texture_filter.texture, np.flipud(texture0), ) texture1 = np.random.random((32, 32, 3)).astype(np.float32) cube_layer.texture = texture1 np.testing.assert_allclose( visual._texture_filter.texture, np.flipud(texture1), ) def test_vertex_colors(cube_layer): np.random.seed(0) cube_layer._slice_dims(Dims(ndim=3, ndisplay=3)) visual = VispySurfaceLayer(cube_layer) n = len(cube_layer.vertices) colors0 = np.random.random((n, 4)).astype(np.float32) cube_layer.vertex_colors = colors0 np.testing.assert_allclose( visual.node.mesh_data.get_vertex_colors(), colors0, ) colors1 = np.random.random((n, 4)).astype(np.float32) cube_layer.vertex_colors = colors1 np.testing.assert_allclose( visual.node.mesh_data.get_vertex_colors(), colors1, ) cube_layer.vertex_colors = None assert visual.node.mesh_data.get_vertex_colors() is None @skip_local_popups def test_check_surface_without_visible_faces(qtbot, qt_viewer): points = np.array( [ [0, 0.0, 0.0, 0.0], [0, 1.0, 0, 0], [0, 1, 1, 0], [2, 0.0, 0.0, 0.0], [2, 1.0, 0, 0], [2, 1, 1, 0], ] ) faces = np.array([[0, 1, 2], [3, 4, 5]]) layer = Surface((points, faces)) qt_viewer.show() viewer = qt_viewer.viewer viewer.add_layer(layer) # The following with throw an exception. viewer.reset() qt_viewer.hide() napari-0.5.6/napari/_vispy/_tests/test_vispy_text_visual.py000066400000000000000000000004161474413133200242750ustar00rootroot00000000000000from 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.6/napari/_vispy/_tests/test_vispy_tracks_layer.py000066400000000000000000000013261474413133200244120ustar00rootroot00000000000000from 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.6/napari/_vispy/_tests/test_vispy_vectors_layer.py000066400000000000000000000075761474413133200246250ustar00rootroot00000000000000import 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', 'style'), [ (0, 0, 2, 'line'), (0.3, 0.3, 2, 'line'), (1, 1, 3, 'line'), (0, 0, 2, 'triangle'), (0.3, 0.3, 2, 'triangle'), (1, 1, 3, 'triangle'), (0, 0, 2, 'arrow'), (0.3, 0.3, 2, 'arrow'), (1, 1, 3, 'arrow'), ], ) def test_generate_vector_meshes(edge_width, length, dims, style): n = 10 data = np.random.random((n, 2, dims)) vertices, faces = generate_vector_meshes( data, width=edge_width, length=length, vector_style=style ) vertices_length, vertices_dims = vertices.shape faces_length, faces_dims = faces.shape if dims == 2: if style == 'line': assert vertices_length == 4 * n assert faces_length == 2 * n elif style == 'triangle': assert vertices_length == 3 * n assert faces_length == n elif style == 'arrow': assert vertices_length == 7 * n assert faces_length == 3 * n elif dims == 3: if style == 'line': assert vertices_length == 8 * n assert faces_length == 4 * n elif style == 'triangle': assert vertices_length == 6 * n assert faces_length == 2 * n elif style == 'arrow': assert vertices_length == 14 * n assert faces_length == 6 * n assert vertices_dims == dims assert faces_dims == 3 @pytest.mark.parametrize( ('edge_width', 'length', 'style', 'p'), [ (0, 0, 'line', (1, 0, 0)), (0.3, 0.3, 'line', (0, 1, 0)), (1, 1, 'line', (0, 0, 1)), (0, 0, 'triangle', (1, 0, 0)), (0.3, 0.3, 'triangle', (0, 1, 0)), (1, 1, 'triangle', (0, 0, 1)), (0, 0, 'arrow', (1, 0, 0)), (0.3, 0.3, 'arrow', (0, 1, 0)), (1, 1, 'arrow', (0, 0, 1)), ], ) def test_generate_vector_meshes_2D(edge_width, length, style, p): n = 10 dims = 2 data = np.random.random((n, 2, dims)) vertices, faces = generate_vector_meshes_2D( data, width=edge_width, length=length, vector_style=style, p=p ) vertices_length, vertices_dims = vertices.shape faces_length, faces_dims = faces.shape if style == 'line': assert vertices_length == 4 * n assert faces_length == 2 * n elif style == 'triangle': assert vertices_length == 3 * n assert faces_length == n elif style == 'arrow': assert vertices_length == 7 * n assert faces_length == 3 * n assert vertices_dims == dims assert faces_dims == 3 @pytest.mark.parametrize( ('initial_vector_style', 'new_vector_style'), [ ('line', 'line'), ('line', 'triangle'), ('line', 'arrow'), ('triangle', 'line'), ('triangle', 'triangle'), ('triangle', 'arrow'), ('arrow', 'line'), ('arrow', 'triangle'), ('arrow', 'arrow'), ], ) def test_vector_style_change( make_napari_viewer, initial_vector_style, new_vector_style ): # initialize viewer viewer = make_napari_viewer() # add a vector layer vector_layer = viewer.add_vectors( vector_style=initial_vector_style, name='vectors' ) class Counter: def __init__(self): self.count = 0 def increment_count(self, event): self.count += 1 # initialize counter counter = Counter() # connect counter to vector_style change vector_layer.events.vector_style.connect(counter.increment_count) # change vector_style vector_layer.vector_style = new_vector_style # check if counter was called if initial_vector_style == new_vector_style: assert counter.count == 0 else: assert counter.count == 1 napari-0.5.6/napari/_vispy/_tests/utils.py000066400000000000000000000020071474413133200205730ustar00rootroot00000000000000import numpy as np from vispy.visuals import VolumeVisual from vispy.visuals.transforms.linear import STTransform from napari._vispy.layers.image import VispyImageLayer def vispy_image_scene_size(vispy_image: VispyImageLayer) -> np.ndarray: """Calculates the size of a vispy image/volume 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). """ node = vispy_image.node 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] napari-0.5.6/napari/_vispy/camera.py000066400000000000000000000203221474413133200173620ustar00rootroot00000000000000import numpy as np from vispy.scene import ArcballCamera, BaseCamera, PanZoomCamera from napari._vispy.utils.quaternion import quaternion2euler_degrees 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 = MouseToggledPanZoomCamera(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 = MouseToggledArcballCamera(fov=0) self._3D_camera.viewbox_key_event = viewbox_key_event # Set 2D camera by default self._view.camera = self._2D_camera 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._camera.events.mouse_pan.connect(self._on_mouse_toggles_change) self._camera.events.mouse_zoom.connect(self._on_mouse_toggles_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_degrees(self._view.camera._quaternion) 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 = (*self._view.camera.center[:2], 0.0) # switch from VisPy xyz ordering to NumPy prc ordering return center[::-1] @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() @property def mouse_zoom(self) -> bool: return self._view.camera.mouse_zoom @mouse_zoom.setter def mouse_zoom(self, mouse_zoom: bool): self._view.camera.mouse_zoom = mouse_zoom @property def mouse_pan(self) -> bool: return self._view.camera.mouse_pan @mouse_pan.setter def mouse_pan(self, mouse_pan: bool): self._view.camera.mouse_pan = mouse_pan 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_mouse_toggles_change() self._on_center_change() self._on_zoom_change() self._on_angles_change() def _on_mouse_toggles_change(self): self.mouse_pan = self._camera.mouse_pan self.mouse_zoom = self._camera.mouse_zoom 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 def add_mouse_pan_zoom_toggles( vispy_camera_cls: type[BaseCamera], ) -> type[BaseCamera]: """Add separate mouse pan and mouse zoom toggles to VisPy. By default, VisPy uses an ``interactive`` toggle that turns *both* panning and zooming on and off. This decorator adds separate toggles, ``mouse_pan`` and ``mouse_zoom``, to enable controlling them separately. Parameters ---------- vispy_camera_cls : Type[vispy.scene.cameras.BaseCamera] A VisPy camera class to decorate. Returns ------- A decorated VisPy camera class. """ class _vispy_camera_cls(vispy_camera_cls): def __init__(self, **kwargs): super().__init__(**kwargs) self.mouse_pan = True self.mouse_zoom = True def viewbox_mouse_event(self, event): if ( self.mouse_zoom and event.type in ('mouse_wheel', 'gesture_zoom') ) or ( self.mouse_pan and event.type in ('mouse_move', 'mouse_press', 'mouse_release') ): super().viewbox_mouse_event(event) else: event.handled = False return _vispy_camera_cls MouseToggledPanZoomCamera = add_mouse_pan_zoom_toggles(PanZoomCamera) MouseToggledArcballCamera = add_mouse_pan_zoom_toggles(ArcballCamera) napari-0.5.6/napari/_vispy/canvas.py000066400000000000000000000625121474413133200174140ustar00rootroot00000000000000"""VispyCanvas class.""" from __future__ import annotations from typing import TYPE_CHECKING from weakref import WeakSet import numpy as np from superqt.utils import qthrottled from vispy.scene import SceneCanvas as SceneCanvas_, Widget from napari._vispy import VispyCamera from napari._vispy.utils.cursor import QtCursorVisual from napari._vispy.utils.gl import get_max_texture_sizes from napari._vispy.utils.visual import create_vispy_overlay from napari.components.overlays import CanvasOverlay, SceneOverlay from napari.utils._proxies import ReadOnlyWrapper from napari.utils.colormaps.standardize_color import transform_color from napari.utils.interactions import ( mouse_double_click_callbacks, mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, mouse_wheel_callbacks, ) from napari.utils.theme import get_theme if TYPE_CHECKING: from typing import Callable, Optional, Union import numpy.typing as npt from qtpy.QtCore import Qt, pyqtBoundSignal from qtpy.QtGui import QCursor, QImage from vispy.app.backends._qt import CanvasBackendDesktop from vispy.app.canvas import DrawEvent, MouseEvent, ResizeEvent from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.overlays.base import VispyBaseOverlay from napari.components import ViewerModel from napari.components.overlays import Overlay from napari.layers import Layer from napari.utils.events.event import Event from napari.utils.key_bindings import KeymapHandler class NapariSceneCanvas(SceneCanvas_): """Vispy SceneCanvas used to allow for ignoring mouse wheel events with modifiers.""" def _process_mouse_event(self, event: MouseEvent): """Ignore mouse wheel events which have modifiers.""" if event.type == 'mouse_wheel' and len(event.modifiers) > 0: return if event.handled: return super()._process_mouse_event(event) class VispyCanvas: """Class for our QtViewer class to interact with Vispy SceneCanvas. Also connects Vispy SceneCanvas events to the napari ViewerModel and vice versa. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. Attributes ---------- layer_to_visual : dict(napari.layers, napari._vispy.layers) A mapping of the napari layers that have been added to the viewer and their corresponding vispy counterparts. max_texture_sizes : Tuple[int, int] The max textures sizes as a (2d, 3d) tuple. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. view : vispy.scene.widgets.viewbox.ViewBox Rectangular widget in which a subscene is rendered. camera : napari._vispy.VispyCamera The camera class which contains both the 2d and 3d camera used to describe the perspective by which a scene is viewed and interacted with. _cursors : QtCursorVisual A QtCursorVisual enum with as names the names of particular cursor styles and as value either a staticmethod creating a bitmap or a Qt.CursorShape enum value corresponding to the particular cursor name. This enum only contains cursors supported by Napari in Vispy. _key_map_handler : napari.utils.key_bindings.KeymapHandler KeymapHandler handling the calling functionality when keys are pressed that have a callback function mapped. _last_theme_color : Optional[npt.NDArray[np.float]] Theme color represented as numpy ndarray of shape (4,) before theme change was applied. _overlay_to_visual : dict(napari.components.overlays, napari._vispy.overlays) A mapping of the napari overlays that are part of the viewer and their corresponding Vispy counterparts. _scene_canvas : napari._vispy.canvas.NapariSceneCanvas SceneCanvas which automatically draws the contents of a scene. It is ultimately a VispySceneCanvas, but allows for ignoring mousewheel events with modifiers. """ _instances: WeakSet[VispyCanvas] = WeakSet() def __init__( self, viewer: ViewerModel, key_map_handler: KeymapHandler, *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 self.viewer = viewer self._scene_canvas = NapariSceneCanvas( *args, keys=None, vsync=True, **kwargs ) self.view = self.central_widget.add_view(border_width=0) self.camera = VispyCamera( self.view, self.viewer.camera, self.viewer.dims ) self.layer_to_visual: dict[Layer, VispyBaseLayer] = {} self._overlay_to_visual: dict[Overlay, VispyBaseOverlay] = {} self._key_map_handler = key_map_handler self._instances.add(self) self.bgcolor = transform_color( get_theme(self.viewer.theme).canvas.as_hex() )[0] # 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() for overlay in self.viewer._overlays.values(): self._add_overlay_to_visual(overlay) self._scene_canvas.events.ignore_callback_errors = False self._scene_canvas.context.set_depth_func('lequal') # Connecting events from SceneCanvas self._scene_canvas.events.key_press.connect( self._key_map_handler.on_key_press ) self._scene_canvas.events.key_release.connect( self._key_map_handler.on_key_release ) self._scene_canvas.events.draw.connect(self.enable_dims_play) self._scene_canvas.events.draw.connect(self.camera.on_draw) self._scene_canvas.events.mouse_double_click.connect( self._on_mouse_double_click ) self._scene_canvas.events.mouse_move.connect( qthrottled(self._on_mouse_move, timeout=5) ) self._scene_canvas.events.mouse_press.connect(self._on_mouse_press) self._scene_canvas.events.mouse_release.connect(self._on_mouse_release) self._scene_canvas.events.mouse_wheel.connect(self._on_mouse_wheel) self._scene_canvas.events.resize.connect(self.on_resize) self._scene_canvas.events.draw.connect(self.on_draw) self.viewer.cursor.events.style.connect(self._on_cursor) self.viewer.cursor.events.size.connect(self._on_cursor) self.viewer.events.theme.connect(self._on_theme_change) self.viewer.camera.events.mouse_pan.connect(self._on_interactive) self.viewer.camera.events.mouse_zoom.connect(self._on_interactive) self.viewer.camera.events.zoom.connect(self._on_cursor) self.viewer.layers.events.reordered.connect(self._reorder_layers) self.viewer.layers.events.removed.connect(self._remove_layer) self.destroyed.connect(self._disconnect_theme) @property def events(self): # This is backwards compatible with the old events system # https://github.com/napari/napari/issues/7054#issuecomment-2205548968 return self._scene_canvas.events @property def destroyed(self) -> pyqtBoundSignal: return self._scene_canvas._backend.destroyed @property def native(self) -> CanvasBackendDesktop: """Returns the native widget of the Vispy SceneCanvas.""" return self._scene_canvas.native @property def screen_changed(self) -> Callable: """Bound method returning signal indicating whether the window screen has changed.""" return self._scene_canvas._backend.screen_changed @property def background_color_override(self) -> Optional[str]: """Background color of VispyCanvas.view returned as hex string. When not None, color is shown instead of VispyCanvas.bgcolor. The setter expects str (any in vispy.color.get_color_names) or hex starting with # or a tuple | np.array ({3,4},) with values between 0 and 1. """ if self.view in self.central_widget._widgets: return self.view.bgcolor.hex return None @background_color_override.setter def background_color_override( self, value: Union[str, npt.ArrayLike, None] ) -> None: if value: self.view.bgcolor = value else: self.view.bgcolor = None def _on_theme_change(self, event: Event) -> None: self._set_theme_change(event.value) def _set_theme_change(self, theme: str) -> None: 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).canvas.as_hex() )[0] self.bgcolor = self._last_theme_color def _disconnect_theme(self) -> None: self.viewer.events.theme.disconnect(self._on_theme_change) @property def bgcolor(self) -> str: """Background color of the vispy scene canvas as a hex string. The setter expects str (any in vispy.color.get_color_names) or hex starting with # or a tuple | np.array ({3,4},) with values between 0 and 1.""" return self._scene_canvas.bgcolor.hex @bgcolor.setter def bgcolor(self, value: Union[str, npt.ArrayLike]) -> None: self._scene_canvas.bgcolor = value @property def central_widget(self) -> Widget: """Overrides SceneCanvas.central_widget to make border_width=0""" if self._scene_canvas._central_widget is None: self._scene_canvas._central_widget = Widget( size=self.size, parent=self._scene_canvas.scene, border_width=0, ) return self._scene_canvas._central_widget @property def size(self) -> tuple[int, int]: """Return canvas size as tuple (height, width) or accepts size as tuple (height, width) and sets Vispy SceneCanvas size as (width, height).""" return self._scene_canvas.size[::-1] @size.setter def size(self, size: tuple[int, int]): self._scene_canvas.size = size[::-1] @property def cursor(self) -> QCursor: """Cursor associated with native widget""" return self.native.cursor() @cursor.setter def cursor(self, q_cursor: Union[QCursor, Qt.CursorShape]): """Setting the cursor of the native widget""" self.native.setCursor(q_cursor) def _on_cursor(self) -> None: """Create a QCursor based on the napari cursor settings and set in Vispy.""" cursor = self.viewer.cursor.style brush_overlay = self.viewer._brush_circle_overlay brush_overlay.visible = False if cursor in {'square', 'circle', 'circle_frozen'}: # 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.size) - 4) ) and cursor != 'circle_frozen': self.cursor = QtCursorVisual['cross'].value elif cursor.startswith('circle'): brush_overlay.size = size if cursor == 'circle_frozen': self.cursor = QtCursorVisual['standard'].value brush_overlay.position_is_frozen = True else: self.cursor = QtCursorVisual.blank() brush_overlay.position_is_frozen = False brush_overlay.visible = True else: self.cursor = QtCursorVisual.square(size) elif cursor == 'crosshair': self.cursor = QtCursorVisual.crosshair() else: self.cursor = QtCursorVisual[cursor].value def delete(self) -> None: """Schedules the native widget for deletion""" self.native.deleteLater() def _on_interactive(self) -> None: """Link interactive attributes of view and viewer.""" # Is this should be changed or renamed? self.view.interactive = ( self.viewer.camera.mouse_zoom or self.viewer.camera.mouse_pan ) def _map_canvas2world( self, position: tuple[int, ...], ) -> tuple[float, float]: """Map position from canvas pixels into world coordinates. Parameters ---------- position : list(int, int) 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 # cartesian to homogeneous coordinates mapped_position = transform.imap(list(position)) if nd == 3: mapped_position = mapped_position[0:nd] / mapped_position[nd] else: mapped_position = mapped_position[0:nd] position_world_slice = np.array(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) def _process_mouse_event( self, mouse_callbacks: Callable, event: MouseEvent ) -> None: """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 : Callable Mouse callbacks function. event : vispy.app.canvas.MouseEvent The vispy mouse event that triggered this method. Returns ------- None """ if event.pos is None: return # Add the view ray to the event event.view_direction = self._calculate_view_direction(event.pos) event.up_direction = self.viewer.camera.calculate_nd_up_direction( self.viewer.dims.ndim, self.viewer.dims.displayed ) # Add the camera zoom scale to the event event.camera_zoom = self.viewer.camera.zoom # Update the cursor position self.viewer.cursor._view_direction = event.view_direction self.viewer.cursor.position = self._map_canvas2world(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, exceptions=('handled',)) mouse_callbacks(self.viewer, event) layer = self.viewer.layers.selection.active if layer is not None: mouse_callbacks(layer, event) def _on_mouse_double_click(self, event: MouseEvent) -> None: """Called whenever a mouse double-click happen on the canvas Parameters ---------- event : vispy.app.canvas.MouseEvent The vispy mouse event that triggered this method. The `event.type` will always be `mouse_double_click` Returns ------- None 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_move(self, event: MouseEvent) -> None: """Called whenever mouse moves over canvas. Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. Returns ------- None """ self._process_mouse_event(mouse_move_callbacks, event) def _on_mouse_press(self, event: MouseEvent) -> None: """Called whenever mouse pressed in canvas. Parameters ---------- event : vispy.app.canvas.MouseEvent The vispy mouse event that triggered this method. Returns ------- None """ self._process_mouse_event(mouse_press_callbacks, event) def _on_mouse_release(self, event: MouseEvent) -> None: """Called whenever mouse released in canvas. Parameters ---------- event : vispy.app.canvas.MouseEvent The vispy mouse event that triggered this method. Returns ------- None """ self._process_mouse_event(mouse_release_callbacks, event) def _on_mouse_wheel(self, event: MouseEvent) -> None: """Called whenever mouse wheel activated in canvas. Parameters ---------- event : vispy.app.canvas.MouseEvent The vispy mouse event that triggered this method. Returns ------- None """ self._process_mouse_event(mouse_wheel_callbacks, event) @property def _canvas_corners_in_world(self) -> npt.NDArray: """Location of the corners of canvas in world coordinates. Returns ------- corners : np.ndarray 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._scene_canvas.size) return np.array([top_left, bottom_right]) def on_draw(self, event: DrawEvent) -> None: """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`. Parameters ---------- event : vispy.app.canvas.DrawEvent The draw event from the vispy canvas. Returns ------- None """ # 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 = list(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._scene_canvas.size, ) def on_resize(self, event: ResizeEvent) -> None: """Called whenever canvas is resized. Parameters ---------- event : vispy.app.canvas.ResizeEvent The vispy event that triggered this method. Returns ------- None """ self.viewer._canvas_size = self.size def add_layer_visual_mapping( self, napari_layer: Layer, vispy_layer: VispyBaseLayer ) -> None: """Maps a napari layer to its corresponding vispy layer and sets the parent scene of the vispy layer. Parameters ---------- napari_layer : Any napari layer, the layer type is the same as the vispy layer. vispy_layer : Any vispy layer, the layer type is the same as the napari layer. Returns ------- None """ vispy_layer.node.parent = self.view.scene self.layer_to_visual[napari_layer] = vispy_layer napari_layer.events.visible.connect(self._reorder_layers) self.viewer.camera.events.angles.connect(vispy_layer._on_camera_move) self._reorder_layers() def _remove_layer(self, event: Event) -> None: """Upon receiving event closes the Vispy visual, deletes it and reorders the still existing layers. Parameters ---------- event : napari.utils.events.event.Event The event causing a particular layer to be removed Returns ------- None """ layer = event.value layer.events.visible.disconnect(self._reorder_layers) vispy_layer = self.layer_to_visual[layer] self.viewer.camera.events.disconnect(vispy_layer._on_camera_move) vispy_layer.close() del vispy_layer del self.layer_to_visual[layer] self._reorder_layers() def _reorder_layers(self) -> None: """When the list is reordered, propagate changes to draw order.""" first_visible_found = False for i, layer in enumerate(self.viewer.layers): vispy_layer = self.layer_to_visual[layer] vispy_layer.order = i # the bottommost visible layer needs special treatment for blending if layer.visible and not first_visible_found: vispy_layer.first_visible = True first_visible_found = True else: vispy_layer.first_visible = False vispy_layer._on_blending_change() self._scene_canvas._draw_order.clear() self._scene_canvas.update() def _add_overlay_to_visual(self, overlay: Overlay) -> None: """Create vispy overlay and add to dictionary of overlay visuals""" vispy_overlay = create_vispy_overlay( 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 _calculate_view_direction(self, event_pos: list[float]) -> list[float]: """calculate view direction by ray shot from the camera""" # this method is only implemented for 3 dimension if self.viewer.dims.ndisplay == 2 or self.viewer.dims.ndim == 2: return self.viewer.camera.calculate_nd_view_direction( self.viewer.dims.ndim, self.viewer.dims.displayed ) x, y = event_pos w, h = self.size nd = self.viewer.dims.ndisplay transform = self.view.scene.transform # map click pos to scene coordinates click_scene = transform.imap([x, y, 0, 1]) # canvas center at infinite far z- (eye position in canvas coordinates) eye_canvas = [w / 2, h / 2, -1e10, 1] # map eye pos to scene coordinates eye_scene = transform.imap(eye_canvas) # homogeneous coordinate to cartesian click_scene = click_scene[0:nd] / click_scene[nd] # homogeneous coordinate to cartesian eye_scene = eye_scene[0:nd] / eye_scene[nd] # calculate direction of the ray d = click_scene - eye_scene d = d[0:nd] d = d / np.linalg.norm(d) # xyz to zyx d = list(d[::-1]) # convert to nd view direction view_direction_nd = np.zeros(self.viewer.dims.ndim) view_direction_nd[list(self.viewer.dims.displayed)] = d return view_direction_nd def screenshot(self) -> QImage: """Return a QImage based on what is shown in the viewer.""" return self.native.grabFramebuffer() def enable_dims_play(self, *args) -> None: """Enable playing of animation. False if awaiting a draw event""" self.viewer.dims._play_ready = True napari-0.5.6/napari/_vispy/filters/000077500000000000000000000000001474413133200172315ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/filters/__init__.py000066400000000000000000000000001474413133200213300ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/filters/tracks.py000066400000000000000000000117511474413133200210770ustar00rootroot00000000000000from typing import Optional, 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 // track should cut off sharply at current_time when head length is 0 // see #6696 for details if ($head_length == 0){ // this prevents a track from being rendered ahead of the // current time point incorrectly due to the interpolation alpha = -1000.; } 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: float = 0, tail_length: float = 30, head_length: float = 0, use_fade: bool = True, vertex_time: Optional[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) -> float: return self._current_time @current_time.setter def current_time(self, n: 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) -> float: return self._tail_length @tail_length.setter def tail_length(self, tail_length: float): self._tail_length = tail_length self.vshader['tail_length'] = float(self._tail_length) @property def head_length(self) -> float: return self._head_length @head_length.setter def head_length(self, head_length: 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.6/napari/_vispy/layers/000077500000000000000000000000001474413133200170605ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/layers/__init__.py000066400000000000000000000000001474413133200211570ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/layers/base.py000066400000000000000000000263671474413133200203620ustar00rootroot00000000000000from abc import ABC, abstractmethod from typing import Generic, TypeVar, cast import numpy as np from vispy.scene import VisualNode from vispy.visuals.transforms import MatrixTransform from napari._vispy.overlays.base import VispyBaseOverlay from napari._vispy.utils.gl import BLENDING_MODES, get_max_texture_sizes from napari.components.overlays.base import ( CanvasOverlay, Overlay, SceneOverlay, ) from napari.layers import Layer from napari.utils.events import disconnect_events _L = TypeVar('_L', bound=Layer) class VispyBaseLayer(ABC, Generic[_L]): """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. """ layer: _L overlays: dict[Overlay, VispyBaseOverlay] def __init__(self, layer: _L, node: VisualNode) -> None: super().__init__() self.events = None # Some derived classes have events. self.layer = layer self._array_like = False self.node = node self.first_visible = False 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 self._on_blending_change() @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, event=None): blending = self.layer.blending blending_kwargs = cast(dict, BLENDING_MODES[blending]).copy() if self.first_visible: # if the first layer, then we should blend differently # the goal is to prevent pathological blending with canvas # for minimum, use the src color, ignore alpha & canvas if blending == 'minimum': src_color_blending = 'one' dst_color_blending = 'zero' # for additive, use the src alpha and blend to black elif blending == 'additive': src_color_blending = 'src_alpha' dst_color_blending = 'zero' # for all others, use translucent blending else: src_color_blending = 'src_alpha' dst_color_blending = 'one_minus_src_alpha' blending_kwargs = { 'depth_test': blending_kwargs['depth_test'], 'cull_face': False, 'blend': True, 'blend_func': ( src_color_blending, dst_color_blending, 'one', 'one', ), 'blend_equation': 'func_add', } 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 with self.layer.events._overlays.blocker(): 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): dims_displayed = self.layer._slice_input.displayed # mypy: self.layer._transforms.simplified cannot be None transform = self.layer._transforms.simplified.set_slice(dims_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 # The following accounts for the offset between samples at different # resolutions of 3D multi-scale array-like layers (e.g. images). # The 2D case is handled differently because that has more complex support # (multiple levels, partial field-of-view) that also currently interacts # with how pixels are centered (see further below). if ( self._array_like and self.layer._slice_input.ndisplay == 3 and self.layer.multiscale and hasattr(self.layer, 'downsample_factors') ): # The last downsample factor is used because we only ever show the # last/lowest multi-scale level for 3D. translate += ( # displayed dimensions, order inverted to match VisPy, then # adjust by half a pixel per downscale level self.layer.downsample_factors[-1][dims_displayed][::-1] - 1 ) / 2 # 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 child_offset = np.zeros(len(dims_displayed)) 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( dims_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 if self.layer.multiscale: # For performance reasons, when displaying multiscale images, # only the part of the data that is visible on the canvas is # sent as a texture to the GPU. This means that the texture # gets an additional transform, to position the texture # correctly offset from the origin of the full data. However, # child nodes, which include overlays such as bounding boxes, # should *not* receive this offset, so we undo it here: child_offset = ( np.ones(offset_matrix.shape[1]) / 2 - self.layer.corner_pixels[0][dims_displayed][::-1] ) else: child_offset = np.ones(offset_matrix.shape[1]) / 2 self._master_transform.matrix = affine_matrix child_matrix = np.eye(4) child_matrix[-1, : len(child_offset)] = child_offset for child in self.node.children: child.transform.matrix = child_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 _on_camera_move(self, event=None): return 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() self._on_camera_move() def _on_poll(self, event=None): """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. """ def close(self): """Vispy visual is closing.""" disconnect_events(self.layer.events, self) self.node.transforms = MatrixTransform() self.node.parent = None napari-0.5.6/napari/_vispy/layers/image.py000066400000000000000000000145621474413133200205240ustar00rootroot00000000000000from __future__ import annotations from typing import Optional import numpy as np from vispy.color import Colormap as VispyColormap from vispy.scene import Node from napari._vispy.layers.scalar_field import ( _VISPY_FORMAT_TO_DTYPE, ScalarFieldLayerNode, VispyScalarFieldBaseLayer, ) from napari._vispy.utils.gl import 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.layers.image.image import Image from napari.utils.colormaps.colormap_utils import _coerce_contrast_limits from napari.utils.translations import trans class ImageLayerNode(ScalarFieldLayerNode): def __init__( self, custom_node: Node = None, texture_format: Optional[str] = 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 if (texture_format is None or texture_format == 'auto') else np.array([[0.0]], dtype=np.float32) ), 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, dtype: Optional[np.dtype] = None ) -> 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. res = self._image_node if ndisplay == 2 else self._volume_node if ( res.texture_format not in {'auto', None} and dtype is not None and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype ): # it is a bug to hit this error — it is here to catch bugs # early when we are creating the wrong nodes or # textures for our data raise ValueError( trans._( 'dtype {dtype} does not match texture_format={texture_format}', dtype=dtype, texture_format=res.texture_format, ) ) return res class VispyImageLayer(VispyScalarFieldBaseLayer): layer: Image def __init__( self, layer: Image, node=None, texture_format='auto', layer_node_class=ImageLayerNode, ) -> None: super().__init__( layer, node=node, texture_format=texture_format, layer_node_class=layer_node_class, ) self.layer.events.interpolation2d.connect( self._on_interpolation_change ) self.layer.events.interpolation3d.connect( self._on_interpolation_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) # 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_interpolation_change(self) -> None: self.node.interpolation = ( self.layer.interpolation2d if self.layer._slice_input.ndisplay == 2 else self.layer.interpolation3d ) def _on_rendering_change(self) -> None: super()._on_rendering_change() self._on_attenuation_change() self._on_iso_threshold_change() def _on_colormap_change(self, event=None) -> None: self.node.cmap = VispyColormap(*self.layer.colormap) def _update_mip_minip_cutoff(self) -> None: # 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) -> None: self.node.clim = _coerce_contrast_limits( self.layer.contrast_limits ).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, event=None) -> None: 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) -> None: self.node.gamma = self.layer.gamma def _on_iso_threshold_change(self) -> None: 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) -> None: if isinstance(self.node, VolumeNode): self.node.attenuation = self.layer.attenuation def reset(self, event=None) -> None: super().reset() self._on_interpolation_change() self._on_colormap_change() self._on_contrast_limits_change() self._on_gamma_change() napari-0.5.6/napari/_vispy/layers/labels.py000066400000000000000000000300461474413133200206770ustar00rootroot00000000000000import math from typing import TYPE_CHECKING import numpy as np from vispy.color import Colormap as VispyColormap from vispy.gloo import Texture2D from vispy.scene.node import Node from napari._vispy.layers.scalar_field import ( _DTYPE_TO_VISPY_FORMAT, _VISPY_FORMAT_TO_DTYPE, ScalarFieldLayerNode, VispyScalarFieldBaseLayer, get_dtype_from_vispy_texture_format, ) from napari._vispy.utils.gl import get_max_texture_sizes from napari._vispy.visuals.labels import LabelNode from napari._vispy.visuals.volume import Volume as VolumeNode from napari.utils.colormaps.colormap import ( CyclicLabelColormap, _texture_dtype, ) if TYPE_CHECKING: from napari.layers import Labels ColorTuple = tuple[float, float, float, float] auto_lookup_shader_uint8 = """ uniform sampler2D texture2D_values; vec4 sample_label_color(float t) { if (($use_selection) && ($selection != int(t * 255))) { return vec4(0); } return texture2D( texture2D_values, vec2(0.0, t) ); } """ auto_lookup_shader_uint16 = """ uniform sampler2D texture2D_values; vec4 sample_label_color(float t) { // uint 16 t = t * 65535; if (($use_selection) && ($selection != int(t))) { return vec4(0); } float v = mod(t, 256); float v2 = (t - v) / 256; return texture2D( texture2D_values, vec2((v + 0.5) / 256, (v2 + 0.5) / 256) ); } """ direct_lookup_shader = """ uniform sampler2D texture2D_values; uniform vec2 LUT_shape; vec4 sample_label_color(float t) { t = t * $scale; return texture2D( texture2D_values, vec2(0.0, (t + 0.5) / $color_map_size) ); } """ direct_lookup_shader_many = """ uniform sampler2D texture2D_values; uniform vec2 LUT_shape; vec4 sample_label_color(float t) { t = t * $scale; float row = mod(t, LUT_shape.x); float col = int(t / LUT_shape.x); return texture2D( texture2D_values, vec2((col + 0.5) / LUT_shape.y, (row + 0.5) / LUT_shape.x) ); } """ class LabelVispyColormap(VispyColormap): def __init__( self, colormap: CyclicLabelColormap, view_dtype: np.dtype, raw_dtype: np.dtype, ): super().__init__( colors=['w', 'w'], controls=None, interpolation='zero' ) if view_dtype.itemsize == 1: shader = auto_lookup_shader_uint8 elif view_dtype.itemsize == 2: shader = auto_lookup_shader_uint16 else: # See https://github.com/napari/napari/issues/6397 # Using f32 dtype for textures resulted in very slow fps # Therefore, when we have {u,}int{8,16}, we use a texture # of that size, but when we have higher bits, we convert # to 8-bit on the CPU before sending to the shader. # It should thus be impossible to reach this condition. raise ValueError( # pragma: no cover f'Cannot use dtype {view_dtype} with LabelVispyColormap' ) selection = colormap._selection_as_minimum_dtype(raw_dtype) self.glsl_map = ( shader.replace('$color_map_size', str(len(colormap.colors))) .replace('$use_selection', str(colormap.use_selection).lower()) .replace('$selection', str(selection)) ) class DirectLabelVispyColormap(VispyColormap): def __init__( self, use_selection=False, selection=0.0, scale=1.0, color_map_size=255, multi=False, ): colors = ['w', 'w'] # dummy values, since we use our own machinery super().__init__(colors, controls=None, interpolation='zero') shader = direct_lookup_shader_many if multi else direct_lookup_shader self.glsl_map = ( shader.replace('$use_selection', str(use_selection).lower()) .replace('$selection', str(selection)) .replace('$scale', str(scale)) .replace('$color_map_size', str(color_map_size)) ) def build_textures_from_dict( color_dict: dict[int, ColorTuple], max_size: int ) -> np.ndarray: """This code assumes that the keys in the color_dict are sequential from 0. If any keys are larger than the size of the dictionary, they will overwrite earlier keys in the best case, or it might just crash. """ if len(color_dict) > 2**23: raise ValueError( # pragma: no cover 'Cannot map more than 2**23 colors because of float32 precision. ' f'Got {len(color_dict)}' ) if len(color_dict) > max_size**2: raise ValueError( 'Cannot create a 2D texture holding more than ' f'{max_size}**2={max_size**2} colors.' f'Got {len(color_dict)}' ) data = np.zeros( ( min(len(color_dict), max_size), math.ceil(len(color_dict) / max_size), 4, ), dtype=np.float32, ) for key, value in color_dict.items(): data[key % data.shape[0], key // data.shape[0]] = value return data def _select_colormap_texture( colormap: CyclicLabelColormap, view_dtype, raw_dtype ) -> np.ndarray: if raw_dtype.itemsize > 2: color_texture = colormap._get_mapping_from_cache(view_dtype) else: color_texture = colormap._get_mapping_from_cache(raw_dtype) if color_texture is None: raise ValueError( # pragma: no cover f'Cannot build a texture for dtype {raw_dtype=} and {view_dtype=}' ) return color_texture.reshape(256, -1, 4) class VispyLabelsLayer(VispyScalarFieldBaseLayer): layer: 'Labels' def __init__(self, layer, node=None, texture_format='r8') -> None: super().__init__( layer, node=node, texture_format=texture_format, layer_node_class=LabelLayerNode, ) self.layer.events.labels_update.connect(self._on_partial_labels_update) self.layer.events.selected_label.connect(self._on_colormap_change) self.layer.events.show_selected_label.connect(self._on_colormap_change) self.layer.events.iso_gradient_mode.connect( self._on_iso_gradient_mode_change ) self.layer.events.data.connect(self._on_colormap_change) # as we generate colormap texture based on the data type, we need to # update it when the data type changes def _on_rendering_change(self): # overriding the Image method, so we can maintain the same old rendering name if isinstance(self.node, VolumeNode): rendering = self.layer.rendering self.node.method = ( rendering if rendering != 'translucent' else 'translucent_categorical' ) def _on_colormap_change(self, event=None): # self.layer.colormap is a labels_colormap, which is an evented model # from napari.utils.colormaps.Colormap (or similar). If we use it # in our constructor, we have access to the texture data we need if ( event is not None and event.type == 'selected_label' and not self.layer.show_selected_label ): return colormap = self.layer.colormap auto_mode = isinstance(colormap, CyclicLabelColormap) view_dtype = self.layer._slice.image.view.dtype raw_dtype = self.layer._slice.image.raw.dtype if auto_mode or raw_dtype.itemsize <= 2: if raw_dtype.itemsize > 2: # If the view dtype is different from the raw dtype, it is possible # that background pixels are not the same value as the `background_value`. # For example, if raw_dtype is int8 and background_value is `-1` # then in view dtype uint8, the background pixels will be 255 # For data types with more than 16 bits we always cast # to uint8 or uint16 and background_value is always 0 in a view array. # The LabelColormap is EventedModel, so we need to make # a copy instead of temporary overwrite the background_value colormap = CyclicLabelColormap(**colormap.dict()) colormap.background_value = ( colormap._background_as_minimum_dtype(raw_dtype) ) color_texture = _select_colormap_texture( colormap, view_dtype, raw_dtype ) self.node.cmap = LabelVispyColormap( colormap, view_dtype=view_dtype, raw_dtype=raw_dtype ) self.node.shared_program['texture2D_values'] = Texture2D( color_texture, internalformat='rgba32f', interpolation='nearest', ) self.texture_data = color_texture elif not auto_mode: # only for raw_dtype.itemsize > 2 color_dict = colormap._values_mapping_to_minimum_values_set()[1] max_size = get_max_texture_sizes()[0] val_texture = build_textures_from_dict(color_dict, max_size) dtype = _texture_dtype( self.layer._direct_colormap._num_unique_colors + 2, raw_dtype, ) if issubclass(dtype.type, np.integer): scale = np.iinfo(dtype).max else: # float32 texture scale = 1.0 self.node.cmap = DirectLabelVispyColormap( use_selection=colormap.use_selection, selection=colormap.selection, scale=scale, color_map_size=val_texture.shape[0], multi=val_texture.shape[1] > 1, ) self.node.shared_program['texture2D_values'] = Texture2D( val_texture, internalformat='rgba32f', interpolation='nearest', ) self.node.shared_program['LUT_shape'] = val_texture.shape[:2] else: self.node.cmap = VispyColormap(*colormap) def _on_iso_gradient_mode_change(self): if isinstance(self.node, VolumeNode): self.node.iso_gradient_mode = self.layer.iso_gradient_mode def _on_partial_labels_update(self, event): if not self.layer.loaded: return raw_displayed = self.layer._slice.image.raw ndims = len(event.offset) if self.node._texture.shape[:ndims] != raw_displayed.shape[:ndims]: # TODO: I'm confused by this whole process; should this refresh be changed? self.layer.refresh() return self.node._texture.scale_and_set_data( event.data, copy=False, offset=event.offset ) self.node.update() def reset(self, event=None) -> None: super().reset() self._on_colormap_change() self._on_iso_gradient_mode_change() class LabelLayerNode(ScalarFieldLayerNode): def __init__(self, custom_node: Node = None, texture_format=None): self._custom_node = custom_node self._setup_nodes(texture_format) def _setup_nodes(self, texture_format): self._image_node = LabelNode( ( None if (texture_format is None or texture_format == 'auto') else np.zeros( (1, 1), dtype=get_dtype_from_vispy_texture_format(texture_format), ) ), method='auto', texture_format=texture_format, ) self._volume_node = VolumeNode( np.zeros( (1, 1, 1), dtype=get_dtype_from_vispy_texture_format(texture_format), ), clim=[0, 2**23 - 1], texture_format=texture_format, interpolation='nearest', ) def get_node(self, ndisplay: int, dtype=None) -> Node: res = self._image_node if ndisplay == 2 else self._volume_node if ( res.texture_format != 'auto' and dtype is not None and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype ): self._setup_nodes(_DTYPE_TO_VISPY_FORMAT[dtype]) return self.get_node(ndisplay, dtype) return res napari-0.5.6/napari/_vispy/layers/points.py000066400000000000000000000174541474413133200207610ustar00rootroot00000000000000import 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): node: PointsVisual def __init__(self, layer) -> None: node = PointsVisual() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) self.layer.events.border_width.connect(self._on_data_change) self.layer.events.border_width_is_relative.connect( self._on_data_change ) self.layer.events.border_color.connect(self._on_data_change) self.layer._border.events.colors.connect(self._on_data_change) self.layer._border.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 = np.zeros(1) border_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) border_width = np.zeros(1) symbol = ['o'] else: data = self.layer._view_data size = self.layer._view_size border_color = self.layer._view_border_color face_color = self.layer._view_face_color border_width = self.layer._view_border_width symbol = [str(x) for x in self.layer._view_symbol] set_data = self.node.points_markers.set_data # use only last dimension to scale point sizes, see #5582 scale = self.layer.scale[-1] if self.layer.border_width_is_relative: border_kw = { 'edge_width': None, 'edge_width_rel': border_width, } else: border_kw = { 'edge_width': border_width * scale, 'edge_width_rel': None, } set_data( data[:, ::-1], size=size * scale, symbol=symbol, # edge_color is the name of the vispy marker visual kwarg edge_color=border_color, face_color=face_color, **border_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] border_width = self.layer._view_border_width[ self.layer._highlight_index ] if self.layer.border_width_is_relative: border_width = ( border_width * self.layer._view_size[self.layer._highlight_index][-1] ) symbol = self.layer._view_symbol[self.layer._highlight_index] else: data = np.zeros((1, self.layer._slice_input.ndisplay)) size = 0 symbol = ['o'] border_width = np.array([0]) scale = self.layer.scale[-1] highlight_thickness = settings.appearance.highlight.highlight_thickness scaled_highlight = highlight_thickness * self.layer.scale_factor scaled_size = (size + border_width) * scale highlight_color = tuple(settings.appearance.highlight.highlight_color) self.node.selection_markers.set_data( data[:, ::-1], size=scaled_size, symbol=symbol, edge_width=scaled_highlight * 2, edge_color=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)) highlight_thickness = 0 else: pos = self.layer._highlight_box self.node.highlight_lines.set_data( pos=pos[:, ::-1], color=highlight_color, width=highlight_thickness, ) 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""" return self.node.text 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, event=None): """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.highlight_lines.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 self.node.spherical = shading == 'spherical' def _on_canvas_size_limits_change(self): self.node.points_markers.canvas_size_limits = ( self.layer.canvas_size_limits ) highlight_thickness = ( get_settings().appearance.highlight.highlight_thickness ) low, high = self.layer.canvas_size_limits self.node.selection_markers.canvas_size_limits = ( low + highlight_thickness, high + highlight_thickness, ) self.node.update() 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.6/napari/_vispy/layers/scalar_field.py000066400000000000000000000214721474413133200220500ustar00rootroot00000000000000from __future__ import annotations import warnings from abc import ABC, abstractmethod from typing import Optional import numpy as np from vispy.scene import Node from vispy.visuals import ImageVisual from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import fix_data_dtype from napari._vispy.visuals.volume import Volume as VolumeNode from napari.layers._scalar_field.scalar_field import ScalarFieldBase from napari.utils.translations import trans class ScalarFieldLayerNode(ABC): """Abstract base class for scalar field layer nodes.""" @abstractmethod def __init__(self, node=None, texture_format='auto') -> None: raise NotImplementedError @abstractmethod def get_node( self, ndisplay: int, dtype: Optional[np.dtype] = None ) -> Node: """Return the appropriate node for the given ndisplay and dtype.""" raise NotImplementedError class VispyScalarFieldBaseLayer(VispyBaseLayer[ScalarFieldBase]): def __init__( self, layer: ScalarFieldBase, node=None, texture_format='auto', layer_node_class=ScalarFieldLayerNode, ) -> None: # Use custom node from caller, or our standard image/volume nodes. self._layer_node = layer_node_class( 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.colormap.connect(self._on_colormap_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) self.layer.events.custom_interpolation_kernel_2d.connect( self._on_custom_interpolation_kernel_2d_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) -> None: parent = self.node.parent self.node.parent = None ndisplay = self.layer._slice_input.ndisplay self.node = self._layer_node.get_node( ndisplay, getattr(data, 'dtype', None) ) if data is None: texture_format = self.node.texture_format data = np.zeros( (1,) * ndisplay, dtype=get_dtype_from_vispy_texture_format(texture_format), ) self.node.visible = not self.layer._slice.empty and self.layer.visible 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) -> None: data = fix_data_dtype(self.layer._data_view) ndisplay = self.layer._slice_input.ndisplay node = self._layer_node.get_node( ndisplay, getattr(data, 'dtype', None) ) if ndisplay > data.ndim: data = data.reshape((1,) * (ndisplay - data.ndim) + data.shape) # 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, ImageVisual)) or node != self.node ): self._on_display_change(data) else: node.set_data(data) node.visible = not self.layer._slice.empty and self.layer.visible # Call to update order of translation values with new dims: self._on_matrix_change() node.update() def _on_custom_interpolation_kernel_2d_change(self) -> None: if self.layer._slice_input.ndisplay == 2: self.node.custom_kernel = self.layer.custom_interpolation_kernel_2d def _on_rendering_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.method = self.layer.rendering def _on_depiction_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.raycasting_mode = str(self.layer.depiction) def _on_blending_change(self, event=None) -> None: super()._on_blending_change() def _on_plane_thickness_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_thickness = self.layer.plane.thickness def _on_plane_position_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_position = self.layer.plane.position def _on_plane_normal_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_normal = self.layer.plane.normal def _on_colormap_change(self, event=None) -> None: raise NotImplementedError def reset(self, event=None) -> None: super().reset() self._on_rendering_change() self._on_depiction_change() self._on_plane_position_change() self._on_plane_normal_change() self._on_plane_thickness_change() self._on_custom_interpolation_kernel_2d_change() def downsample_texture( self, data: np.ndarray, MAX_TEXTURE_SIZE: int ) -> np.ndarray: """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 individual 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] # tile2data is a ScaleTransform thus is has a .scale attribute, but # mypy cannot know this. 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 _VISPY_FORMAT_TO_DTYPE: dict[Optional[str], np.dtype] = { 'r8': np.dtype(np.uint8), 'r16': np.dtype(np.uint16), 'r32f': np.dtype(np.float32), } _DTYPE_TO_VISPY_FORMAT = {v: k for k, v in _VISPY_FORMAT_TO_DTYPE.items()} # this is moved after reverse mapping is defined # to always have non None values in _DTYPE_TO_VISPY_FORMAT _VISPY_FORMAT_TO_DTYPE[None] = np.dtype(np.float32) def get_dtype_from_vispy_texture_format(format_str: str) -> np.dtype: """Get the numpy dtype from a vispy texture format string. Parameters ---------- format_str : str The vispy texture format string. Returns ------- dtype : numpy.dtype The numpy dtype corresponding to the vispy texture format string. """ return _VISPY_FORMAT_TO_DTYPE.get(format_str, np.dtype(np.float32)) napari-0.5.6/napari/_vispy/layers/shapes.py000066400000000000000000000124511474413133200207200ustar00rootroot00000000000000import typing import 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 if typing.TYPE_CHECKING: from napari.layers import Shapes class VispyShapesLayer(VispyBaseLayer): node: ShapesVisual layer: 'Shapes' 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.highlight_vertices.symbol = 'square' self.node.highlight_vertices.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.shape_faces.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.highlight_thickness ) self.layer._highlight_color = ( settings.appearance.highlight.highlight_color ) # 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.shape_highlights.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, _, ) = self.layer._compute_vertices_and_box() width = settings.appearance.highlight.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.highlight_vertices.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.highlight_lines.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""" return self.node.text 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.6/napari/_vispy/layers/surface.py000066400000000000000000000202031474413133200210570ustar00rootroot00000000000000import numpy as np from vispy.color import Colormap as VispyColormap from vispy.geometry import MeshData from vispy.visuals.filters import TextureFilter 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._texture_filter = None self._light_direction = (-1, 1, 1) 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.events.texture.connect(self._on_texture_change) self.layer.events.texcoords.connect(self._on_texture_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): vertices = None faces = None vertex_values = None vertex_colors = None if len(self.layer._data_view) and len(self.layer._view_faces): # 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] values = self.layer._view_vertex_values if len(values): vertex_values = values colors = self.layer._view_vertex_colors if len(colors): vertex_colors = colors # making sure the vertex data is 3D prevents shape errors with # attached filters, instead of trying to attach/detach each time if vertices is not None and vertices.shape[-1] == 2: vertices = np.pad( vertices, ((0, 0), (0, 1)), mode='constant', constant_values=0, ) assert vertices is None or vertices.shape[-1] == 3 self.node.set_data( vertices=vertices, faces=faces, vertex_values=vertex_values, vertex_colors=vertex_colors, ) # disable normals in 2D to avoid shape errors if self.layer._slice_input.ndisplay == 2: self._meshdata = MeshData() else: self._meshdata = self.node.mesh_data self._on_face_normals_change() self._on_vertex_normals_change() self._on_texture_change() self._on_shading_change() self.node.update() # Call to update order of translation values with new dims: self._on_matrix_change() def _on_texture_change(self): """Update or apply the texture filter""" # texture images need to be flipped (np.flipud) because of how OpenGL # expects the texture data to be ordered in memory we flip them here # when setting up the TextureFilter so napari users can load images # for textures normally # https://registry.khronos.org/OpenGL-Refpages/gl4/html/glTexImage2D.xhtml if self.layer._has_texture and self._texture_filter is None: self._texture_filter = TextureFilter( np.flipud(self.layer.texture), self.layer.texcoords, ) self.node.attach(self._texture_filter) elif self.layer._has_texture: self._texture_filter.texture = np.flipud(self.layer.texture) self._texture_filter.texcoords = self.layer.texcoords if self._texture_filter is not None: self._texture_filter.enabled = self.layer._has_texture self.node.update() 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 not self.node.mesh_data.is_empty(): self.node.shading = shading self._on_camera_move() 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 _on_camera_move(self, event=None): if ( event is not None and event.type == 'angles' and self.layer._slice_input.ndisplay == 3 ): camera = event.source # take displayed up and view directions and flip zyx for vispy up = np.array(camera.up_direction)[::-1] view = np.array(camera.view_direction)[::-1] # combine to get light behind the camera on the top right self._light_direction = view - up + np.cross(up, view) if ( self.node.shading_filter is not None and self._meshdata._vertices is not None ): self.node.shading_filter.light_dir = self._light_direction def reset(self, event=None): super().reset() self._on_colormap_change() self._on_contrast_limits_change() self._on_shading_change() self._on_texture_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.6/napari/_vispy/layers/tracks.py000066400000000000000000000115021474413133200207200ustar00rootroot00000000000000from 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.6/napari/_vispy/layers/vectors.py000066400000000000000000000302341474413133200211210ustar00rootroot00000000000000import 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, self.layer.vector_style, ) 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, vector_style): """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 vectors' bases length : float length multiplier of the line to be drawn vector_style : VectorStyle display style of the vectors Returns ------- vertices : (aN, 2) array for 2D and (2aN, 2) array for 3D, with a=4, 3, or 7 for vector_style='line', 'triangle', or 'arrow' respectively Vertices of all triangles triangles : (bN, 3) array for 2D or (2bN, 3) array for 3D, with b=2, 1, or 3 for vector_style='line', 'triangle', or 'arrow' respectively Vertex indices that form the mesh triangles """ ndim = vectors.shape[2] if ndim == 2: vertices, triangles = generate_vector_meshes_2D( vectors, width, length, vector_style ) else: v_a, t_a = generate_vector_meshes_2D( vectors, width, length, vector_style, p=(0, 0, 1) ) v_b, t_b = generate_vector_meshes_2D( vectors, width, length, vector_style, 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, vector_style, 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 vectors' bases length : float length multiplier of the line to be drawn vector_style : VectorStyle display style of the vectors p : 3-tuple, optional orthogonal vector for segment calculation in 3D. Returns ------- vertices : (aN, 2) array for 2D, with a=4, 3, or 7 for vector_style='line', 'triangle', or 'arrow' respectively Vertices of all triangles triangles : (bN, 3) array for 2D, with b=2, 1, or 3 for vector_style='line', 'triangle', or 'arrow' respectively Vertex indices that form the mesh triangles """ if vector_style == 'line': vertices, triangles = generate_meshes_line_2D( vectors, width, length, p ) elif vector_style == 'triangle': vertices, triangles = generate_meshes_triangle_2D( vectors, width, length, p ) elif vector_style == 'arrow': vertices, triangles = generate_meshes_arrow_2D( vectors, width, length, p ) return vertices, triangles def generate_meshes_line_2D(vectors, width, length, p): """Generates list of mesh vertices and triangles from a list of vectors. Vectors are composed of 4 vertices and 2 triangles. Vertices are generated according to the following scheme:: 1---x---0 | . | | . | | . | 3---v---2 Where x marks the start point of the vector, and v its end point. In the case of k 2D vectors, the output 'triangles' is: [ [0,1,2], # vector 0, triangle i=0 [1,2,3], # vector 0, triangle i=1 [4,5,6], # vector 1, triangle i=2 [5,6,7], # vector 1, triangle i=3 ..., [2i, 2i + 1, 2i + 2], # vector k-1, triangle i=2k-2 (i%2=0) [2i - 1, 2i, 2i + 1] # vector k-1, triangle i=2k-1 (i%2=1) ] 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 vectors' bases length : float length multiplier of the line to be drawn p : 3-tuple orthogonal vector for segment calculation in 3D. Returns ------- vertices : (4N, D) array Vertices of all triangles triangles : (2N, 3) array Vertex indices that form the mesh triangles """ nvectors, _, ndim = vectors.shape vectors_starts = vectors[:, 0] vectors_ends = vectors_starts + length * vectors[:, 1] vertices = np.zeros((4 * nvectors, ndim)) offsets = segment_normal(vectors_starts, vectors_ends, p=p) offsets = np.repeat(offsets, 4, axis=0) signs = np.ones((len(offsets), ndim)) signs[::2] = -1 offsets = offsets * signs vertices[::4] = vectors_starts vertices[1::4] = vectors_starts vertices[2::4] = vectors_ends vertices[3::4] = vectors_ends vertices = vertices + width * offsets / 2 # Generate triangles in two steps: # 1. Repeat the vertices pattern # [[0,1,2], # [1,2,3]] # as described in the docstring vertices_pattern = np.tile([[0, 1, 2], [1, 2, 3]], (nvectors, 1)) # 2. Add an offset to differentiate between vectors triangles = ( vertices_pattern + np.repeat(4 * np.arange(nvectors), 2)[:, np.newaxis] ) triangles = triangles.astype(np.uint32) return vertices, triangles def generate_meshes_triangle_2D(vectors, width, length, p): """Generate meshes forming 2D isosceles triangles to represent input vectors. Vectors are composed of 3 vertices and 1 triangles. Vertices are generated according to the following scheme:: 1---x---0 . . . . . . 2 Where x marks the start point of the vector, and the vertex 2 its end point. In the case of k 2D vectors, the output 'triangles' is: [ [0,1,2], # vector 0, triangle i=0 [3,4,5], # vector 1, triangle i=1 ..., [3i, 3i + 1, 3i + 2] # vector k-1, triangle i=k-1 ] 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 vectors' bases length : float length multiplier of the line to be drawn p : 3-tuple orthogonal vector for segment calculation in 3D. Returns ------- vertices : (3N, D) array Vertices of all triangles triangles : (N, 3) array Vertex indices that form the mesh triangles """ nvectors, _, ndim = vectors.shape vectors_starts = vectors[:, 0] vectors_ends = vectors_starts + length * vectors[:, 1] vertices = np.zeros((3 * nvectors, ndim)) offsets = segment_normal(vectors_starts, vectors_ends, p=p) offsets = np.repeat(offsets, 3, axis=0) signs = np.ones((len(offsets), ndim)) signs[::3] = -1 multipliers = np.ones((len(offsets), ndim)) multipliers[2::3] = 0 # here 'multipliers' is used to prevent vertex 2 from being offset offsets = offsets * signs * multipliers vertices[::3] = vectors_starts vertices[1::3] = vectors_starts vertices[2::3] = vectors_ends vertices = vertices + width * offsets / 2 # faster than using the formula in the docstring triangles = np.arange(3 * nvectors, dtype=np.uint32).reshape((-1, 3)) return vertices, triangles def generate_meshes_arrow_2D(vectors, width, length, p): """Generate mesh forming 2D arrows given input vectors. Vectors are composed of 7 vertices and 3 triangles. Vertices are generated according to the following scheme:: 1---x---0 | . | | . | | . | 5---3-------2---4 . . . . 6 Where x marks the start point of the vector, and the vertex 6 its end point. In the case of k 2D vectors, the output 'triangles' is: [ [0,1,2], # vector 0, triangle i=0 [1,2,3], # vector 0, triangle i=1 [4,5,6], # vector 0, triangle i=2 [7,8,9], # vector 1, triangle i=3 [8,9,10], # vector 1, triangle i=4 [11,12,13], # vector 1, triangle i=5 ..., [7i/3, 7i/3 + 1, 7i/3 + 2], # vector k-1, triangle i=3k-3 (i%3=0) [7(i - 1)/3 + 1, 7(i - 1)/3 + 2, 7(i - 1)/3 + 3], # vector k-1, triangle i=3k-2 (i%3=1) [7(i - 2)/3 + 4, 7(i - 2)/3 + 5, 7(i - 2)/3 + 6] # vector k-1, triangle i=3k-1 (i%3=2) ] 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 vectors' bases length : float length multiplier of the line to be drawn p : 3-tuple orthogonal vector for segment calculation in 3D. Returns ------- vertices : (7N, D) array Vertices of all triangles triangles : (3N, 3) array Vertex indices that form the mesh triangles """ nvectors, _, ndim = vectors.shape vectors_starts = vectors[:, 0] # Will be used to generate the vertices 2,3,4 and 5. # Right now the head of the arrow is put at 75% of the length # of the vector. vectors_intermediates = vectors_starts + 0.75 * length * vectors[:, 1] vectors_ends = vectors_starts + length * vectors[:, 1] vertices = np.zeros((7 * nvectors, ndim)) offsets = segment_normal(vectors_starts, vectors_ends, p=p) offsets = np.repeat(offsets, 7, axis=0) signs = np.ones((len(offsets), ndim)) signs[::2] = -1 multipliers = np.ones((len(offsets), ndim)) multipliers[4::7] = 2 multipliers[5::7] = 2 multipliers[6::7] = 0 # here 'multipliers' is used to prevent vertex 6 from being offset, # and to offset vertices 4 and 5 twice as much as vertices 2 and 3 offsets = offsets * signs * multipliers vertices[::7] = vectors_starts vertices[1::7] = vectors_starts vertices[2::7] = vectors_intermediates vertices[3::7] = vectors_intermediates vertices[4::7] = vectors_intermediates vertices[5::7] = vectors_intermediates vertices[6::7] = vectors_ends vertices = vertices + width * offsets / 2 # Generate triangles in two steps: # 1. Repeat the vertices pattern # [[0,1,2], # [1,2,3] # [4,5,6]] # as described in the docstring vertices_pattern = np.tile( [[0, 1, 2], [1, 2, 3], [4, 5, 6]], (nvectors, 1) ) # 2. Add an offset to differentiate between vectors triangles = ( vertices_pattern + np.repeat(7 * np.arange(nvectors), 3)[:, np.newaxis] ) triangles = triangles.astype(np.uint32) return vertices, triangles napari-0.5.6/napari/_vispy/overlays/000077500000000000000000000000001474413133200174255ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/overlays/__init__.py000066400000000000000000000000001474413133200215240ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/overlays/axes.py000066400000000000000000000055141474413133200207440ustar00rootroot00000000000000import 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.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).canvas, dashed=self.overlay.dashed, arrows=self.overlay.arrows, ) self._on_labels_text_change() 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] axis_labels = [self.viewer.dims.axis_labels[a] for a in axes] self.node.text.text = axis_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_zoom_change() napari-0.5.6/napari/_vispy/overlays/base.py000066400000000000000000000130721474413133200207140ustar00rootroot00000000000000from typing import TYPE_CHECKING from vispy.visuals.transforms import MatrixTransform, STTransform from napari._vispy.utils.gl import BLENDING_MODES from napari.components._viewer_constants import CanvasPosition from napari.utils.events import disconnect_events from napari.utils.translations import trans if TYPE_CHECKING: from napari.layers import Layer 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) self.overlay.events.blending.connect(self._on_blending_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 _on_blending_change(self): self.node.set_gl_state(**BLENDING_MODES[self.overlay.blending]) self.node.update() def reset(self): self._on_visible_change() self._on_opacity_change() self._on_blending_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: '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.6/napari/_vispy/overlays/bounding_box.py000066400000000000000000000044021474413133200224540ustar00rootroot00000000000000import numpy as np from 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_augmented_data_level( self.layer._slice_input.displayed ) if len(bounds) == 2: # 2d layers are assumed to be at 0 in the 3rd dimension bounds = np.pad(bounds, ((1, 0), (0, 0))) self.node.set_bounds(bounds[::-1]) # invert for vispy def _on_lines_change(self): self.node.lines.visible = self.overlay.lines def _on_points_change(self): self.node.markers.visible = self.overlay.points def _on_line_thickness_change(self): self.node._line_thickness = self.overlay.line_thickness self._on_bounds_change() def _on_line_color_change(self): self.node._line_color = self.overlay.line_color self._on_bounds_change() 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.6/napari/_vispy/overlays/brush_circle.py000066400000000000000000000055761474413133200224600ustar00rootroot00000000000000from vispy.scene.visuals import Compound, Ellipse from napari._vispy.overlays.base import ViewerOverlayMixin, VispyCanvasOverlay class VispyBrushCircleOverlay(ViewerOverlayMixin, VispyCanvasOverlay): def __init__(self, *, viewer, overlay, parent=None): self._white_circle = Ellipse( center=(0, 0), color=(0, 0, 0, 0.0), border_color='white', border_method='agg', ) self._black_circle = Ellipse( center=(0, 0), color=(0, 0, 0, 0.0), border_color='black', border_method='agg', ) super().__init__( node=Compound([self._white_circle, self._black_circle]), viewer=viewer, overlay=overlay, parent=parent, ) self._last_mouse_pos = None self.overlay.events.size.connect(self._on_size_change) self.node.events.canvas_change.connect(self._on_canvas_change) self.viewer.events.mouse_over_canvas.connect( self._on_mouse_over_canvas ) # no need to connect position, since that's in the base classes of CanvasOverlay self.reset() def _on_position_change(self, event=None): self._set_position(self.overlay.position) def _on_size_change(self, event=None): self._white_circle.radius = self.overlay.size / 2 self._black_circle.radius = self._white_circle.radius - 1 def _on_visible_change(self): if self._last_mouse_pos is not None: self._set_position(self._last_mouse_pos) self.node.visible = ( self.overlay.visible and self.viewer.mouse_over_canvas ) def _on_mouse_move(self, event): self._last_mouse_pos = event.pos if self.overlay.visible: self.overlay.position = event.pos.tolist() def _set_position(self, pos): if not self.overlay.position_is_frozen: self.node.transform.translate = [pos[0], pos[1], 0, 0] def _on_canvas_change(self, event): if event.new is not None: event.new.events.mouse_move.connect(self._on_mouse_move) if event.old is not None: event.old.events.mouse_move.disconnect(self._on_mouse_move) def _on_mouse_over_canvas(self): if self.viewer.mouse_over_canvas: # Move the cursor outside the canvas when the mouse leaves it. # It fixes the bug described in PR #5763: # https://github.com/napari/napari/pull/5763#issuecomment-1523182141 self._set_position((-1000, -1000)) self.node.visible = self.overlay.visible else: if self.overlay.visible: self.node.visible = self.overlay.position_is_frozen else: self.node.visible = False def reset(self): super().reset() self._on_size_change() self._last_mouse_pos = None napari-0.5.6/napari/_vispy/overlays/interaction_box.py000066400000000000000000000060171474413133200231720ustar00rootroot00000000000000from 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_augmented_data_level( 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.6/napari/_vispy/overlays/labels_polygon.py000066400000000000000000000201471474413133200230140ustar00rootroot00000000000000import numpy as np from vispy.scene.visuals import Compound, Line, Markers, Polygon from napari._vispy.overlays.base import LayerOverlayMixin, VispySceneOverlay from napari.components.overlays import LabelsPolygonOverlay from napari.layers import Labels from napari.layers.labels._labels_constants import Mode from napari.layers.labels._labels_utils import mouse_event_to_labels_coordinate from napari.settings import get_settings def _only_when_enabled(callback): """Decorator that wraps a callback of VispyLabelsPolygonOverlay. It ensures that the callback is only executed when all the conditions are met: 1) The overlay is enabled; 2) The number of displayed dimensions is 2 (it can only work in 2D); 3) The number of dimensions across which labels will be edited is 2. If 2, 3 are not met, the Labels mode is automatically switched to PAN_ZOOM. """ def decorated_callback(self, layer: Labels, event): if not self.overlay.enabled: return if layer._slice_input.ndisplay != 2 or layer.n_edit_dimensions != 2: layer.mode = Mode.PAN_ZOOM return callback(self, layer, event) return decorated_callback class VispyLabelsPolygonOverlay(LayerOverlayMixin, VispySceneOverlay): layer: Labels def __init__( self, *, layer: Labels, overlay: LabelsPolygonOverlay, parent=None ): points = [(0, 0), (1, 1)] self._nodes_kwargs = { 'face_color': (1, 1, 1, 1), 'size': 8.0, 'edge_width': 1.0, 'edge_color': (0, 0, 0, 1), } self._nodes = Markers(pos=np.array(points), **self._nodes_kwargs) self._polygon = Polygon( pos=points, border_method='agg', ) self._line = Line(pos=points, method='agg') super().__init__( node=Compound([self._polygon, self._nodes, self._line]), layer=layer, overlay=overlay, parent=parent, ) self.layer.mouse_move_callbacks.append(self._on_mouse_move) self.layer.mouse_drag_callbacks.append(self._on_mouse_press) self.layer.mouse_double_click_callbacks.append( self._on_mouse_double_click ) self.overlay.events.points.connect(self._on_points_change) self.overlay.events.enabled.connect(self._on_enabled_change) layer.events.selected_label.connect(self._update_color) layer.events.colormap.connect(self._update_color) layer.events.opacity.connect(self._update_color) self._first_point_pos = np.zeros(2) # set completion radius based on settings self._on_completion_radius_settings_change() get_settings().experimental.events.completion_radius.connect( self._on_completion_radius_settings_change ) self.reset() self._update_color() # If there are no points, it won't be visible self.overlay.visible = True def _on_completion_radius_settings_change(self, event=None): completion_radius_setting = ( get_settings().experimental.completion_radius ) # if setting is -1, then the completion_radius is disabled # so double click always works. If >0, use the radius if completion_radius_setting > 0: self.overlay.use_double_click_completion_radius = True self.overlay.completion_radius = completion_radius_setting def _on_enabled_change(self): if self.overlay.enabled: self._on_points_change() def _on_points_change(self): num_points = len(self.overlay.points) if num_points: points = np.array(self.overlay.points)[ :, self._dims_displayed[::-1] ] else: points = np.empty((0, 2)) if num_points > 2: self._polygon.visible = True self._line.visible = False self._polygon.pos = points else: self._polygon.visible = False self._line.visible = num_points == 2 if self._line.visible: self._line.set_data(pos=points) self._nodes.set_data( pos=points, **self._nodes_kwargs, ) def _set_color(self, color): border_color = tuple(color[:3]) + (1,) # always opaque polygon_color = color # Clean up polygon faces before making it transparent, otherwise # it keeps the previous visualization of the polygon without cleaning if polygon_color[-1] == 0: self._polygon.mesh.set_data(faces=[]) self._polygon.color = polygon_color self._polygon.border_color = border_color self._line.set_data(color=border_color) def _update_color(self): layer = self.layer if layer._selected_label == layer.colormap.background_value: self._set_color((1, 0, 0, 0)) else: self._set_color( layer._selected_color.tolist()[:3] + [layer.opacity] ) @_only_when_enabled def _on_mouse_move(self, layer, event): """Continuously redraw the latest polygon point with the current mouse position.""" if self._num_points == 0: return pos = self._get_mouse_coordinates(event) self.overlay.points = self.overlay.points[:-1] + [pos.tolist()] @_only_when_enabled def _on_mouse_press(self, layer, event): pos = self._get_mouse_coordinates(event) dims_displayed = self._dims_displayed if event.button == 1: # left mouse click orig_pos = pos.copy() # recenter the point in the center of the image pixel pos[dims_displayed] = np.floor(pos[dims_displayed]) + 0.5 if not self.overlay.points: self._first_point_pos = np.array(event.pos) prev_point = ( self.overlay.points[-2] if self._num_points > 1 else None ) # Add a new point only if it differs from the previous one if prev_point is None or np.linalg.norm(pos - prev_point) > 0: self.overlay.points = self.overlay.points[:-1] + [ pos.tolist(), # add some epsilon to avoid points duplication, # the latest point is used only for visualization of the cursor (orig_pos + 1e-3).tolist(), ] elif event.button == 2 and self._num_points > 0: # right mouse click if self._num_points < 3: self.overlay.points = [] else: self.overlay.points = self.overlay.points[:-2] + [pos.tolist()] @_only_when_enabled def _on_mouse_double_click(self, layer, event): if event.button == 2: self._on_mouse_press(layer, event) return None first_point_dist = np.linalg.norm(event.pos - self._first_point_pos) if ( self.overlay.use_double_click_completion_radius and first_point_dist > self.overlay.completion_radius ): return self._on_mouse_press(layer, event) if self.overlay.use_double_click_completion_radius: # Remove the latest 2 points as double click always follows a simple click, # the double-click is close to initial vertex, and another # point is reserved for the visualization purpose self.overlay.points = self.overlay.points[:-2] else: # Remove the last point from double click, but keep the vertex self.overlay.points = self.overlay.points[:-1] self.overlay.add_polygon_to_labels(layer) return None def _get_mouse_coordinates(self, event): pos = mouse_event_to_labels_coordinate(self.layer, event) if pos is None: return None pos = np.array(pos, dtype=float) pos[self._dims_displayed] += 0.5 return pos @property def _dims_displayed(self): return self.layer._slice_input.displayed @property def _num_points(self): return len(self.overlay.points) def reset(self): super().reset() self._on_points_change() napari-0.5.6/napari/_vispy/overlays/scale_bar.py000066400000000000000000000207111474413133200217130ustar00rootroot00000000000000import bisect from decimal import Decimal from math import floor, log import numpy as np import pint 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.0 self._scale = 1 self._unit: pint.Unit 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.overlay.events.length.connect(self._on_length_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 _on_length_change(self): self._on_zoom_change(force=True) def _calculate_best_length( self, desired_length: float ) -> tuple[float, pint.Quantity]: """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 and also # validate if quantity is dimensionless and lower than 1 to prevent # the scale bar to extend beyond the canvas when zooming. # If the value falls in those conditions, we use the corresponding # preferred value but scaled to take into account the actual value # magnitude. See https://github.com/napari/napari/issues/5914 magnitude_1000 = floor(log(new_quantity.magnitude, 1000)) scaled_magnitude = new_quantity.magnitude * 1000 ** (-magnitude_1000) index = bisect.bisect_left(PREFERRED_VALUES, scaled_magnitude) if index > 0: # When we get the lowest index of the list, removing -1 will # return the last index. index -= 1 new_value: float = PREFERRED_VALUES[index] if new_quantity.dimensionless and new_quantity.magnitude < 1: # using Decimal is necessary to avoid `4.999999e-6` # at really small scale. new_value = float( Decimal(new_value) * Decimal(1000) ** magnitude_1000 ) # get the new pixel length utilizing the user-specified units new_length = ( (new_value * factor) / (1 * 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 # If length is set, use that value to calculate the scale bar length if self.overlay.length is not None: target_canvas_pixels = self.overlay.length / scale_canvas2world new_dim = self.overlay.length * self._unit.units else: # 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 = ( target_world_pixels_rounded / scale_canvas2world ) scale = target_canvas_pixels # Update scalebar and text self.node.transform.scale = [scale, 1, 1, 1] self.node.text.text = f'{new_dim:g~#P}' self.x_size = scale # needed to offset properly super()._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.canvas.bgcolor ): background_color = self.node.parent.canvas.bgcolor.rgba else: background_color = get_theme( self.viewer.theme ).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""" # update the dpi scale factor to account for screen dpi # because vispy scales pixel height of text by screen dpi if self.node.text.transforms.dpi: # use 96 as the napari reference dpi for historical reasons dpi_scale_factor = 96 / self.node.text.transforms.dpi else: dpi_scale_factor = 1 self.node.text.font_size = self.overlay.font_size * dpi_scale_factor # ensure we recalculate the y_offset from the text size when at top of canvas if 'top' in self.overlay.position: self._on_position_change() def _on_position_change(self, event=None): # prevent the text from being cut off by shifting down if 'top' in self.overlay.position: # convert font_size to logical pixels as vispy does # in vispy/visuals/text/text.py # 72 is the vispy reference dpi # 96 dpi is used as the napari reference dpi font_logical_pix = self.overlay.font_size * 96 / 72 # 7 is base value for the default 10 font size self.y_offset = 7 + font_logical_pix else: self.y_offset = 20 super()._on_position_change() def _on_visible_change(self): # ensure that dpi is updated when the scale bar is visible self._on_text_change() return super()._on_visible_change() def reset(self): super().reset() self._on_unit_change() self._on_data_change() self._on_box_change() self._on_text_change() self._on_length_change() napari-0.5.6/napari/_vispy/overlays/text.py000066400000000000000000000036011474413133200207630ustar00rootroot00000000000000from 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.6/napari/_vispy/utils/000077500000000000000000000000001474413133200167215ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/utils/__init__.py000066400000000000000000000000001474413133200210200ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/utils/cursor.py000066400000000000000000000060131474413133200206100ustar00rootroot00000000000000from __future__ import annotations from enum import Enum from qtpy.QtCore import QPoint, QSize, Qt from qtpy.QtGui import QCursor, QPainter, QPen, QPixmap 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 def square_pixmap(size: int): """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 def create_square_cursor(size: int): return QCursor(square_pixmap(size)) def create_crosshair_cursor(): return QCursor(crosshair_pixmap()) def create_blank_cursor(): return QCursor(Qt.BlankCursor) class QtCursorVisual(Enum): blank = staticmethod(create_blank_cursor) square = staticmethod(create_square_cursor) cross = Qt.CursorShape.CrossCursor forbidden = Qt.CursorShape.ForbiddenCursor pointing = Qt.CursorShape.PointingHandCursor standard = Qt.CursorShape.ArrowCursor crosshair = staticmethod(create_crosshair_cursor) napari-0.5.6/napari/_vispy/utils/gl.py000066400000000000000000000107461474413133200177050ustar00rootroot00000000000000"""OpenGL Utilities.""" from collections.abc import Generator from contextlib import contextmanager from functools import lru_cache from typing import Any, Union, cast import numpy as np import numpy.typing as npt 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() -> Generator[None, None, None]: """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: npt.NDArray) -> npt.NDArray: """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 try: dtype_ = cast( 'type[Union[np.unsignedinteger[Any], np.floating[Any]]]', { 'i': np.float32, 'f': np.float32, 'u': np.uint16, 'b': np.uint8, }[dtype.kind], ) if dtype_ == np.uint16 and dtype.itemsize > 2: dtype_ = np.float32 except KeyError as e: # not an int or float raise TypeError( trans._( 'type {dtype} not allowed for texture; must be one of {textures}', deferred=True, dtype=dtype, textures=set(texture_dtypes), ) ) from e return data.astype(dtype_) # blend_func parameters are multiplying: # - source color # - destination color # - source alpha # - destination alpha # they do not apply to min/max blending equation BLENDING_MODES = { 'opaque': { 'depth_test': True, 'cull_face': False, 'blend': False, }, 'translucent': { 'depth_test': True, 'cull_face': False, 'blend': True, 'blend_func': ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), 'blend_equation': 'func_add', }, 'translucent_no_depth': { 'depth_test': False, 'cull_face': False, 'blend': True, 'blend_func': ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), 'blend_equation': 'func_add', # see vispy/vispy#2324 }, 'additive': { 'depth_test': False, 'cull_face': False, 'blend': True, 'blend_func': ('src_alpha', 'dst_alpha', 'one', 'one'), 'blend_equation': 'func_add', }, 'minimum': { 'depth_test': False, 'cull_face': False, 'blend': True, 'blend_equation': 'min', }, } napari-0.5.6/napari/_vispy/utils/quaternion.py000066400000000000000000000031131474413133200214560ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast import numpy as np if TYPE_CHECKING: from vispy.util.quaternion import Quaternion def quaternion2euler_degrees( quaternion: Quaternion, ) -> tuple[float, float, float]: """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. Returns ------- angles : 3-tuple Euler angles in (rx, ry, rz) order, in degrees. """ 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) return cast(tuple[float, float, float], tuple(np.degrees(angles))) napari-0.5.6/napari/_vispy/utils/text.py000066400000000000000000000041411474413133200202570ustar00rootroot00000000000000from 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 return len(layer._indices_view) != 0 napari-0.5.6/napari/_vispy/utils/visual.py000066400000000000000000000127151474413133200206040ustar00rootroot00000000000000from __future__ import annotations from typing import Optional 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.brush_circle import VispyBrushCircleOverlay from napari._vispy.overlays.interaction_box import ( VispySelectionBoxOverlay, VispyTransformBoxOverlay, ) from napari._vispy.overlays.labels_polygon import VispyLabelsPolygonOverlay from napari._vispy.overlays.scale_bar import VispyScaleBarOverlay from napari._vispy.overlays.text import VispyTextOverlay from napari.components.overlays import ( AxesOverlay, BoundingBoxOverlay, BrushCircleOverlay, LabelsPolygonOverlay, Overlay, ScaleBarOverlay, SelectionBoxOverlay, TextOverlay, TransformBoxOverlay, ) from napari.layers import ( Image, Labels, Layer, Points, Shapes, Surface, Tracks, Vectors, ) 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: dict[type[Overlay], type[VispyBaseOverlay]] = { ScaleBarOverlay: VispyScaleBarOverlay, TextOverlay: VispyTextOverlay, AxesOverlay: VispyAxesOverlay, BoundingBoxOverlay: VispyBoundingBoxOverlay, TransformBoxOverlay: VispyTransformBoxOverlay, SelectionBoxOverlay: VispySelectionBoxOverlay, BrushCircleOverlay: VispyBrushCircleOverlay, LabelsPolygonOverlay: VispyLabelsPolygonOverlay, } 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) -> VispyBaseOverlay: """ Create vispy visual for Overlay based on its 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], ) -> Optional[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[: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.6/napari/_vispy/visuals/000077500000000000000000000000001474413133200172475ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/visuals/__init__.py000066400000000000000000000000001474413133200213460ustar00rootroot00000000000000napari-0.5.6/napari/_vispy/visuals/axes.py000066400000000000000000000204271474413133200205660ustar00rootroot00000000000000import 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, ) if len(colors) == 3: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 2, [colors[2]] * 2], axis=0, ) 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, ) if len(colors) == 3: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 4 * 2, [colors[2]] * 8 * 2], axis=0, ) 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, ) if len(colors) == 3: return np.concatenate( [ [colors[0]] * num_segments, [colors[1]] * num_segments, [colors[2]] * num_segments, ], axis=0, ) 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.6/napari/_vispy/visuals/bounding_box.py000066400000000000000000000032011474413133200222720ustar00rootroot00000000000000from 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._line_color = 'red' self._line_thickness = 2 self._marker_color = (1, 1, 1, 1) self._marker_size = 1 super().__init__([Line(), Markers(antialias=0)], *args, **kwargs) @property def lines(self): return self._subvisuals[0] @property def markers(self): return self._subvisuals[1] def set_bounds(self, bounds): """Update the bounding box based on a layer's bounds.""" if any(b is None for b in bounds): return vertices = np.array(list(product(*bounds))) self.lines.set_data( pos=vertices, connect=self._edges.copy(), color=self._line_color, width=self._line_thickness, ) self.lines.visible = True self.markers.set_data( pos=vertices, size=self._marker_size, face_color=self._marker_color, edge_width=0, ) napari-0.5.6/napari/_vispy/visuals/clipping_planes_mixin.py000066400000000000000000000017061474413133200242000ustar00rootroot00000000000000from typing import 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']] _clip_filter: PlanesClipper 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: clip_filter = PlanesClipper() self._clip_filter = clip_filter super().__init__(*args, **kwargs) self.attach(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.6/napari/_vispy/visuals/image.py000066400000000000000000000006141474413133200207040ustar00rootroot00000000000000from vispy.scene.visuals import Image as BaseImage from napari._vispy.visuals.util import TextureMixin # If data is not present, we need bounds to be None (see napari#3517) class Image(TextureMixin, BaseImage): def _compute_bounds(self, axis, view): if self._data is None: return None if axis > 1: return (0, 0) return (0, self.size[axis]) napari-0.5.6/napari/_vispy/visuals/interaction_box.py000066400000000000000000000037641474413133200230220ustar00rootroot00000000000000import 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.6/napari/_vispy/visuals/labels.py000066400000000000000000000021451474413133200210650ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional from vispy.scene.visuals import create_visual_node from vispy.visuals.image import ImageVisual from vispy.visuals.shaders import Function, FunctionChain from napari._vispy.visuals.util import TextureMixin if TYPE_CHECKING: from vispy.visuals.visual import VisualView class LabelVisual(TextureMixin, ImageVisual): """Visual subclass displaying a 2D array of labels.""" def _build_color_transform(self) -> FunctionChain: """Build the color transform function chain.""" funcs = [ Function(self._func_templates['red_to_luminance']), Function(self.cmap.glsl_map), ] return FunctionChain( funcs=funcs, ) BaseLabel = create_visual_node(LabelVisual) class LabelNode(BaseLabel): # type: ignore [valid-type,misc] def _compute_bounds( self, axis: int, view: 'VisualView' ) -> Optional[tuple[float, float]]: if self._data is None: return None elif axis > 1: # noqa: RET505 return 0, 0 else: return 0, self.size[axis] napari-0.5.6/napari/_vispy/visuals/markers.py000066400000000000000000000030471474413133200212710ustar00rootroot00000000000000from typing import ClassVar from 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: ClassVar[dict[str, str]] = { '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()) 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.6/napari/_vispy/visuals/points.py000066400000000000000000000050651474413133200211430ustar00rootroot00000000000000from __future__ import annotations from 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 points_markers(self) -> Markers: """Points markers visual""" return self._subvisuals[0] @property def selection_markers(self) -> Markers: """Highlight markers visual""" return self._subvisuals[1] @property def highlight_lines(self) -> Line: """Highlight lines visual""" return self._subvisuals[2] @property def text(self) -> Text: """Text labels visual""" return self._subvisuals[3] @property def scaling(self) -> bool: """ 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.points_markers.scaling == 'visual' @scaling.setter def scaling(self, value: bool) -> None: scaling_txt = 'visual' if value else 'fixed' self.points_markers.scaling = scaling_txt self.selection_markers.scaling = scaling_txt @property def antialias(self) -> float: return self.points_markers.antialias @antialias.setter def antialias(self, value: float) -> None: self.points_markers.antialias = value self.selection_markers.antialias = value @property def spherical(self) -> bool: return self.points_markers.spherical @spherical.setter def spherical(self, value: bool) -> None: self.points_markers.spherical = value @property def canvas_size_limits(self) -> tuple[int, int]: return self.points_markers.canvas_size_limits @canvas_size_limits.setter def canvas_size_limits(self, value: tuple[int, int]) -> None: self.points_markers.canvas_size_limits = value self.selection_markers.canvas_size_limits = value napari-0.5.6/napari/_vispy/visuals/scale_bar.py000066400000000000000000000022531474413133200215360ustar00rootroot00000000000000import 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.6/napari/_vispy/visuals/shapes.py000066400000000000000000000024151474413133200211060ustar00rootroot00000000000000from 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()]) @property def shape_faces(self) -> Mesh: """Mesh for shape faces""" return self._subvisuals[0] @property def shape_highlights(self) -> Mesh: """Mesh for shape highlights""" return self._subvisuals[1] @property def highlight_lines(self) -> Line: """Lines for shape highlights""" return self._subvisuals[2] @property def highlight_vertices(self) -> Markers: """Vertices for shape highlights""" return self._subvisuals[3] @property def text(self) -> Text: """Text labels""" return self._subvisuals[4] napari-0.5.6/napari/_vispy/visuals/surface.py000066400000000000000000000014201474413133200212460ustar00rootroot00000000000000from 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.6/napari/_vispy/visuals/tracks.py000066400000000000000000000015611474413133200211130ustar00rootroot00000000000000from 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.6/napari/_vispy/visuals/util.py000066400000000000000000000017161474413133200206030ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from vispy.visuals.visual import Visual else: class Visual: pass class TextureMixin(Visual): """Store texture format passed to VisPy classes. We need to refer back to the texture format, but VisPy stores it in a private attribute — ``node._texture.internalformat``. This mixin is added to our Node subclasses to avoid having to access private VisPy attributes. """ def __init__(self, *args, texture_format: Optional[str], **kwargs) -> None: # type: ignore [no-untyped-def] super().__init__(*args, texture_format=texture_format, **kwargs) # classes using this mixin may be frozen dataclasses. # we save the texture format between unfreeze/freeze. self.unfreeze() self._texture_format = texture_format self.freeze() @property def texture_format(self) -> Optional[str]: return self._texture_format napari-0.5.6/napari/_vispy/visuals/vectors.py000066400000000000000000000003571474413133200213130ustar00rootroot00000000000000from 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.6/napari/_vispy/visuals/volume.py000066400000000000000000000304751474413133200211410ustar00rootroot00000000000000from vispy.scene.visuals import Volume as BaseVolume from napari._vispy.visuals.util import TextureMixin from napari.layers.labels._labels_constants import IsoCategoricalGradientMode FUNCTION_DEFINITIONS = """ // switch for clamping values at volume limits uniform bool u_clamp_at_border; // 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; } """ CALCULATE_COLOR_DEFINITION = """ vec4 calculateShadedCategoricalColor(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; // View direction vec3 V = normalize(view_ray); // Calculate normal vector from gradient vec3 N; N = calculateGradient(loc, step, val0); // 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 ); // 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; } """ FAST_GRADIENT_DEFINITION = """ vec3 calculateGradient(vec3 loc, vec3 step, float current_val) { // calculate gradient within the volume by finite differences vec3 G = vec3(0.0); float prev; float next; int in_bounds; for (int i=0; i<3; i++) { vec3 ax_step = vec3(0.0); ax_step[i] = step[i]; vec3 prev_loc = loc - ax_step; if (u_clamp_at_border || (prev_loc[i] >= 0.0 && prev_loc[i] <= 1.0)) { prev = colorToVal($get_data(prev_loc)); } else { prev = categorical_bg_value; } vec3 next_loc = loc + ax_step; if (u_clamp_at_border || (next_loc[i] >= 0.0 && next_loc[i] <= 1.0)) { next = colorToVal($get_data(next_loc)); } else { next = categorical_bg_value; } // add to the gradient where the adjacent voxels are both background // to fix dim pixels due to poor normal estimation G[i] = next - prev + (next - current_val) * 2.0 * detectAdjacentBackground(prev, next); } return G; } """ SMOOTH_GRADIENT_DEFINITION = """ vec3 calculateGradient(vec3 loc, vec3 step, float current_val) { // calculate gradient within the volume by finite differences // using a 3D sobel-feldman convolution kernel // the kernel here is a 3x3 cube, centered on the sample at `loc` // the kernel for G.z looks like this: // [ +1 +2 +1 ] // [ +2 +4 +2 ] <-- "loc - step.z" is in the center // [ +1 +2 +1 ] // [ 0 0 0 ] // [ 0 0 0 ] <-- "loc" is in the center // [ 0 0 0 ] // [ -1 -2 -1 ] // [ -2 -4 -2 ] <-- "loc + step.z" is in the center // [ -1 -2 -1 ] // kernels for G.x and G.y similar, but transposed // see https://en.wikipedia.org/wiki/Sobel_operator#Extension_to_other_dimensions vec3 G = vec3(0.0); // next and prev are the directly adjacent values along x, y, and z vec3 next = vec3(0.0); vec3 prev = vec3(0.0); float val; bool is_on_border = false; for (int i=-1; i <= 1; i++) { for (int j=-1; j <= 1; j++) { for (int k=-1; k <= 1; k++) { if (is_on_border && (i != 0 && j != 0 && k != 0)) { // we only care about on-axis values if we are on a border continue; } vec3 sample_loc = loc + vec3(i, j, k) * step; bool is_in_bounds = all(greaterThanEqual(sample_loc, vec3(0.0))) && all(lessThanEqual(sample_loc, vec3(1.0))); if (is_in_bounds || u_clamp_at_border) { val = colorToVal($get_data(sample_loc)); } else { val = categorical_bg_value; } G.x += val * -float(i) * (1 + float(j == 0 || k == 0) + 2 * float(j == 0 && k == 0)); G.y += val * -float(j) * (1 + float(i == 0 || k == 0) + 2 * float(i == 0 && k == 0)); G.z += val * -float(k) * (1 + float(i == 0 || j == 0) + 2 * float(i == 0 && j == 0)); next.x += int(i == 1 && j == 0 && k == 0) * val; next.y += int(i == 0 && j == 1 && k == 0) * val; next.z += int(i == 0 && j == 0 && k == 1) * val; prev.x += int(i == -1 && j == 0 && k == 0) * val; prev.y += int(i == 0 && j == -1 && k == 0) * val; prev.z += int(i == 0 && j == 0 && k == -1) * val; is_on_border = is_on_border || (!is_in_bounds && (i == 0 || j == 0 || k == 0)); } } } if (is_on_border && u_clamp_at_border) { // fallback to simple gradient calculation if we are on the border // and clamping is enabled (old behavior with dark/hollow faces at the border) // this makes the faces in `fast` and `smooth` look the same in both clamping modes G = next - prev; } else { // add to the gradient where the adjacent voxels are both background // to fix dim pixels due to poor normal estimation G.x = G.x + (next.x - current_val) * 2.0 * detectAdjacentBackground(prev.x, next.x); G.y = G.y + (next.y - current_val) * 2.0 * detectAdjacentBackground(prev.y, next.y); G.z = G.z + (next.z - current_val) * 2.0 * detectAdjacentBackground(prev.z, next.z); } return G; } """ ISO_CATEGORICAL_SNIPPETS = { '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; vec4 label_id = vec4(0.0); """, '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++) { label_id = $get_data(iloc); color = sample_label_color(label_id.r); if (floatNotEqual(color.a, 0) ) { // fully transparent color is considered as background, see napari/napari#5227 // when the value mapped to non-transparent color is reached // calculate the shaded color (apply lighting effects) color = calculateShadedCategoricalColor(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; """, } TRANSLUCENT_CATEGORICAL_SNIPPETS = { 'before_loop': """ vec4 color3 = vec4(0.0); // final color gl_FragColor = vec4(0.0); bool discard_fragment = true; vec4 label_id = vec4(0.0); """, '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++) { label_id = $get_data(iloc); color = sample_label_color(label_id.r); if (floatNotEqual(color.a, 0) ) { // fully transparent color is considered as background, see napari/napari#5227 // when the value mapped to non-transparent color is reached // calculate the color (apply lighting effects) 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()') FAST_GRADIENT_SHADER = ( before + FUNCTION_DEFINITIONS + FAST_GRADIENT_DEFINITION + CALCULATE_COLOR_DEFINITION + 'void main()' + after ) SMOOTH_GRADIENT_SHADER = ( before + FUNCTION_DEFINITIONS + SMOOTH_GRADIENT_DEFINITION + CALCULATE_COLOR_DEFINITION + 'void main()' + after ) shaders['fragment'] = FAST_GRADIENT_SHADER rendering_methods = BaseVolume._rendering_methods.copy() rendering_methods['iso_categorical'] = ISO_CATEGORICAL_SNIPPETS rendering_methods['translucent_categorical'] = TRANSLUCENT_CATEGORICAL_SNIPPETS class Volume(TextureMixin, BaseVolume): """This class extends the vispy Volume visual to add categorical isosurface rendering.""" # add the new rendering method to the snippets dict _shaders = shaders _rendering_methods = rendering_methods def __init__(self, *args, **kwargs) -> None: # type: ignore [no-untyped-def] super().__init__(*args, **kwargs) self.unfreeze() self.clamp_at_border = False self.iso_gradient_mode = IsoCategoricalGradientMode.FAST.value self.freeze() @property def iso_gradient_mode(self) -> str: return str(self._iso_gradient_mode) @iso_gradient_mode.setter def iso_gradient_mode(self, value: str) -> None: self._iso_gradient_mode = IsoCategoricalGradientMode(value) self.shared_program.frag = ( SMOOTH_GRADIENT_SHADER if value == IsoCategoricalGradientMode.SMOOTH else FAST_GRADIENT_SHADER ) self.shared_program['u_clamp_at_border'] = self._clamp_at_border self.update() @property def clamp_at_border(self) -> bool: """Clamp values beyond volume limits when computing isosurface gradients. This has an effect on the appearance of labels at the border of the volume. True: labels will appear darker at the border. [DEFAULT] False: labels will appear brighter at the border, as if the volume extends beyond its actual limits but the labels do not. """ return self._clamp_at_border @clamp_at_border.setter def clamp_at_border(self, value: bool) -> None: self._clamp_at_border = value self.shared_program['u_clamp_at_border'] = self._clamp_at_border self.update() napari-0.5.6/napari/benchmarks/000077500000000000000000000000001474413133200163655ustar00rootroot00000000000000napari-0.5.6/napari/benchmarks/README.md000066400000000000000000000022201474413133200176400ustar00rootroot00000000000000# 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` ## Debugging To simplify debugging we can run the benchmarks in the current environment as simple python functions script. You could do this by running the following command: ```bash python -m napari.benchmarks benchmark_shapes_layer.Shapes3DSuite.time_get_value ``` or ```bash python napari/benchmarks/benchmark_shapes_layer.py Shapes3DSuite.time_get_value ``` Passing the proper benchmark identifier as argument. napari-0.5.6/napari/benchmarks/__init__.py000066400000000000000000000000001474413133200204640ustar00rootroot00000000000000napari-0.5.6/napari/benchmarks/__main__.py000066400000000000000000000020221474413133200204530ustar00rootroot00000000000000import argparse import importlib from typing import NamedTuple from .utils import run_benchmark_from_module class BenchmarkIdentifier(NamedTuple): module: str klass: str method: str def split_identifier(value: str) -> BenchmarkIdentifier: """Split a string into a module and class identifier.""" parts = value.split('.') if len(parts) != 3: raise argparse.ArgumentError( "Benchmark identifier should be in the form 'module.class.benchmark'" ) return BenchmarkIdentifier(*parts) def main(): parser = argparse.ArgumentParser( description='Run selected napari benchmarks for debugging.' ) parser.add_argument( 'benchmark', type=split_identifier, help='Benchmark to run.' ) args = parser.parse_args() module = importlib.import_module( f'.{args.benchmark.module}', package='napari.benchmarks' ) run_benchmark_from_module( module, args.benchmark.klass, args.benchmark.method ) if __name__ == '__main__': main() napari-0.5.6/napari/benchmarks/benchmark_evented_model.py000066400000000000000000000024061474413133200235650ustar00rootroot00000000000000from math import ceil from napari.utils.events import EventedModel def empty(event): pass def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) class Model(EventedModel): a: int = 3 b: float = 2.0 c: int = 3 @property def d(self): return (self.c + self.a) ** self.b @d.setter def d(self, value): self.c = value self.a = value self.b = value * 1.1 @property def e(self): return (fibonacci(self.c) + fibonacci(self.a)) ** fibonacci( ceil(self.b) ) class EventedModelSuite: """Benchmarks for EventedModel.""" def setup(self): self.model = Model() self.model.events.a.connect(empty) self.model.events.b.connect(empty) self.model.events.c.connect(empty) self.model.events.e.connect(empty) def time_event_firing(self): self.model.d = 4 self.model.d = 18 def time_long_connection(self): def long_connection(event): for _i in range(5): fibonacci(event.source.c) self.model.events.e.connect(long_connection) self.model.d = 15 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_image_layer.py000066400000000000000000000056461474413133200232420ustar00rootroot00000000000000# 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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)] if 'CI' in os.environ: skip_params = [(2**i,) for i in range(10, 11)] # not enough memory on CI if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 11)] def setup(self, n): 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 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_import.py000066400000000000000000000004211474413133200222600ustar00rootroot00000000000000import subprocess import sys class ImportTimeSuite: def time_import(self): cmd = [sys.executable, '-c', 'import napari'] subprocess.run(cmd, stderr=subprocess.PIPE) if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_labels_layer.py000066400000000000000000000154461474413133200234210ustar00rootroot00000000000000# 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 copy import copy import numpy as np from packaging.version import parse as parse_version import napari from napari.components.dims import Dims from napari.layers import Labels from napari.utils.colormaps import DirectLabelColormap from .utils import Skip, labeled_particles MAX_VAL = 2**23 NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') class Labels2DSuite: """Benchmarks for the Labels layer with 2D data""" param_names = ['n', 'dtype'] params = ([2**i for i in range(4, 13)], [np.uint8, np.int32]) skip_params = Skip(if_in_pr=lambda n, dtype: n > 2**5) def setup(self, n, dtype): np.random.seed(0) self.data = labeled_particles( (n, n), dtype=dtype, n=int(np.log2(n) ** 2), seed=1 ) self.layer = Labels(self.data) self.layer._raw_to_displayed(self.data, (slice(0, n), slice(0, n))) def time_create_layer(self, *_): """Time to create layer.""" Labels(self.data) 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._slice.image.raw[0, :] += 1 # simulate changes self.layer._raw_to_displayed(self.layer._slice.image.raw) def time_paint_circle(self, *_): """Time to paint circle.""" 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 mem_layer(self, *_): """Memory used by layer.""" return copy(self.layer) def mem_data(self, *_): """Memory used by raw data.""" return self.data class LabelsDrawing2DSuite: """Benchmark for brush drawing in the Labels layer with 2D data.""" param_names = ['n', 'brush_size', 'color_mode', 'contour'] params = ([512, 3072], [8, 64, 256], ['auto', 'direct'], [0, 1]) skip_params = Skip( if_in_pr=lambda n, brush_size, *_: n > 512 or brush_size > 64 ) def setup(self, n, brush_size, color_mode, contour): np.random.seed(0) self.data = labeled_particles( (n, n), dtype=np.int32, n=int(np.log2(n) ** 2), seed=1 ) self.layer = Labels(self.data) if color_mode == 'direct': random_label_ids = np.random.randint(64, size=50) colors = {i + 1: np.random.random(4) for i in random_label_ids} colors[None] = np.array([0, 0, 0, 0.3]) self.layer.colormap = DirectLabelColormap(color_dict=colors) self.layer.brush_size = brush_size self.layer.contour = contour self.layer.mode = 'paint' def time_draw(self, n, brush_size, color_mode, contour): new_label = self.layer._slice.image.raw[0, 0] + 1 with self.layer.block_history(): last_coord = (0, 0) for x in np.linspace(0, n - 1, num=30)[1:]: self.layer._draw( new_label=new_label, last_cursor_coord=last_coord, coordinates=(x, x), ) last_coord = (x, x) class Labels2DColorDirectSuite(Labels2DSuite): skip_params = Skip(if_in_pr=lambda n, dtype: n > 32) def setup(self, n, dtype): np.random.seed(0) info = np.iinfo(dtype) self.data = labeled_particles( (n, n), dtype=dtype, n=int(np.log2(n) ** 2), seed=1 ) random_label_ids = np.random.randint( low=max(-10000, info.min), high=min(10000, info.max), size=20 ) colors = {i + 1: np.random.random(4) for i in random_label_ids} colors[None] = np.array([0, 0, 0, 0.3]) self.layer = Labels( self.data, colormap=DirectLabelColormap(color_dict=colors) ) self.layer._raw_to_displayed( self.layer._slice.image.raw, (slice(0, n), slice(0, n)) ) class Labels3DSuite: """Benchmarks for the Labels layer with 3D data.""" param_names = ['n', 'dtype'] params = ([2**i for i in range(4, 11)], [np.uint8, np.uint32]) skip_params = Skip( if_in_pr=lambda n, dtype: n > 2**6, if_on_ci=lambda n, dtype: n > 2**9 ) # CI skip above 2**9 because of memory limits def setup(self, n, dtype): np.random.seed(0) self.data = labeled_particles( (n, n, n), dtype=dtype, n=int(np.log2(n) ** 2), seed=1 ) self.layer = Labels(self.data) if NAPARI_0_4_19: self.layer._slice_dims((0, 0, 0), ndisplay=3) else: self.layer._slice_dims(Dims(ndim=3, ndisplay=3)) self.layer._raw_to_displayed( self.layer._slice.image.raw, (slice(0, n), slice(0, n), slice(0, n)), ) # @mark.skip_params_if([(2**i,) for i in range(6, 11)], condition="PR" in os.environ) def time_create_layer(self, *_): """Time to create layer.""" Labels(self.data) 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,) * 3) def time_raw_to_displayed(self, *_): """Time to convert raw to displayed.""" self.layer._slice.image.raw[0, 0, :] += 1 # simulate changes self.layer._raw_to_displayed(self.layer._slice.image.raw) def time_paint_circle(self, *_): """Time to paint circle.""" self.layer.paint((0,) * 3, self.layer.selected_label) def time_fill(self, *_): """Time to fill.""" self.layer.fill( (0,) * 3, 1, self.layer.selected_label, ) def mem_layer(self, *_): """Memory used by layer.""" return copy(self.layer) def mem_data(self, *_): """Memory used by raw data.""" return self.data if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_points_layer.py000066400000000000000000000105021474413133200234570ustar00rootroot00000000000000# 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 packaging.version import parse as parse_version import napari from napari.components import Dims from napari.layers import Points from .utils import Skip NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') class Points2DSuite: """Benchmarks for the Points layer with 2D data""" params = [2**i for i in range(4, 18, 2)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(8, 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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 skip_params = Skip(always=lambda _: NAPARI_0_4_19) def setup(self, flatten_slice_axis): np.random.seed(0) size = 20000 if 'PR' in os.environ else 20000000 self.data = np.random.uniform(size=(size, 3), low=0, high=500) if flatten_slice_axis: self.data[:, 0] = np.round(self.data[:, 0]) self.layer = Points(self.data) self.dims = Dims(ndim=3, point=(249, 0, 0)) def time_slice_points(self, flatten_slice_axis): """Time to take one slice of points""" self.layer._make_slice_request(self.dims)() 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], ] skip_params = Skip( if_in_pr=lambda num_points, mask_shape, points_size: num_points > 256 or mask_shape[0] > 512 ) 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) if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_python_layer.py000066400000000000000000000010161474413133200234640ustar00rootroot00000000000000import numpy as np from napari.layers.points._points_utils import coerce_symbols class CoerceSymbolsSuite: def setup(self): self.symbols1 = np.array(['o' for _ in range(10**6)]) self.symbols2 = np.array(['o' for _ in range(10**6)]) self.symbols2[10000] = 's' def time_coerce_symbols1(self): coerce_symbols(self.symbols1) def time_coerce_symbols2(self): coerce_symbols(self.symbols2) if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_qt_slicing.py000066400000000000000000000130401474413133200231030ustar00rootroot00000000000000# 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 from .utils import Skip 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(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: params = get_image_params() timeout = 300 skip_params = Skip(if_in_pr=lambda latency, dataname: latency > 0) 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) 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() def _skip_3d_rgb(_latency, dataname): shape = SAMPLE_PARAMS[dataname]['shape'] return len(shape) == 3 and shape[2] == 3 class QtViewerAsyncImage2DSuite: params = get_image_params() skip_params = Skip( always=_skip_3d_rgb, if_in_pr=lambda latency, dataname: latency > 0 ) 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) _ = 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) 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: n_points = [2**i for i in range(12, 18)] params = n_points skip_params = Skip(if_in_pr=lambda n_points: n_points > 2**12) 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) self.app = QApplication.instance() or QApplication([]) def time_z_scroll(self, *args): for z in range(self.empty_image.shape[0]): self.viewer.dims.set_current_step(0, z) self.app.processEvents() def teardown(self, *args): self.viewer.window.close() class QtViewerAsyncPointsAndImage2DSuite: n_points = [2**i for i in range(12, 18, 2)] chunksize = [256, 512, 1024] latency = [0.05 * i for i in range(3)] params = (n_points, latency, chunksize) timeout = 600 skip_params = Skip( if_in_pr=lambda n_points, latency, chunksize: n_points > 2**14 or chunksize > 512 or latency > 0, ) 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) self.app = QApplication.instance() or QApplication([]) def time_z_scroll(self, *args): for z in range(self.image_data.shape[0]): self.viewer.dims.set_current_step(0, z) self.app.processEvents() def teardown(self, *args): self.viewer.window.close() if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_qt_viewer.py000066400000000000000000000011771474413133200227640ustar00rootroot00000000000000# 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() if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_qt_viewer_image.py000066400000000000000000000206211474413133200241210ustar00rootroot00000000000000# 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 packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication import napari NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') class QtViewerViewImageSuite: """Benchmarks for viewing images in the viewer.""" params = [2**i for i in range(4, 13)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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.""" if NAPARI_0_4_19: 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) ) else: self.viewer.window._qt_viewer.canvas.view.camera.zoom( 0.5, center=(0.5, 0.5) ) self.viewer.window._qt_viewer.canvas.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.""" if NAPARI_0_4_19: 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) ) else: self.viewer.window._qt_viewer.canvas.view.camera.zoom( 0.5, center=(0.5, 0.5) ) self.viewer.window._qt_viewer.canvas.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.""" if NAPARI_0_4_19: 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) ) else: self.viewer.window._qt_viewer.canvas.view.camera.zoom( 0.5, center=(0.5, 0.5) ) self.viewer.window._qt_viewer.canvas.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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_qt_viewer_labels.py000066400000000000000000000160431474413133200243040ustar00rootroot00000000000000# 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 from dataclasses import dataclass from functools import lru_cache from itertools import cycle import numpy as np from packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication from skimage.morphology import diamond, octahedron import napari from napari.components.viewer_model import ViewerModel from napari.qt import QtViewer from napari.utils.colormaps import DirectLabelColormap from .utils import Skip NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') @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.""" if NAPARI_0_4_19: self.viewer.window._qt_viewer.on_mouse_move(self.event) else: self.viewer.window._qt_viewer.canvas._on_mouse_move(self.event) @lru_cache def setup_rendering_data(radius, dtype): if radius < 1000: data = octahedron(radius=radius, dtype=dtype) else: data = np.zeros((radius // 50, radius * 2, radius * 2), dtype=dtype) for i in range(1, data.shape[0] // 2): part = diamond(radius=i * 100, dtype=dtype) shift = (data.shape[1] - part.shape[0]) // 2 data[i, shift : -shift - 1, shift : -shift - 1] = part data[-i - 1, shift : -shift - 1, shift : -shift - 1] = part count = np.count_nonzero(data) data[data > 0] = np.random.randint( 1, min(2000, np.iinfo(dtype).max), size=count, dtype=dtype ) return data class LabelRendering: """Benchmarks for rendering the Labels layer.""" param_names = ['radius', 'dtype', 'mode'] params = ( [10, 30, 300, 1500], [np.uint8, np.uint16, np.uint32], ['auto', 'direct'], ) skip_params = Skip( if_in_pr=lambda radius, *_: radius > 20, if_on_ci=lambda radius, *_: radius > 20, ) def setup(self, radius, dtype, label_mode): self.steps = 4 if 'GITHUB_ACTIONS' in os.environ else 10 self.app = QApplication.instance() or QApplication([]) self.data = setup_rendering_data(radius, dtype) scale = self.data.shape[-1] / np.array(self.data.shape) self.viewer = ViewerModel() self.qt_viewr = QtViewer(self.viewer) self.layer = self.viewer.add_labels(self.data, scale=scale) if label_mode == 'direct': colors = dict( zip( range(10, 2000), cycle(['red', 'green', 'blue', 'pink', 'magenta']), ) ) colors[None] = 'yellow' colors[0] = 'transparent' self.layer.colormap = DirectLabelColormap(color_dict=colors) self.qt_viewr.show() @staticmethod def teardown(self, *_): if hasattr(self, 'viewer'): self.qt_viewr.close() def _time_iterate_components(self, *_): """Time to iterate over components.""" self.layer.show_selected_label = True for i in range(0, 201, (200 // self.steps) or 1): self.layer.selected_label = i self.app.processEvents() def _time_zoom_change(self, *_): """Time to zoom in and zoom out.""" initial_zoom = self.viewer.camera.zoom self.viewer.camera.zoom = 0.5 * initial_zoom self.app.processEvents() self.viewer.camera.zoom = 2 * initial_zoom self.app.processEvents() class LabelRenderingSuite2D(LabelRendering): def setup(self, radius, dtype, label_mode): super().setup(radius, dtype, label_mode) self.viewer.dims.ndisplay = 2 self.app.processEvents() def time_iterate_over_z(self, *_): """Time to render the layer.""" z_size = self.data.shape[0] for i in range(0, z_size, z_size // (self.steps * 2)): self.viewer.dims.set_point(0, i) self.app.processEvents() def time_load_3d(self, *_): """Time to first render of the layer in 3D.""" self.app.processEvents() self.viewer.dims.ndisplay = 3 self.app.processEvents() self.viewer.dims.ndisplay = 2 self.app.processEvents() def time_iterate_components(self, *args): self._time_iterate_components(*args) def time_zoom_change(self, *args): self._time_zoom_change(*args) class LabelRenderingSuite3D(LabelRendering): def setup(self, radius, dtype, label_mode): super().setup(radius, dtype, label_mode) self.viewer.dims.ndisplay = 3 self.app.processEvents() def time_rotate(self, *_): """Time to rotate the layer.""" for i in range(0, (self.steps * 20), 5): self.viewer.camera.angles = (0, i / 2, i) self.app.processEvents() def time_iterate_components(self, *args): self._time_iterate_components(*args) def time_zoom_change(self, *args): self._time_zoom_change(*args) if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_qt_viewer_vectors.py000066400000000000000000000032301474413133200245210ustar00rootroot00000000000000# 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 packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication import napari NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') class QtViewerViewVectorSuite: """Benchmarks for viewing vectors in the viewer.""" params = [2**i for i in range(4, 18, 2)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(8, 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) if NAPARI_0_4_19: self.visual = self.viewer.window._qt_viewer.layer_to_visual[ self.layer ] else: self.visual = self.viewer.window._qt_viewer.canvas.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() if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_shapes_layer.py000066400000000000000000000140051474413133200234300ustar00rootroot00000000000000# 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 Shapes from napari.utils._test_utils import read_only_mouse_event from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) class Shapes2DSuite: """Benchmarks for the Shapes layer with 2D data""" params = [2**i for i in range(4, 9)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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.""" for i in range(100): self.layer.get_value((i,) * 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)] if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 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 = read_only_mouse_event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) # Simulate click mouse_press_callbacks(self.layer, click_event) release_event = read_only_mouse_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 = read_only_mouse_event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) # Simulate click mouse_press_callbacks(self.layer, click_event) # create events drag_event = read_only_mouse_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 = read_only_mouse_event( type='mouse_press', is_dragging=True, modifiers=[], position=position, ) # Simulate move, click, and release mouse_move_callbacks(self.layer, drag_event) release_event = read_only_mouse_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 = read_only_mouse_event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) # Simulate click mouse_press_callbacks(self.layer, click_event) release_event = read_only_mouse_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'] if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_surface_layer.py000066400000000000000000000050241474413133200235760ustar00rootroot00000000000000# 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 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_text_manager.py000066400000000000000000000046721474413133200234400ustar00rootroot00000000000000# 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 from .utils import Skip 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}', ], ] skip_params = Skip(if_in_pr=lambda n, string: n > 2**6) 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 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_tracks_layer.py000066400000000000000000000025531474413133200234410ustar00rootroot00000000000000import numpy as np from napari.layers import Tracks from .utils import Skip class TracksSuite: param_names = ['size', 'n_tracks'] params = [(5 * np.power(10, np.arange(7))).tolist(), [1, 10, 100, 1000]] skip_params = Skip( if_in_pr=lambda size, n_tracks: size > 500 or n_tracks > 10, always=lambda size, n_tracks: n_tracks * 5 > size, ) # we skip cases where the number of tracks times five is larger than the size as it is not useful def setup(self, size, n_tracks): """ Create tracks data """ 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 # create layer for the update benchmark self.layer = Tracks(self.data) def time_create_layer(self, *_) -> None: Tracks(self.data) def time_update_layer(self, *_) -> None: self.layer.data = self.data if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/benchmark_vectors_layer.py000066400000000000000000000053121474413133200236330ustar00rootroot00000000000000# 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 if __name__ == '__main__': from utils import run_benchmark run_benchmark() napari-0.5.6/napari/benchmarks/utils.py000066400000000000000000000171511474413133200201040ustar00rootroot00000000000000import itertools import os from collections.abc import Sequence from functools import lru_cache from types import ModuleType from typing import ( Callable, Literal, Optional, Union, overload, ) import numpy as np from skimage import morphology def always_false(*_): return False class Skip: def __init__( self, if_in_pr: Callable[..., bool] = always_false, if_on_ci: Callable[..., bool] = always_false, always: Callable[..., bool] = always_false, ): self.func_pr = if_in_pr if 'PR' in os.environ else always_false self.func_ci = if_on_ci if 'CI' in os.environ else always_false self.func_always = always def __contains__(self, item): return ( self.func_pr(*item) or self.func_ci(*item) or self.func_always(*item) ) def _generate_ball(radius: int, ndim: int) -> np.ndarray: """Generate a ball of given radius and dimension. Parameters ---------- radius : int Radius of the ball. ndim : int Dimension of the ball. Returns ------- ball : ndarray of uint8 Binary array of the hyper ball. """ if ndim == 2: return morphology.disk(radius) if ndim == 3: return morphology.ball(radius) shape = (2 * radius + 1,) * ndim radius_sq = radius**2 coords = np.indices(shape) - radius return (np.sum(coords**2, axis=0) <= radius_sq).astype(np.uint8) def _generate_density(radius: int, ndim: int) -> np.ndarray: """Generate gaussian density of given radius and dimension.""" shape = (2 * radius + 1,) * ndim coords = np.indices(shape) - radius dist = np.sqrt(np.sum(coords**2 / ((radius / 4) ** 2), axis=0)) res = np.exp(-dist) res[res < 0.02] = 0 return res def _structure_at_coordinates( shape: tuple[int], coordinates: np.ndarray, structure: np.ndarray, *, multipliers: Sequence = itertools.repeat(1), dtype=None, reduce_fn: Callable[ [np.ndarray, np.ndarray, Optional[np.ndarray]], np.ndarray ], ): """Update data with structure at given coordinates. Parameters ---------- data : ndarray Array to update. coordinates : ndarray Coordinates of the points. The structures will be added at these points (center). structure : ndarray Array with encoded structure. For example, ball (boolean) or density (0,1) float. multipliers : ndarray These values are multiplied by the values in the structure before updating the array. Can be used to generate different labels, or to vary the intensity of floating point gaussian densities. reduce_fn : function Function with which to update the array at a particular position. It should take two arrays as input and an optional output array. """ radius = (structure.shape[0] - 1) // 2 data = np.zeros(shape, dtype=dtype) for point, value in zip(coordinates, multipliers): slice_im, slice_ball = _get_slices_at(shape, point, radius) reduce_fn( data[slice_im], value * structure[slice_ball], out=data[slice_im] ) return data def _get_slices_at(shape, point, radius): slice_im = [] slice_ball = [] for i, p in enumerate(point): slice_im.append( slice(max(0, p - radius), min(shape[i], p + radius + 1)) ) ball_start = max(0, radius - p) ball_stop = slice_im[-1].stop - slice_im[-1].start + ball_start slice_ball.append(slice(ball_start, ball_stop)) return tuple(slice_im), tuple(slice_ball) def _update_data_with_mask(data, struct, out=None): """Update ``data`` with ``struct`` where ``struct`` is nonzero.""" # these branches are needed because np.where does not support # an out= keyword argument if out is None: return np.where(struct, struct, data) else: # noqa: RET505 nz = struct != 0 out[nz] = struct[nz] return out def _smallest_dtype(n: int) -> np.dtype: """Find the smallest dtype that can hold n values.""" for dtype in [np.uint8, np.uint16, np.uint32, np.uint64]: if np.iinfo(dtype).max >= n: return dtype break else: raise ValueError(f'{n=} is too large for any dtype.') @overload def labeled_particles( shape: Sequence[int], dtype: Optional[np.dtype] = None, n: int = 144, seed: Optional[int] = None, return_density: Literal[False] = False, ) -> np.ndarray: ... @overload def labeled_particles( shape: Sequence[int], dtype: Optional[np.dtype] = None, n: int = 144, seed: Optional[int] = None, return_density: Literal[True] = True, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ... @lru_cache def labeled_particles( shape: Sequence[int], dtype: Optional[np.dtype] = None, n: int = 144, seed: Optional[int] = None, return_density: bool = False, ) -> Union[np.ndarray, tuple[np.ndarray, np.ndarray, np.ndarray]]: """Generate labeled blobs of given shape and dtype. Parameters ---------- shape : Sequence[int] Shape of the resulting array. dtype : Optional[np.dtype] Dtype of the resulting array. n : int Number of blobs to generate. seed : Optional[int] Seed for the random number generator. return_density : bool Whether to return the density array and center coordinates. """ if dtype is None: dtype = _smallest_dtype(n) rng = np.random.default_rng(seed) ndim = len(shape) points = rng.integers(shape, size=(n, ndim)) values = rng.integers( np.iinfo(dtype).min, np.iinfo(dtype).max, size=n, dtype=dtype ) sigma = int(max(shape) / (4.0 * n ** (1 / ndim))) ball = _generate_ball(sigma, ndim) labels = _structure_at_coordinates( shape, points, ball, multipliers=values, reduce_fn=_update_data_with_mask, dtype=dtype, ) if return_density: dens = _generate_density(sigma * 2, ndim) densities = _structure_at_coordinates( shape, points, dens, reduce_fn=np.maximum, dtype=np.float32 ) return labels, densities, points else: # noqa: RET505 return labels def run_benchmark_from_module( module: ModuleType, klass_name: str, method_name: str ): klass = getattr(module, klass_name) if getattr(klass, 'params', None): skip_if = getattr(klass, 'skip_params', {}) if isinstance(klass.params[0], Sequence): params = itertools.product(*klass.params) else: params = ((i,) for i in klass.params) for param in params: if param in skip_if: continue obj = klass() try: obj.setup(*param) except NotImplementedError: continue getattr(obj, method_name)(*param) getattr(obj, 'teardown', lambda: None)() else: obj = klass() try: obj.setup() except NotImplementedError: return getattr(obj, method_name)() getattr(obj, 'teardown', lambda: None)() def run_benchmark(): import argparse import inspect parser = argparse.ArgumentParser(description='Run benchmark') parser.add_argument( 'benchmark', type=str, help='Name of the benchmark to run', default='' ) args = parser.parse_args() benchmark_selection = args.benchmark.split('.') # get module of parent frame call_module = inspect.getmodule(inspect.currentframe().f_back) run_benchmark_from_module(call_module, *benchmark_selection) napari-0.5.6/napari/components/000077500000000000000000000000001474413133200164355ustar00rootroot00000000000000napari-0.5.6/napari/components/__init__.py000066400000000000000000000017561474413133200205570ustar00rootroot00000000000000"""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.6/napari/components/_layer_slicer.py000066400000000000000000000306321474413133200216270ustar00rootroot00000000000000"""Handles the logic of asynchronously slicing of multiple layers. See the NAP for more details: https://napari.org/dev/naps/4-async-slicing.html """ from __future__ import annotations import logging import weakref from collections.abc import Iterable from concurrent.futures import Executor, Future, ThreadPoolExecutor, wait from contextlib import contextmanager from threading import RLock from typing import ( TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable, ) from napari.layers import Layer from napari.settings import get_settings from napari.utils.events.event import EmitterGroup, Event if TYPE_CHECKING: from napari.components import Dims 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 are coupled but will # vary per layer type, which means that the values of the dictionary # result of ``_slice_layers`` cannot be fixed to a single type. class _SliceRequest(Protocol): id: int def __call__(self) -> Any: ... @runtime_checkable class _AsyncSliceable(Protocol): """The methods needed for async slicing to be supported on a layer. These methods are private to avoid inflating the public API of layers while async slicing is being developed. """ def _make_slice_request(self, dims: Dims) -> _SliceRequest: """Makes a callable slice request that returns a response. This method should run quickly, as it is expected to run on the main thread. Slower parts of slicing should be moved into the callable request, which can be run off the main thread. The request should capture any state it needs from a layer to generate the response and should not modify any of that state. In combination with other design choices, this allows us to avoid using locks while slicing. """ def _update_slice_response(self, response: Any) -> None: """Passes through a completed slice response. This method should run on the main thread and is mostly needed to update slice state on layers. """ def _set_unloaded_slice_id(self, slice_id: int) -> None: """Sets the ID associated with the latest slice request. This is needed to support ``Layer.loaded`` in async slicing. This could be done at the end of ``_make_slice_request``, but was separated to avoid mutations in that method and to clarify responsibilities. """ 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 of tuples of layer weakrefs to futures 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 = not get_settings().experimental.async_ self._layers_to_task: dict[ tuple[weakref.ReferenceType[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, force: bool = False, ) -> 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. force : bool True if slicing should be forced to occur, even when some cache thinks it already has a valid slice ready. False otherwise. 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. """ logger.debug( '_LayerSlicer.submit: layers=%s, dims=%s, force=%s', layers, dims, force, ) if existing_task := self._find_existing_task(layers): logger.debug('Cancelling task %s', id(existing_task)) 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: dict[weakref.ref, _SliceRequest] = {} sync_layers = [] for layer in layers: # Slicing of non-visible layers is handled differently by sync # and async slicing. For async, we do not make request since a # later change to visibility triggers slicing. For sync, we want # to set the slice input with `Layer._slice_dims` but don't want # to fetch data yet (only if/when it becomes visible in the future). # Further development should allow us to remove this special case # by making the sync and async slicing code paths almost identical. if ( isinstance(layer, _AsyncSliceable) and not self._force_sync and layer.visible ): logger.debug('Making async slice request for %s', layer) request = layer._make_slice_request(dims) weak_layer = weakref.ref(layer) requests[weak_layer] = request layer._set_unloaded_slice_id(request.id) else: logger.debug('Sync slicing for %s', layer) sync_layers.append(layer) # First maybe submit an async slicing task to start it ASAP. task = None if len(requests) > 0: logger.debug('Submitting task %s', id(task)) 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=dims, force=force, ) return task def shutdown(self) -> None: """Shuts this down, preventing any new slice tasks from being submitted. This waits for any running tasks to finish, cancels any pending tasks, and disconnects any observers from this LayerSlicer's events. This should only be called from the main thread. """ logger.debug('_LayerSlicer.shutdown') self._executor.shutdown(wait=True, cancel_futures=True) self.events.disconnect() self.events.ready.disconnect() 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 """ logger.debug('_LayerSlicer._slice_layers: %s', requests) result = {layer: request() for layer, request in requests.items()} self.events.ready(value=result) return result 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. """ logger.debug('_LayerSlicer._on_slice_done: %s', id(task)) if not self._try_to_remove_task(task): logger.debug('Task not found: %s', id(task)) if task.cancelled(): logger.debug('Cancelled task: %s', id(task)) return 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 weak_task_layers, task in self._layers_to_task.items(): task_layers = {w() for w in weak_task_layers} - {None} if task_layers.issubset(layer_set): logger.debug('Found existing task for %s', task_layers) return task return None napari-0.5.6/napari/components/_tests/000077500000000000000000000000001474413133200177365ustar00rootroot00000000000000napari-0.5.6/napari/components/_tests/test_add_layers.py000066400000000000000000000256751474413133200234750ustar00rootroot00000000000000from 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""" _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) def test_add_layers_with_plugins_full_layers(layer): """Test that add_layers_with_plugins works for full Layer objects.""" with patch( 'napari.plugins.io.read_data_with_plugins', MagicMock(return_value=([layer], _testimpl)), ): v = ViewerModel() v._add_layers_with_plugins(['mock_path'], stack=False) assert len(v.layers) == 1 assert v.layers[0].source.path == 'mock_path' assert v.layers[0].source.reader_plugin == 'testimpl' def test_add_layers_with_plugins_layer_mix(layer): """Test add_layers_with_plugins handles mixed Layer and LayerDataTuple.""" layer_tuple = layer.as_layer_data_tuple() with patch( 'napari.plugins.io.read_data_with_plugins', # return one instantiated layer and one layer tuple MagicMock(return_value=([layer, layer_tuple], _testimpl)), ): v = ViewerModel() v._add_layers_with_plugins(['mock_path'], stack=False) # both were added assert len(v.layers) == 2 for lyr in v.layers: assert lyr.source.path == 'mock_path' assert lyr.source.reader_plugin == 'testimpl' @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) viewer.open([], stack=[], plugin=None) assert len(viewer.layers) == 2 def test_viewer_open_no_plugin(tmp_path): viewer = ViewerModel() fname = tmp_path / 'gibberish.gbrsh' fname.touch() with pytest.raises(ValueError, match='.*gibberish.gbrsh.*'): # 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 def test_add_points_layer_with_different_range_updates_all_slices(): """See https://github.com/napari/napari/pull/4819""" viewer = ViewerModel() # Adding the first point should show the point initial_point = viewer.add_points([[10, 5, 5]]) np.testing.assert_array_equal(initial_point._indices_view, [0]) assert viewer.dims.point == (10, 5, 5) # Adding an earlier point should keep the dim slider at the position # and therefore should not change the viewport. earlier_point = viewer.add_points([[8, 1, 1]]) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal(earlier_point._indices_view, []) assert viewer.dims.point == (10, 5, 5) # Adding a point on the same slice as the initial point should keep the # dim slider at the position and should additionally show the added point # in the viewport. same_slice_as_initial_point = viewer.add_points([[10, 1, 1]]) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal(earlier_point._indices_view, []) np.testing.assert_array_equal( same_slice_as_initial_point._indices_view, [0] ) assert viewer.dims.point == (10, 5, 5) # Adding a later point should keep the dim slider at the position # and therefore should not change the viewport. later_point = viewer.add_points([[14, 1, 1]]) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal(earlier_point._indices_view, []) np.testing.assert_array_equal( same_slice_as_initial_point._indices_view, [0] ) np.testing.assert_array_equal(later_point._indices_view, []) assert viewer.dims.point == (10, 5, 5) # Removing the earlier point should keep the dim slider at the position # and therefore should not change the viewport. viewer.layers.remove(earlier_point) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal( same_slice_as_initial_point._indices_view, [0] ) np.testing.assert_array_equal(later_point._indices_view, []) assert viewer.dims.point == (10, 5, 5) # Removing the point on the same slice as the initial point should keep # the dim slider at the position and should additionally remove the added # point from the viewport. viewer.layers.remove(same_slice_as_initial_point) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal(later_point._indices_view, []) assert viewer.dims.point == (10, 5, 5) # Removing the initial point should move the dim slider to the later # position and update the viewport. viewer.layers.remove(initial_point) np.testing.assert_array_equal(later_point._indices_view, [0]) assert viewer.dims.point == (14, 1, 1) # Adding an earlier point should keep the dim slider at the position # and therefore should not change the viewport. earlier_point2 = viewer.add_points([[8, 0, 0]]) np.testing.assert_array_equal(initial_point._indices_view, [0]) np.testing.assert_array_equal(earlier_point._indices_view, []) assert viewer.dims.point == (14, 1, 1) # Removing the second earlier point should move the dim slider to the # later position and update the viewport. viewer.layers.remove(later_point) np.testing.assert_array_equal(earlier_point2._indices_view, [0]) assert viewer.dims.point == (8, 0, 0) # Removing all points should reset the viewport. viewer.layers.remove(earlier_point2) assert viewer.dims.point == (0, 0) @pytest.mark.xfail(reason='https://github.com/napari/napari/issues/6198') def test_last_point_is_visible_in_viewport(): viewer = ViewerModel() # Removing the last point while viewing it should cause # us to view the first point due to the layer's new extent. points = viewer.add_points([[0, 1, 1], [1, 2, 2]]) viewer.dims.set_point(0, 1) assert viewer.dims.point[0] == 1 np.testing.assert_array_equal(points._indices_view, [1]) points.data = [[0, 1, 1]] assert viewer.dims.point[0] == 0 np.testing.assert_array_equal(points._indices_view, [0]) viewer.layers.remove(points) # Removing the first point while viewing it should cause us # to view the last point due to the layer's new extent. points = viewer.add_points([[0, 1, 1], [1, 2, 2]]) viewer.dims.set_point(0, 0) assert viewer.dims.point[0] == 0 np.testing.assert_array_equal(points._indices_view, [0]) points.data = [[1, 2, 2]] assert viewer.dims.point[0] == 1 np.testing.assert_array_equal(points._indices_view, [0]) @pytest.mark.xfail(reason='https://github.com/napari/napari/issues/6199') def test_dimension_change_is_visible_in_viewport(): viewer = ViewerModel() # Adding a 4d point leads to a visible 4d point with dims.point # having the same values. point_4d = viewer.add_points([[0] * 4]) assert viewer.dims.point == tuple([0] * 4) np.testing.assert_array_equal(point_4d._indices_view, [0]) # Adding a 5d point with different 4d coordinates does not change the viewport. # Only the first (actual 5th) dimension of the dims.point should change. point_5d = viewer.add_points([[2] * 5]) assert viewer.dims.point == tuple([2] + [0] * 4) np.testing.assert_array_equal(point_4d._indices_view, [0]) np.testing.assert_array_equal(point_5d._indices_view, []) # Removing the 4d point leads to an update of the viewport and dims. viewer.layers.remove(point_4d) assert viewer.dims.point == tuple([2] * 5) np.testing.assert_array_equal(point_5d._indices_view, [0]) # Adding another 4d point does not lead to an update of the viewport # because the current dims.point is still in the unified extent. point_4d = viewer.add_points([[0] * 4]) assert viewer.dims.point == tuple([2] * 5) np.testing.assert_array_equal(point_4d._indices_view, []) np.testing.assert_array_equal(point_5d._indices_view, [0]) # Removing the 5d point leads to an update of the viewport and dims. viewer.layers.remove(point_5d) assert viewer.dims.point == tuple([0] * 4) np.testing.assert_array_equal(point_4d._indices_view, [0]) napari-0.5.6/napari/components/_tests/test_axes.py000066400000000000000000000002441474413133200223070ustar00rootroot00000000000000from napari.components.overlays.axes import AxesOverlay def test_axes(): """Test creating axes object""" axes = AxesOverlay() assert axes is not None napari-0.5.6/napari/components/_tests/test_brush_circle_overlay.py000066400000000000000000000003351474413133200255550ustar00rootroot00000000000000from napari.components.overlays.brush_circle import BrushCircleOverlay def test_brush_circle(): """Test creating a brush circle overlay""" brush_circle = BrushCircleOverlay() assert brush_circle is not None napari-0.5.6/napari/components/_tests/test_camera.py000066400000000000000000000064731474413133200226110ustar00rootroot00000000000000import 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.6/napari/components/_tests/test_cursor.py000066400000000000000000000002331474413133200226620ustar00rootroot00000000000000from napari.components.cursor import Cursor def test_cursor(): """Test creating cursor object""" cursor = Cursor() assert cursor is not None napari-0.5.6/napari/components/_tests/test_dims.py000066400000000000000000000255751474413133200223210ustar00rootroot00000000000000import pytest from napari._pydantic_compat import ValidationError from napari.components import Dims from napari.components.dims import ( ensure_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(ValidationError, match='Invalid ordering'): 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_sanitize_input_setters(): dims = Dims() # axis out of range with pytest.raises(ValueError, match='not defined for dimensionality'): dims._sanitize_input(axis=2, value=3) # one value with pytest.raises(ValueError, match='cannot set multiple values'): dims._sanitize_input(axis=0, value=(1, 2, 3)) ax, val = dims._sanitize_input( axis=0, value=(1, 2, 3), value_is_sequence=True ) assert ax == [0] assert val == [(1, 2, 3)] # multiple axes ax, val = dims._sanitize_input(axis=(0, 1), value=(1, 2)) assert ax == [0, 1] assert val == [1, 2] ax, val = dims._sanitize_input(axis=(0, 1), value=((1, 2), (3, 4))) assert ax == [0, 1] assert val == [(1, 2), (3, 4)] def test_point(): """ Test point setting. """ dims = Dims(ndim=4) assert dims.point == (0,) * 4 dims.range = ((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.1, 2.6, 0.0, 4.0) 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.range = desired_range assert dims.range == desired_range # set point updates current_step indirectly dims.point = (2.9, 2.9, 2.9) assert dims.current_step == (6, 3, 1) assert dims.point == (2.9, 2.9, 2.9) # 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, 3) assert dims.point == (0.5, 0, 6) dims.set_current_step(0, -1) assert dims.current_step == (0, 0, 3) assert dims.point == (0, 0, 6) # mismatched len(axis) vs. len(value) with pytest.raises(ValueError, match='must have equal length'): dims.set_point((0, 1), (0, 0, 0)) with pytest.raises(ValueError, match='must have equal length'): 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),) # start must be lower than stop with pytest.raises(ValidationError, match='must be strictly increasing'): dims.set_range(0, (1, 0, 1)) # step must be positive with pytest.raises(ValidationError, match='must be strictly positive'): dims.set_range(0, (0, 2, 0)) with pytest.raises(ValidationError, match='must be strictly positive'): dims.set_range(0, (0, 2, -1)) 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 ValidationError with pytest.raises(ValueError, match='not defined for dimensionality'): 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, match='must have equal length'): 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, match='must have equal length'): 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 = ensure_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, match='not defined for dimensionality'): ensure_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_changing_focus_changing_ndisplay(): dims = Dims(ndim=4, ndisplay=2) # simulates putting focus from slider 0 to slider 1 dims.last_used = 1 assert dims.last_used == 1 dims.ndisplay = 3 # last_used should change from 1 to 0 since dim 1 is displayed now assert dims.last_used == 0 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] == 6 @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 def test_nsteps(): dims = Dims(range=((0, 5, 1), (0, 10, 0.5))) assert dims.nsteps == (6, 21) dims.nsteps = (11, 11) assert dims.range == ((0, 5, 0.5), (0, 10, 1)) def test_thickness(): dims = Dims(margin_left=(0, 0.5), margin_right=(1, 1)) assert dims.thickness == (1, 1.5) napari-0.5.6/napari/components/_tests/test_grid.py000066400000000000000000000047301474413133200223000ustar00rootroot00000000000000from 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.6/napari/components/_tests/test_interaction_box.py000066400000000000000000000055451474413133200245470ustar00rootroot00000000000000import 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] # size of 2 means wider bounding box by 1 in every direction pt_size = 2 layer = Points(pts, translate=translate, scale=scale, size=pt_size) 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( [ [-1, -1], [11, -1], [-1, 11], [11, 11], [-1, 5], [5, -1], [5, 11], [11, 5], [-2.2, 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.6/napari/components/_tests/test_layer_slicer.py000066400000000000000000000315271474413133200240340ustar00rootroot00000000000000import time import weakref from concurrent.futures import Future, wait from dataclasses import dataclass from threading import RLock, current_thread, main_thread from typing import Any import numpy as np import pytest from napari._tests.utils import DEFAULT_TIMEOUT_SECS, LockableData from napari.components import Dims from napari.components._layer_slicer import _LayerSlicer from napari.layers import Image, Labels, Points # 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._last_slice_id: int = 0 self._slice_request_count: int = 0 self.slice_count: int = 0 self.visible: bool = True 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 def _set_unloaded_slice_id(self, slice_id: int) -> None: self._last_slice_id = slice_id class FakeSyncLayer: def __init__(self) -> None: self.slice_count: int = 0 self.visible: bool = True def _slice_dims(self, *args, **kwargs) -> None: self.slice_count += 1 @pytest.fixture def layer_slicer(): layer_slicer = _LayerSlicer() layer_slicer._force_sync = False 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_response(future)[layer].id == 1 assert _wait_for_response(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_response(future)[layer1].id == 1 assert _wait_for_response(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_response(future)[layer1].id == 1 assert layer2 not in _wait_for_response(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_response(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_response(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_response(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_response(blocked)[layer1].id == 1 assert _wait_for_response(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_response(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_response(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)), point=(2, 0, 0), ) with lockable_data.lock: future = layer_slicer.submit(layers=[layer], dims=dims) assert not future.done() layer_result = _wait_for_response(future)[layer] np.testing.assert_equal(layer_result.image.view, data[2, :, :]) def test_submit_with_3d_labels(layer_slicer): np.random.seed(0) data = np.random.randint(20, size=(8, 7, 6)) lockable_data = LockableData(data) layer = Labels(lockable_data, multiscale=False) dims = Dims( ndim=3, ndisplay=2, range=((0, 8, 1), (0, 7, 1), (0, 6, 1)), point=(2, 0, 0), ) with lockable_data.lock: future = layer_slicer.submit(layers=[layer], dims=dims) assert not future.done() layer_result = _wait_for_response(future)[layer] np.testing.assert_equal(layer_result.image.view, 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)), point=(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.' ) # if remove quotes once we are Python 3.9+ def _wait_for_result(future: 'Future[Any]') -> Any: """Waits until the given future is finished returns its result.""" return future.result(timeout=DEFAULT_TIMEOUT_SECS) # remove quotes in types once we are python 3.9+ only. def _wait_for_response( task: 'Future[dict[weakref.ReferenceType[Any], Any]]', ) -> dict: """Waits until the given slice task is finished and returns its result.""" weak_result = _wait_for_result(task) result = {} for weak_layer, response in weak_result.items(): if layer := weak_layer(): result[layer] = response return result napari-0.5.6/napari/components/_tests/test_layers_base.py000066400000000000000000000010401474413133200236330ustar00rootroot00000000000000import numpy as np import pytest from numpy import array from napari.layers.base import Layer @pytest.mark.parametrize( ('dims', 'nworld', 'nshape', 'expected'), [ ([2, 1, 0, 3], 4, 2, [0, 1]), ([2, 1, 0, 3], 4, 3, [1, 0, 2]), ([2, 1, 0, 3], 4, 4, [2, 1, 0, 3]), ([0, 1, 2, 3, 4, 5, 6, 7], 4, 4, [0, 1, 2, 3, 4, 5, 6, 7]), ], ) def test_world_to_layer(dims, nworld, nshape, expected): assert np.array_equal( Layer._world_to_layer_dims_impl(array(dims), nworld, nshape), expected ) napari-0.5.6/napari/components/_tests/test_layers_list.py000066400000000000000000000424101474413133200237020ustar00rootroot00000000000000import os import npe2 import numpy as np import pytest from napari.components import LayerList from napari.layers import Image from napari.layers.utils._link_layers import get_linked_layers 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 def test_remove_linked_layer(): """Test removing a layer that is linked to other 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.extend([layer_a, layer_b, layer_c]) # link layer_c with layer_b layers.link_layers([layer_c, layer_b]) assert len(get_linked_layers(layer_c)) == 1 assert len(get_linked_layers(layer_b)) == 1 layers.selection.add(layer_b) layers.remove_selected() assert len(get_linked_layers(layer_c)) == 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 # TODO: this warning filter can be removed when a new version # of napari-svg includes the following PR: # https://github.com/napari/napari-svg/pull/38 @pytest.mark.filterwarnings('ignore:edge_:FutureWarning') 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.""" layers = LayerList() # Empty data is taken to be 512 x 512 np.testing.assert_allclose(layers.extent.world[0], (0, 0)) np.testing.assert_allclose(layers.extent.world[1], (511, 511)) 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], (10, 20, 5)) np.testing.assert_allclose(layer_a.extent.world[1], (25, 29, 19)) np.testing.assert_allclose(layers.extent.world[0], (10, 20, 5)) np.testing.assert_allclose(layers.extent.world[1], (25, 29, 19)) 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], (-5, -10, 10)) np.testing.assert_allclose(layer_b.extent.world[1], (37, 0, 24)) np.testing.assert_allclose(layers.extent.world[0], (-5, -10, 5)) np.testing.assert_allclose(layers.extent.world[1], (37, 29, 24)) 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.""" 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[0], (0, 0, 0)) np.testing.assert_allclose(layers.extent.world[1], (56, 168, 28)) # 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[0], (0, 0, 0)) np.testing.assert_allclose(layers.extent.world[1], (56, 168, 36)) 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 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.""" 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.random((10, 10, 10))) layers.append(img) imgs.append(img) assert layers == imgs with pytest.raises(ValueError, match='already present'): layers.append(imgs[1]) assert layers == imgs layers[1] = layers[1] assert layers == imgs with pytest.raises(ValueError, match='already present'): 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, match='already present'): layers[:3] = layers[:] assert set(layers) == set(imgs) napari-0.5.6/napari/components/_tests/test_multichannel.py000066400000000000000000000200761474413133200240370ustar00rootroot00000000000000import 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 ((45, 40, 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': dict(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 np.testing.assert_array_equal( 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.array_equal(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.array_equal(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.6/napari/components/_tests/test_prune_kwargs.py000066400000000000000000000036761474413133200240720ustar00rootroot00000000000000import pytest from napari.components.viewer_model import prune_kwargs TEST_KWARGS = { 'scale': (0.75, 1), 'blending': 'translucent', 'edge_color': 'red', 'border_color': 'blue', 'z_index': 20, 'edge_width': 2, 'border_width': 1, '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), 'multiscale': False, 'name': 'name', 'blending': 'translucent', }, ), ( 'points', { 'scale': (0.75, 1), 'blending': 'translucent', 'border_color': 'blue', 'border_width': 1, '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, match='Invalid layer_type'): prune_kwargs({}, 'nonexistent_layer_type') napari-0.5.6/napari/components/_tests/test_scale_bar.py000066400000000000000000000005621474413133200232650ustar00rootroot00000000000000from 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 def test_scale_bar_fixed_length(): """Test creating scale bar object with fixed length""" scale_bar = ScaleBarOverlay(length=50) assert scale_bar.length == 50 napari-0.5.6/napari/components/_tests/test_text_overlay.py000066400000000000000000000002121474413133200240670ustar00rootroot00000000000000from napari.components.overlays.text import TextOverlay def test_text_overlay(): label = TextOverlay() assert label is not None napari-0.5.6/napari/components/_tests/test_viewer_keybindings.py000066400000000000000000000146241474413133200252450ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import ( add_layer_by_type, layer_test_data, ) from napari.components._viewer_key_bindings import ( hold_for_pan_zoom, rotate_layers, show_only_layer_above, show_only_layer_below, toggle_selected_visibility, toggle_theme, toggle_unselected_visibility, ) from napari.components.viewer_model import ViewerModel from napari.layers.points import Points from napari.settings import get_settings from napari.utils.theme import available_themes, get_system_theme @pytest.mark.key_bindings def test_theme_toggle_keybinding(): viewer = ViewerModel() assert viewer.theme == get_settings().appearance.theme assert viewer.theme != 'light' toggle_theme(viewer) # toggle_theme should not change settings assert 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 viewer.theme != current_theme # toggle_theme should toggle only actual themes assert 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 viewer.theme != actual_initial_theme assert 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 viewer.theme != current_theme # toggle_theme should toggle only actual themes assert viewer.theme != 'system' # ensure we have looped back to whatever system was assert viewer.theme == actual_initial_theme def test_hold_for_pan_zoom(): viewer = ViewerModel() data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) viewer.layers.append(layer) layer.mode = 'transform' viewer.layers.selection.active = viewer.layers[0] gen = hold_for_pan_zoom(viewer) assert layer.mode == 'transform' next(gen) assert layer.mode == 'pan_zoom' with pytest.raises(StopIteration): next(gen) assert layer.mode == 'transform' def test_selected_visibility_toggle(): viewer = make_viewer_with_three_layers() viewer.layers.selection.active = viewer.layers[0] assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible toggle_selected_visibility(viewer) assert not viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible toggle_selected_visibility(viewer) assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible def test_unselected_visibility_toggle(): viewer = make_viewer_with_three_layers() viewer.layers.selection.active = viewer.layers[0] assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible toggle_unselected_visibility(viewer) assert viewer.layers[0].visible assert not viewer.layers[1].visible assert not viewer.layers[2].visible toggle_unselected_visibility(viewer) assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible def test_show_only_layer_above(): viewer = make_viewer_with_three_layers() viewer.layers.selection.active = viewer.layers[0] assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible show_only_layer_above(viewer) assert not viewer.layers[0].visible assert viewer.layers[1].visible assert not viewer.layers[2].visible show_only_layer_above(viewer) assert not viewer.layers[0].visible assert not viewer.layers[1].visible assert viewer.layers[2].visible def test_show_only_layer_below(): viewer = make_viewer_with_three_layers() viewer.layers.selection.active = viewer.layers[2] assert viewer.layers[0].visible assert viewer.layers[1].visible assert viewer.layers[2].visible show_only_layer_below(viewer) assert not viewer.layers[2].visible assert viewer.layers[1].visible assert not viewer.layers[0].visible show_only_layer_below(viewer) assert not viewer.layers[2].visible assert not viewer.layers[1].visible assert viewer.layers[0].visible @pytest.mark.parametrize(('layer_class', 'data', 'ndim'), layer_test_data) def test_rotate_layers(layer_class, data, ndim): """Test rotate layers works with all layer types/data""" viewer = ViewerModel() layer = add_layer_by_type(viewer, layer_class, data, visible=True) np.testing.assert_array_equal( layer.affine.rotate, np.eye(ndim, dtype=float) ) rotate_layers(viewer) np.testing.assert_array_equal( layer.affine.rotate[-2:, -2:], np.array([[0, -1], [1, 0]], dtype=float) ) def test_rotate_layers_in_3D(): """Test that rotate layers is disabled in 3D viewer mode""" viewer = ViewerModel() layer = add_layer_by_type( viewer, Points, np.array([[1, 2, 3]]), visible=True ) initial_rotation_matrix = layer.affine.rotate[-2:, -2:] viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 rotate_layers(viewer) # with ndisplay == 3 rotation is disabled, the matrix should not have changed np.testing.assert_array_equal( layer.affine.rotate[-2:, -2:], initial_rotation_matrix ) def make_viewer_with_three_layers(): """Helper function to create a viewer with three layers""" viewer = ViewerModel() layer1 = Points() layer2 = Points() layer3 = Points() viewer.layers.append(layer1) viewer.layers.append(layer2) viewer.layers.append(layer3) return viewer napari-0.5.6/napari/components/_tests/test_viewer_labels_io.py000066400000000000000000000013411474413133200246600ustar00rootroot00000000000000import 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 np.testing.assert_array_equal(labeled, viewer.layers[0].data) assert isinstance(viewer.layers[0], Labels) napari-0.5.6/napari/components/_tests/test_viewer_model.py000066400000000000000000001017541474413133200240400ustar00rootroot00000000000000import time import numpy as np import pytest from npe2 import DynamicPlugin from napari._tests.utils import ( count_warning_events, 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.layers.shapes._tests.conftest import ( ten_four_corner, # noqa: F401 ) # import to not put this data in top level conftest.py 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.array_equal(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_image_accepts_all_arguments_as_sequence(): """See https://github.com/napari/napari/pull/7089.""" viewer = ViewerModel(ndisplay=3) img = viewer.add_image(np.random.rand(2, 2)) viewer.add_image(**img._get_state()) 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.array_equal(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 # this is not an nd array but a list of ndarray. # I think that might be a edge case of MultiScaleData. assert 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 # this is not an nd array but a list of ndarray. # I think that might be a edge case of MultiScaleData. assert 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.array_equal(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.array_equal(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, 0.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.array_equal(viewer.layers[0].data, data) assert viewer.dims.ndim == 2 def test_add_shapes(ten_four_corner): # noqa: F811 """Test adding shapes.""" viewer = ViewerModel() viewer.add_shapes(ten_four_corner) assert len(viewer.layers) == 1 assert np.array_equal(viewer.layers[0].data, ten_four_corner) 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.array_equal(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.array_equal(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.array_equal(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, 4, 4) viewer.dims.set_point(0, 0) # viewer point shouldn't change after this assert tuple(viewer.dims.point) == (0, 4, 4) pts_layer = viewer.add_points(ndim=3) assert tuple(viewer.dims.point) == (0, 4, 4) pts_layer.add([(0, 8, 8)]) assert tuple(viewer.dims.point) == (0, 4, 4) def test_view_centering_with_scale(): """Regression test for issue #5735""" image = np.zeros((5, 10, 10)) viewer = ViewerModel() viewer.add_image(image, scale=(1, 1, 1)) assert tuple(viewer.dims.point) == (2, 4, 4) viewer.layers.pop() viewer.add_image(image, scale=(2, 1, 1)) assert tuple(viewer.dims.point) == (4, 4, 4) 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.array_equal( viewer.layers[image_name]._data_view, image_data[3, 5, :, :] ) 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.array_equal( viewer.layers[labels_name]._slice.image.raw, labels_data[3, 5, :, :] ) # Swap dims viewer.dims.order = [0, 2, 1, 3] assert viewer.dims.order == (0, 2, 1, 3) assert np.array_equal( viewer.layers[image_name]._data_view, image_data[3, :, 4, :] ) assert np.array_equal( viewer.layers[labels_name]._slice.image.raw, labels_data[3, :, 4, :] ) 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.array_equal(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, match='Unrecognized layer_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, match='data does not have suitable dimensions' ): 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] assert viewer.layers[0]._highlight_visible # 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] assert not viewer.layers[0]._highlight_visible assert viewer.layers[1]._highlight_visible # Check no active layer after unselecting all viewer.layers.selection.clear() assert viewer.layers.selection.active is None assert not viewer.layers[0]._highlight_visible assert not viewer.layers[1]._highlight_visible # Check selected layer is active viewer.layers.selection.add(viewer.layers[0]) assert viewer.layers.selection.active == viewer.layers[0] assert viewer.layers[0]._highlight_visible assert not viewer.layers[1]._highlight_visible # Check no layer is active if both layers are selected viewer.layers.selection.add(viewer.layers[1]) assert viewer.layers.selection.active is None assert not viewer.layers[0]._highlight_visible assert not viewer.layers[1]._highlight_visible 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._calc_status_from_cursor()[ 0 ] == 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_augmented[0], (-0.5, -0.5) ) np.testing.assert_allclose( viewer._sliced_extent_world_augmented[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_augmented[0], (8.5, 19.5, 4.5) ) np.testing.assert_allclose( viewer.layers._extent_world_augmented[1], (26.5, 29.5, 19.5) ) np.testing.assert_allclose( viewer._sliced_extent_world_augmented[0], (19.5, 4.5) ) np.testing.assert_allclose( viewer._sliced_extent_world_augmented[1], (29.5, 19.5) ) # Change displayed dims order viewer.dims.order = (1, 2, 0) np.testing.assert_allclose( viewer._sliced_extent_world_augmented[0], (4.5, 8.5) ) np.testing.assert_allclose( viewer._sliced_extent_world_augmented[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.array_equal(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, 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 - 1) * 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) == count_warning_events(em.callbacks) 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) == count_warning_events(em.callbacks) @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) == count_warning_events(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) > count_warning_events(em.callbacks) 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) == count_warning_events(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) def test_make_layer_visible_after_slicing(): """See https://github.com/napari/napari/issues/6760""" viewer = ViewerModel(ndisplay=2) data = np.array([np.ones((2, 2)) * i for i in range(3)]) layer: Image = viewer.add_image(data) layer.visible = False assert viewer.dims.current_step[0] != 0 assert not np.array_equal(layer._slice.image.raw, data[0]) viewer.dims.current_step = (0, 0, 0) layer.visible = True np.testing.assert_array_equal(layer._slice.image.raw, data[0]) def test_get_status_text(): viewer = ViewerModel(ndisplay=2) viewer.mouse_over_canvas = False assert viewer._calc_status_from_cursor() is None viewer.mouse_over_canvas = True assert viewer._calc_status_from_cursor() == ('Ready', '') viewer.cursor.position = (1, 2) viewer.add_labels( np.zeros((10, 10), dtype='uint8'), features={'a': [1, 2]} ) viewer.tooltip.visible = False assert viewer._calc_status_from_cursor() == ( { 'coordinates': ' [1 2]: 0; a: 1', 'layer_base': 'Labels', 'layer_name': 'Labels', 'plugin': '', 'source_type': '', }, '', ) viewer.tooltip.visible = True assert viewer._calc_status_from_cursor() == ( { 'coordinates': ' [1 2]: 0; a: 1', 'layer_base': 'Labels', 'layer_name': 'Labels', 'plugin': '', 'source_type': '', }, 'a: 1', ) viewer.update_status_from_cursor() assert viewer.status == { 'coordinates': ' [1 2]: 0; a: 1', 'layer_base': 'Labels', 'layer_name': 'Labels', 'plugin': '', 'source_type': '', } assert viewer.tooltip.text == 'a: 1' napari-0.5.6/napari/components/_tests/test_viewer_mouse_bindings.py000066400000000000000000000076401474413133200257440ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import pytest from napari.components import ViewerModel from napari.components._viewer_mouse_bindings import double_click_to_zoom from napari.utils._test_utils import read_only_mouse_event from napari.utils.interactions import mouse_wheel_callbacks 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], [4, 5, 5], [3, 5, 5], [0, 5, 5]], ), ( ['Control'], WheelEvent(True), [[5, 5, 5], [6, 5, 5], [7, 5, 5], [9, 5, 5]], ), ], ) def test_paint(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 = 0 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 = read_only_mouse_event( delta=[0, 0.6], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[0]).all() # Simulate tiny scroll event = read_only_mouse_event( delta=[0, 0.6], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[1]).all() # Simulate tiny scroll event = read_only_mouse_event( delta=[0, 0.9], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[2]).all() # Simulate large scroll event = read_only_mouse_event( delta=[0, 3], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[3]).all() def test_double_click_to_zoom(): viewer = ViewerModel() data = np.zeros((10, 10, 10)) viewer.add_image(data) # Ensure `pan_zoom` mode is active assert viewer.layers.selection.active.mode == 'pan_zoom' # Mock the mouse event event = Mock() event.modifiers = [] event.position = [100, 100] viewer.camera.center = (0, 0, 0) initial_zoom = viewer.camera.zoom initial_center = np.asarray(viewer.camera.center) assert viewer.dims.ndisplay == 2 double_click_to_zoom(viewer, event) assert viewer.camera.zoom == initial_zoom * 2 # should be half way between the old center and the event.position assert np.allclose(viewer.camera.center, (0, 50, 50)) # Assert the camera center has moved correctly in 3D viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 # reset to initial values viewer.camera.center = initial_center viewer.camera.zoom = initial_zoom event.position = [0, 100, 100] double_click_to_zoom(viewer, event) assert viewer.camera.zoom == initial_zoom * 2 assert np.allclose(viewer.camera.center, (0, 50, 50)) # Test with Alt key pressed event.modifiers = ['Alt'] double_click_to_zoom(viewer, event) # Assert the zoom level is back to initial assert viewer.camera.zoom == initial_zoom # Assert the camera center is back to initial assert np.allclose(viewer.camera.center, (0, 0, 0)) # Test in a mode other than pan_zoom viewer.layers.selection.active.mode = 'transform' assert viewer.layers.selection.active.mode != 'pan_zoom' double_click_to_zoom(viewer, event) # Assert nothing has changed assert viewer.camera.zoom == initial_zoom assert np.allclose(viewer.camera.center, (0, 0, 0)) napari-0.5.6/napari/components/_tests/test_world_coordinates.py000066400000000000000000000066361474413133200251030ustar00rootroot00000000000000import 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, 19, 1) assert viewer.dims.range[1] == (0, 9, 1) assert viewer.dims.range[2] == (0, 9, 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]) # TODO: non-integer with mixed scale? assert viewer.dims.range[0] == (0, 9, 1) assert viewer.dims.range[1] == (0, 9, 1) assert viewer.dims.range[2] == (0, 9, 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]) # TODO: non-integer with mixed scale? assert viewer.dims.range[0] == (0, 18, 1) assert viewer.dims.range[1] == (0, 9, 1) assert viewer.dims.range[2] == (0, 9, 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, 38, 2) assert viewer.dims.range[1] == (0, 9, 1) assert viewer.dims.range[2] == (0, 9, 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.6/napari/components/_viewer_constants.py000066400000000000000000000026551474413133200225530ustar00rootroot00000000000000from napari.utils.compat import StrEnum class CanvasPosition(StrEnum): """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(StrEnum): """CursorStyle: Style on the cursor. Sets the style of the cursor * square: A square * circle: A circle * circle_frozen: A brush circle with a frozen position along with the standard cursor. It is used to show the brush size change while using Ctrl+Alt + mouse move. * cross: A cross * forbidden: A forbidden symbol * pointing: A finger for pointing * standard: The standard cursor # crosshair: A crosshair """ SQUARE = 'square' CIRCLE = 'circle' CIRCLE_FROZEN = 'circle_frozen' CROSS = 'cross' FORBIDDEN = 'forbidden' POINTING = 'pointing' STANDARD = 'standard' CROSSHAIR = 'crosshair' napari-0.5.6/napari/components/_viewer_key_bindings.py000066400000000000000000000172671474413133200232110ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from app_model.types import KeyCode, KeyMod from napari.components.viewer_model import ViewerModel from napari.utils.action_manager import action_manager from napari.utils.notifications import show_info from napari.utils.theme import available_themes, get_system_theme from napari.utils.transforms import Affine from napari.utils.translations import trans if TYPE_CHECKING: from napari.viewer import Viewer def register_viewer_action(description, repeatable=False): """ 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, repeatable=repeatable, ) return func return _inner @ViewerModel.bind_key(KeyMod.Shift | KeyCode.UpArrow, overwrite=True) def extend_selection_to_layer_above(viewer: Viewer): viewer.layers.select_next(shift=True) @ViewerModel.bind_key(KeyMod.Shift | KeyCode.DownArrow, overwrite=True) def extend_selection_to_layer_below(viewer: Viewer): viewer.layers.select_previous(shift=True) @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 2D/3D view')) def toggle_ndisplay(viewer: Viewer): if viewer.dims.ndisplay == 2: viewer.dims.ndisplay = 3 else: 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._('Delete selected layers')) def delete_selected_layers(viewer: Viewer): viewer.layers.remove_selected() @register_viewer_action( trans._('Increment dimensions slider to the left'), repeatable=True ) def increment_dims_left(viewer: Viewer): viewer.dims._increment_dims_left() @register_viewer_action( trans._('Increment dimensions slider to the right'), repeatable=True ) 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() # Use non-breaking spaces and non-breaking hyphen for Preferences table @register_viewer_action( trans._( 'Change order of the visible axes, e.g.\u00a0[0,\u00a01,\u00a02]\u00a0\u2011>\u00a0[2,\u00a00,\u00a01]' ), ) def roll_axes(viewer: Viewer): viewer.dims.roll() # Use non-breaking spaces and non-breaking hyphen for Preferences table @register_viewer_action( trans._( 'Transpose order of the last two visible axes, e.g.\u00a0[0,\u00a01]\u00a0\u2011>\u00a0[1,\u00a00]' ), ) def transpose_axes(viewer: Viewer): viewer.dims.transpose() @register_viewer_action(trans._('Rotate layers 90 degrees counter-clockwise')) def rotate_layers(viewer: Viewer): if viewer.dims.ndisplay == 3: show_info(trans._('Rotating layers only works in 2D')) return for layer in viewer.layers: if layer.ndim == 2: visible_dims = [0, 1] else: visible_dims = list(viewer.dims.displayed) initial_affine = layer.affine.set_slice(visible_dims) # want to rotate around a fixed refernce for all layers center = ( np.asarray(viewer.dims.range)[:, 0][ np.asarray(viewer.dims.displayed) ] + ( np.asarray(viewer.dims.range)[:, 1][ np.asarray(viewer.dims.displayed) ] - np.asarray(viewer.dims.range)[:, 0][ np.asarray(viewer.dims.displayed) ] ) / 2 ) new_affine = ( Affine(translate=center) .compose(Affine(rotate=90)) .compose(Affine(translate=-center)) .compose(initial_affine) ) layer.affine = layer.affine.replace_slice(visible_dims, new_affine) @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._('Toggle visibility of unselected layers')) def toggle_unselected_visibility(viewer: Viewer): for layer in viewer.layers: if layer not in viewer.layers.selection: layer.visible = not layer.visible @register_viewer_action(trans._('Select and show only layer above')) def show_only_layer_above(viewer): viewer.layers.select_next() _show_only_selected_layer(viewer) @register_viewer_action(trans._('Select and show only layer below')) def show_only_layer_below(viewer): viewer.layers.select_previous() _show_only_selected_layer(viewer) @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() @register_viewer_action(trans._('Press and hold for pan/zoom mode')) def hold_for_pan_zoom(viewer: ViewerModel): selected_layer = viewer.layers.selection.active if selected_layer is None: yield return previous_mode = selected_layer.mode # Each layer has its own Mode enum class with different values, # but they should all have a PAN_ZOOM value. At the time of writing # these enums do not share a base class or protocol, so ignore the # attribute check for now. pan_zoom = selected_layer._modeclass.PAN_ZOOM # type: ignore[attr-defined] if previous_mode != pan_zoom: selected_layer.mode = pan_zoom yield selected_layer.mode = previous_mode @register_viewer_action(trans._('Show all key bindings')) def show_shortcuts(viewer: Viewer): pref_list = viewer.window._open_preferences_dialog()._list for i in range(pref_list.count()): if (item := pref_list.item(i)) and item.text() == 'Shortcuts': pref_list.setCurrentRow(i) def _show_only_selected_layer(viewer): """Helper function to show only selected layer""" for layer in viewer.layers: if layer not in viewer.layers.selection: layer.visible = False else: layer.visible = True napari-0.5.6/napari/components/_viewer_mouse_bindings.py000066400000000000000000000026501474413133200235370ustar00rootroot00000000000000import numpy as np def 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 def double_click_to_zoom(viewer, event): """Zoom in on double click by zoom_factor; zoom out with Alt.""" if ( viewer.layers.selection.active and viewer.layers.selection.active.mode != 'pan_zoom' ): return # if Alt held down, zoom out instead zoom_factor = 0.5 if 'Alt' in event.modifiers else 2 viewer.camera.zoom *= zoom_factor if viewer.dims.ndisplay == 3: viewer.camera.center = np.asarray(viewer.camera.center) + ( np.asarray(event.position)[np.asarray(viewer.dims.displayed)] - np.asarray(viewer.camera.center) ) * (1 - 1 / zoom_factor) else: viewer.camera.center = np.asarray(viewer.camera.center)[-2:] + ( np.asarray(event.position)[-2:] - np.asarray(viewer.camera.center)[-2:] ) * (1 - 1 / zoom_factor) napari-0.5.6/napari/components/camera.py000066400000000000000000000171631474413133200202470ustar00rootroot00000000000000import warnings from typing import Optional, Union import numpy as np from scipy.spatial.transform import Rotation as R from napari._pydantic_compat import validator 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 mouse pan/zoom is enabled or not. This attribute is deprecated since 0.5.0 and should not be used. Use the mouse_pan and mouse_zoom attributes instead. mouse_pan : bool If the camera interactive panning with the mouse is enabled or not. mouse_zoom : bool If the camera interactive zooming with the mouse is enabled or not. """ # fields center: Union[tuple[float, float, float], tuple[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 mouse_pan: bool = True mouse_zoom: bool = True # validators @validator('center', 'angles', pre=True, allow_reuse=True) def _ensure_3_tuple(cls, 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 ( rotation_matrix[2, 2], rotation_matrix[1, 2], rotation_matrix[0, 2], ) 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_vector = np.asarray(view_direction, dtype=float)[::-1] view_vector /= np.linalg.norm(view_vector) up_vector = np.asarray(up_direction, dtype=float)[::-1] up_vector = np.cross(view_vector, up_vector) up_vector /= np.linalg.norm(up_vector) # explicit check for parallel view direction and up direction if np.allclose(np.cross(view_vector, up_vector), 0): raise ValueError( trans._( 'view direction and up direction are parallel', deferred=True, ) ) x_vector = np.cross(up_vector, view_vector) x_vector /= np.linalg.norm(x_vector) # construct rotation matrix, convert to euler angles rotation_matrix = np.column_stack((up_vector, view_vector, x_vector)) 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, ...] ) -> Optional[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 @property def interactive(self) -> bool: warnings.warn( '`Camera.interactive` is deprecated since 0.5.0 and will be removed in 0.6.0.', category=DeprecationWarning, ) return self.mouse_pan or self.mouse_zoom @interactive.setter def interactive(self, interactive: bool): warnings.warn( '`Camera.interactive` is deprecated since 0.5.0 and will be removed in 0.6.0.', category=DeprecationWarning, ) self.mouse_pan = interactive self.mouse_zoom = interactive napari-0.5.6/napari/components/cursor.py000066400000000000000000000026231474413133200203270ustar00rootroot00000000000000from typing import Optional 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, float, 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 = 1.0 style: CursorStyle = CursorStyle.STANDARD _view_direction: Optional[tuple[float, float, float]] = None napari-0.5.6/napari/components/dims.py000066400000000000000000000433501474413133200177500ustar00rootroot00000000000000from collections.abc import Sequence from numbers import Integral from typing import ( Any, Literal, NamedTuple, Optional, Union, ) import numpy as np from napari._pydantic_compat 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 RangeTuple(NamedTuple): start: float stop: float step: float class Dims(EventedModel): """Dimensions object modeling slicing and displaying. Parameters ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension in world coordinates space. Lower and upper bounds are inclusive. point : tuple of floats Dims position in world coordinates for each dimension. margin_left : tuple of floats Left margin in world pixels of the slice for each dimension. margin_right : tuple of floats Right margin in world pixels of the slice for each dimension. 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. last_used : int Dimension which was last interacted with. Attributes ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension in world coordinates space. Lower and upper bounds are inclusive. point : tuple of floats Dims position in world coordinates for each dimension. margin_left : tuple of floats Left margin (=thickness) in world pixels of the slice for each dimension. margin_right : tuple of floats Right margin (=thickness) in world pixels of the slice for each dimension. 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. last_used : int Dimension which was last used. Tuple the slider position for each dims slider, in world coordinates. current_step : tuple of int Current step for each dimension (same as point, but in slider coordinates). nsteps : tuple of int Number of steps available to each slider. These are calculated from the ``range``. thickness : tuple of floats Thickness of the slice (sum of both margins) for each dimension in world coordinates. 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. rollable : tuple of bool Tuple of axis roll state. If True the axis is rollable. """ # fields ndim: int = 2 ndisplay: Literal[2, 3] = 2 order: tuple[int, ...] = () axis_labels: tuple[str, ...] = () rollable: tuple[bool, ...] = () range: tuple[RangeTuple, ...] = () margin_left: tuple[float, ...] = () margin_right: tuple[float, ...] = () point: tuple[float, ...] = () last_used: int = 0 # private vars _play_ready: bool = True # False if currently awaiting a draw event _scroll_progress: int = 0 # validators # check fields is false to allow private fields to work @validator( 'order', 'axis_labels', 'rollable', 'point', 'margin_left', 'margin_right', pre=True, allow_reuse=True, ) def _as_tuple(v): return tuple(v) @validator('range', pre=True) def _check_ranges(ranges): """ Ensure the range values are sane. - start < stop - step > 0 """ for axis, (start, stop, step) in enumerate(ranges): if start > stop: raise ValueError( trans._( 'start and stop must be strictly increasing, but got ({start}, {stop}) for axis {axis}', deferred=True, start=start, stop=stop, axis=axis, ) ) if step <= 0: raise ValueError( trans._( 'step must be strictly positive, but got {step} for axis {axis}.', deferred=True, step=step, axis=axis, ) ) return ranges @root_validator(skip_on_failure=True, allow_reuse=True) def _check_dims(cls, values): """Check the consistency of dimensionality for all attributes. Parameters ---------- values : dict Values dictionary to update dims model with. """ updated = {} ndim = values['ndim'] range_ = ensure_len(values['range'], ndim, pad_width=(0.0, 2.0, 1.0)) updated['range'] = tuple(RangeTuple(*rng) for rng in range_) point = ensure_len(values['point'], ndim, pad_width=0.0) # ensure point is limited to range updated['point'] = tuple( np.clip(pt, rng.start, rng.stop) for pt, rng in zip(point, updated['range']) ) updated['margin_left'] = ensure_len( values['margin_left'], ndim, pad_width=0.0 ) updated['margin_right'] = ensure_len( values['margin_right'], ndim, pad_width=0.0 ) # order and label default computation is too different to include in ensure_len() # Check the order tuple has same number of elements as ndim order = values['order'] if len(order) < ndim: order_ndim = len(order) # new dims are always prepended prepended_dims = tuple(range(ndim - order_ndim)) # maintain existing order, but shift accordingly existing_order = tuple(o + ndim - order_ndim for o in order) order = prepended_dims + existing_order elif len(order) > ndim: order = reorder_after_dim_reduction(order[-ndim:]) updated['order'] = order # Check the order is a permutation of 0, ..., ndim - 1 if set(updated['order']) != set(range(ndim)): raise ValueError( trans._( 'Invalid ordering {order} for {ndim} dimensions', deferred=True, order=updated['order'], ndim=ndim, ) ) # Check the axis labels tuple has same number of elements as ndim axis_labels = values['axis_labels'] labels_ndim = len(axis_labels) if labels_ndim < ndim: # Append new "default" labels to existing ones if axis_labels == tuple(map(str, range(labels_ndim))): updated['axis_labels'] = tuple(map(str, range(ndim))) else: updated['axis_labels'] = ( tuple(map(str, range(ndim - labels_ndim))) + axis_labels ) elif labels_ndim > ndim: updated['axis_labels'] = axis_labels[-ndim:] # Check the rollable axes tuple has same number of elements as ndim updated['rollable'] = ensure_len(values['rollable'], ndim, True) # If the last used slider is no longer visible, use the first. last_used = values['last_used'] ndisplay = values['ndisplay'] dims_range = updated['range'] nsteps = cls._nsteps_from_range(dims_range) not_displayed = [ d for d in order[:-ndisplay] if len(nsteps) > d and nsteps[d] > 1 ] if len(not_displayed) > 0 and last_used not in not_displayed: updated['last_used'] = not_displayed[0] return {**values, **updated} @staticmethod def _nsteps_from_range(dims_range) -> tuple[float, ...]: return tuple( # "or 1" ensures degenerate dimension works int((rng.stop - rng.start) / (rng.step or 1)) + 1 for rng in dims_range ) @property def nsteps(self) -> tuple[float, ...]: return self._nsteps_from_range(self.range) @nsteps.setter def nsteps(self, value): self.range = tuple( RangeTuple( rng.start, rng.stop, (rng.stop - rng.start) / (nsteps - 1) ) for rng, nsteps in zip(self.range, value) ) @property def current_step(self): return tuple( int(round((point - rng.start) / (rng.step or 1))) for point, rng in zip(self.point, self.range) ) @current_step.setter def current_step(self, value): self.point = tuple( rng.start + point * rng.step for point, rng in zip(value, self.range) ) @property def thickness(self) -> tuple[float, ...]: return tuple( left + right for left, right in zip(self.margin_left, self.margin_right) ) @thickness.setter def thickness(self, value): self.margin_left = self.margin_right = tuple(val / 2 for val in value) @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. """ axis, value = self._sanitize_input( axis, _range, value_is_sequence=True ) full_range = list(self.range) for ax, val in zip(axis, value): full_range[ax] = val self.range = tuple(full_range) def set_point( self, axis: Union[int, Sequence[int]], value: Union[float, Sequence[float]], ): """Sets point to slice dimension in world coordinates. 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. """ axis, value = self._sanitize_input( axis, value, value_is_sequence=False ) full_point = list(self.point) for ax, val in zip(axis, value): full_point[ax] = val self.point = tuple(full_point) def set_current_step( self, axis: Union[int, Sequence[int]], value: Union[int, Sequence[int]], ): axis, value = self._sanitize_input( axis, value, value_is_sequence=False ) range_ = list(self.range) value_world = [] for ax, val in zip(axis, value): rng = range_[ax] value_world.append(rng.start + val * rng.step) self.set_point(axis, value_world) 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. """ axis, label = self._sanitize_input( axis, label, value_is_sequence=False ) full_axis_labels = list(self.axis_labels) for ax, val in zip(axis, label): full_axis_labels[ax] = val self.axis_labels = tuple(full_axis_labels) def reset(self): """Reset dims values to initial states.""" # Don't reset axis labels # TODO: could be optimized with self.update, but need to fix # event firing in EventedModel first self.range = ((0, 2, 1),) * self.ndim self.point = (0,) * self.ndim self.order = tuple(range(self.ndim)) self.margin_left = (0,) * self.ndim self.margin_right = (0,) * self.ndim self.rollable = (True,) * 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: Optional[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: Optional[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) # we combine "rollable" and "nsteps" into a mask for rolling # this mask has to be aligned to "order" as "rollable" and # "nsteps" are static but order is dynamic, meaning "rollable" # and "nsteps" encode the axes by position, whereas "order" # encodes axis by number valid = np.logical_and(self.rollable, np.array(self.nsteps) > 1)[order] order[valid] = np.roll(order[valid], shift=1) self.order = order def _go_to_center_step(self): self.current_step = [int((ns - 1) / 2) for ns in self.nsteps] def _sanitize_input( self, axis, value, value_is_sequence=False ) -> tuple[list[int], list]: """ Ensure that axis and value are the same length, that axes are not out of bounds, and coerces to lists for easier processing. """ if isinstance(axis, Integral): if ( isinstance(value, Sequence) and not isinstance(value, str) and not value_is_sequence ): raise ValueError( trans._('cannot set multiple values to a single axis') ) axis = [axis] value = [value] else: axis = list(axis) value = list(value) if len(axis) != len(value): raise ValueError( trans._('axis and value sequences must have equal length') ) for ax in axis: ensure_axis_in_bounds(ax, self.ndim) return axis, value def ensure_len(value: tuple, length: int, pad_width: Any): """ Ensure that the value has the required number of elements. Right-crop if value is too long; left-pad with default if too short. Parameters ---------- value : Tuple A tuple of values to be resized. ndim : int Number of desired values. default : Tuple Default element for left-padding. """ if len(value) < length: # left pad value = (pad_width,) * (length - len(value)) + value elif len(value) > length: # right-crop value = value[-length:] return value def ensure_axis_in_bounds(axis: int, ndim: int) -> int: """Ensure 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.6/napari/components/experimental/000077500000000000000000000000001474413133200211325ustar00rootroot00000000000000napari-0.5.6/napari/components/experimental/__init__.py000066400000000000000000000000001474413133200232310ustar00rootroot00000000000000napari-0.5.6/napari/components/experimental/commands.py000066400000000000000000000023321474413133200233050ustar00rootroot00000000000000"""ExperimentalNamespace and CommandProcessor classes.""" HELP_STR = """ 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.6/napari/components/experimental/monitor/000077500000000000000000000000001474413133200226215ustar00rootroot00000000000000napari-0.5.6/napari/components/experimental/monitor/__init__.py000066400000000000000000000003101474413133200247240ustar00rootroot00000000000000"""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.6/napari/components/experimental/monitor/_api.py000066400000000000000000000163061474413133200241110ustar00rootroot00000000000000"""MonitorApi class.""" import logging from multiprocessing.managers import SharedMemoryManager from queue import Empty, Queue from threading import Event from typing import ClassVar, 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: ClassVar[dict] = {} _napari_messages_queue: ClassVar[Queue] = Queue() _napari_shutdown_event: ClassVar[Event] = Event() _client_data_dict: ClassVar[dict] = {} _client_messages_queue: ClassVar[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.6/napari/components/experimental/monitor/_monitor.py000066400000000000000000000143671474413133200250340ustar00rootroot00000000000000"""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 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 not ENABLE_MONITOR: logging.warning('Monitor: not starting, disabled') return None # The NAPARI_MON environment variable points to our config file. config = _load_monitor_config() if config is None: logging.warning('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.6/napari/components/experimental/monitor/_service.py000066400000000000000000000124131474413133200247730ustar00rootroot00000000000000"""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 typing import Union 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: dict[str, Union[str, int]] = { '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.6/napari/components/experimental/monitor/_utils.py000066400000000000000000000017061474413133200244760ustar00rootroot00000000000000"""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.6/napari/components/experimental/remote/000077500000000000000000000000001474413133200224255ustar00rootroot00000000000000napari-0.5.6/napari/components/experimental/remote/__init__.py000066400000000000000000000001461474413133200245370ustar00rootroot00000000000000from napari.components.experimental.remote._manager import RemoteManager __all__ = ['RemoteManager'] napari-0.5.6/napari/components/experimental/remote/_commands.py000066400000000000000000000041441474413133200247420ustar00rootroot00000000000000"""RemoteCommands class.""" import json import logging from napari.components.layerlist import LayerList 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 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.exception('RemoteCommands.%s does not exist.', name) napari-0.5.6/napari/components/experimental/remote/_manager.py000066400000000000000000000026101474413133200245470ustar00rootroot00000000000000"""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.6/napari/components/experimental/remote/_messages.py000066400000000000000000000032231474413133200247450ustar00rootroot00000000000000"""RemoteMessages class. Sends messages to remote clients. """ import logging import time from typing import Optional from napari.components.experimental.monitor import monitor from napari.components.layerlist import LayerList 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: Optional[float] = 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] = {} 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.6/napari/components/grid.py000066400000000000000000000070661474413133200177450ustar00rootroot00000000000000import 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 # See https://github.com/pydantic/pydantic/issues/156 for why # these need a type: ignore comment stride: GridStride = 1 # type: ignore[valid-type] shape: tuple[GridHeight, GridWidth] = (-1, -1) # type: ignore[valid-type] 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 not self.enabled: return (1, 1) 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) 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 not self.enabled: return (0, 0) n_row, n_column = self.actual_shape(nlayers) # Adjust for forward or reverse ordering adj_i = nlayers - index - 1 if self.stride < 0 else 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) napari-0.5.6/napari/components/layerlist.py000066400000000000000000000420651474413133200210260ustar00rootroot00000000000000from __future__ import annotations import itertools import typing import warnings from collections.abc import Iterable from functools import cached_property from typing import TYPE_CHECKING, Optional, Union import numpy as np from napari.components.dims import RangeTuple from napari.layers import Layer from napari.layers.utils.layer_utils import Extent from napari.utils.events.containers import SelectableEventedList from napari.utils.naming import inc_name_count from napari.utils.translations import trans if TYPE_CHECKING: from npe2.manifest.io import WriterContribution from typing_extensions import Self def get_name(layer: Layer) -> str: """Return the name of a layer.""" return layer.name 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 item at ``index`` is changed from ``old_value`` to ``value`` changed : (index: slice, old_value: List[_T], value: List[_T]) emitted when item at ``index`` is changed 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: get_name}, ) self._create_contexts() def _create_contexts(self): """Create contexts to manage enabled/visible action/menu states. Connects LayerList and Selection[Layer] to their context keys to allow actions and menu items (in the GUI) to be dynamically enabled/disabled and visible/hidden based on the state of layers in the list. """ # 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, LayerListSelectionContextKeys, ) self._ctx = create_context(self) if self._ctx is not None: # happens during Viewer type creation self._ctx_keys = LayerListContextKeys(self._ctx) self.events.inserted.connect(self._ctx_keys.update) self.events.removed.connect(self._ctx_keys.update) self._selection_ctx_keys = LayerListSelectionContextKeys(self._ctx) self.selection.events.changed.connect( self._selection_ctx_keys.update ) def _process_delete_item(self, item: Layer): super()._process_delete_item(item) item.events.extent.disconnect(self._clean_cache) item.events._extent_augmented.disconnect(self._clean_cache) self._clean_cache() def _clean_cache(self): cached_properties = ( 'extent', '_extent_world', '_extent_world_augmented', '_step_size', ) [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 @typing.overload def __getitem__(self, item: Union[int, str]) -> Layer: ... @typing.overload def __getitem__(self, item: slice) -> Self: ... def __getitem__(self, item): return super().__getitem__(item) 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) new_layer.events._extent_augmented.connect(self._clean_cache) super().insert(index, new_layer) def remove_selected(self): """Remove selected layers from LayerList, but first unlink them.""" if not self.selection: return self.unlink_layers(self.selection) super().remove_selected() 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]) @cached_property def _extent_world_augmented(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_augmented for layer in self], augmented=True, ) 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, augmented=False): """Extent of layers in world coordinates. Default to 2D image-like with (0, 511) min/ max values if no data is present. Corresponds to image with 512 pixels in each dimension. Returns ------- extent_world : array, shape (2, D) """ if len(self) == 0: min_v = np.zeros(self.ndim) max_v = np.full(self.ndim, 511.0) # image-like augmented extent is actually expanded by 0.5 if augmented: min_v -= 0.5 max_v += 0.5 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. Extent bounds are inclusive. 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. Extent bounds are inclusive. See Layer.extent for a detailed explanation of how extents are calculated. """ return self.get_extent(list(self)) @property def _ranges(self) -> tuple[RangeTuple, ...]: """Get ranges for Dims.range in world coordinates.""" ext = self.extent return tuple( RangeTuple(*x) for x in zip(ext.world[0], ext.world[1], ext.step) ) @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.6/napari/components/overlays/000077500000000000000000000000001474413133200203015ustar00rootroot00000000000000napari-0.5.6/napari/components/overlays/__init__.py000066400000000000000000000015431474413133200224150ustar00rootroot00000000000000from 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.brush_circle import BrushCircleOverlay from napari.components.overlays.interaction_box import ( SelectionBoxOverlay, TransformBoxOverlay, ) from napari.components.overlays.labels_polygon import LabelsPolygonOverlay from napari.components.overlays.scale_bar import ScaleBarOverlay from napari.components.overlays.text import TextOverlay __all__ = [ 'AxesOverlay', 'BoundingBoxOverlay', 'BrushCircleOverlay', 'CanvasOverlay', 'LabelsPolygonOverlay', 'Overlay', 'ScaleBarOverlay', 'SceneOverlay', 'SelectionBoxOverlay', 'TextOverlay', 'TransformBoxOverlay', ] napari-0.5.6/napari/components/overlays/axes.py000066400000000000000000000021201474413133200216060ustar00rootroot00000000000000from napari.components.overlays.base import SceneOverlay class AxesOverlay(SceneOverlay): """Axes indicating world coordinate origin and orientation. Attributes ---------- labels : bool If axis labels are visible or not. Note that the actual axis labels are stored in `viewer.dims.axis_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.6/napari/components/overlays/base.py000066400000000000000000000041621474413133200215700ustar00rootroot00000000000000from typing import Union from napari.components._viewer_constants import CanvasPosition from napari.layers.base._base_constants import Blending 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 = 10**6 blending: Blending 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: Union[CanvasPosition, tuple[int, int]] = ( CanvasPosition.BOTTOM_RIGHT ) blending: Blending = Blending.TRANSLUCENT_NO_DEPTH 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. """ blending: Blending = Blending.TRANSLUCENT napari-0.5.6/napari/components/overlays/bounding_box.py000066400000000000000000000022561474413133200233350ustar00rootroot00000000000000from napari._pydantic_compat import Field from 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 = Field(default_factory=lambda: ColorValue('red')) points: bool = True point_size: float = 5 point_color: ColorValue = Field(default_factory=lambda: ColorValue('blue')) napari-0.5.6/napari/components/overlays/brush_circle.py000066400000000000000000000011061474413133200233150ustar00rootroot00000000000000from napari.components.overlays.base import CanvasOverlay class BrushCircleOverlay(CanvasOverlay): """ Overlay that displays a circle for a brush on a canvas. Attributes ---------- size : int The diameter of the brush circle in canvas pixels. position : Tuple[int, int] The position (x, y) of the center of the brush circle on the canvas. position_is_frozen : bool If True, the overlay does not respond to mouse movements. """ size: int = 10 position: tuple[int, int] = (0, 0) position_is_frozen: bool = False napari-0.5.6/napari/components/overlays/interaction_box.py000066400000000000000000000032761474413133200240520ustar00rootroot00000000000000from typing import Optional 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.6/napari/components/overlays/labels_polygon.py000066400000000000000000000033711474413133200236700ustar00rootroot00000000000000from napari._pydantic_compat import Field from napari.components.overlays.base import SceneOverlay from napari.layers import Labels class LabelsPolygonOverlay(SceneOverlay): """Overlay that displays a polygon on a scene. This overlay was created for drawing polygons on Labels layers. It handles the following mouse events to update the overlay: - Mouse move: Continuously redraw the latest polygon point with the current mouse position. - Mouse press (left button): Adds the current mouse position as a new polygon point. - Mouse double click (left button): If there are at least three points in the polygon and the double-click position is within completion_radius from the first vertex, the polygon will be painted in the image using the current label. - Mouse press (right button): Removes the most recent polygon point from the list. Attributes ---------- enabled : bool Controls whether the overlay is activated. points : list A list of (x, y) coordinates of the vertices of the polygon. use_double_click_completion_radius : bool Whether double-click to complete drawing the polygon requires being within completion_radius of the first point. completion_radius : int | float Defines the radius from the first polygon vertex within which the drawing process can be completed by a left double-click. """ enabled: bool = False points: list = Field(default_factory=list) use_double_click_completion_radius: bool = False completion_radius: int = 20 def add_polygon_to_labels(self, layer: Labels) -> None: if len(self.points) > 2: layer.paint_polygon(self.points, layer.selected_label) self.points = [] napari-0.5.6/napari/components/overlays/scale_bar.py000066400000000000000000000040601474413133200225660ustar00rootroot00000000000000"""Scale bar model.""" from typing import Optional from napari._pydantic_compat import Field 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. length : Optional[float] Fixed length of the scale bar in physical units. If set to `None`, it is determined automatically based on zoom level. 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 = Field(default_factory=lambda: ColorValue([1, 0, 1, 1])) ticks: bool = True font_size: float = 10 box: bool = False box_color: ColorValue = Field( default_factory=lambda: ColorValue([0, 0, 0, 0.6]) ) unit: Optional[str] = None length: Optional[float] = None napari-0.5.6/napari/components/overlays/text.py000066400000000000000000000017041474413133200216410ustar00rootroot00000000000000"""Text label model.""" from napari._pydantic_compat import Field 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 = Field( default_factory=lambda: ColorValue((0.5, 0.5, 0.5, 1.0)) ) font_size: float = 10 text: str = '' napari-0.5.6/napari/components/tooltip.py000066400000000000000000000005131474413133200205000ustar00rootroot00000000000000from 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.6/napari/components/viewer_model.py000066400000000000000000002037651474413133200215050ustar00rootroot00000000000000from __future__ import annotations import inspect import itertools import os import warnings from collections.abc import Iterator, Mapping, MutableMapping, Sequence from functools import lru_cache from pathlib import Path from typing import ( TYPE_CHECKING, Any, Optional, Union, cast, ) import numpy as np # This cannot be condition to TYPE_CHECKING or the stubgen fails # with undefined Context. from app_model.expressions import Context from napari import layers from napari._pydantic_compat import Extra, Field, PrivateAttr, validator from napari.components._layer_slicer import _LayerSlicer from napari.components._viewer_mouse_bindings import ( dims_scroll, double_click_to_zoom, ) from napari.components.camera import Camera from napari.components.cursor import Cursor, CursorStyle from napari.components.dims import Dims from napari.components.grid import GridCanvas from napari.components.layerlist import LayerList from napari.components.overlays import ( AxesOverlay, BrushCircleOverlay, 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 Source, 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.types import ( FullLayerData, LayerData, LayerTypeName, PathLike, PathOrPaths, SampleData, ) 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 if TYPE_CHECKING: from npe2.types import SampleDataCreator 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'}) Dict = dict # rename, because ViewerModel has method dict __all__ = ['ViewerModel', 'valid_add_kwargs'] def _current_theme() -> str: return get_settings().appearance.theme DEFAULT_OVERLAYS = { 'scale_bar': ScaleBarOverlay, 'text': TextOverlay, 'axes': AxesOverlay, 'brush_circle': BrushCircleOverlay, } # 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 ---------- camera: napari.components.camera.Camera The camera object modeling the position and view. cursor: napari.components.cursor.Cursor The cursor object containing the position and properties of the cursor. dims : napari.components.dims.Dimensions Contains axes, indices, dimensions and sliders. grid: napari.components.grid.Gridcanvas Gridcanvas allowing for the current implementation of a gridview of the canvas. help: str A help message of the viewer model layers : napari.components.layerlist.LayerList List of contained layers. mouse_over_canvas: bool Indicating whether the mouse cursor is on the viewer canvas. theme: str Name of the Napari theme of the viewer title: str The title of the viewer model tooltip: napari.components.tooltip.Tooltip A tooltip showing extra information on the cursor window : napari._qt.qt_main_window.Window Parent window. _canvas_size: Tuple[int, int] The canvas size following the Numpy convention of height x width _ctx: Mapping Viewer object context mapping. _layer_slicer: napari.components._layer_slicer._Layer_Slicer A layer slicer object controlling the creation of a slice _overlays: napari.utils.events.containers._evented_dict.EventedDict[str, Overlay] An EventedDict with as keys the string names of different napari overlays and as values the napari.Overlay objects. """ # 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] = (800, 600) _ctx: Context # To check if mouse is over canvas to avoid race conditions between # different events systems mouse_over_canvas: bool = False # Need to use default factory because slicer is not copyable which # is required for default values. _layer_slicer: _LayerSlicer = PrivateAttr(default_factory=_LayerSlicer) 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 ) settings.experimental.events.async_.connect(self._update_async) # 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_name='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.point.connect(self._update_layers) # FIXME: the next line is a temporary workaround. With #5522 and #5751 Dims.point became # the source of truth, and is now defined in world space. This exposed an existing # bug where if a field in Dims is modified by the root_validator, events won't # be fired for it. This won't happen for properties because we have dependency # checks. To fix this, we need dep checks for fields (psygnal!) and then we # can remove the following line. Note that because of this we fire double events, # but this should be ok because we have early returns when slices are unchanged. self.dims.events.current_step.connect(self._update_layers) self.dims.events.margin_left.connect(self._update_layers) self.dims.events.margin_right.connect(self._update_layers) self.cursor.events.position.connect(self.update_status_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.mouse_double_click_callbacks.append(double_click_to_zoom) 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'] @property def _brush_circle_overlay(self): return self._overlays['brush_circle'] 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', allow_reuse=True) 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_augmented(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 not layers are present, assume image-like with dimensions of size 512 if len(self.layers) == 0: return np.vstack( [np.full(self.dims.ndim, -0.5), np.full(self.dims.ndim, 511.5)] ) return self.layers._extent_world_augmented[:, self.dims.displayed] def reset_view( self, *, margin: float = 0.05, reset_camera_angle: bool = True ) -> None: """Reset the camera view. Parameters ---------- margin : float in [0, 1) Margin as fraction of the canvas, showing blank space around the data. """ extent = self._sliced_extent_world_augmented 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_array = np.add(corner, np.divide(size, 2))[ -self.dims.ndisplay : ] center = cast( Union[tuple[float, float, float], tuple[float, float]], tuple( [0.0] * (self.dims.ndisplay - len(center_array)) + list(center_array) ), ) assert len(center) in (2, 3) self.camera.center = center # zoom is defined 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 0 <= margin < 1: scale_factor = 1 - margin else: raise ValueError( trans._( 'margin must be between 0 and 1; got {margin} instead.', deferred=True, margin=margin, ) ) if np.max(size) == 0: self.camera.zoom = scale_factor * np.min(self._canvas_size) else: scale = np.array(size[-2:]) scale[np.isclose(scale, 0)] = 1 self.camera.zoom = scale_factor * np.min( np.array(self._canvas_size) / scale ) if reset_camera_angle: 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] shape = [ np.round(s / sc).astype('int') + 1 for s, sc in zip(scene_size, scale) ] dtype_str = get_settings().application.new_labels_dtype empty_labels = np.zeros(shape, dtype=dtype_str) self.add_labels(empty_labels, translate=np.array(corner), scale=scale) # type: ignore[attr-defined] # We define `add_labels` dynamically, so mypy doesn't know about it. def _on_layer_reload(self, event: Event) -> None: self._layer_slicer.submit( layers=[event.layer], dims=self.dims, force=True ) 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 self._layer_slicer.submit(layers=layers, dims=self.dims) # If the currently selected layer is sliced asynchronously, then the value # shown with this position may be incorrect. See the discussion for more details: # https://github.com/napari/napari/pull/5377#discussion_r1036280855 position = list(self.cursor.position) if len(position) < self.dims.ndim: # cursor dimensionality is outdated — reset to correct dimension position = [0.0] * self.dims.ndim for ind in self.dims.order[: -self.dims.ndisplay]: position[ind] = self.dims.point[ind] self.cursor.position = tuple(position) def _on_active_layer(self, event): """Update viewer state for a new active layer.""" active_layer = event.value if active_layer is None: for layer in self.layers: layer.update_transform_box_visibility(False) layer.update_highlight_visibility(False) self.help = '' self.cursor.style = CursorStyle.STANDARD self.camera.mouse_pan = True self.camera.mouse_zoom = True else: active_layer.update_transform_box_visibility(True) active_layer.update_highlight_visibility(True) for layer in self.layers: if layer != active_layer: layer.update_transform_box_visibility(False) layer.update_highlight_visibility(False) self.help = active_layer.help self.cursor.style = active_layer.cursor self.cursor.size = active_layer.cursor_size self.camera.mouse_pan = active_layer.mouse_pan self.camera.mouse_zoom = active_layer.mouse_zoom self.update_status_from_cursor() @staticmethod def rounded_division(min_val, max_val, precision): warnings.warn( trans._( 'Viewer.rounded_division is deprecated since v0.4.18 and will be removed in 0.6.0.' ), FutureWarning, stacklevel=2, ) 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 # TODO: can be optimized with dims.update(), but events need fixing self.dims.ndim = len(ranges) self.dims.range = 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_mouse_pan(self, event): """Set the viewer interactive mouse panning""" if event.source is self.layers.selection.active: self.camera.mouse_pan = event.mouse_pan def _update_mouse_zoom(self, event): """Set the viewer interactive mouse zoom""" if event.source is self.layers.selection.active: self.camera.mouse_zoom = event.mouse_zoom 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_async(self, event: Event) -> None: """Set layer slicer to force synchronous if async is disabled.""" self._layer_slicer._force_sync = not event.value def _calc_status_from_cursor( self, ) -> Optional[tuple[Union[str, Dict], str]]: if not self.mouse_over_canvas: return None active = self.layers.selection.active if active is not None and active._loaded: status = active.get_status( self.cursor.position, view_direction=self.cursor._view_direction, dims_displayed=list(self.dims.displayed), world=True, ) if self.tooltip.visible: tooltip_text = active._get_tooltip_text( np.asarray(self.cursor.position), view_direction=np.asarray(self.cursor._view_direction), dims_displayed=list(self.dims.displayed), world=True, ) else: tooltip_text = '' return status, tooltip_text return 'Ready', '' def update_status_from_cursor(self): """Update the status and tooltip from the cursor position.""" status = self._calc_status_from_cursor() if status is not None: self.status, self.tooltip.text = status if (active := self.layers.selection.active) is not None: self.help = active.help def _on_grid_change(self): """Arrange the current layers is a 2D grid.""" extent = self._sliced_extent_world_augmented 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.mouse_pan.connect(self._update_mouse_pan) layer.events.mouse_zoom.connect(self._update_mouse_zoom) 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) layer.events.reload.connect(self._on_layer_reload) 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: # set dims slider to the middle of all dimensions self.reset_view() self.dims._go_to_center_step() @staticmethod def _layer_help_from_mode(layer: Layer): """ Update layer help text base on layer mode. """ layer_to_func_and_mode: dict[type[Layer], list] = { 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) # Clean up overlays for overlay in list(layer._overlays): del layer._overlays[overlay] 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( from_name='interpolation', to_name='interpolation2d', version='0.6.0', since_version='0.4.17', ) def add_image( self, data=None, *, channel_axis=None, affine=None, axis_labels=None, attenuation=0.05, blending=None, cache=True, colormap=None, contrast_limits=None, custom_interpolation_kernel_2d=None, depiction='volume', experimental_clipping_planes=None, gamma=1.0, interpolation2d='nearest', interpolation3d='linear', iso_threshold=None, metadata=None, multiscale=None, name=None, opacity=1.0, plane=None, projection_mode='none', rendering='mip', rgb=None, rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ) -> Union[Image, list[Image]]: """Add one or more Image layers 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, other parameters MAY be provided as lists. The Nth value of the list will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. All parameters except data, rgb, and multiscale can be provided as list of values. If a list is provided, it must be the 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. axis_labels : tuple of str Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). attenuation : float or list of float Attenuation rate for attenuated maximum intensity projection. blending : str or list of 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 {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. cache : bool or list of bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. colormap : str, napari.utils.Colormap, tuple, dict, list or list of these types Colormaps to use for luminance images. If a string, it can be the name of a supported colormap from vispy or matplotlib or the name of a vispy color or a hexadecimal RGB color representation. 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,) Intensity value limits to be used for determining the minimum and maximum colormap bounds for luminance images. If not passed, they will be calculated as the min and max intensity value of the image. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. depiction : str or list of str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. 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. gamma : float or list of float Gamma correction for determining colormap linearity; defaults to 1. interpolation2d : str or list of str Interpolation mode used by vispy for rendering 2d data. Must be one of our supported modes. (for list of supported modes see Interpolation enum) 'custom' is a special mode for 2D interpolation in which a regular grid of samples is taken from the texture around a position using 'linear' interpolation before being multiplied with a custom interpolation kernel (provided with 'custom_interpolation_kernel_2d'). interpolation3d : str or list of str Same as 'interpolation2d' but for 3D rendering. iso_threshold : float or list of float Threshold for isosurface. metadata : dict or list of dict Layer metadata. 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. name : str or list of str Name of the layer. opacity : float or list Opacity of the layer visual, between 0.0 and 1.0. 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'}. projection_mode : str How data outside the viewed dimensions, but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass rendering : str or list of str 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. rgb : bool, optional Whether the image is RGB or RGBA if rgb. If not specified by user, but 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. 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. scale : tuple of float or list of tuple of float Scale factors for the layer. shear : 1-D array or list. A vector of shear values for an upper triangular n-D shear matrix. translate : tuple of float or list of tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool or list of bool Whether the layer visual is currently being displayed. 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, 'axis_labels': axis_labels, '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, 'custom_interpolation_kernel_2d': custom_interpolation_kernel_2d, 'projection_mode': projection_mode, 'units': units, } # these arguments are *already* iterables in the single-channel case. iterable_kwargs = { 'scale', 'translate', 'rotate', 'shear', 'affine', 'contrast_limits', 'metadata', 'experimental_clipping_planes', 'custom_interpolation_kernel_2d', 'axis_labels', 'units', } 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 layerdata_list = split_channels(data, channel_axis, **kwargs) layer_list = [ Image(image, **i_kwargs) for image, i_kwargs, _ in layerdata_list ] self.layers.extend(layer_list) 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 use, passed to ``viewer.open``. Only used if the sample data is an URI (Uniform Resource Identifier). 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 plugin_spec_reader = None data: Union[None, SampleDataCreator, SampleData] # 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__') and hasattr(data.__self__, 'uri'): if ( hasattr(data.__self__, 'reader_plugin') and data.__self__.reader_plugin != reader_plugin ): # if the user chose a reader_plugin, we use their choice # but we remember what the plugin declared so we can inform the user if it fails plugin_spec_reader = data.__self__.reader_plugin reader_plugin = reader_plugin or plugin_spec_reader 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 if isinstance(data, (str, Path)): try: return self.open(data, plugin=reader_plugin) except Exception as e: # user chose a different reader to the one specified by the plugin # and it failed - let them know the plugin declared something else if ( plugin_spec_reader is not None and reader_plugin != plugin_spec_reader ): raise ValueError( trans._( 'Chosen reader {chosen_reader} failed to open sample. Plugin {plugin} declares {original_reader} as the reader for this sample - try calling `open_sample` with no `reader_plugin` or passing {original_reader} explicitly.', deferred=True, plugin=plugin, chosen_reader=reader_plugin, original_reader=plugin_spec_reader, ) ) from e raise e # noqa: TRY201 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[PathLike]]] = False, plugin: Optional[str] = 'napari', layer_type: Optional[LayerTypeName] = 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[PathLike] = ( [os.fspath(path)] if isinstance(path, (Path, str)) else [os.fspath(p) for p in path] ) paths: Sequence[PathOrPaths] = paths_ # 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 = [paths_] 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 = isinstance(_path, list) # 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: Optional[Dict[str, Any]] = None, layer_type: Optional[LayerTypeName] = None, stack: bool = 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: 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[PathLike], *, stack: bool, kwargs: Optional[Dict] = None, plugin: Optional[str] = None, layer_type: Optional[LayerTypeName] = 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 ) if layer_data is None: return [] # 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: Iterator[PathLike] 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)) # actually add the layer if isinstance(data, Layer): data._set_source(Source(path=filename, reader_plugin=plugin)) lyr = self.add_layer(data) current_added = [lyr] else: _data = _unify_data_and_user_kwargs( data, kwargs, layer_type, fallback_name=basename ) with layer_source(path=filename, reader_plugin=plugin): current_added = self._add_layer_from_data(*_data) added.extend(current_added) return added def _add_layer_from_data( self, data, meta: Optional[Mapping[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) """ if layer_type is None or layer_type == '': # assumes that big integer type arrays are likely labels. layer_type = guess_labels(data) else: layer_type = layer_type.lower() 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 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], MutableMapping): raise ValueError( trans._( 'The second item in a LayerData tuple must be a dict or other MutableMapping.', deferred=True, ) ) else: _data.append({}) 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) def _unify_data_and_user_kwargs( data: LayerData, kwargs: Optional[dict] = None, layer_type: Optional[LayerTypeName] = None, fallback_name: Optional[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 not isinstance(meta_, dict): meta_ = dict(meta_) 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: Mapping[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', ... 'size': 10, ... } >>> prune_kwargs(test_kwargs, 'image') {'scale': (0.75, 1), 'blending': 'additive'} >>> # only labels has the ``num_colors`` argument >>> prune_kwargs(test_kwargs, 'points') {'scale': (0.75, 1), 'blending': 'additive', 'size': 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 = {} 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) setattr(ViewerModel, func.__name__, func) napari-0.5.6/napari/conftest.py000066400000000000000000001070611474413133200164540ustar00rootroot00000000000000""" Notes for using the plugin-related fixtures here: 1. The `npe2pm_` 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 contextlib import os import sys import threading from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from functools import partial from itertools import chain from multiprocessing.pool import ThreadPool from pathlib import Path from typing import TYPE_CHECKING, Optional from weakref import WeakKeyDictionary from npe2 import PackageMetadata with suppress(ModuleNotFoundError): __import__('dotenv').load_dotenv() from datetime import timedelta from time import perf_counter import dask.threaded import numpy as np import pytest from _pytest.pathlib import bestrelpath from IPython.core.history import HistoryManager from packaging.version import parse as parse_version from pytest_pretty import CustomTerminalReporter from napari.components import LayerList from napari.layers import Image, Labels, Points, Shapes, Vectors from napari.utils.misc import ROOT_DIR if TYPE_CHECKING: from npe2._pytest_plugin import TestPluginManager # touch ~/.Xauthority for Xlib support, must happen before importing pyautogui if os.getenv('CI') and sys.platform.startswith('linux'): xauth = Path('~/.Xauthority').expanduser() if not xauth.exists(): xauth.touch() @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) if request.param == 'labels': data = np.random.randint(10, size=(20, 20)) return Labels(data) if request.param == 'points': data = np.random.rand(20, 2) return Points(data) if 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) if request.param == 'shapes-rectangles': data = np.random.rand(7, 4, 2) return Shapes(data) if request.param == 'vectors': data = np.random.rand(20, 2, 2) return Vectors(data) 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) @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 from napari.settings._experimental import ExperimentalSettings # prevent the developer's config file from being used if it exists cp = NapariSettings.__private_attributes__['_config_path'] monkeypatch.setattr(cp, 'default', None) monkeypatch.setattr( ExperimentalSettings.__fields__['compiled_triangulation'], 'default', True, ) # 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) settings._SETTINGS = None # this makes sure that we start with fresh settings for every test. return @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 version if parse_version(version('napari-svg')) < parse_version('0.1.6'): return 'svg' return 'napari-svg' @pytest.fixture(autouse=True) def npe2pm_(npe2pm, monkeypatch): """Autouse npe2 & npe1 mock plugin managers 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 = PackageMetadata( # type: ignore[call-arg] version='0.1.0', name='test' ) plugin.manifest.display_name = 'Temp Plugin' yield plugin @pytest.fixture def viewer_model(): from napari.components import ViewerModel return ViewerModel() @pytest.fixture def qt_viewer_(qtbot, viewer_model, monkeypatch): from napari._qt.qt_viewer import QtViewer viewer = QtViewer(viewer_model) original_controls = viewer.__class__.controls.fget original_layers = viewer.__class__.layers.fget original_layer_buttons = viewer.__class__.layerButtons.fget original_viewer_buttons = viewer.__class__.viewerButtons.fget original_dock_layer_list = viewer.__class__.dockLayerList.fget original_dock_layer_controls = viewer.__class__.dockLayerControls.fget original_dock_console = viewer.__class__.dockConsole.fget original_dock_performance = viewer.__class__.dockPerformance.fget def hide_widget(widget): widget.hide() def hide_and_clear_qt_viewer(viewer: QtViewer): viewer._instances.clear() viewer.hide() def patched_controls(self): if self._controls is None: self._controls = original_controls(self) qtbot.addWidget(self._controls, before_close_func=hide_widget) return self._controls def patched_layers(self): if self._layers is None: self._layers = original_layers(self) qtbot.addWidget(self._layers, before_close_func=hide_widget) return self._layers def patched_layer_buttons(self): if self._layersButtons is None: self._layersButtons = original_layer_buttons(self) qtbot.addWidget(self._layersButtons, before_close_func=hide_widget) return self._layersButtons def patched_viewer_buttons(self): if self._viewerButtons is None: self._viewerButtons = original_viewer_buttons(self) qtbot.addWidget(self._viewerButtons, before_close_func=hide_widget) return self._viewerButtons def patched_dock_layer_list(self): if self._dockLayerList is None: self._dockLayerList = original_dock_layer_list(self) qtbot.addWidget(self._dockLayerList, before_close_func=hide_widget) return self._dockLayerList def patched_dock_layer_controls(self): if self._dockLayerControls is None: self._dockLayerControls = original_dock_layer_controls(self) qtbot.addWidget( self._dockLayerControls, before_close_func=hide_widget ) return self._dockLayerControls def patched_dock_console(self): if self._dockConsole is None: self._dockConsole = original_dock_console(self) qtbot.addWidget(self._dockConsole, before_close_func=hide_widget) return self._dockConsole def patched_dock_performance(self): if self._dockPerformance is None: self._dockPerformance = original_dock_performance(self) qtbot.addWidget( self._dockPerformance, before_close_func=hide_widget ) return self._dockPerformance monkeypatch.setattr( viewer.__class__, 'controls', property(patched_controls) ) monkeypatch.setattr(viewer.__class__, 'layers', property(patched_layers)) monkeypatch.setattr( viewer.__class__, 'layerButtons', property(patched_layer_buttons) ) monkeypatch.setattr( viewer.__class__, 'viewerButtons', property(patched_viewer_buttons) ) monkeypatch.setattr( viewer.__class__, 'dockLayerList', property(patched_dock_layer_list) ) monkeypatch.setattr( viewer.__class__, 'dockLayerControls', property(patched_dock_layer_controls), ) monkeypatch.setattr( viewer.__class__, 'dockConsole', property(patched_dock_console) ) monkeypatch.setattr( viewer.__class__, 'dockPerformance', property(patched_dock_performance) ) qtbot.addWidget(viewer, before_close_func=hide_and_clear_qt_viewer) return viewer @pytest.fixture def qt_viewer(qt_viewer_): """We created `qt_viewer_` fixture to allow modifying qt_viewer if module-level-specific modifications are necessary. For example, in `test_qt_viewer.py`. """ return qt_viewer_ @pytest.fixture(autouse=True) def _clear_cached_action_injection(): """Automatically clear cached property `Action.injected`. Allows action manager actions to be injected using current provider/processors and dependencies. See #7219 for details. To be removed after ActionManager deprecation. """ from napari.utils.action_manager import action_manager for action in action_manager._actions.values(): if 'injected' in action.__dict__: del action.__dict__['injected'] 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_subset = os.environ.get('NAPARI_TEST_SUBSET') 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: if test_subset: if test_subset.lower() == 'qt' and 'qapp' not in item.fixturenames: # Skip non Qt tests continue if ( test_subset.lower() == 'headless' and 'qapp' in item.fixturenames ): # Skip Qt tests continue 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() def _get_calling_stack(): # pragma: no cover stack = [] for i in range(2, sys.getrecursionlimit()): try: frame = sys._getframe(i) except ValueError: break stack.append(f'{frame.f_code.co_filename}:{frame.f_lineno}') return '\n'.join(stack) def _get_calling_place(depth=1): # pragma: no cover 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() base_constructor = QThread.__init__ def run_with_trace(self): # pragma: no cover """ QThread.run but adding execution to sys.settrace when measuring coverage. See https://github.com/nedbat/coveragepy/issues/686#issuecomment-634932753 and `init_with_trace`. When running QThreads during testing, we monkeypatch the QThread constructor and run methods with traceable equivalents. """ if 'coverage' in sys.modules: # https://github.com/nedbat/coveragepy/issues/686#issuecomment-634932753 sys.settrace(threading._trace_hook) self._base_run() def init_with_trace(self, *args, **kwargs): """Constructor for QThread adding tracing for coverage measurements. Functions running in QThreads don't get measured by coverage.py, see https://github.com/nedbat/coveragepy/issues/686. Therefore, we will monkeypatch the constructor to add to the thread to `sys.settrace` when we call `run` and `coverage` is in `sys.modules`. """ base_constructor(self, *args, **kwargs) self._base_run = self.run self.run = partial(run_with_trace, self) # dict of threads that have been started but not yet terminated if 'disable_qthread_start' in request.keywords: def start_with_save_reference(self, priority=QThread.InheritPriority): """Dummy function to prevent thread starts.""" else: def start_with_save_reference(self, priority=QThread.InheritPriority): """Thread start function with logs to detect hanging threads. Saves a weak reference to the thread and detects hanging threads, as well as where the threads were started. """ thread_dict[self] = _get_calling_place() base_start(self, priority) monkeypatch.setattr(QThread, 'start', start_with_save_reference) monkeypatch.setattr(QThread, '__init__', init_with_trace) 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(self, runnable, priority=0): """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(): thread_pool.clear() thread_pool.waitForDone(20) 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(self, msec=None): """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): calling_place = _get_calling_place() if 'superqt' in calling_place and 'throttler' in calling_place: calling_place += f' - {_get_calling_place(2)}' timer_dkt[self] = 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)) calling_place = _get_calling_place(2) if 'superqt' in calling_place and 'throttler' in calling_place: calling_place += _get_calling_stack() 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' def _check_throttle_info(path): if 'superqt' in path and 'throttler' in path: return ( path + " it's possible that there was a problem with unfinished work by a " 'qthrottler; to solve this, you can either try to wait (such as with ' '`qtbot.wait`) or disable throttling with the disable_throttling fixture' ) return path assert not dangling_timers, long_desc + '\n'.join( _check_throttle_info(x[1]) for x in dangling_timers ) def _throttle_mock(self): self.triggered.emit() def _flush_mock(self): """There are no waiting events.""" @pytest.fixture def _disable_throttling(monkeypatch): """Disable qthrottler from superqt. This is sometimes necessary to avoid flaky failures in tests due to dangling qt timers. """ # if this monkeypath fails then you should update path to GenericSignalThrottler monkeypatch.setattr( 'superqt.utils._throttler.GenericSignalThrottler.throttle', _throttle_mock, ) monkeypatch.setattr( 'superqt.utils._throttler.GenericSignalThrottler.flush', _flush_mock ) @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(self): """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 ) with contextlib.suppress(ImportError): # in headless test suite we don't have Qt bindings # So we cannot inherit from QtBot and declare the fixture from pytestqt.qtbot import QtBot class QtBotWithOnCloseRenaming(QtBot): """Modified QtBot that renames widgets when closing them in tests. After a test ends that uses QtBot, all instantiated widgets added to the bot have their name changed to 'handled_widget'. This allows us to detect leaking widgets at the end of a test run, and avoid the segmentation faults that often result from such leaks. [1]_ See Also -------- `_find_dangling_widgets`: fixture that finds all widgets that have not been renamed to 'handled_widget'. References ---------- .. [1] https://czaki.github.io/blog/2024/09/16/preventing-segfaults-in-test-suite-that-has-qt-tests/ """ def addWidget(self, widget, *, before_close_func=None): if widget.objectName() == '': # object does not have a name, so we can set it widget.setObjectName('handled_widget') before_close_func_ = before_close_func elif before_close_func is None: # there is no custom teardown function, # so we provide one that will set object name def before_close_func_(w): w.setObjectName('handled_widget') else: # user provided custom teardown function, # so we need to wrap it to set object name def before_close_func_(w): before_close_func(w) w.setObjectName('handled_widget') super().addWidget(widget, before_close_func=before_close_func_) @pytest.fixture def qtbot(qapp, request): # pragma: no cover """Fixture to create a QtBotWithOnCloseRenaming instance for testing. Make sure to call addWidget for each top-level widget you create to ensure that they are properly closed after the test ends. The `qapp` fixture is used to ensure that the QApplication is created before, so we need it, even without using it directly in this fixture. """ return QtBotWithOnCloseRenaming(request) @pytest.fixture def _find_dangling_widgets(request, qtbot): yield from qtpy.QtWidgets import QApplication from napari._qt.qt_main_window import _QtMainWindow top_level_widgets = QApplication.topLevelWidgets() viewer_weak_set = getattr(request.node, '_viewer_weak_set', set()) problematic_widgets = [] for widget in top_level_widgets: if widget.parent() is not None: continue if ( isinstance(widget, _QtMainWindow) and widget._qt_viewer.viewer in viewer_weak_set ): continue if widget.__class__.__module__.startswith('qtconsole'): continue if widget.objectName() == 'handled_widget': continue if widget.__class__.__name__ == 'CanvasBackendDesktop': # TODO: we don't understand why this class leaks in # napari/_tests/test_sys_info.py, so we make an exception # here and we don't raise when this class leaks. continue problematic_widgets.append(widget) if problematic_widgets: text = '\n'.join( f'Widget: {widget} of type {type(widget)} with name {widget.objectName()}' for widget in problematic_widgets ) for widget in problematic_widgets: widget.setObjectName('handled_widget') raise RuntimeError(f'Found dangling widgets:\n{text}') def pytest_runtest_setup(item): """Add Qt leak detection fixtures *only* in tests using the qapp fixture. Because we have headless test suite that does not include Qt, we cannot simply use `@pytest.fixture(autouse=True)` on all our fixtures for detecting leaking Qt objects. Instead, here we detect whether the `qapp` fixture is being used, detecting tests that use Qt and need to be checked for Qt objects leaks. A note to maintainers: tests *may* attempt to use Qt classes but not use the `qapp` fixture. This is BAD, and may cause Qt failures to be reported far away from the problematic code or test. If you find any tests instantiating Qt objects but not using qapp or qtbot, please submit a PR adding the qtbot fixture and adding any top-level Qt widgets with:: qtbot.addWidget(widget_instance) """ 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( [ '_find_dangling_widgets', '_dangling_qthread_pool', '_dangling_qanimations', '_dangling_qthreads', '_dangling_qtimers', ] ) class NapariTerminalReporter(CustomTerminalReporter): """ This ia s custom terminal reporter to how long it takes to finish given part of tests. It prints time each time when test from different file is started. It is created to be able to see if timeout is caused by long time execution, or it is just hanging. """ currentfspath: Optional[Path] def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: if getattr(self, '_start_time', None) is None: self._start_time = perf_counter() fspath = self.config.rootpath / nodeid.split('::')[0] if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() if os.environ.get('CI', False): self.write( f' [{timedelta(seconds=int(perf_counter() - self._start_time))}]' ) self.currentfspath = fspath relfspath = bestrelpath(self.startpath, fspath) self._tw.line() self.write(relfspath + ' ') self.write(res, flush=True, **markup) @pytest.hookimpl(trylast=True) def pytest_configure(config): # Get the standard terminal reporter plugin and replace it with our standard_reporter = config.pluginmanager.getplugin('terminalreporter') custom_reporter = NapariTerminalReporter(config, sys.stdout) if standard_reporter._session is not None: custom_reporter._session = standard_reporter._session config.pluginmanager.unregister(standard_reporter) config.pluginmanager.register(custom_reporter, 'terminalreporter') napari-0.5.6/napari/errors/000077500000000000000000000000001474413133200155645ustar00rootroot00000000000000napari-0.5.6/napari/errors/__init__.py000066400000000000000000000003311474413133200176720ustar00rootroot00000000000000from napari.errors.reader_errors import ( MultipleReaderError, NoAvailableReaderError, ReaderPluginError, ) __all__ = [ 'MultipleReaderError', 'NoAvailableReaderError', 'ReaderPluginError', ] napari-0.5.6/napari/errors/reader_errors.py000066400000000000000000000047131474413133200210010ustar00rootroot00000000000000from napari.types import PathLike 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[PathLike], *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[PathLike], *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[PathLike], *args: object ) -> None: super().__init__(message, *args) self.paths = paths napari-0.5.6/napari/experimental/000077500000000000000000000000001474413133200167455ustar00rootroot00000000000000napari-0.5.6/napari/experimental/__init__.py000066400000000000000000000002641474413133200210600ustar00rootroot00000000000000from napari.layers.utils._link_layers import ( layers_linked, link_layers, unlink_layers, ) __all__ = [ 'layers_linked', 'link_layers', 'unlink_layers', ] napari-0.5.6/napari/layers/000077500000000000000000000000001474413133200155475ustar00rootroot00000000000000napari-0.5.6/napari/layers/__init__.py000066400000000000000000000016651474413133200176700ustar00rootroot00000000000000"""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 # isabstract check is to exclude _ImageBase class NAMES: set[str] = { subclass.__name__.lower() for subclass in _all_subcls(Layer) if not _inspect.isabstract(subclass) } __all__ = [ 'NAMES', 'Image', 'Labels', 'Layer', 'Points', 'Shapes', 'Surface', 'Tracks', 'Vectors', ] napari-0.5.6/napari/layers/_data_protocols.py000066400000000000000000000054141474413133200213010ustar00rootroot00000000000000"""This module holds Protocols that layer.data objects are expected to provide.""" from __future__ import annotations from typing import ( TYPE_CHECKING, Any, Protocol, 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 numpy.typing import DTypeLike # https://github.com/python/typing/issues/684#issuecomment-548203158 class ellipsis(Enum): Ellipsis = '...' Ellipsis = ellipsis.Ellipsis # noqa: A001 else: ellipsis = type(Ellipsis) def _raise_protocol_error(obj: Any, protocol: type) -> None: """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].""" @property def size(self) -> int: """The size is necessary to calculate the data range""" @property def ndim(self) -> int: """The number of dimension of the underlying data""" def assert_protocol(obj: Any, protocol: type = LayerDataProtocol) -> None: """Assert `obj` is an instance of `protocol` or raise helpful error.""" if not isinstance(obj, protocol): _raise_protocol_error(obj, protocol) napari-0.5.6/napari/layers/_layer_actions.py000066400000000000000000000163261474413133200211240ustar00rootroot00000000000000"""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 import warnings from typing import TYPE_CHECKING, cast import numpy as np import numpy.typing as npt from napari import layers 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 = '') -> None: 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) -> None: 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) -> None: return _split_stack(ll) def _convert(ll: LayerList, type_: str) -> None: from napari.layers import Shapes for lay in list(ll.selection): idx = ll.index(lay) if isinstance(lay, Shapes) and type_ == 'labels': data = lay.to_labels() idx += 1 elif ( not np.issubdtype(lay.data.dtype, np.integer) and type_ == 'labels' ): data = lay.data.astype(int) idx += 1 else: data = lay.data # int image layer to labels is fully reversible ll.pop(idx) # projection mode may not be compatible with new type, # we're ok with dropping it in that case layer_type = getattr(layers, type_.title()) state = lay._get_base_state() try: layer_type._projectionclass(state['projection_mode'].value) except ValueError: state['projection_mode'] = 'none' warnings.warn( trans._( 'projection mode "{mode}" is not compatible with {type_} layers. Falling back to "none".', mode=state['projection_mode'], type_=type_.title(), deferred=True, ), category=UserWarning, stacklevel=1, ) new_layer = Layer.create(data, 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) -> None: return _convert(ll, 'labels') def _convert_to_image(ll: LayerList) -> None: return _convert(ll, 'image') def _merge_stack(ll: LayerList, rgb: bool = False) -> None: # force selection to follow LayerList ordering imgs = cast(list[Image], [layer for layer in ll if layer in ll.selection]) 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) -> None: current_visibility_state = [] for layer in ll.selection: current_visibility_state.append(layer.visible) for visibility, layer in zip(current_visibility_state, ll.selection): if layer.visible == visibility: layer.visible = not visibility def _show_selected(ll: LayerList) -> None: for lay in ll.selection: lay.visible = True def _hide_selected(ll: LayerList) -> None: for lay in ll.selection: lay.visible = False def _show_unselected(ll: LayerList) -> None: for lay in ll: if lay not in ll.selection: lay.visible = True def _hide_unselected(ll: LayerList) -> None: for lay in ll: if lay not in ll.selection: lay.visible = False def _link_selected_layers(ll: LayerList) -> None: ll.link_layers(ll.selection) def _unlink_selected_layers(ll: LayerList) -> None: ll.unlink_layers(ll.selection) def _select_linked_layers(ll: LayerList) -> None: linked_layers_in_list = [ x for x in get_linked_layers(*ll.selection) if x in ll ] ll.selection.update(linked_layers_in_list) def _convert_dtype(ll: LayerList, mode: npt.DTypeLike = 'int64') -> None: 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, ) ) layer.data = layer.data.astype(np.dtype(mode)) def _project(ll: LayerList, axis: int = 0, mode: str = 'max') -> None: 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, # the transforms are updated bellow as projection of transforms # requires a bit more work than just copying them # (e.g., the axis of the projection should be removed). # It is done in `set_slice` method of `TransformChain` meta = { key: layer._get_base_state()[key] for key in layer._get_base_state() if key not in ( 'scale', 'translate', 'rotate', 'shear', 'affine', 'axis_labels', 'units', ) } 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.6/napari/layers/_multiscale_data.py000066400000000000000000000056151474413133200214220ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Sequence from typing import 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 size(self) -> int: """Return size of the first scale..""" return self._data[0].size @property def ndim(self) -> int: """Return ndim of the first scale..""" return self._data[0].ndim @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.6/napari/layers/_scalar_field/000077500000000000000000000000001474413133200203165ustar00rootroot00000000000000napari-0.5.6/napari/layers/_scalar_field/__init__.py000066400000000000000000000000001474413133200224150ustar00rootroot00000000000000napari-0.5.6/napari/layers/_scalar_field/_tests/000077500000000000000000000000001474413133200216175ustar00rootroot00000000000000napari-0.5.6/napari/layers/_scalar_field/_tests/__init__.py000066400000000000000000000000001474413133200237160ustar00rootroot00000000000000napari-0.5.6/napari/layers/_scalar_field/_tests/test_scalar_filed.py000066400000000000000000000007171474413133200256450ustar00rootroot00000000000000from napari.layers._scalar_field.scalar_field import ScalarFieldBase from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_docstring_parent_class_consistency, validate_kwargs_sorted, ) def test_docstring(): validate_all_params_in_docstring(ScalarFieldBase) validate_kwargs_sorted(ScalarFieldBase) validate_docstring_parent_class_consistency( ScalarFieldBase, skip=('data', 'ndim', 'multiscale') ) napari-0.5.6/napari/layers/_scalar_field/scalar_field.py000066400000000000000000000654151474413133200233130ustar00rootroot00000000000000from __future__ import annotations import types from abc import ABC, abstractmethod from collections.abc import Sequence from contextlib import nullcontext from typing import TYPE_CHECKING, Optional, Union, cast import numpy as np from numpy import typing as npt from napari.layers import Layer from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData from napari.layers.image._image_constants import Interpolation, VolumeDepiction from napari.layers.image._image_mouse_bindings import ( move_plane_along_normal as plane_drag_callback, set_plane_position as plane_double_click_callback, ) from napari.layers.image._image_utils import guess_multiscale from napari.layers.image._slice import _ImageSliceRequest, _ImageSliceResponse from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.plane import SlicingPlane from napari.utils._dask_utils import DaskIndexer 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.geometry import clamp_point_to_bounding_box from napari.utils.naming import magic_name from napari.utils.translations import trans if TYPE_CHECKING: from napari.components import Dims __all__ = ('ScalarFieldBase',) # 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 ScalarFieldBase(Layer, ABC): """Base class for volumetric layers. 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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. depiction : str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. 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. metadata : dict Layer metadata. 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. name : str Name of the layer. If not provided then will be guessed using heuristics. ndim : int Number of dimensions in the data. opacity : float Opacity of the layer visual, between 0.0 and 1.0. 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'}. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass. rendering : str Rendering mode used by vispy. Must be one of our supported modes. 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 : tuple of float Scale factors for the layer. 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 : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. 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. axis_labels : tuple of str Dimension names of the layer data. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. depiction : str 3D Depiction mode used by vispy. Must be one of our supported modes. experimental_clipping_planes : ClippingPlaneList Clipping planes defined in data coordinates, used to clip the volume. metadata : dict Image metadata. 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. 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. plane : SlicingPlane or dict Properties defining plane rendering in 3D. Valid dictionary keys are {'position', 'normal', 'thickness'}. rendering : str Rendering mode used by vispy. Must be one of our supported modes. units: tuple of pint.Unit Units of the layer data in world coordinates. 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`. """ _colormaps = AVAILABLE_COLORMAPS _interpolation2d: Interpolation _interpolation3d: Interpolation def __init__( self, data, *, affine=None, axis_labels=None, blending='translucent', cache=True, custom_interpolation_kernel_2d=None, depiction='volume', experimental_clipping_planes=None, metadata=None, multiscale=None, name=None, ndim=None, opacity=1.0, plane=None, projection_mode='none', rendering='mip', rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ): 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 dimensionality of the data if ndim is None: ndim = len(data.shape) super().__init__( data, ndim, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, multiscale=multiscale, name=name, opacity=opacity, projection_mode=projection_mode, scale=scale, shear=shear, rotate=rotate, translate=translate, units=units, visible=visible, ) self.events.add( attenuation=Event, custom_interpolation_kernel_2d=Event, depiction=Event, interpolation=WarningEmitter( trans._( "'layer.events.interpolation' is deprecated please use `interpolation2d` and `interpolation3d`", deferred=True, ), type_name='select', ), interpolation2d=Event, interpolation3d=Event, iso_threshold=Event, plane=Event, rendering=Event, ) self._array_like = True # Set data self._data = data if isinstance(data, MultiScaleData): self._data_level = len(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] = ( np.array(self.level_shapes)[self._data_level][displayed_axes] - 1 ) self._slice = _ImageSliceResponse.make_empty( slice_input=self._slice_input, rgb=len(self.data.shape) != self.ndim, ) self._plane = SlicingPlane(thickness=1) # Whether to calculate clims on the next set_view_slice self._should_calc_clims = False # 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.rendering = rendering self.depiction = depiction if plane is not None: self.plane = plane connect_no_arg(self.plane.events, self.events, 'plane') self.custom_interpolation_kernel_2d = custom_interpolation_kernel_2d def _post_init(self): # Trigger generation of view slice and thumbnail self.refresh() @property def _data_view(self) -> np.ndarray: """Viewable image for the current slice. (compatibility)""" return self._slice.image.view @property def dtype(self): return self._data.dtype @property def data_raw( self, ) -> Union[LayerDataProtocol, Sequence[LayerDataProtocol]]: """Data, exactly as provided by the user.""" return self._data_raw def _get_ndim(self) -> int: """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 - 1]) @property def _extent_data_augmented(self) -> np.ndarray: extent = self._extent_data return extent + [[-0.5], [+0.5]] @property def _extent_level_data(self) -> np.ndarray: """Extent of layer, accounting for current multiscale level, in data coordinates. Returns ------- extent_data : array, shape (2, D) """ shape = self.level_shapes[self.data_level] return np.vstack([np.zeros(len(shape)), shape - 1]) @property def _extent_level_data_augmented(self) -> np.ndarray: extent = self._extent_level_data return extent + [[-0.5], [+0.5]] @property def data_level(self) -> int: """int: Current level of multiscale, or 0 if image.""" return self._data_level @data_level.setter def data_level(self, level: int) -> None: if self._data_level == level: return self._data_level = level self.refresh(extent=False) def _get_level_shapes(self): data = self.data if isinstance(data, MultiScaleData): shapes = data.shapes else: shapes = [self.data.shape] return shapes @property def level_shapes(self) -> np.ndarray: """array: Shapes of each level of the multiscale or just of image.""" return np.array(self._get_level_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 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]) -> None: """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]) -> None: self._plane.update(value) self.events.plane() @property def custom_interpolation_kernel_2d(self): return self._custom_interpolation_kernel_2d @custom_interpolation_kernel_2d.setter def custom_interpolation_kernel_2d(self, value): if value is None: value = [[1]] self._custom_interpolation_kernel_2d = np.array(value, np.float32) self.events.custom_interpolation_kernel_2d() @abstractmethod def _raw_to_displayed(self, raw: np.ndarray) -> np.ndarray: """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. """ raise NotImplementedError def _set_view_slice(self) -> None: """Set the slice output based on this layer's current state.""" # 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, data_slice=self._data_slice, 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) # 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_slice(self._data_to_world.inverse) return self._make_slice_request_internal( slice_input=slice_input, data_slice=indices, dask_indexer=self.dask_optimized_slicing, ) def _make_slice_request_internal( self, *, slice_input: _SliceInput, data_slice: _ThickNDSlice, 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( slice_input=slice_input, data=self.data, dask_indexer=dask_indexer, data_slice=data_slice, projection_mode=self.projection_mode, multiscale=self.multiscale, corner_pixels=self.corner_pixels, rgb=len(self.data.shape) != self.ndim, data_level=self.data_level, thumbnail_level=self._thumbnail_level, level_shapes=self.level_shapes, downsample_factors=self.downsample_factors, ) def _update_slice_response(self, response: _ImageSliceResponse) -> None: """Update the slice output state currently on the layer. Currently used for both sync and async slicing. """ response = response.to_displayed(self._raw_to_displayed) # We call to_displayed here to ensure that if the contrast limits # are outside the range of supported by vispy, then data view is # rescaled to fit within the range. self._slice_input = response.slice_input self._transforms[0] = response.tile_to_data self._slice = response 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 shape = ( raw.shape[:-1] if self.ndim != len(self._data.shape) else 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_value_ray( self, start_point: Optional[np.ndarray], end_point: Optional[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-background value encountered along the ray. If none was encountered or the viewer is in 2D mode, returns None. """ 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 # Account for downsampling in the case of multiscale # -1 means lowest resolution here. start_point = ( start_point[dims_displayed] / self.downsample_factors[-1][dims_displayed] ) end_point = ( end_point[dims_displayed] / self.downsample_factors[-1][dims_displayed] ) start_point = cast(np.ndarray, start_point) end_point = cast(np.ndarray, end_point) 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 # ensure the bounding box is for the proper multiscale level bounding_box = self._display_bounding_box_at_level( dims_displayed, self.data_level ) # the display bounding box is returned as a closed interval # (i.e. the endpoint is included) by the method, but we need # open intervals in the code that follows, so we add 1. bounding_box[:, 1] += 1 clamped = clamp_point_to_bounding_box( sample_points, bounding_box, ).astype(int) values = im_slice[tuple(clamped.T)] return self._calculate_value_from_ray(values) return None @abstractmethod def _calculate_value_from_ray(self, values): raise NotImplementedError def _get_value_3d( self, start_point: Optional[np.ndarray], end_point: Optional[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 None. """ return self._get_value_ray( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) def _get_offset_data_position(self, position: npt.NDArray) -> npt.NDArray: """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 position + 0.5 def _display_bounding_box_at_level( self, dims_displayed: list[int], data_level: int ) -> npt.NDArray: """An axis aligned (ndisplay, 2) bounding box around the data at a given level""" shape = self.level_shapes[data_level] extent_at_level = np.vstack([np.zeros(len(shape)), shape - 1]) return extent_at_level[:, dims_displayed].T def _display_bounding_box_augmented_data_level( self, dims_displayed: list[int] ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. If the layer is multiscale layer, then returns the bounding box of the data at the current level """ return self._extent_level_data_augmented[:, dims_displayed].T napari-0.5.6/napari/layers/_source.py000066400000000000000000000074321474413133200175660ustar00rootroot00000000000000from __future__ import annotations import weakref from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar from typing import Any, Optional from weakref import ReferenceType from magicgui.widgets import FunctionGui from typing_extensions import Self from napari._pydantic_compat 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', allow_reuse=True) def make_weakref(cls, layer: Layer) -> ReferenceType[Layer]: return weakref.ref(layer) def __deepcopy__(self, memo: Any) -> Self: """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 | None] = ContextVar( '_LAYER_SOURCE', default=None ) @contextmanager def layer_source(**source_kwargs: Any) -> Generator[None, None, None]: """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() or {}), **source_kwargs}) try: yield finally: _LAYER_SOURCE.reset(token) def current_source() -> 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() or {})) napari-0.5.6/napari/layers/_tests/000077500000000000000000000000001474413133200170505ustar00rootroot00000000000000napari-0.5.6/napari/layers/_tests/__init__.py000066400000000000000000000000001474413133200211470ustar00rootroot00000000000000napari-0.5.6/napari/layers/_tests/_utils.py000066400000000000000000000014201474413133200207160ustar00rootroot00000000000000import numpy as np def compare_dicts(dict1, dict2): """ The compare_dicts method compares two dictionaries for equality. This is mainly used to allow for layer.data.events tests in order to avoid comparison of 2 arrays. dict1 dict to be compared to other dict2 dict2 dict to be compared to other dict1 Returns ------- bool Whether the two dictionaries are equal """ if dict1.keys() != dict2.keys(): return False for key in dict1: val1 = dict1[key] val2 = dict2[key] if isinstance(val1, np.ndarray) and isinstance(val2, np.ndarray): if not np.array_equal(val1, val2): return False elif val1 != val2: return False return True napari-0.5.6/napari/layers/_tests/test_dask_layers.py000066400000000000000000000254531474413133200227730ustar00rootroot00000000000000from 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.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 create 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 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 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 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 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 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.6/napari/layers/_tests/test_data_protocol.py000066400000000000000000000015431474413133200233160ustar00rootroot00000000000000import 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.6/napari/layers/_tests/test_layer_actions.py000066400000000000000000000230301474413133200233130ustar00rootroot00000000000000import numpy as np import pint import pytest import zarr 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, _hide_selected, _hide_unselected, _link_selected_layers, _merge_stack, _project, _show_selected, _show_unselected, _split_rgb, _split_stack, _toggle_visibility, ) REG = pint.get_application_registry() def test_split_stack(): layer_list = LayerList() layer_list.append(Image(np.random.rand(8, 8, 8))) assert len(layer_list) == 1 layer_list.selection.active = layer_list[0] _split_stack(layer_list) assert len(layer_list) == 8 for idx in range(8): assert layer_list[idx].data.shape == (8, 8) def test_split_rgb(): layer_list = LayerList() layer_list.append(Image(np.random.random((48, 48, 3)))) assert len(layer_list) == 1 assert layer_list[0].rgb is True layer_list.selection.active = layer_list[0] _split_rgb(layer_list) assert len(layer_list) == 3 for idx in range(3): assert layer_list[idx].data.shape == (48, 48) def test_merge_stack(): layer_list = LayerList() layer_list.append(Image(np.random.rand(8, 8))) layer_list.append(Image(np.random.rand(8, 8))) assert len(layer_list) == 2 layer_list.selection.active = layer_list[0] layer_list.selection.add(layer_list[1]) _merge_stack(layer_list) assert len(layer_list) == 1 assert layer_list[0].data.shape == (2, 8, 8) def test_merge_stack_rgb(): layer_list = LayerList() layer_list.append(Image(np.random.rand(8, 8))) layer_list.append(Image(np.random.rand(8, 8))) layer_list.append(Image(np.random.rand(8, 8))) assert len(layer_list) == 3 layer_list.selection.active = layer_list[0] layer_list.selection.add(layer_list[1]) layer_list.selection.add(layer_list[2]) # check that without R G B colormaps we warn with pytest.raises(ValueError, match='Missing colormap'): _merge_stack(layer_list, rgb=True) layer_list[0].colormap = 'red' layer_list[1].colormap = 'green' layer_list[2].colormap = 'blue' _merge_stack(layer_list, rgb=True) assert len(layer_list) == 1 assert layer_list[0].data.shape == (8, 8, 3) assert layer_list[0].rgb is True def test_toggle_visibility(): """Test toggling visibility of a layer.""" layer_list = LayerList() layer_list.append(Points([[0, 0]])) layer_list[0].visible = False layer_list.selection.active = layer_list[0] _toggle_visibility(layer_list) assert layer_list[0].visible is True def test_toggle_visibility_with_linked_layers(): """Test toggling visibility of a layer.""" layer_list = LayerList() layer_list.append(Points([[0, 0]])) layer_list.append(Points([[0, 0]])) layer_list.append(Points([[0, 0]])) layer_list.append(Points([[0, 0]])) layer_list.selection.active = layer_list[0] layer_list.selection.add(layer_list[1]) layer_list.selection.add(layer_list[2]) _link_selected_layers(layer_list) layer_list[3].visible = False layer_list.selection.remove(layer_list[0]) layer_list.selection.add(layer_list[3]) _toggle_visibility(layer_list) assert layer_list[0].visible is False assert layer_list[1].visible is False assert layer_list[2].visible is False assert layer_list[3].visible is True @pytest.mark.parametrize('layer_type', [Points, Shapes]) def test_duplicate_layers(layer_type): def _dummy(): pass layer_list = LayerList() layer_list.append(layer_type([], 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] def test_hide_unselected_layers(): layer_list = make_three_layer_layerlist() layer_list[0].visible = True layer_list[1].visible = True layer_list[2].visible = True layer_list.selection.active = layer_list[1] assert layer_list[0].visible is True assert layer_list[1].visible is True assert layer_list[2].visible is True _hide_unselected(layer_list) assert layer_list[0].visible is False assert layer_list[1].visible is True assert layer_list[2].visible is False def test_show_unselected_layers(): layer_list = make_three_layer_layerlist() layer_list[0].visible = False layer_list[1].visible = True layer_list[2].visible = True layer_list.selection.active = layer_list[1] assert layer_list[0].visible is False assert layer_list[1].visible is True assert layer_list[2].visible is True _show_unselected(layer_list) assert layer_list[0].visible is True assert layer_list[1].visible is True assert layer_list[2].visible is True def test_hide_selected_layers(): layer_list = make_three_layer_layerlist() layer_list[0].visible = False layer_list[1].visible = True layer_list[2].visible = True layer_list.selection.active = layer_list[0] layer_list.selection.add(layer_list[1]) assert layer_list[0].visible is False assert layer_list[1].visible is True assert layer_list[2].visible is True _hide_selected(layer_list) assert layer_list[0].visible is False assert layer_list[1].visible is False assert layer_list[2].visible is True def test_show_selected_layers(): layer_list = make_three_layer_layerlist() layer_list[0].visible = False layer_list[1].visible = True layer_list[2].visible = True layer_list.selection.active = layer_list[0] layer_list.selection.add(layer_list[1]) assert layer_list[0].visible is False assert layer_list[1].visible is True assert layer_list[2].visible is True _show_selected(layer_list) assert layer_list[0].visible is True assert layer_list[1].visible is True assert layer_list[2].visible is True @pytest.mark.parametrize( 'mode', ['max', 'min', 'std', 'sum', 'mean', 'median'] ) def test_projections(mode): ll = LayerList() ll.append( Image( np.random.rand(7, 8, 8), scale=(3, 2, 2), translate=(10, 5, 5), units=('nm', 'um', 'um'), axis_labels=('z', 'y', 'x'), ) ) 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) assert tuple(ll[-1].scale) == (2, 2) assert tuple(ll[-1].translate) == (5, 5) assert ll[-1].units == (REG.um, REG.um) assert ll[-1].axis_labels == ('y', 'x') @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( ('layer', 'type_'), [ (Image(np.random.rand(10, 10)), 'labels'), (Image(np.array([[1.5, 2.5], [3.5, 4.5]])), 'labels'), (Image(np.array([[1, 2], [3, 4]], dtype=(int))), 'labels'), ( Image(zarr.array([[1, 2], [3, 4]], dtype=(int), chunks=(1, 2))), '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(layer, type_): ll = LayerList() layer.scale *= 1.5 original_scale = layer.scale.copy() ll.append(layer) assert ll[0]._type_string != type_ _convert(ll, type_) if isinstance(layer, Shapes) or ( type_ == 'labels' and isinstance(layer, Image) and np.issubdtype(layer.data.dtype, float) ): assert ll[1]._type_string == type_ assert np.array_equal(ll[1].scale, original_scale) else: assert ( layer.data is ll[0].data ) # check array data not copied unnecessarily def test_convert_warns_with_projecton_mode(): # inplace ll = LayerList( [Image(np.random.rand(10, 10).astype(int), projection_mode='mean')] ) with pytest.warns(UserWarning, match='projection mode'): _convert(ll, 'labels') assert isinstance(ll['Image'], Labels) # not inplace ll = LayerList([Image(np.random.rand(10, 10), projection_mode='mean')]) with pytest.warns(UserWarning, match='projection mode'): _convert(ll, 'labels') assert isinstance(ll['Image [1]'], Labels) def make_three_layer_layerlist(): layer_list = LayerList() layer_list.append(Points([[0, 0]], name='test')) layer_list.append(Image(np.random.rand(8, 8, 8))) layer_list.append(Image(np.random.rand(8, 8, 8))) return layer_list napari-0.5.6/napari/layers/_tests/test_layer_attributes.py000066400000000000000000000141461474413133200240510ustar00rootroot00000000000000from unittest.mock import MagicMock import numpy as np import pytest from napari._tests.utils import layer_test_data from napari.components.dims import Dims from napari.layers import Image, Labels @pytest.mark.parametrize( ('image_shape', 'dims_displayed', 'expected'), [ ((10, 20, 30), (0, 1, 2), [[0, 9.0], [0, 19.0], [0, 29.0]]), ((10, 20, 30), (0, 2, 1), [[0, 9.0], [0, 29.0], [0, 19.0]]), ((10, 20, 30), (2, 1, 0), [[0, 29.0], [0, 19.0], [0, 9.0]]), ], ) 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, match='must be monotonically increasing'): Image(np.random.rand(8, 8), contrast_limits=[1, 1]) with pytest.raises(ValueError, match='must be monotonically increasing'): 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(Dims(ndim=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(Dims(ndim=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) @pytest.mark.parametrize(('Layer', 'data', 'ndim'), layer_test_data) def test_layer_unique_id(Layer, data, ndim): layer = Layer(data) assert layer.unique_id is not None def test_layer_id_unique(): layer1 = Image(np.random.rand(10, 10)) layer2 = Labels(np.ones((10, 10)).astype(int)) assert layer1.unique_id != layer2.unique_id def test_zero_scale_layer(): with pytest.raises(ValueError, match='scale values of 0'): Image(np.zeros((64, 64)), scale=(0, 1)) @pytest.mark.parametrize(('Layer', 'data', 'ndim'), layer_test_data) def test_sync_refresh_block(Layer, data, ndim): my_layer = Layer(data) my_layer.set_view_slice = MagicMock() with my_layer._block_refresh(): my_layer.refresh() my_layer.set_view_slice.assert_not_called my_layer.refresh() my_layer.set_view_slice.assert_called_once() @pytest.mark.parametrize(('Layer', 'data', 'ndim'), layer_test_data) def test_async_refresh_block(Layer, data, ndim): from napari import settings settings.get_settings().experimental.async_ = True my_layer = Layer(data) mock = MagicMock() my_layer.events.reload.connect(mock) with my_layer._block_refresh(): my_layer.refresh() mock.assert_not_called() my_layer.refresh() mock.assert_called_once() napari-0.5.6/napari/layers/_tests/test_serialize.py000066400000000000000000000031231474413133200224470ustar00rootroot00000000000000import inspect import numpy as np import pytest from napari._tests.utils import ( are_objects_equal, count_warning_events, 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: 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: 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) == count_warning_events(em.callbacks) napari-0.5.6/napari/layers/_tests/test_source.py000066400000000000000000000045031474413133200217630ustar00rootroot00000000000000import pytest from napari._pydantic_compat import ValidationError 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_cant_overwrite_source(): """Test that we can't overwrite the source of a layer.""" with layer_source(path='some_path', reader_plugin='napari'): points = Points() assert points.source == Source(path='some_path', reader_plugin='napari') with pytest.raises(ValueError, match='Tried to set source on layer'): points._set_source( Source(path='other_path', reader_plugin='other_plugin') ) 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(ValidationError): with layer_source(parent=''): current_source() assert current_source() == Source() napari-0.5.6/napari/layers/_tests/test_utils.py000066400000000000000000000044261474413133200216270ustar00rootroot00000000000000import numpy as np import pytest from numpy.testing import assert_array_equal 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_array_equal(data, convert_to_uint8(data_scaled)) assert_array_equal(img_as_ubyte(data), convert_to_uint8(data)) assert_array_equal( 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_array_equal(img_as_ubyte(data), convert_to_uint8(data)) assert_array_equal(2 * data, convert_to_uint8(data_scaled)) assert_array_equal( img_as_ubyte(data_scaled), convert_to_uint8(data_scaled) ) assert_array_equal(img_as_ubyte(data - 10), convert_to_uint8(data - 10)) assert_array_equal( 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_array_equal(convert_to_uint8(data), res) data = np.linspace(0, 1, 256, dtype=dtype) res = np.arange(256, dtype=np.uint8) assert_array_equal(convert_to_uint8(data), res) assert_array_equal(img_as_ubyte(data), convert_to_uint8(data)) assert_array_equal(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_array_equal(img_as_ubyte(data), converted) napari-0.5.6/napari/layers/base/000077500000000000000000000000001474413133200164615ustar00rootroot00000000000000napari-0.5.6/napari/layers/base/__init__.py000066400000000000000000000002271474413133200205730ustar00rootroot00000000000000from napari.layers.base._base_constants import ActionType from napari.layers.base.base import Layer, no_op __all__ = ['ActionType', 'Layer', 'no_op'] napari-0.5.6/napari/layers/base/_base_constants.py000066400000000000000000000107631474413133200222070ustar00rootroot00000000000000from 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: 'InteractionBoxHandle' ) -> 'InteractionBoxHandle': 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)) is None: raise ValueError(f'{handle} has no opposite handle.') return opposite @classmethod def corners( cls, ) -> tuple[ 'InteractionBoxHandle', 'InteractionBoxHandle', 'InteractionBoxHandle', 'InteractionBoxHandle', ]: return ( cls.TOP_LEFT, cls.TOP_RIGHT, cls.BOTTOM_LEFT, cls.BOTTOM_RIGHT, ) class ActionType(StringEnum): """ Action types for layer.events.data of Shapes and Points layer. """ ADDING = auto() REMOVING = auto() CHANGING = auto() ADDED = auto() REMOVED = auto() CHANGED = auto() class BaseProjectionMode(StringEnum): """ Projection mode for aggregating a thick nD slice onto displayed dimensions. * NONE: ignore slice thickness, only using the dims point """ NONE = auto() napari-0.5.6/napari/layers/base/_base_mouse_bindings.py000066400000000000000000000205271474413133200231770ustar00rootroot00000000000000import warnings from collections.abc import Generator from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt from napari.layers.utils.interaction_box import ( InteractionBoxHandle, generate_transform_box_from_layer, get_nearby_handle, ) from napari.utils.events import Event from napari.utils.transforms import Affine from napari.utils.translations import trans if TYPE_CHECKING: from napari.layers.base import Layer def highlight_box_handles(layer: 'Layer', event: Event) -> None: """ Highlight the hovered handle of a TransformBox. """ if 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(layer._slice_input.displayed).inverse ) pos = np.array(world_to_data(event.position))[event.dims_displayed] handle_coords = generate_transform_box_from_layer( layer, layer._slice_input.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_handle = nearby_handle def _translate_with_box( layer: 'Layer', initial_affine: Affine, initial_mouse_pos: npt.NDArray, mouse_pos: npt.NDArray, event: Event, ) -> None: offset = mouse_pos - initial_mouse_pos new_affine = Affine(translate=offset).compose(initial_affine) layer.affine = layer.affine.replace_slice( layer._slice_input.displayed, new_affine ) def _rotate_with_box( layer: 'Layer', initial_affine: Affine, initial_mouse_pos: npt.NDArray, initial_handle_coords: npt.NDArray, initial_center: npt.NDArray, mouse_pos: npt.NDArray, event: Event, ) -> None: # 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) # if shift held, snap rotation to 45 degree steps if 'Shift' in event.modifiers: angle = np.round( np.arctan2(center_to_mouse[1], center_to_mouse[0]) / np.deg2rad(45) ) * np.deg2rad(45) - np.arctan2( center_to_handle[1], center_to_handle[0] ) else: 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( layer._slice_input.displayed, new_affine ) def _scale_with_box( layer: 'Layer', initial_affine: Affine, initial_world_to_data: Affine, initial_data2physical: Affine, nearby_handle: InteractionBoxHandle, initial_center: npt.NDArray, initial_handle_coords_data: npt.NDArray, mouse_pos: npt.NDArray, event: Event, ) -> None: 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.mean(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( layer._slice_input.displayed, new_affine ) def transform_with_box( layer: 'Layer', event: Event ) -> Generator[None, None, None]: """ Translate, rescale or rotate a layer by dragging a TransformBox handle. """ if 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 simplified = layer._transforms[1:].simplified initial_data_to_world = simplified.set_slice(layer._slice_input.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, layer._slice_input.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(layer._slice_input.displayed) # needed for rescaling initial_data2physical = layer._transforms['data2physical'].set_slice( layer._slice_input.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.6/napari/layers/base/_slice.py000066400000000000000000000004611474413133200202720ustar00rootroot00000000000000from itertools import count # We use an incrementing non-negative integer to uniquely identify # slices that is unbounded based on Python 3's int. _request_ids = count() def _next_request_id() -> int: """Returns the next integer identifier associated with a slice.""" return next(_request_ids) napari-0.5.6/napari/layers/base/_test_util_sample_layer.py000066400000000000000000000043751474413133200237540ustar00rootroot00000000000000from typing import Any import numpy as np from napari.layers import Layer class SampleLayer(Layer): def __init__( # type: ignore [no-untyped-def] self, data: np.ndarray, ndim=None, *, affine=None, axis_labels=None, blending='translucent', cache=True, # this should move to future "data source" object. experimental_clipping_planes=None, metadata=None, mode='pan_zoom', multiscale=False, name=None, opacity=1.0, projection_mode='none', rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ) -> None: if ndim is None: ndim = data.ndim super().__init__( ndim=ndim, data=data, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, mode=mode, multiscale=multiscale, name=name, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) # type: ignore [no-untyped-call] self._data = data self.a = 2 @property def data(self) -> np.ndarray: return self._data @data.setter def data(self, data: np.ndarray) -> None: self._data = data self.events.data(value=data) @property def _extent_data(self) -> np.ndarray: shape = np.array(self.data.shape) return np.vstack([np.zeros(len(shape)), shape - 1]) def _get_ndim(self) -> int: return self.ndim def _get_state(self) -> dict[str, Any]: base_state = self._get_base_state() base_state['data'] = self.data return base_state def _set_view_slice(self) -> None: pass def _update_thumbnail(self) -> None: pass def _get_value(self, position: tuple[int, ...]) -> np.ndarray: return self.data[position] def _post_init(self) -> None: self.a = 1 napari-0.5.6/napari/layers/base/_tests/000077500000000000000000000000001474413133200177625ustar00rootroot00000000000000napari-0.5.6/napari/layers/base/_tests/test_base.py000066400000000000000000000101031474413133200223000ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import pint import pytest from napari.layers.base._test_util_sample_layer import SampleLayer REG = pint.get_application_registry() def test_assign_units(): layer = SampleLayer(np.empty((10, 10))) mock = Mock() layer.events.units.connect(mock) assert layer.units == (REG.pixel, REG.pixel) layer.units = ('nm', 'nm') mock.assert_called_once() mock.reset_mock() assert layer.units == (REG.nm, REG.nm) layer.units = (REG.mm, REG.mm) mock.assert_called_once() mock.reset_mock() assert layer.units == (REG.mm, REG.mm) layer.units = ('mm', 'mm') mock.assert_not_called() layer.units = 'km' mock.assert_called_once() mock.reset_mock() assert layer.units == (REG.km, REG.km) layer.units = None mock.assert_called_once() assert layer.units == (REG.pixel, REG.pixel) def test_units_constructor(): layer = SampleLayer(np.empty((10, 10)), units=('nm', 'nm')) assert layer.units == (REG.nm, REG.nm) layer = SampleLayer(np.empty((10, 10)), units=(REG.mm, REG.mm)) assert layer.units == (REG.mm, REG.mm) layer = SampleLayer(np.empty((10, 10)), units=('mm', 'mm')) assert layer.units == (REG.mm, REG.mm) layer = SampleLayer(np.empty((10, 10)), units=None) assert layer.units == (REG.pixel, REG.pixel) def test_assign_units_error(): layer = SampleLayer(np.empty((10, 10))) with pytest.raises(ValueError, match='must have length ndim'): layer.units = ('m', 'm', 'm') with pytest.raises(ValueError, match='Could not find unit'): layer.units = ('ugh', 'ugh') with pytest.raises(ValueError, match='Could not find unit'): SampleLayer(np.empty((10, 10)), units=('ugh', 'ugh')) with pytest.raises(ValueError, match='must have length ndim'): SampleLayer(np.empty((10, 10)), units=('m', 'm', 'm')) def test_axis_labels_assign(): layer = SampleLayer(np.empty((10, 10))) mock = Mock() layer.events.axis_labels.connect(mock) assert layer.axis_labels == ('axis -2', 'axis -1') layer.axis_labels = ('x', 'y') mock.assert_called_once() mock.reset_mock() assert layer.axis_labels == ('x', 'y') layer.axis_labels = ('x', 'y') mock.assert_not_called() layer.axis_labels = None mock.assert_called_once() assert layer.axis_labels == ('axis -2', 'axis -1') def test_axis_labels_constructor(): layer = SampleLayer(np.empty((10, 10)), axis_labels=('x', 'y')) assert layer.axis_labels == ('x', 'y') layer = SampleLayer(np.empty((10, 10)), axis_labels=None) assert layer.axis_labels == ('axis -2', 'axis -1') def test_axis_labels_error(): layer = SampleLayer(np.empty((10, 10))) with pytest.raises(ValueError, match='must have length ndim'): layer.axis_labels = ('x', 'y', 'z') with pytest.raises(ValueError, match='must have length ndim'): SampleLayer(np.empty((10, 10)), axis_labels=('x', 'y', 'z')) def test_non_visible_mode(): layer = SampleLayer(np.empty((10, 10))) layer.mode = 'transform' # change layer visibility and check the layer mode gets updated layer.visible = False assert layer.mode == 'pan_zoom' layer.visible = True assert layer.mode == 'transform' def test_world_to_displayed_data_normal_3D(): layer = SampleLayer(np.empty((10, 10, 10))) layer.scale = (1, 3, 2) normal_vector = [0, 1, 1] expected_transformed_vector = [0, 3 * (13**0.5) / 13, 2 * (13**0.5) / 13] transformed_vector = layer._world_to_displayed_data_normal( normal_vector, dims_displayed=[0, 1, 2] ) assert np.allclose(transformed_vector, expected_transformed_vector) def test_world_to_displayed_data_normal_4D(): layer = SampleLayer(np.empty((10, 10, 10, 10))) layer.scale = (1, 3, 2, 1) normal_vector = [0, 1, 1] expected_transformed_vector = [0, 3 * (13**0.5) / 13, 2 * (13**0.5) / 13] transformed_vector = layer._world_to_displayed_data_normal( normal_vector, dims_displayed=[0, 1, 2] ) assert np.allclose(transformed_vector, expected_transformed_vector) napari-0.5.6/napari/layers/base/_tests/test_mouse_bindings.py000066400000000000000000000104241474413133200244010ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import pytest from napari.layers.base._base_constants import InteractionBoxHandle from napari.layers.base._base_mouse_bindings import ( _rotate_with_box, _scale_with_box, _translate_with_box, ) from napari.utils.transforms import Affine @pytest.mark.parametrize('dims_displayed', [[0, 1], [1, 2]]) def test_interaction_box_translation(dims_displayed): layer = Mock(affine=Affine()) layer._slice_input.displayed = [0, 1] initial_affine = Affine() initial_mouse_pos = np.asarray([3, 3], dtype=np.float32) mouse_pos = np.asarray([6, 5], dtype=np.float32) event = Mock(dims_displayed=dims_displayed, modifiers=[None]) _translate_with_box( layer, initial_affine, initial_mouse_pos, mouse_pos, event, ) # translate should be equal to [3, 2] from doing [6, 5] - [3, 3] assert np.array_equal( layer.affine.translate, Affine(translate=np.asarray([3, 2], dtype=np.float32)).translate, ) @pytest.mark.parametrize('dims_displayed', [[0, 1], [1, 2]]) def test_interaction_box_rotation(dims_displayed): layer = Mock(affine=Affine()) layer._slice_input.displayed = [0, 1] initial_affine = Affine() initial_mouse_pos = Mock() # rotation handle is 8th initial_handle_coords = np.asarray( [ [0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [6, 3], ], dtype=np.float32, ) initial_center = np.asarray([3, 3], dtype=np.float32) mouse_pos = np.asarray([6, 5], dtype=np.float32) event = Mock(dims_displayed=dims_displayed, modifiers=[None]) _rotate_with_box( layer, initial_affine, initial_mouse_pos, initial_handle_coords, initial_center, mouse_pos, event, ) # should be approximately 33 degrees assert np.allclose(layer.affine.rotate, Affine(rotate=33.69).rotate) @pytest.mark.parametrize('dims_displayed', [[0, 1], [1, 2]]) def test_interaction_box_fixed_rotation(dims_displayed): layer = Mock(affine=Affine()) layer._slice_input.displayed = [0, 1] initial_affine = Affine() initial_mouse_pos = Mock() # rotation handle is 8th initial_handle_coords = np.asarray( [ [0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [6, 3], ], dtype=np.float32, ) initial_center = np.asarray([3, 3], dtype=np.float32) mouse_pos = np.asarray([6, 5], dtype=np.float32) # use Shift to snap rotation to steps of 45 degrees event = Mock(dims_displayed=[0, 1], modifiers=['Shift']) _rotate_with_box( layer, initial_affine, initial_mouse_pos, initial_handle_coords, initial_center, mouse_pos, event, ) # should be 45 degrees assert np.allclose(layer.affine.rotate, Affine(rotate=45).rotate) @pytest.mark.parametrize('dims_displayed', [[0, 1], [1, 2]]) def test_interaction_box_scale_with_fixed_aspect(dims_displayed): layer = Mock(affine=Affine()) layer._slice_input.displayed = [0, 1] initial_handle_coords_data = np.asarray( [ [0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [6, 3], ], dtype=np.float32, ) initial_affine = Affine() initial_world_to_data = Affine() initial_data2physical = Affine() nearby_handle = InteractionBoxHandle.TOP_LEFT initial_center = np.asarray([3, 3], dtype=np.float32) mouse_pos = np.asarray([0, 0], dtype=np.float32) event = Mock(dims_displayed=dims_displayed, modifiers=['Shift']) _scale_with_box( layer, initial_affine, initial_world_to_data, initial_data2physical, nearby_handle, initial_center, initial_handle_coords_data, mouse_pos, event, ) # when clicking on handle, scale should be 1 assert np.allclose( layer.affine.scale, Affine(scale=np.asarray([1, 1], dtype=np.float32)).scale, ) napari-0.5.6/napari/layers/base/base.py000066400000000000000000002474571474413133200177700ustar00rootroot00000000000000from __future__ import annotations import copy import inspect import itertools import logging import os.path import uuid import warnings from abc import ABC, ABCMeta, abstractmethod from collections import defaultdict from collections.abc import Generator, Hashable, Mapping, Sequence from contextlib import contextmanager from functools import cached_property from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Optional, Union, ) import magicgui as mgui import numpy as np import pint from npe2 import plugin_manager as pm from napari.layers.base._base_constants import ( BaseProjectionMode, Blending, Mode, ) from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.interactivity_utils import ( drag_data_to_projected_distance, ) from napari.layers.utils.layer_utils import ( Extent, 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.settings import get_settings 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.migrations import _DeprecatingDict from napari.utils.misc import StringEnum 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 if TYPE_CHECKING: import numpy.typing as npt from napari.components.dims import Dims from napari.components.overlays.base import Overlay from napari.layers._source import Source logger = logging.getLogger('napari.layers.base.base') 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 class PostInit(ABCMeta): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) sig = inspect.signature(self.__init__) params = tuple(sig.parameters.values()) self.__signature__ = sig.replace(parameters=params[1:]) def __call__(self, *args, **kwargs): obj = super().__call__(*args, **kwargs) obj._post_init() return obj @mgui.register_type(choices=get_layers, return_callback=add_layer_to_viewer) class Layer(KeymapProvider, MousemapProvider, ABC, metaclass=PostInit): """Base layer class. Parameters ---------- data : array or list of array Data that the layer is visualizing. Can be N-dimensional. ndim : int Number of spatial dimensions. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. 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. metadata : dict Layer metadata. mode: str The layer's interactive mode. 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. name : str, optional Name of the layer. If not provided then will be guessed using heuristics. opacity : float Opacity of the layer visual, between 0.0 and 1.0. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass. 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 : tuple of float Scale factors for the layer. 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 : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. Attributes ---------- 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. axis_labels : tuple of str Dimension names of the layer data. 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')``. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. 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. cursor : str String identifying which cursor displayed over canvas. cursor_size : int | None Size of cursor if custom. None yields default size help : str Displayed in status bar bottom right. interactive : bool Determine if canvas pan/zoom interactivity is enabled. This attribute is deprecated since 0.5.0 and should not be used. Use the mouse_pan and mouse_zoom attributes instead. mouse_pan : bool Determine if canvas interactive panning is enabled with the mouse. mouse_zoom : bool Determine if canvas interactive zooming is enabled with the mouse. 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. name : str Unique name of the layer. ndim : int Dimensionality of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimenions. 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 : tuple of float Scale factors for the layer. scale_factor : float Conversion factor from canvas coordinates to image coordinates, which depends on the current zoom level. 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. source : Source source of the layer (such as a plugin or widget) status : str Displayed in status bar bottom left. translate : tuple of float Translation values for the layer. thumbnail : (N, M, 4) array Array of thumbnail data for the layer. unique_id : Hashable Unique id of the layer. Guaranteed to be unique across the lifetime of a viewer. visible : bool Whether the layer visual is currently being displayed. units: tuple of pint.Unit Units of the layer data in world coordinates. z_index : int Depth of the layer visual relative to other visuals in the scenecanvas. 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: type[StringEnum] = Mode _projectionclass: type[StringEnum] = BaseProjectionMode ModeCallable = Callable[ ['Layer', Event], Union[None, Generator[None, None, None]] ] _drag_modes: ClassVar[dict[StringEnum, ModeCallable]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, } _move_modes: ClassVar[dict[StringEnum, ModeCallable]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, } _cursor_modes: ClassVar[dict[StringEnum, str]] = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', } events: EmitterGroup def __init__( self, data, ndim, *, affine=None, axis_labels=None, blending='translucent', cache=True, # this should move to future "data source" object. experimental_clipping_planes=None, metadata=None, mode='pan_zoom', multiscale=False, name=None, opacity=1.0, projection_mode='none', rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ): 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._highlight_visible = True self._unique_id = None 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._visible_mode = None self._freeze = False self._status = 'Ready' self._help = '' self._cursor = 'standard' self._cursor_size = 1 self._mouse_pan = True self._mouse_zoom = True self._value = None self.scale_factor = 1 self.multiscale = multiscale self._experimental_clipping_planes = ClippingPlaneList() self._mode = self._modeclass('pan_zoom') self._projection_mode = self._projectionclass(str(projection_mode)) self._refresh_blocked = False self._ndim = ndim self._slice_input = _SliceInput( ndisplay=2, world_slice=_ThickNDSlice.make_full(ndim=ndim), order=tuple(range(ndim)), ) self._loaded: bool = True self._last_slice_id: int = -1 # 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._initial_affine = coerce_affine( affine, ndim=ndim, name='physical2world' ) self._transforms: TransformChain[Affine] = TransformChain( [ Affine(np.ones(ndim), np.zeros(ndim), name='tile2data'), CompositeAffine( scale, translate, axis_labels=axis_labels, rotate=rotate, shear=shear, ndim=ndim, name='data2physical', units=units, ), self._initial_affine, 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[str, Overlay] = EventedDict() self.events = EmitterGroup( source=self, axis_labels=Event, data=Event, metadata=Event, affine=Event, blending=Event, cursor=Event, cursor_size=Event, editable=Event, extent=Event, help=Event, loaded=Event, mode=Event, mouse_pan=Event, mouse_zoom=Event, name=Event, opacity=Event, projection_mode=Event, refresh=Event, reload=Event, rotate=Event, scale=Event, set_data=Event, shear=Event, status=Event, thumbnail=Event, translate=Event, units=Event, visible=Event, interactive=WarningEmitter( trans._( 'layer.events.interactive is deprecated since 0.4.18 and will be removed in 0.6.0. Please use layer.events.mouse_pan and layer.events.mouse_zoom', deferred=True, ), type_name='interactive', ), _extent_augmented=Event, _overlays=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 _post_init(self): """Post init hook for subclasses to use.""" def __str__(self) -> str: """Return self.name.""" return self.name def __repr__(self) -> str: cls = type(self) return f'<{cls.__name__} layer {self.name!r} at {hex(id(self))}>' def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: """ Helper to manage callbacks in multiple layers This will return a valid mode for the current layer, to for example refuse to set a mode that is not supported by the layer if it is not editable. This will as well manage the mouse callbacks. Parameters ---------- mode : type(self._modeclass) | str New mode for the current layer. Returns ------- mode : type(self._modeclass) New mode for the current layer. """ mode = self._modeclass(mode_in) # Sub-classes can have their own Mode enum, so need to get members # from the specific mode class set on this layer. PAN_ZOOM = self._modeclass.PAN_ZOOM # type: ignore[attr-defined] TRANSFORM = self._modeclass.TRANSFORM # type: ignore[attr-defined] assert mode is not None if not self.editable or not self.visible: mode = PAN_ZOOM if mode == self._mode: return mode if mode not in self._modeclass: 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.mouse_pan = mode == PAN_ZOOM self._overlays['transform_box'].visible = mode == TRANSFORM if mode == TRANSFORM: self.help = trans._( 'hold to pan/zoom, hold to preserve aspect ratio and rotate in 45° increments' ) elif mode == PAN_ZOOM: self.help = '' return mode def update_transform_box_visibility(self, visible): if 'transform_box' in self._overlays: TRANSFORM = self._modeclass.TRANSFORM # type: ignore[attr-defined] self._overlays['transform_box'].visible = ( self.mode == TRANSFORM and visible ) def update_highlight_visibility(self, visible): self._highlight_visible = visible self._set_highlight(force=True) @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: Union[Mode, str]) -> None: mode_enum = self._mode_setter_helper(mode) if mode_enum == self._mode: return self._mode = mode_enum self.events.mode(mode=str(mode_enum)) @property def projection_mode(self): """Mode of projection of the thick slice onto the viewed dimensions. The sliced data is described by an n-dimensional bounding box ("thick slice"), which needs to be projected onto the visible dimensions to be visible. The projection mode controls the projection logic. """ return self._projection_mode @projection_mode.setter def projection_mode(self, mode): mode = self._projectionclass(str(mode)) if self._projection_mode != mode: self._projection_mode = mode self.events.projection_mode() self.refresh(extent=False) @property def unique_id(self) -> Hashable: """Unique ID of the layer. This is guaranteed to be unique to this specific layer instance over the lifetime of the program. """ if self._unique_id is None: self._unique_id = uuid.uuid4() return self._unique_id @classmethod def _basename(cls) -> str: return f'{cls.__name__}' @property def name(self) -> str: """str: Unique name of the layer.""" return self._name @name.setter def name(self, name: Optional[str]) -> None: 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) self.events.metadata() @property def source(self) -> Source: return self._source def _set_source(self, source: Source) -> None: if any( getattr(self._source, attr) for attr in [ 'path', 'reader_plugin', 'sample', 'widget', 'parent', ] ): raise ValueError( f'Tried to set source on layer {self.name} when source is already set to {self._source}' ) self._source = source @property def loaded(self) -> bool: """True if this layer is fully loaded in memory, False otherwise. Layers that only support sync slicing are always fully loaded. Layers that support async slicing can be temporarily not loaded while slicing is occurring. """ return self._loaded def _set_loaded(self, loaded: bool) -> None: """Set the loaded state and notify a change with the loaded event.""" if self._loaded != loaded: self._loaded = loaded self.events.loaded() def _set_unloaded_slice_id(self, slice_id: int) -> None: """Set this layer to be unloaded and associated with a pending slice ID. This is private but accessed externally because it is related to slice state, which is intended to be moved off the layer in the future. """ self._last_slice_id = slice_id self._set_loaded(False) def _update_loaded_slice_id(self, slice_id: int) -> None: """Potentially update the loaded state based on the given completed slice ID. This is private but accessed externally because it is related to slice state, which is intended to be moved off the layer in the future. """ if self._last_slice_id == slice_id: self._set_loaded(True) @property def opacity(self) -> float: """float: Opacity value between 0.0 and 1.0.""" return self._opacity @opacity.setter def opacity(self, opacity: float) -> None: 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 = float(opacity) self._update_thumbnail() self.events.opacity() @property def blending(self) -> str: """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) -> None: self._visible = visible if visible: # needed because things might have changed while invisible # and refresh is noop while invisible self.refresh(extent=False) self._on_visible_changed() self.events.visible() def _on_visible_changed(self) -> None: """Execute side-effects on this layer related to changes of the visible state.""" if self.visible and self._visible_mode: self.mode = self._visible_mode else: self._visible_mode = self.mode self.mode = self._modeclass.PAN_ZOOM # type: ignore[attr-defined] @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) -> None: 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.""" @property def axis_labels(self) -> tuple[str, ...]: """tuple of axis labels for the layer.""" return self._transforms['data2physical'].axis_labels @axis_labels.setter def axis_labels(self, axis_labels: Optional[Sequence[str]]) -> None: prev = self._transforms['data2physical'].axis_labels # mypy bug https://github.com/python/mypy/issues/3004 self._transforms['data2physical'].axis_labels = axis_labels # type: ignore[assignment] if self._transforms['data2physical'].axis_labels != prev: self.events.axis_labels() @property def units(self) -> tuple[pint.Unit, ...]: """List of units for the layer.""" return self._transforms['data2physical'].units @units.setter def units(self, units: Optional[Sequence[pint.Unit]]) -> None: prev = self.units # mypy bug https://github.com/python/mypy/issues/3004 self._transforms['data2physical'].units = units # type: ignore[assignment] if self.units != prev: self.events.units() @property def scale(self) -> npt.NDArray: """array: Anisotropy factors to scale data into world coordinates.""" return self._transforms['data2physical'].scale @scale.setter def scale(self, scale: Optional[npt.NDArray]) -> None: if scale is None: scale = np.array([1] * self.ndim) self._transforms['data2physical'].scale = np.array(scale) self.refresh() self.events.scale() @property def translate(self) -> npt.NDArray: """array: Factors to shift the layer by in units of world coordinates.""" return self._transforms['data2physical'].translate @translate.setter def translate(self, translate: npt.ArrayLike) -> None: self._transforms['data2physical'].translate = np.array(translate) self.refresh() self.events.translate() @property def rotate(self) -> npt.NDArray: """array: Rotation matrix in world coordinates.""" return self._transforms['data2physical'].rotate @rotate.setter def rotate(self, rotate: npt.NDArray) -> None: self._transforms['data2physical'].rotate = rotate self.refresh() self.events.rotate() @property def shear(self) -> npt.NDArray: """array: Shear matrix in world coordinates.""" return self._transforms['data2physical'].shear @shear.setter def shear(self, shear: npt.NDArray) -> None: self._transforms['data2physical'].shear = shear self.refresh() self.events.shear() @property def affine(self) -> Affine: """napari.utils.transforms.Affine: Extra affine transform to go from physical to world coordinates.""" return self._transforms['physical2world'] @affine.setter def affine(self, affine: Union[npt.ArrayLike, Affine]) -> None: # 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.refresh() self.events.affine() def _reset_affine(self) -> None: self.affine = self._initial_affine @property def _translate_grid(self) -> npt.NDArray: """array: Factors to shift the layer by.""" return self._transforms['world2grid'].translate @_translate_grid.setter def _translate_grid(self, translate_grid: npt.NDArray) -> None: if np.array_equal(self._translate_grid, translate_grid): return self._transforms['world2grid'].translate = np.array(translate_grid) self.events.translate() def _update_dims(self) -> None: """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.refresh() @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_data_augmented(self) -> np.ndarray: """Extent of layer in data coordinates. Differently from Layer._extent_data, this also includes the "size" of data points; for example, Point sizes and Image pixel width are included. Returns ------- extent_data : array, shape (2, D) """ return self._extent_data @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) @property def _extent_world_augmented(self) -> np.ndarray: """Range of layer in world coordinates. Differently from Layer._extent_world, this also includes the "size" of data points; for example, Point sizes and Image pixel width are included. Returns ------- extent_world : array, shape (2, D) """ # Get full nD bounding box return get_extent_world( self._extent_data_augmented, self._data_to_world ) @cached_property def extent(self) -> Extent: """Extent of layer in data and world coordinates. For image-like layers, these coordinates are the locations of the pixels in `Layer.data` which are treated like sample points that are centered in the rendered version of those pixels. For other layers, these coordinates are the points or vertices stored in `Layer.data`. Lower and upper bounds are inclusive. """ extent_data = self._extent_data data_to_world = self._data_to_world extent_world = get_extent_world(extent_data, data_to_world) return Extent( data=extent_data, world=extent_world, step=abs(data_to_world.scale), ) @cached_property def _extent_augmented(self) -> Extent: """Augmented extent of layer in data and world coordinates. Differently from Layer.extent, this also includes the "size" of data points; for example, Point sizes and Image pixel width are included. For image-like layers, these coordinates are the locations of the pixels in `Layer.data` which are treated like sample points that are centered in the rendered version of those pixels. For other layers, these coordinates are the points or vertices stored in `Layer.data`. """ extent_data = self._extent_data_augmented data_to_world = self._data_to_world extent_world = get_extent_world(extent_data, data_to_world) return Extent( data=extent_data, world=extent_world, step=abs(data_to_world.scale), ) def _clear_extent(self) -> None: """Clear extent cache and emit extent event.""" if 'extent' in self.__dict__: del self.extent self.events.extent() def _clear_extent_augmented(self) -> None: """Clear extent_augmented cache and emit extent_augmented event.""" if '_extent_augmented' in self.__dict__: del self._extent_augmented self.events._extent_augmented() @property def _data_slice(self) -> _ThickNDSlice: """Slice in data coordinates.""" if len(self._slice_input.not_displayed) == 0: # all dims are displayed dimensions # early return to avoid evaluating data_to_world.inverse return _ThickNDSlice.make_full(point=(np.nan,) * self.ndim) return self._slice_input.data_slice( self._data_to_world.inverse, ) @abstractmethod def _get_ndim(self) -> int: raise NotImplementedError def _get_base_state(self) -> dict[str, Any]: """Get dictionary of attributes on base layer. This is useful for serialization and deserialization of the layer. And similarly for plugins to pass state without direct dependencies on napari types. Returns ------- dict of str to Any Dictionary of attributes on base layer. """ base_dict = { 'affine': self.affine.affine_matrix, 'axis_labels': self.axis_labels, 'blending': self.blending, 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], 'metadata': self.metadata, 'name': self.name, 'opacity': self.opacity, 'projection_mode': self.projection_mode, 'rotate': [list(r) for r in self.rotate], 'scale': list(self.scale), 'shear': list(self.shear), 'translate': list(self.translate), 'units': self.units, 'visible': self.visible, } return base_dict @abstractmethod def _get_state(self) -> dict[str, Any]: raise NotImplementedError @property def _type_string(self) -> str: return self.__class__.__name__.lower() def as_layer_data_tuple(self): state = self._get_state() state.pop('data', None) if hasattr(self.__init__, '_rename_argument'): state = _DeprecatingDict(state) for element in self.__init__._rename_argument: state.set_deprecated_from_rename(**element._asdict()) return self.data, state, self._type_string @property def thumbnail(self) -> npt.NDArray[np.uint8]: """array: Integer array of thumbnail for the layer""" return self._thumbnail @thumbnail.setter def thumbnail(self, thumbnail: npt.NDArray) -> None: if 0 in thumbnail.shape: thumbnail = np.zeros(self._thumbnail_shape, dtype=np.uint8) if thumbnail.dtype != np.uint8: 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: """int: Number of dimensions in the data.""" return self._ndim @property def help(self) -> str: """str: displayed in status bar bottom right.""" return self._help @help.setter def help(self, help_text: str) -> None: if help_text == self.help: return self._help = help_text self.events.help(help=help_text) @property def interactive(self) -> bool: warnings.warn( trans._( 'Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead' ), FutureWarning, stacklevel=2, ) return self.mouse_pan or self.mouse_zoom @interactive.setter def interactive(self, interactive: bool) -> None: warnings.warn( trans._( 'Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead' ), FutureWarning, stacklevel=2, ) with self.events.interactive.blocker(): self.mouse_pan = interactive self.mouse_zoom = interactive @property def mouse_pan(self) -> bool: """bool: Determine if canvas interactive panning is enabled with the mouse.""" return self._mouse_pan @mouse_pan.setter def mouse_pan(self, mouse_pan: bool) -> None: if mouse_pan == self._mouse_pan: return self._mouse_pan = mouse_pan self.events.mouse_pan(mouse_pan=mouse_pan) self.events.interactive( interactive=self.mouse_pan or self.mouse_zoom ) # Deprecated since 0.5.0 @property def mouse_zoom(self) -> bool: """bool: Determine if canvas interactive zooming is enabled with the mouse.""" return self._mouse_zoom @mouse_zoom.setter def mouse_zoom(self, mouse_zoom: bool) -> None: if mouse_zoom == self._mouse_zoom: return self._mouse_zoom = mouse_zoom self.events.mouse_zoom(mouse_zoom=mouse_zoom) self.events.interactive( interactive=self.mouse_pan or self.mouse_zoom ) # Deprecated since 0.5.0 @property def cursor(self) -> str: """str: String identifying cursor displayed over canvas.""" return self._cursor @cursor.setter def cursor(self, cursor: str) -> None: if cursor == self.cursor: return self._cursor = cursor self.events.cursor(cursor=cursor) @property def cursor_size(self) -> int: """int: Size of cursor if custom. None yields default size.""" return self._cursor_size @cursor_size.setter def cursor_size(self, cursor_size: int) -> None: 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) -> ClippingPlaneList: return self._experimental_clipping_planes @experimental_clipping_planes.setter def experimental_clipping_planes( self, value: Union[ dict, ClippingPlane, list[Union[ClippingPlane, dict]], ClippingPlaneList, ], ) -> None: 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) -> Overlay: return self._overlays['bounding_box'] def set_view_slice(self) -> None: with self.dask_optimized_slicing(): self._set_view_slice() @abstractmethod def _set_view_slice(self): raise NotImplementedError def _slice_dims( self, dims: Dims, force: bool = False, ) -> None: """Slice data with values from a global dims model. Note this will likely be moved off the base layer soon. Parameters ---------- dims : Dims The dims model to use to slice this layer. force : bool True if slicing should be forced to occur, even when some cache thinks it already has a valid slice ready. False otherwise. """ logger.debug( 'Layer._slice_dims: %s, dims=%s, force=%s', self, dims, force, ) slice_input = self._make_slice_input(dims) if force or (self._slice_input != slice_input): self._slice_input = slice_input self._refresh_sync( data_displayed=True, thumbnail=True, highlight=True, extent=True, ) def _make_slice_input( self, dims: Dims, ) -> _SliceInput: world_ndim: int = self.ndim if dims is None else dims.ndim if dims is None: # if no dims is given, "world" has same dimensionality of self # this happens for example if a layer is not in a viewer # in this case, we assume all dims are displayed dimensions world_slice = _ThickNDSlice.make_full((np.nan,) * self.ndim) else: world_slice = _ThickNDSlice.from_dims(dims) order_array = ( np.arange(world_ndim) if dims.order is None else np.asarray(dims.order) ) order = tuple( self._world_to_layer_dims( world_dims=order_array, ndim_world=world_ndim, ) ) return _SliceInput( ndisplay=dims.ndisplay, world_slice=world_slice[-self.ndim :], order=order[-self.ndim :], ) @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: npt.ArrayLike, *, view_direction: Optional[npt.ArrayLike] = None, dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> Optional[tuple]: """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. """ position = np.asarray(position) 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(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: Optional[np.ndarray], end_point: Optional[np.ndarray], dims_displayed: list[int], ) -> Union[ float, int, None, tuple[Union[float, int, None], Optional[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. """ def projected_distance_from_mouse_drag( self, start_position: npt.ArrayLike, end_position: npt.ArrayLike, view_direction: npt.ArrayLike, vector: np.ndarray, dims_displayed: list[int], ) -> npt.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 : List[int] (3,) list of currently displayed dimensions Returns ------- projected_distance : (1, ) or (n, ) np.ndarray of float """ start_position = np.asarray(start_position) end_position = np.asarray(end_position) view_direction = np.asarray(view_direction) 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) -> Generator[None, None, None]: previous = self._update_properties self._update_properties = False try: yield finally: self._update_properties = previous def _set_highlight(self, force: bool = False) -> None: """Render layer highlights when appropriate. Parameters ---------- force : bool Bool that forces a redraw to occur when `True`. """ @contextmanager def _block_refresh(self): """Prevent refresh calls from updating view.""" previous = self._refresh_blocked self._refresh_blocked = True try: yield finally: self._refresh_blocked = previous def refresh( self, event: Optional[Event] = None, *, thumbnail: bool = True, data_displayed: bool = True, highlight: bool = True, extent: bool = True, force: bool = False, ) -> None: """Refresh all layer data based on current view slice.""" if self._refresh_blocked: logger.debug('Layer.refresh blocked: %s', self) return logger.debug('Layer.refresh: %s', self) # If async is enabled then emit an event that the viewer should handle. if get_settings().experimental.async_: self.events.reload(layer=self) # Otherwise, slice immediately on the calling thread. else: self._refresh_sync( thumbnail=thumbnail, data_displayed=data_displayed, highlight=highlight, extent=extent, force=force, ) def _refresh_sync( self, *, thumbnail: bool = False, data_displayed: bool = False, highlight: bool = False, extent: bool = False, force: bool = False, ) -> None: logger.debug('Layer._refresh_sync: %s', self) if not (self.visible or force): return if extent: self._clear_extent() self._clear_extent_augmented() if data_displayed: self.set_view_slice() self.events.set_data() if thumbnail: self._update_thumbnail() if highlight: self._set_highlight(force=True) def world_to_data(self, position: npt.ArrayLike) -> npt.NDArray: """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. """ position = np.asarray(position) if len(position) >= self.ndim: coords = list(position[-self.ndim :]) else: coords = [0] * (self.ndim - len(position)) + list(position) simplified = self._transforms[1:].simplified return 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: list[int] ) -> npt.NDArray: """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[int] 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 = position_nd[dims_displayed] return 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: npt.ArrayLike) -> npt.NDArray: """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 normalized_vector def _world_to_displayed_data_ray( self, vector_world: npt.ArrayLike, dims_displayed: list[int] ) -> 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 : 1D array A vector in world coordinates. Returns ------- tuple Vector in data coordinates. """ vector_data_nd = 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_displayed_data_normal( self, vector_world: npt.ArrayLike, dims_displayed: list[int] ) -> np.ndarray: """Convert a normal vector defining an orientation from world coordinates to data coordinates. Parameters ---------- vector_world : tuple, list, 1D array A vector in world coordinates. dims_displayed : list[int] Indices of displayed dimensions of the data. Returns ------- np.ndarray Transformed normal vector (unit vector) in data coordinates. Notes ----- This method is adapted from napari-threedee under BSD-3-Clause License. For more information see also: https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/geometry/transforming-normals.html """ # the napari transform is from layer -> world. # We want the inverse of the world -> layer, so we just take the napari transform inverse_transform = self._transforms[1:].simplified.linear_matrix # Extract the relevant submatrix based on dims_displayed submatrix = inverse_transform[np.ix_(dims_displayed, dims_displayed)] transpose_inverse_transform = submatrix.T # transform the vector transformed_vector = np.matmul( transpose_inverse_transform, vector_world ) transformed_vector /= np.linalg.norm(transformed_vector) return transformed_vector def _world_to_layer_dims( self, *, world_dims: npt.NDArray, ndim_world: int ) -> np.ndarray: """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. Let's keep in mind a few facts: - each dimension index is present exactly once. - the lowest represented dimension index will be 0 That is to say both the `world_dims` input and return results are _some_ permutation of 0...N Examples -------- `[2, 1, 0, 3]` sliced in N=2 dimensions. - we want to keep the N=2 dimensions with the biggest index - `[2, None, None, 3]` - we filter the None - `[2, 3]` - reindex so that the lowest dimension is 0 by subtracting 2 from all indices - `[0, 1]` `[2, 1, 0, 3]` sliced in N=3 dimensions. - we want to keep the N=3 dimensions with the biggest index - `[2, 1, None, 3]` - we filter the None - `[2, 1, 3]` - reindex so that the lowest dimension is 0 by subtracting 1 from all indices - `[1, 0, 2]` Conveniently if the world (layer) dimension is bigger than our displayed dims, we can return everything Parameters ---------- world_dims : ndarray The world dimensions. ndim_world : int The number of dimensions in the world coordinate system. Returns ------- ndarray The corresponding layer dimensions with the same ordering as the given world dimensions. """ return self._world_to_layer_dims_impl( world_dims, ndim_world, self.ndim ) @staticmethod def _world_to_layer_dims_impl( world_dims: npt.NDArray, ndim_world: int, ndim: int ) -> npt.NDArray: """ Static for ease of testing """ world_dims = np.asarray(world_dims) assert world_dims.min() == 0 assert world_dims.max() == len(world_dims) - 1 assert world_dims.ndim == 1 offset = ndim_world - ndim order = world_dims - offset order = order[order >= 0] return order - order.min() def _display_bounding_box(self, dims_displayed: list[int]) -> npt.NDArray: """An axis aligned (ndisplay, 2) bounding box around the data""" return self._extent_data[:, dims_displayed].T def _display_bounding_box_augmented( self, dims_displayed: list[int] ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. This bounding box includes the size of the layer in best resolution, including required padding """ return self._extent_data_augmented[:, dims_displayed].T def _display_bounding_box_augmented_data_level( self, dims_displayed: list[int] ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. If the layer is multiscale layer, then returns the bounding box of the data at the current level """ return self._display_bounding_box_augmented(dims_displayed) def click_plane_from_click_data( self, click_position: npt.ArrayLike, view_direction: npt.ArrayLike, dims_displayed: list[int], ) -> 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[int] 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: npt.ArrayLike, view_direction: npt.ArrayLike, dims_displayed: list[int], world: bool = True, ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: """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 : List[int] 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. """ position = np.asarray(position) view_direction = np.asarray(view_direction) if len(dims_displayed) != 3: return None, None # create the bounding box in data coordinates bounding_box = self._display_bounding_box(dims_displayed) # bounding box is with upper limit excluded in the uses below bounding_box[:, 1] += 1 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: npt.NDArray) -> npt.NDArray: """Adjust position for offset between viewer and data coordinates.""" return np.asarray(position) def _get_ray_intersections( self, position: npt.NDArray, view_direction: np.ndarray, dims_displayed: list[int], bounding_box: npt.NDArray, world: bool = True, ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: """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 : List[int] 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 = view_direction[dims_displayed] click_pos_data = 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 = position.copy() start_point[dims_displayed] = start_point_displayed_dimensions end_point = position.copy() 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) - 1 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.array_equal( self.corner_pixels, corners ): self._data_level = level self.corner_pixels = corners self.refresh(extent=False, thumbnail=False) else: # set the data_level so that it is the lowest resolution in 3d view if self.multiscale is True: self._data_level = len(self.level_shapes) - 1 # 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) -> dict: components = {} if self.source.reader_plugin: components['layer_name'] = self.name 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 if self.source.sample: components['layer_name'] = self.name 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 if self.source.widget: components['layer_name'] = self.name components['layer_base'] = self.name components['source_type'] = 'widget' components['plugin'] = self.source.widget._function.__name__ return components components['layer_name'] = self.name components['layer_base'] = self.name components['source_type'] = '' components['plugin'] = '' return components def get_source_str(self) -> str: source_info = self._get_source_info() source_str = source_info['layer_name'] if source_info['layer_base'] != source_info['layer_name']: source_str += '\n' + source_info['layer_base'] if source_info['source_type']: source_str += ( '\n' + source_info['source_type'] + ' : ' + source_info['plugin'] ) return source_str def get_status( self, position: Optional[npt.ArrayLike] = None, *, view_direction: Optional[npt.ArrayLike] = None, dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> dict: """ 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: position = np.asarray(position) value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) else: value = None source_info = self._get_source_info() if position is not None: source_info['coordinates'] = generate_layer_coords_status( position[-self.ndim :], value ) else: source_info['coordinates'] = generate_layer_coords_status( position, value ) return source_info def _get_tooltip_text( self, position: npt.NDArray, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> str: """ tooltip message of the data at a coordinate position. Parameters ---------- position : ndarray Position in either data or world coordinates. view_direction : Optional[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 __copy__(self): """Create a copy of this layer. Returns ------- layer : napari.layers.Layer Copy of this layer. Notes ----- This method is defined for purpose of asv memory benchmarks. The copy of data is intentional for properly estimating memory usage for layer. If you want a to copy a layer without coping the data please use `layer.create(*layer.as_layer_data_tuple())` If you change this method, validate if memory benchmarks are still working properly. """ data, meta, layer_type = self.as_layer_data_tuple() return self.create(copy.copy(data), meta=meta, layer_type=layer_type) @classmethod def create( cls, data: Any, meta: Optional[Mapping] = 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 is None or 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: if 'unexpected keyword argument' not in str(exc): raise 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.6/napari/layers/image/000077500000000000000000000000001474413133200166315ustar00rootroot00000000000000napari-0.5.6/napari/layers/image/__init__.py000066400000000000000000000005221474413133200207410ustar00rootroot00000000000000from 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.6/napari/layers/image/_image_constants.py000066400000000000000000000075351474413133200225320ustar00rootroot00000000000000from collections import OrderedDict from enum import auto from typing import Literal from napari.utils.misc import StringEnum from napari.utils.translations import trans InterpolationStr = Literal[ 'bessel', 'cubic', 'linear', 'blackman', 'catrom', 'gaussian', 'hamming', 'hanning', 'hermite', 'kaiser', 'lanczos', 'mitchell', 'nearest', 'spline16', 'spline36', 'custom', ] 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() CUSTOM = auto() value: InterpolationStr @classmethod def view_subset( cls, ) -> tuple[ 'Interpolation', 'Interpolation', 'Interpolation', 'Interpolation', 'Interpolation', ]: return ( cls.CUBIC, cls.LINEAR, cls.KAISER, cls.NEAREST, cls.SPLINE36, ) def __str__(self) -> InterpolationStr: return self.value 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() ImageRenderingStr = Literal[ 'translucent', 'additive', 'iso', 'mip', 'minip', 'attenuated_mip', 'average', ] 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')), ] ) class ImageProjectionMode(StringEnum): """ Projection mode for aggregating a thick nD slice onto displayed dimensions. * NONE: ignore slice thickness, only using the dims point * SUM: sum data across the thick slice * MEAN: average data across the thick slice * MAX: display the maximum value across the thick slice * MIN: display the minimum value across the thick slice """ NONE = auto() SUM = auto() MEAN = auto() MAX = auto() MIN = auto() napari-0.5.6/napari/layers/image/_image_key_bindings.py000066400000000000000000000071531474413133200231570ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator from typing import Callable, Union 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, register_layer_attr_action, ) from napari.utils.action_manager import action_manager from napari.utils.events import Event from napari.utils.translations import trans def register_image_action( description: str, repeatable: bool = False ) -> Callable[[Callable], Callable]: return register_layer_action(Image, description, repeatable) def register_image_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Image, description, 'mode') @register_image_action(trans._('Orient plane normal along z-axis')) def orient_plane_normal_along_z(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(1, 0, 0)) @register_image_action(trans._('Orient plane normal along y-axis')) def orient_plane_normal_along_y(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(0, 1, 0)) @register_image_action(trans._('Orient plane normal along x-axis')) def orient_plane_normal_along_x(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(0, 0, 1)) @register_image_action( trans._( 'Orient plane normal along view direction\nHold down to have plane follow camera' ) ) def orient_plane_normal_along_view_direction( layer: Image, ) -> Union[None, Generator[None, None, None]]: viewer = napari.viewer.current_viewer() if viewer is None or viewer.dims.ndisplay != 3: return None def sync_plane_normal_with_view_direction( event: Union[None, Event] = None, ) -> None: """Plane normal syncronisation mouse callback.""" layer.plane.normal = layer._world_to_displayed_data_normal( 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 None # remove callback on key release viewer.camera.events.angles.disconnect( sync_plane_normal_with_view_direction ) return None # The generator function above can't be bound to a button, so here # is a non-generator version of the function def orient_plane_normal_along_view_direction_no_gen(layer: Image) -> None: viewer = napari.viewer.current_viewer() if viewer is None or viewer.dims.ndisplay != 3: return layer.plane.normal = layer._world_to_displayed_data_normal( viewer.camera.view_direction, [-3, -2, -1] ) # register the non-generator without a keybinding # this way the generator version owns the keybinding action_manager.register_action( name='napari:orient_plane_normal_along_view_direction_no_gen', command=orient_plane_normal_along_view_direction_no_gen, description=trans._('Orient plane normal along view direction button'), keymapprovider=None, ) @register_image_mode_action(trans._('Transform')) def activate_image_transform_mode(layer: Image) -> None: layer.mode = str(Mode.TRANSFORM) @register_image_mode_action(trans._('Pan/zoom')) def activate_image_pan_zoom_mode(layer: Image) -> None: layer.mode = str(Mode.PAN_ZOOM) image_fun_to_mode = [ (activate_image_pan_zoom_mode, Mode.PAN_ZOOM), (activate_image_transform_mode, Mode.TRANSFORM), ] napari-0.5.6/napari/layers/image/_image_mouse_bindings.py000066400000000000000000000067161474413133200235230ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator from typing import TYPE_CHECKING, Union 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 ) -> Union[None, Generator[None, None, None]]: """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.mouse_pan is False or len(event.dims_displayed) < 3 ): return None # 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 None layer.plane.position = intersection # Store original plane position and disable interactivity during plane drag original_plane_position = np.copy(layer.plane.position) layer.mouse_pan = False yield None 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_augmented(event.dims_displayed), ) layer.plane.position = clamped_plane_position yield None # Re-enable volume_layer interactivity after the drag layer.mouse_pan = True return None def set_plane_position(layer: Image, event: Event) -> None: """Set plane position on double click.""" # early exit clauses if ( layer.visible is False or layer.mouse_pan 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.6/napari/layers/image/_image_utils.py000066400000000000000000000101451474413133200216450ustar00rootroot00000000000000"""guess_rgb, guess_multiscale, guess_labels.""" from collections.abc import Sequence from typing import Any, Callable, Literal, Union import numpy as np import numpy.typing as npt from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData from napari.layers.image._image_constants import ImageProjectionMode from napari.utils.translations import trans def guess_rgb(shape: tuple[int, ...], min_side_len: int = 30) -> bool: """Guess if the passed shape comes from rgb data. If last dim is 3 or 4 and other dims are larger (>30), 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] viewed_dims = shape[-3:-1] return ( ndim > 2 and last_dim in (3, 4) and all(d > min_side_len for d in viewed_dims) ) def guess_multiscale( data: Union[MultiScaleData, list, tuple], ) -> tuple[bool, Union[LayerDataProtocol, Sequence[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] sizes = [d.size for d in data] if len(sizes) <= 1: return False, data consistent = all(s1 > s2 for s1, s2 in zip(sizes[:-1], sizes[1:])) if all(s == sizes[0] for s in sizes): # 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 size: {size}', deferred=True, size=sizes[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, sizes: {sizes}', deferred=True, sizes=sizes, ) ) return True, MultiScaleData(data) def guess_labels(data: Any) -> Literal['labels', 'image']: """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' def project_slice( data: npt.NDArray, axis: tuple[int, ...], mode: ImageProjectionMode ) -> npt.NDArray: """Project a thick slice along axis based on mode.""" func: Callable if mode == ImageProjectionMode.SUM: func = np.sum elif mode == ImageProjectionMode.MEAN: func = np.mean elif mode == ImageProjectionMode.MAX: func = np.max elif mode == ImageProjectionMode.MIN: func = np.min else: raise NotImplementedError(f'unimplemented projection: {mode}') return func(data, tuple(axis)) napari-0.5.6/napari/layers/image/_slice.py000066400000000000000000000346341474413133200204530ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Any, Callable, Optional, Union import numpy as np from napari.layers.base._slice import _next_request_id from napari.layers.image._image_constants import ImageProjectionMode from napari.layers.image._image_utils import project_slice from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.types import ArrayLike from napari.utils._dask_utils import DaskIndexer from napari.utils.misc import reorder_after_dim_reduction from napari.utils.transforms import Affine @dataclass(frozen=True) class _ImageView: """A raw image and a potentially different viewable version of it. This is only needed for labels, and not other image layers, because sliced labels data are passed to vispy as floats in [0, 1] to use continuous colormaps. In that case, a conversion function is defined by `Labels._raw_to_displayed` to handle the desired colormapping behavior. For non-labels image layers the raw and viewable images should be the same instance and no conversion should be necessary. This is defined for images in general because `Labels` and `_ImageBase` share code through inheritance. Attributes ---------- raw : array The raw image. view : array The viewable image, which should either be the same instance of raw, or a converted version of it. """ raw: np.ndarray view: np.ndarray @classmethod def from_view(cls, view: np.ndarray) -> '_ImageView': """Makes an image view from the view where no conversion is needed.""" return cls(raw=view, view=view) @classmethod def from_raw( cls, *, raw: np.ndarray, converter: Callable[[np.ndarray], np.ndarray] ) -> '_ImageView': """Makes an image view from the raw image and a conversion function.""" view = converter(raw) return cls(raw=raw, view=view) @dataclass(frozen=True) class _ImageSliceResponse: """Contains all the output data of slicing an image layer. Attributes ---------- image : _ImageView The sliced image data. thumbnail: _ImageView The thumbnail image data. This may come from a different resolution to the sliced image data for multi-scale images. Otherwise, it's the same instance as 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. slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. """ image: _ImageView = field(repr=False) thumbnail: _ImageView = field(repr=False) tile_to_data: Affine = field(repr=False) slice_input: _SliceInput request_id: int empty: bool = False @classmethod def make_empty( cls, *, slice_input: _SliceInput, rgb: bool, request_id: Optional[int] = None, ) -> '_ImageSliceResponse': """Returns an empty image slice response. An empty slice indicates that there is no valid slice data for an image layer, but allows other functionality that relies on slice data existing to continue to work without special casing. Parameters ---------- slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. rgb : bool True if the underlying image is an RGB or RGBA image (i.e. that the last dimension represents a color channel that should not be sliced), False otherwise. request_id : int | None The request id for which we are responding with an empty slice. If None, a new request id will be returned, which guarantees that the empty slice never appears as loaded. (Used for layer initialisation before attempting data loading.) """ shape = (1,) * slice_input.ndisplay if rgb: shape = shape + (3,) data = np.zeros(shape, dtype=np.uint8) image = _ImageView.from_view(data) ndim = slice_input.ndim tile_to_data = Affine( name='tile2data', linear_matrix=np.eye(ndim), ndim=ndim ) if request_id is None: request_id = _next_request_id() return _ImageSliceResponse( image=image, thumbnail=image, tile_to_data=tile_to_data, slice_input=slice_input, request_id=request_id, empty=True, ) def to_displayed( self, converter: Callable[[np.ndarray], np.ndarray] ) -> '_ImageSliceResponse': """ Returns a raw slice converted for display, which is needed for Labels and Image. Parameters ---------- converter : Callable[[np.ndarray], np.ndarray] A function that converts the raw image to a vispy viewable image. Returns ------- _ImageSliceResponse Contains the converted image and thumbnail. """ image = _ImageView.from_raw(raw=self.image.raw, converter=converter) thumbnail = image if self.thumbnail is not self.image: thumbnail = _ImageView.from_raw( raw=self.thumbnail.raw, converter=converter ) return _ImageSliceResponse( image=image, thumbnail=thumbnail, tile_to_data=self.tile_to_data, slice_input=self.slice_input, request_id=self.request_id, ) @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 ---------- slice_input : _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. data_slice : _ThickNDSlice The slicing coordinates and margins in data space. others See the corresponding attributes in `Layer` and `Image`. id : int The identifier of this slice request. """ slice_input: _SliceInput data: Any = field(repr=False) dask_indexer: DaskIndexer data_slice: _ThickNDSlice projection_mode: ImageProjectionMode 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) id: int = field(default_factory=_next_request_id) def __call__(self) -> _ImageSliceResponse: if self._slice_out_of_bounds(): return _ImageSliceResponse.make_empty( slice_input=self.slice_input, rgb=self.rgb, request_id=self.id ) with self.dask_indexer(): return ( self._call_multi_scale() if self.multiscale else self._call_single_scale() ) def _call_single_scale(self) -> _ImageSliceResponse: order = self._get_order() data = self._project_thick_slice(self.data, self.data_slice) data = np.transpose(data, order) image = _ImageView.from_view(data) # `Layer.multiscale` is mutable so we need to pass back the identity # transform to ensure `tile2data` is properly set on the layer. ndim = self.slice_input.ndim tile_to_data = Affine( name='tile2data', linear_matrix=np.eye(ndim), ndim=ndim ) return _ImageSliceResponse( image=image, thumbnail=image, tile_to_data=tile_to_data, slice_input=self.slice_input, request_id=self.id, ) def _call_multi_scale(self) -> _ImageSliceResponse: if self.slice_input.ndisplay == 3: level = len(self.data) - 1 else: level = self.data_level # Calculate the tile-to-data transform. scale = np.ones(self.slice_input.ndim) for d in self.slice_input.displayed: scale[d] = self.downsample_factors[level][d] data = self.data[level] translate = np.zeros(self.slice_input.ndim) disp_slice = [slice(None) for _ in data.shape] if self.slice_input.ndisplay == 2: for d in self.slice_input.displayed: disp_slice[d] = slice( self.corner_pixels[0, d], self.corner_pixels[1, d] + 1, 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.slice_input.ndim, ) # slice displayed dimensions to get the right tile data data = data[tuple(disp_slice)] # project the thick slice data_slice = self._thick_slice_at_level(level) data = self._project_thick_slice(data, data_slice) order = self._get_order() data = np.transpose(data, order) image = _ImageView.from_view(data) thumbnail_data_slice = self._thick_slice_at_level(self.thumbnail_level) thumbnail_data = self._project_thick_slice( self.data[self.thumbnail_level], thumbnail_data_slice ) thumbnail_data = np.transpose(thumbnail_data, order) thumbnail = _ImageView.from_view(thumbnail_data) return _ImageSliceResponse( image=image, thumbnail=thumbnail, tile_to_data=tile_to_data, slice_input=self.slice_input, request_id=self.id, ) def _thick_slice_at_level(self, level: int) -> _ThickNDSlice: """ Get the data_slice rescaled for a specific level. """ slice_arr = self.data_slice.as_array() # downsample based on level slice_arr /= self.downsample_factors[level] slice_arr[0] = np.clip(slice_arr[0], 0, self.level_shapes[level] - 1) return _ThickNDSlice.from_array(slice_arr) def _project_thick_slice( self, data: ArrayLike, data_slice: _ThickNDSlice ) -> np.ndarray: """ Slice the given data with the given data slice and project the extra dims. This is also responsible for materializing the data if it is backed by a lazy store or compute graph (e.g. dask). """ if self.projection_mode == 'none': # early return with only the dims point being used slices = self._point_to_slices(data_slice.point) return np.asarray(data[slices]) slices = self._data_slice_to_slices( data_slice, self.slice_input.displayed ) return project_slice( data=np.asarray(data[slices]), axis=tuple(self.slice_input.not_displayed), mode=self.projection_mode, ) 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) return order def _slice_out_of_bounds(self) -> bool: """Check if the data slice is out of bounds for any dimension.""" data = self.data[0] if self.multiscale else self.data for d in self.slice_input.not_displayed: pt = self.data_slice.point[d] max_idx = data.shape[d] - 1 if self.projection_mode == 'none': if np.round(pt) < 0 or np.round(pt) > max_idx: return True else: pt = self.data_slice.point[d] low = np.round(pt - self.data_slice.margin_left[d]) high = np.round(pt + self.data_slice.margin_right[d]) if high < 0 or low > max_idx: return True return False @staticmethod def _point_to_slices( point: tuple[float, ...], ) -> tuple[Union[slice, int], ...]: # no need to check out of bounds here cause it's guaranteed # values in point and margins are np.nan if no slicing should happen along that dimension # which is always the case for displayed dims, so that becomes `slice(None)` for actually # indexing the layer. # For the rest, indices are rounded to the closest integer return tuple( slice(None) if np.isnan(p) else int(np.round(p)) for p in point ) @staticmethod def _data_slice_to_slices( data_slice: _ThickNDSlice, dims_displayed: list[int] ) -> tuple[slice, ...]: slices = [slice(None) for _ in range(data_slice.ndim)] for dim, (point, m_left, m_right) in enumerate(data_slice): if dim in dims_displayed: # leave slice(None) for displayed dimensions # point and margin values here are np.nan; if np.nans pass through this check, # something is likely wrong with the data_slice creation at a previous step! continue # max here ensures we don't start slicing from negative values (=end of array) low = max(int(np.round(point - m_left)), 0) high = max(int(np.round(point + m_right)), 0) # if high is already exactly at an integer value, we need to round up # to next integer because slices have non-inclusive stop if np.isclose(high, point + m_right): high += 1 # ensure we always get at least 1 slice (we're guaranteed to be # in bounds from a previous check) if low == high: high += 1 slices[dim] = slice(low, high) return tuple(slices) napari-0.5.6/napari/layers/image/_tests/000077500000000000000000000000001474413133200201325ustar00rootroot00000000000000napari-0.5.6/napari/layers/image/_tests/__init__.py000066400000000000000000000000001474413133200222310ustar00rootroot00000000000000napari-0.5.6/napari/layers/image/_tests/test_big_image_timing.py000066400000000000000000000022021474413133200250110ustar00rootroot00000000000000import 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', [ {'multiscale': False, 'contrast_limits': [0, 1]}, {'multiscale': False}, {'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.6/napari/layers/image/_tests/test_image.py000066400000000000000000001002371474413133200226300ustar00rootroot00000000000000import dask.array as da import numpy as np import numpy.testing as npt import pytest import xarray as xr from napari._tests.utils import check_layer_world_data_extent from napari.components.dims import Dims 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._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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 = (40, 45, 3) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.array_equal(layer.data, data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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 = (40, 45, 4) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.array_equal(layer.data, data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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 = (40, 45, 4) np.random.seed(0) # Data between -1.0 and 1.0 data = 2 * np.random.random(shape) - 1 layer = Image(data) assert np.array_equal(layer.data, data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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.array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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', 'render_mode', 'result', ), [ ((0, 0, 0), [1, 0, 0], [0, 1, 2], False, 'mip', 0), ((0, 0, 0), [1, 0, 0], [0, 1, 2], True, 'mip', 0), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'mip', 1), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'minip', 0), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'average', 1 / 5), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'translucent', 0), # not quite as expected for additive ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'additive', 2), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'iso', None), ((2, 2, 2), [1, 0, 0], [0, 1, 2], False, 'attenuated_mip', 0), ((0, 2, 2, 2), [0, 1, 0, 0], [1, 2, 3], False, 'mip', 1), ], ) def test_value_3d( position, view_direction, dims_displayed, world, render_mode, result ): data = np.zeros((5, 5, 5, 5)) data[:, 2, 2, 2] = 1 layer = Image(data, rendering=render_mode) layer._slice_dims(Dims(ndim=4, ndisplay=3)) value = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) if result is None: assert value is None else: npt.assert_allclose(value, result) 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 isinstance(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(Dims(ndim=3, ndisplay=3)) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) assert isinstance(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.array_equiv(thumbnail[: middle_row - 1], 0) assert np.array_equiv(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, [s - 1 for s in shape])) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5)) 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.array_equal(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), ], ) 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(Dims(ndim=3, 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 def test_thick_slice(): data = np.ones((5, 5, 5)) * np.arange(5).reshape(-1, 1, 1) layer = Image(data) layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) np.testing.assert_array_equal(layer._slice.image.raw, data[0]) # round down if at 0.5 and no margins layer._slice_dims(Dims(ndim=3, point=(0.5, 0, 0))) np.testing.assert_array_equal(layer._slice.image.raw, data[0]) # no changes if projection mode is 'none' layer._slice_dims( Dims( ndim=3, point=(0, 0, 0), margin_left=(1, 0, 0), margin_right=(1, 0, 0), ) ) np.testing.assert_array_equal(layer._slice.image.raw, data[0]) layer.projection_mode = 'mean' np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data[:2], axis=0) ) layer._slice_dims( Dims( ndim=3, point=(1, 0, 0), margin_left=(1, 0, 0), margin_right=(1, 0, 0), ) ) np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data[:3], axis=0) ) layer._slice_dims( Dims( ndim=3, range=((0, 3, 1), (0, 2, 1), (0, 2, 1)), point=(2.3, 0, 0), margin_left=(0, 0, 0), margin_right=(1.7, 0, 0), ) ) np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data[2:5], axis=0) ) layer._slice_dims( Dims( ndim=3, range=((0, 3, 1), (0, 2, 1), (0, 2, 1)), point=(2.3, 0, 0), margin_left=(0, 0, 0), margin_right=(1.6, 0, 0), ) ) np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data[2:4], axis=0) ) layer.projection_mode = 'max' np.testing.assert_array_equal( layer._slice.image.raw, np.max(data[2:4], axis=0) ) def test_adjust_contrast_out_of_range(): arr = np.linspace(1, 9, 5 * 5, dtype=np.float64).reshape((5, 5)) img_lay = Image(arr) npt.assert_array_equal(img_lay._slice.image.view, img_lay._slice.image.raw) img_lay.contrast_limits = (0, float(np.finfo(np.float32).max) * 2) assert not np.array_equal( img_lay._slice.image.view, img_lay._slice.image.raw ) def test_adjust_contrast_limits_range_set_data(): arr = np.linspace(1, 9, 5 * 5, dtype=np.float64).reshape((5, 5)) img_lay = Image(arr) img_lay._keep_auto_contrast = True npt.assert_array_equal(img_lay._slice.image.view, img_lay._slice.image.raw) img_lay.data = arr * 1e39 assert not np.array_equal( img_lay._slice.image.view, img_lay._slice.image.raw ) def test_thick_slice_multiscale(): data = np.ones((5, 5, 5)) * np.arange(5).reshape(-1, 1, 1) data_zoom = data.repeat(2, 0).repeat(2, 1).repeat(2, 2) layer = Image([data_zoom, data]) # ensure we're slicing level 0. We also need to update corner_pixels # to ensure the full image is in view layer.corner_pixels = np.array([[0, 0, 0], [10, 10, 10]]) layer.data_level = 0 layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) np.testing.assert_array_equal(layer._slice.image.raw, data_zoom[0]) layer.projection_mode = 'mean' # NOTE that here we rescale slicing to twice the non-multiscale test # in order to get the same results, because the actual full scale image # is doubled in size layer._slice_dims( Dims( ndim=3, range=((0, 5, 1), (0, 2, 1), (0, 2, 1)), point=(4.6, 0, 0), margin_left=(0, 0, 0), margin_right=(3.4, 0, 0), ) ) np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data_zoom[4:10], axis=0) ) # check level 1 layer.corner_pixels = np.array([[0, 0, 0], [5, 5, 5]]) layer.data_level = 1 layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) np.testing.assert_array_equal(layer._slice.image.raw, data[0]) layer.projection_mode = 'mean' # here we slice in the same point as earlier, but to get the expected value # we need to slice `data` with halved indices layer._slice_dims( Dims( ndim=3, range=((0, 5, 1), (0, 2, 1), (0, 2, 1)), point=(4.6, 0, 0), margin_left=(0, 0, 0), margin_right=(3.4, 0, 0), ) ) np.testing.assert_array_equal( layer._slice.image.raw, np.mean(data[2:5], axis=0) ) def test_contrast_outside_range(): data = np.zeros((64, 64), dtype=np.uint8) Image(data, contrast_limits=(0, 1000)) def test_docstring(): validate_all_params_in_docstring(Image) validate_kwargs_sorted(Image) napari-0.5.6/napari/layers/image/_tests/test_image_utils.py000066400000000000000000000112141474413133200240440ustar00rootroot00000000000000import inspect import time import dask 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 from napari.layers.image._slice import _ImageSliceRequest from napari.layers.utils._slice_input import _ThickNDSlice data_dask = da.random.random( size=(100_000, 1000, 1000), chunks=(1, 1000, 1000) ) def test_guess_rgb(): sig = inspect.signature(guess_rgb) min_side_len = sig.parameters['min_side_len'].default shape = (10, 15) # 2D only assert not guess_rgb(shape) shape = (40, 45, 6) # final dim is too long assert not guess_rgb(shape) shape = (min_side_len - 1, min_side_len - 1, 3) # 2D sides too small assert not guess_rgb(shape) shape = (min_side_len - 1, min_side_len + 1, 3) # one 2D side too small assert not guess_rgb(shape) shape = (min_side_len + 1, min_side_len + 1, 3) assert guess_rgb(shape) shape = (512, 512, 3) assert guess_rgb(shape) shape = (100, 100, 4) assert guess_rgb(shape) shape = (10, 10, 3) assert guess_rgb(shape, min_side_len=5) @given(shape=array_shapes(min_dims=3, min_side=0)) def test_guess_rgb_property(shape): sig = inspect.signature(guess_rgb) min_side_len = sig.parameters['min_side_len'].default assert guess_rgb(shape) == ( shape[-1] in (3, 4) and shape[-2] > min_side_len and shape[-3] > min_side_len ) 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] # Test for overflow in calculating array sizes s = 17179869184 data = [ da.from_delayed( dask.delayed(lambda: None), shape=(s,) * 2, dtype=np.float64 ), da.from_delayed( dask.delayed(lambda: None), shape=(s // 2,) * 2, dtype=np.float64 ), ] 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 with pytest.raises(ValueError, match='decreasing size'): _, _ = 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, match='decreasing size'): _, _ = 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' def test_create_data_indexing(): point = (np.nan, 10.1, 2.6, 4) idx = _ImageSliceRequest._point_to_slices(point) expected = (slice(None), 10, 3, 4) assert idx == expected # note that testing entirely out of bounds slices is wrong because these methods # assume the bounds check already happened data_slice = _ThickNDSlice( point=(np.nan, 10.1, 2.6, 4, -1), margin_left=(np.nan, 0, 1.6, 0.3, 1), margin_right=(np.nan, 0.1, 0.3, 0.5, 0.6), ) idx = _ImageSliceRequest._data_slice_to_slices( data_slice, dims_displayed=(0,) ) expected = ( slice(None), slice(10, 11), slice(1, 3), slice(4, 5), slice(0, 1), ) assert idx == expected napari-0.5.6/napari/layers/image/_tests/test_multiscale.py000066400000000000000000000400121474413133200237020ustar00rootroot00000000000000import 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], [s - 1 for s in 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], [s - 1 for s in 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], [s - 1 for s in 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) np.testing.assert_array_equal(layer.data, data) assert layer.multiscale is False assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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], [s - 1 for s in 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], [s - 1 for s in 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], [s - 1 for s in 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, 32, 3), (20, 16, 3), (10, 8, 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], [s - 1 for s in 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, 32, 3), (4, 20, 16, 3), (2, 10, 8, 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], [s - 1 for s in 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, 32, 3), (20, 16, 3), (10, 8, 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], [s - 1 for s in 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] = ( np.array(shapes[target_level]) - 1 ) # 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 isinstance(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) np.testing.assert_array_equal(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, [s - 1 for s in shapes[0]])) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5)) 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], [9, 9]]), ([[-11, -11], [31, 31]], 2, [[0, 0], [4, 4]]), ], ) 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], [19, 19]]), ([8, 8], 1, [[0, 0], [9, 9]]), ([4, 4], 2, [[0, 0], [4, 4]]), ], ) 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.6/napari/layers/image/_tests/test_volume.py000066400000000000000000000137221474413133200230570ustar00rootroot00000000000000import numpy as np from napari.components.dims import Dims 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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) # check displayed data is initially 2D assert layer._data_view.shape == shape[-2:] layer._slice_dims(Dims(ndim=3, ndisplay=3)) # check displayed data is now 3D assert layer._data_view.shape == shape[-3:] layer._slice_dims(Dims(ndim=3, ndisplay=2)) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] layer = Image(data) layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) # check displayed data is initially 3D assert layer._data_view.shape == shape[-3:] layer._slice_dims(Dims(ndim=3, ndisplay=2)) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] layer._slice_dims(Dims(ndim=3, 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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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(Dims(ndim=4, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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(Dims(ndim=3, ndisplay=3)) layer.data = data_b np.testing.assert_array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal( layer.extent.world[1] - layer.extent.world[0], np.asarray(full_shape) - scale, ) # 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(Dims(ndim=3, 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(Dims(ndim=3, ndisplay=3)) msg = layer.get_status((0,) * 3) assert isinstance(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.6/napari/layers/image/image.py000066400000000000000000000731661474413133200203020ustar00rootroot00000000000000"""Image class.""" from __future__ import annotations import typing import warnings from typing import Any, Literal, Union, cast 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._scalar_field.scalar_field import ScalarFieldBase from napari.layers.image._image_constants import ( ImageProjectionMode, ImageRendering, Interpolation, InterpolationStr, ) from napari.layers.image._image_utils import guess_rgb from napari.layers.image._slice import _ImageSliceResponse from napari.layers.intensity_mixin import IntensityVisualizationMixin from napari.layers.utils.layer_utils import calc_data_range from napari.utils._dtype import get_dtype_limits, normalize_dtype from napari.utils.colormaps import ensure_colormap from napari.utils.colormaps.colormap_utils import _coerce_contrast_limits from napari.utils.migrations import rename_argument from napari.utils.translations import trans __all__ = ('Image',) class Image(IntensityVisualizationMixin, ScalarFieldBase): """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. 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. attenuation : float Attenuation rate for attenuated maximum intensity projection. axis_labels : tuple of str Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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 {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. colormap : str, napari.utils.Colormap, tuple, dict Colormaps to use for luminance images. If a string, it can be the name of a supported colormap from vispy or matplotlib or the name of a vispy color or a hexadecimal RGB color representation. 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,) Intensity value limits to be used for determining the minimum and maximum colormap bounds for luminance images. If not passed, they will be calculated as the min and max intensity value of the image. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. depiction : str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. 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. gamma : float Gamma correction for determining colormap linearity; defaults to 1. interpolation2d : str Interpolation mode used by vispy for rendering 2d data. Must be one of our supported modes. (for list of supported modes see Interpolation enum) 'custom' is a special mode for 2D interpolation in which a regular grid of samples is taken from the texture around a position using 'linear' interpolation before being multiplied with a custom interpolation kernel (provided with 'custom_interpolation_kernel_2d'). interpolation3d : str Same as 'interpolation2d' but for 3D rendering. iso_threshold : float Threshold for isosurface. metadata : dict Layer metadata. 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. name : str Name of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. 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'}. projection_mode : str How data outside the viewed dimensions, but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to ImageProjectionMode rendering : str Rendering mode used by vispy. Must be one of our supported modes. rgb : bool, optional Whether the image is RGB or RGBA if rgb. If not specified by user, but 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. 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 : tuple of float Scale factors for the layer. 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 : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. 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. axis_labels : tuple of str Dimension names of the layer data. 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. interpolation2d : str Interpolation mode used by vispy. Must be one of our supported modes. 'custom' is a special mode for 2D interpolation in which a regular grid of samples are taken from the texture around a position using 'linear' interpolation before being multiplied with a custom interpolation kernel (provided with 'custom_interpolation_kernel_2d'). interpolation3d : str Same as 'interpolation2d' but for 3D rendering. 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. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. units: tuple of pint.Unit Units of the layer data in world coordinates. 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`. """ _projectionclass = ImageProjectionMode @rename_argument( from_name='interpolation', to_name='interpolation2d', version='0.6.0', since_version='0.4.17', ) def __init__( self, data, *, affine=None, attenuation=0.05, axis_labels=None, blending='translucent', cache=True, colormap='gray', contrast_limits=None, custom_interpolation_kernel_2d=None, depiction='volume', experimental_clipping_planes=None, gamma=1.0, interpolation2d='nearest', interpolation3d='linear', iso_threshold=None, metadata=None, multiscale=None, name=None, opacity=1.0, plane=None, projection_mode='none', rendering='mip', rgb=None, rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ): # Determine if rgb data_shape = data.shape if hasattr(data, 'shape') else data[0].shape if rgb and not guess_rgb(data_shape, min_side_len=0): raise ValueError( trans._( "'rgb' was set to True but data does not have suitable dimensions." ) ) if rgb is None: rgb = guess_rgb(data_shape) self.rgb = rgb super().__init__( data, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, custom_interpolation_kernel_2d=custom_interpolation_kernel_2d, depiction=depiction, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, multiscale=multiscale, name=name, ndim=len(data_shape) - 1 if rgb else len(data_shape), opacity=opacity, plane=plane, projection_mode=projection_mode, rendering=rendering, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) self.rgb = rgb self._colormap = ensure_colormap(colormap) self._gamma = gamma self._interpolation2d = Interpolation.NEAREST self._interpolation3d = Interpolation.NEAREST self.interpolation2d = interpolation2d self.interpolation3d = interpolation3d self._attenuation = attenuation # Set contrast limits, colormaps and plane parameters 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[float, float] = self.contrast_limits_range self.contrast_limits = self._contrast_limits if iso_threshold is None: cmin, cmax = self.contrast_limits_range self._iso_threshold = cmin + (cmax - cmin) / 2 else: self._iso_threshold = iso_threshold @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) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() state.update( { 'rgb': self.rgb, 'multiscale': self.multiscale, 'colormap': self.colormap.dict(), '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, 'custom_interpolation_kernel_2d': self.custom_interpolation_kernel_2d, } ) return state def _update_slice_response(self, response: _ImageSliceResponse) -> None: if self._keep_auto_contrast: data = response.image.raw input_data = data[-1] if self.multiscale else data self.contrast_limits = calc_data_range( typing.cast(LayerDataProtocol, input_data), rgb=self.rgb ) super()._update_slice_response(response) # 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 attenuation(self) -> float: """float: attenuation rate for attenuated_mip rendering.""" return self._attenuation @attenuation.setter def attenuation(self, value: float) -> None: self._attenuation = value self._update_thumbnail() self.events.attenuation() @property def data(self) -> Union[LayerDataProtocol, MultiScaleData]: """Data, possibly in multiscale wrapper. Obeys LayerDataProtocol.""" return self._data @data.setter def data(self, data: Union[LayerDataProtocol, MultiScaleData]) -> None: 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() if self._keep_auto_contrast: self.reset_contrast_limits() self.events.data(value=self.data) self._reset_editable() @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) -> InterpolationStr: return cast(InterpolationStr, str(self._interpolation2d)) @interpolation2d.setter def interpolation2d( self, value: Union[InterpolationStr, Interpolation] ) -> None: 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) -> InterpolationStr: return cast(InterpolationStr, str(self._interpolation3d)) @interpolation3d.setter def interpolation3d( self, value: Union[InterpolationStr, Interpolation] ) -> None: if value == 'custom': raise NotImplementedError( 'custom interpolation is not implemented yet for 3D rendering' ) 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 iso_threshold(self) -> float: """float: threshold for isosurface.""" return self._iso_threshold @iso_threshold.setter def iso_threshold(self, value: float) -> None: self._iso_threshold = value self._update_thumbnail() self.events.iso_threshold() def _get_level_shapes(self): shapes = super()._get_level_shapes() if self.rgb: shapes = [s[:-1] for s in shapes] return shapes def _update_thumbnail(self): """Update thumbnail with current image data and colormap.""" # don't bother updating thumbnail if we don't have any data # this also avoids possible dtype mismatch issues below # for example np.clip may raise an OverflowError (in numpy 2.0) if self._slice.empty: return image = self._slice.thumbnail.raw 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: 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: downsampled = ndi.zoom( image, zoom_factor, prefilter=False, order=0 ) low, high = self.contrast_limits if np.issubdtype(downsampled.dtype, np.integer): low = max(low, np.iinfo(downsampled.dtype).min) high = min(high, np.iinfo(downsampled.dtype).max) 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 _calc_data_range( self, mode: Literal['data', 'slice'] = 'data' ) -> tuple[float, float]: """ 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.raw # 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( cast(LayerDataProtocol, input_data), rgb=self.rgb ) def _raw_to_displayed(self, raw: np.ndarray) -> np.ndarray: """Determine displayed image from raw image. This function checks if current contrast_limits are within the range supported by vispy. If yes, it returns the raw image. If not, it rescales the raw image to fit within the range supported by vispy. Parameters ---------- raw : array Raw array. Returns ------- image : array Displayed array. """ fixed_contrast_info = _coerce_contrast_limits(self.contrast_limits) if np.allclose( fixed_contrast_info.contrast_limits, self.contrast_limits ): return raw return fixed_contrast_info.coerce_data(raw) @IntensityVisualizationMixin.contrast_limits.setter # type: ignore [attr-defined] def contrast_limits(self, contrast_limits): IntensityVisualizationMixin.contrast_limits.fset(self, contrast_limits) if not np.allclose( _coerce_contrast_limits(self.contrast_limits).contrast_limits, self.contrast_limits, ): prev = self._keep_auto_contrast self._keep_auto_contrast = False try: self.refresh(highlight=False, extent=False) finally: self._keep_auto_contrast = prev def _calculate_value_from_ray(self, values): # translucent is special: just return the first value, no matter what if self.rendering == ImageRendering.TRANSLUCENT: return np.ravel(values)[0] # iso is weird too: just return None always if self.rendering == ImageRendering.ISO: return None # if the whole ray is NaN, we should see nothing, so return None # this check saves us some warnings later as well, so better do it now if np.all(np.isnan(values)): return None # "summary" renderings; they do not represent a specific pixel, so we just # return the summary value. We should probably differentiate these somehow. # these are also probably not the same as how the gpu does it... if self.rendering == ImageRendering.AVERAGE: return np.nanmean(values) if self.rendering == ImageRendering.ADDITIVE: # TODO: this is "broken" cause same pixel gets multisampled... # but it looks like it's also overdoing it in vispy vis too? # I don't know if there's a way to *not* do it... return np.nansum(values) # all the following cases are returning the *actual* value of the image at the # "selected" pixel, whose position changes depending on the rendering mode. if self.rendering == ImageRendering.MIP: return np.nanmax(values) if self.rendering == ImageRendering.MINIP: return np.nanmin(values) if self.rendering == ImageRendering.ATTENUATED_MIP: # normalize values so attenuation applies from 0 to 1 values_attenuated = ( values - self.contrast_limits[0] ) / self.contrast_limits[1] # approx, step size is actually calculated with int(lenght(ray) * 2) step_size = 0.5 sumval = ( step_size * np.cumsum(np.clip(values_attenuated, 0, 1)) * len(values_attenuated) ) scale = np.exp(-self.attenuation * (sumval - 1)) return values[np.nanargmin(values_attenuated * scale)] raise RuntimeError( # pragma: no cover f'ray value calculation not implemented for {self.rendering}' ) napari-0.5.6/napari/layers/intensity_mixin.py000066400000000000000000000132341474413133200213560ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional 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._scalar_field.scalar_field import ScalarFieldBase 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: tuple[Optional[float], Optional[float]] = ( None, None, ) self._contrast_limits_range: tuple[ Optional[float], Optional[float] ] = (None, None) self._auto_contrast_source = 'slice' self._keep_auto_contrast = False def reset_contrast_limits(self: 'ScalarFieldBase', mode=None): """Scale contrast limits to data range""" mode = mode or self._auto_contrast_source self.contrast_limits = self._calc_data_range(mode) def _calc_data_range(self, mode): raise NotImplementedError 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 @colormap.setter def colormap(self, colormap): self._set_colormap(colormap) def _set_colormap(self, colormap): self._colormap = ensure_colormap(colormap) self._update_thumbnail() self.events.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 = float(value) self._update_thumbnail() self.events.gamma() napari-0.5.6/napari/layers/labels/000077500000000000000000000000001474413133200170115ustar00rootroot00000000000000napari-0.5.6/napari/layers/labels/__init__.py000066400000000000000000000005331474413133200211230ustar00rootroot00000000000000from 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.6/napari/layers/labels/_labels_constants.py000066400000000000000000000066361474413133200230730ustar00rootroot00000000000000import 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. In POLYGON mode, the mouse is used to draw a polygon by clicking the left mouse button to place its vertices. Right mouse click removes the latest polygon vertex. Left double-click finishes the polygon drawing and updates the labels pixels. If the background label `0` is selected, any pixels will be changed to background and this tool functions like an eraser. This mode is valid only for 2D images. """ PAN_ZOOM = auto() TRANSFORM = auto() PICK = auto() PAINT = auto() FILL = auto() ERASE = auto() POLYGON = 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() class IsoCategoricalGradientMode(StringEnum): """IsoCategoricalGradientMode: Gradient mode for the IsoCategorical rendering mode. Selects the finite-difference gradient method for the isosurface shader: * fast: use a simple finite difference gradient along each axis * smooth: use an isotropic Sobel gradient, smoother but more computationally expensive """ FAST = auto() SMOOTH = auto() napari-0.5.6/napari/layers/labels/_labels_key_bindings.py000066400000000000000000000124121474413133200235110ustar00rootroot00000000000000from typing import cast import 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.notifications import show_info from napari.utils.translations import trans MIN_BRUSH_SIZE = 1 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: Labels): layer.mode = Mode.TRANSFORM @register_label_mode_action(trans._('Pan/zoom')) def activate_labels_pan_zoom_mode(layer: Labels): 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 polygon tool')) def activate_labels_polygon_mode(layer: Labels): layer.mode = Mode.POLYGON @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_polygon_mode, Mode.POLYGON), (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.""" if isinstance(layer.data, np.ndarray): new_selected_label = np.max(layer.data) + 1 if layer.selected_label == new_selected_label: show_info( trans._( 'Current selected label is not being used. You will need to use it first ' 'to be able to set the current select label to the next one available', ) ) else: layer.selected_label = new_selected_label else: show_info( trans._( 'Calculating empty label on non-numpy array is not supported' ) ) @register_label_action( trans._('Swap between the selected label and the background label'), ) def swap_selected_and_background_labels(layer: Labels): """Swap between the selected label and the background label.""" layer.swap_selected_and_background_labels() @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""" 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, overwrite=True) 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, overwrite=True) def redo(layer: Labels): """Redo any previously undone actions.""" layer.redo() @register_label_action( trans._('Reset the current polygon'), ) def reset_polygon(layer: Labels): """Reset the drawing of the current polygon.""" layer._overlays['polygon'].points = [] @register_label_action( trans._('Complete the current polygon'), ) def complete_polygon(layer: Labels): """Complete the drawing of the current polygon.""" # Because layer._overlays has type Overlay, mypy doesn't know that # ._overlays["polygon"] has type LabelsPolygonOverlay, so type ignore for now # TODO: Improve typing of layer._overlays to fix this from napari.components.overlays.labels_polygon import ( LabelsPolygonOverlay, ) cast( LabelsPolygonOverlay, layer._overlays['polygon'] ).add_polygon_to_labels(layer) napari-0.5.6/napari/layers/labels/_labels_mouse_bindings.py000066400000000000000000000101601474413133200240470ustar00rootroot00000000000000from napari.layers.labels._labels_constants import Mode from napari.layers.labels._labels_utils import mouse_event_to_labels_coordinate from napari.settings import get_settings 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 """ # Do not allow drawing while adjusting the brush size with the mouse if layer.cursor == 'circle_frozen': return coordinates = mouse_event_to_labels_coordinate(layer, event) if layer._mode == Mode.ERASE: new_label = layer.colormap.background_value 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 ) class BrushSizeOnMouseMove: """Enables changing the brush size by moving the mouse while holding down the specified modifiers When hold down specified modifiers and move the mouse, the callback will adjust the brush size based on the direction of the mouse movement. Moving the mouse right will increase the brush size, while moving it left will decrease it. The amount of change is proportional to the distance moved by the mouse. Parameters ---------- min_brush_size : int The minimum brush size. """ def __init__(self, min_brush_size: int = 1): self.min_brush_size = min_brush_size self.init_pos = None self.init_brush_size = None get_settings().application.events.brush_size_on_mouse_move_modifiers.connect( self._on_modifiers_change ) self._on_modifiers_change() def __call__(self, layer, event): if all(modifier in event.modifiers for modifier in self.modifiers): pos = event.pos # position in the canvas coordinates (x, y) if self.init_pos is None: self.init_pos = pos self.init_brush_size = layer.brush_size layer.cursor = 'circle_frozen' else: brush_size_delta = round( (pos[0] - self.init_pos[0]) / event.camera_zoom ) new_brush_size = self.init_brush_size + brush_size_delta bounded_brush_size = max(new_brush_size, self.min_brush_size) layer.brush_size = bounded_brush_size else: self.init_pos = None if layer.cursor == 'circle_frozen': layer.cursor = 'circle' def _on_modifiers_change(self): modifiers_setting = ( get_settings().application.brush_size_on_mouse_move_modifiers ) self.modifiers = modifiers_setting.value.split('+') napari-0.5.6/napari/layers/labels/_labels_utils.py000066400000000000000000000167111474413133200222120ustar00rootroot00000000000000from functools import lru_cache import numpy as np from scipy import ndimage as ndi 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 def get_contours(labels: np.ndarray, thickness: int, background_label: int): """Computes the contours of a 2D label image. Parameters ---------- labels : array of integers An input labels image. thickness : int It controls the thickness of the inner boundaries. The outside thickness is always 1. The final thickness of the contours will be `thickness + 1`. background_label : int That label is used to fill everything outside the boundaries. Returns ------- A new label image in which only the boundaries of the input image are kept. """ struct_elem = ndi.generate_binary_structure(labels.ndim, 1) thick_struct_elem = ndi.iterate_structure(struct_elem, thickness).astype( bool ) dilated_labels = ndi.grey_dilation(labels, footprint=struct_elem) eroded_labels = ndi.grey_erosion(labels, footprint=thick_struct_elem) not_boundaries = dilated_labels == eroded_labels contours = labels.copy() contours[not_boundaries] = background_label return contours def expand_slice( axes_slice: tuple[slice, ...], shape: tuple, offset: int ) -> tuple[slice, ...]: """Expands or shrinks a provided multi-axis slice by a given offset""" return tuple( slice( max(0, min(max_size, s.start - offset)), max(0, min(max_size, s.stop + offset)), s.step, ) for s, max_size in zip(axes_slice, shape) ) napari-0.5.6/napari/layers/labels/_tests/000077500000000000000000000000001474413133200203125ustar00rootroot00000000000000napari-0.5.6/napari/layers/labels/_tests/test_labels.py000066400000000000000000001535201474413133200231730ustar00rootroot00000000000000import copy import itertools import time from collections import defaultdict from dataclasses import dataclass from importlib.metadata import version import numpy as np import numpy.testing as npt import pandas as pd import pytest import xarray as xr import zarr from packaging.version import parse as parse_version from skimage import data as sk_data from napari._tests.utils import check_layer_world_data_extent from napari.components import ViewerModel from napari.components.dims import Dims from napari.layers import Labels from napari.layers.labels._labels_constants import LabelsRendering from napari.layers.labels._labels_utils import get_contours from napari.utils import Colormap from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) from napari.utils.colormaps import ( CyclicLabelColormap, DirectLabelColormap, label_colormap, ) @pytest.fixture def direct_colormap(): """Return a DirectLabelColormap.""" return DirectLabelColormap( color_dict={ 0: [0, 0, 0, 0], 1: [1, 0, 0, 1], 2: [0, 1, 0, 1], None: [0, 0, 1, 1], } ) @pytest.fixture def random_colormap(): """Return a LabelColormap.""" return label_colormap(50) 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) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in 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) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) assert layer._data_view.shape == shape[-2:] assert layer.editable is True layer._slice_dims(Dims(ndim=3, 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 np.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(np.issubdtype(d.dtype, np.integer) for d in layer.data) def test_editing_bool_labels(): # make random data, mostly 0s data = np.random.random((10, 10)) > 0.7 # create layer, which may convert bool to uint8 *as a view* layer = Labels(data) # paint the whole layer with 1 layer.paint_polygon( points=[[-1, -1], [-1, 11], [11, 11], [11, -1]], new_label=1, ) # check that the original data has been correspondingly modified assert np.all(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 np.testing.assert_array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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 np.testing.assert_array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal( layer.extent.data[1], [s - 1 for s in 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.mouse_pan is True layer.mode = 'fill' assert layer.mode == 'fill' assert layer.mouse_pan is False layer.mode = 'paint' assert layer.mode == 'paint' assert layer.mouse_pan is False layer.mode = 'pick' assert layer.mode == 'pick' assert layer.mouse_pan is False layer.mode = 'polygon' assert layer.mode == 'polygon' assert layer.mouse_pan is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.mouse_pan 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' @pytest.mark.filterwarnings('ignore:.*seed is deprecated.*') 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 all four colors are represented in a large set of random # labels. # we choose non-zero labels, and then there should not be any transparent # values. labels = np.random.randint(1, 2**23, size=(100, 100)).astype(np.float32) colormapped = colormap.map(labels) linear = np.reshape(colormapped, (-1, 4)) unique = np.unique(linear, axis=0) assert len(unique) == 4 def test_custom_color_dict(): """Test custom color dict.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) cmap = DirectLabelColormap( color_dict=defaultdict( lambda: 'black', {2: 'white', 4: 'red', 8: 'blue', 16: 'red', 32: 'blue'}, ) ) layer = Labels(data, colormap=cmap) # test with custom color dict assert isinstance(layer.get_color(2), np.ndarray) assert isinstance(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 disable custom color dict # should not initialize as white since we are using random.seed assert not (layer.get_color(1) == np.array([1.0, 1.0, 1.0, 1.0])).all() @pytest.mark.parametrize( 'colormap_like', [ ['red', 'blue'], [[1, 0, 0, 1], [0, 0, 1, 1]], {None: 'transparent', 1: 'red', 2: 'blue'}, {None: [0, 0, 0, 0], 1: [1, 0, 0, 1], 2: [0, 0, 1, 1]}, defaultdict(lambda: 'transparent', {1: 'red', 2: 'blue'}), ], ) def test_colormap_simple_data_types(colormap_like): """Test that setting colormap with list or dict of colors works.""" data = np.random.randint(20, size=(10, 15)) # test in constructor _ = Labels(data, colormap=colormap_like) # test assignment layer = Labels(data) layer.colormap = colormap_like 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), ), ], ids=['touching objects', 'touching border', 'full array'], ) 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.astype(np.float32)), 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, 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 ) with pytest.raises(ValueError, match='contour value must be >= 0'): layer.contour = -1 @pytest.mark.parametrize('background_num', [0, 1, 2, -1]) def test_background_label(background_num): data = np.zeros((10, 10), dtype=np.int32) data[1:-1, 1:-1] = 1 data[2:-2, 2:-2] = 2 data[4:-4, 4:-4] = -1 layer = Labels(data) layer.colormap = label_colormap(49, background_value=background_num) np.testing.assert_array_equal( layer._data_view == 0, data == background_num ) np.testing.assert_array_equal( layer._data_view != 0, data != background_num ) 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_contour_local_updates(): """Checks if contours are rendered correctly with local updates""" data = np.zeros((7, 7), dtype=np.int32) layer = Labels(data) layer.contour = 1 assert np.allclose( layer._raw_to_displayed(layer._slice.image.raw), np.zeros((7, 7), dtype=np.float32), ) painting_mask = np.array( [ [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=np.int32, ) layer.data_setitem(np.nonzero(painting_mask), 1, refresh=True) assert np.array_equiv( (layer._slice.image.view > 0), get_contours(painting_mask, 1, 0) ) def test_data_setitem_multi_dim(): """ this test checks if data_setitem works when some of the indices are outside currently rendered slice """ # create zarr zeros array in memory data = zarr.zeros((10, 10, 10), chunks=(5, 5, 5), dtype=np.uint32) labels = Labels(data) labels.data_setitem( (np.array([0, 1, 1]), np.array([1, 1, 2]), np.array([0, 0, 0])), [1, 2, 0], ) def test_data_setitiem_transposed_axes(): data = np.zeros((10, 100), dtype=np.uint32) labels = Labels(data) dims = Dims(ndim=2, ndisplay=2, order=(1, 0)) labels.data_setitem((np.array([9]), np.array([99])), 1) labels._slice_dims(dims) labels.data_setitem((np.array([9]), np.array([99])), 2) 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.colormap.background_value ) 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.colormap.background_value) # 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([layer.get_color(x) for x in 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 a 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_paint_polygon(): """Test painting labels with polygons.""" data = np.zeros((10, 15), dtype=int) data[:10, :10] = 1 layer = Labels(data) layer.paint_polygon([[0, 0], [0, 5], [5, 5], [5, 0]], 2) assert np.array_equiv(layer.data[:5, :5], 2) assert np.array_equiv(layer.data[:10, 6:10], 1) assert np.array_equiv(layer.data[6:10, :10], 1) layer.paint_polygon([[7, 7], [7, 7], [7, 7]], 3) assert layer.data[7, 7] == 3 assert np.array_equiv( layer.data[[6, 7, 8, 7, 8, 6], [7, 6, 7, 8, 8, 6]], 1 ) data[:10, :10] = 0 gt_pattern = np.array( [ [0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 0, 0, 1, 1, 0], [0, 1, 1, 0, 0, 1, 1, 0], [0, 1, 1, 0, 0, 1, 1, 0], [0, 0, 0, 0, 0, 0, 0, 0], ] ) polygon_points = [ [1, 1], [1, 6], [5, 6], [5, 5], [2, 5], [2, 2], [5, 2], [5, 1], ] layer.paint_polygon(polygon_points, 1) assert np.allclose(layer.data[:7, :8], gt_pattern) data[:10, :10] = 0 layer.paint_polygon(polygon_points[::-1], 1) assert np.allclose(layer.data[:7, :8], gt_pattern) def test_paint_polygon_2d_in_3d(): """Test painting labels with polygons in a 3D array""" data = np.zeros((3, 10, 10), dtype=int) layer = Labels(data) assert layer.n_edit_dimensions == 2 layer.paint_polygon([[1, 0, 0], [1, 0, 9], [1, 9, 9], [1, 9, 0]], 1) assert np.array_equiv(data[1, :], 1) assert np.array_equiv(data[[0, 2], :], 0) 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(Dims(ndim=3, 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 isinstance(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 @pytest.mark.parametrize('value', [1, 10, 50, -2, -10]) @pytest.mark.parametrize('dtype', [np.int8, np.int32]) def test_thumbnail_single_color(value, dtype): labels = Labels(np.full((10, 10), value, dtype=dtype), opacity=1) labels._update_thumbnail() mid_point = tuple(np.array(labels.thumbnail.shape[:2]) // 2) npt.assert_array_equal( labels.thumbnail[mid_point], labels.get_color(value) * 255 ) 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, [s - 1 for s in shape])) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5)) @pytest.mark.parametrize( ( 'brush_size', 'mode', 'selected_label', 'preserve_labels', 'n_edit_dimensions', ), list( itertools.product( list(range(1, 22, 5)), ['fill', 'erase', 'paint'], [1, 20, 100], [True, False], [3, 2], ) ), ) def test_undo_redo( brush_size, mode, selected_label, preserve_labels, n_edit_dimensions, ): blobs = sk_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 = n_edit_dimensions 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]) assert not np.any(layer.data[2:]) layer.n_edit_dimensions = 2 # 3x3 square layer._slice_dims(Dims(ndim=4, 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_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_large_label_values(): label_array = 2**23 + np.arange(4, dtype=np.uint64).reshape((2, 2)) layer = Labels(label_array) mapped = layer._random_colormap.map(layer.data) assert len(np.unique(mapped.reshape((-1, 4)), axis=0)) == 4 if parse_version(version('zarr')) > parse_version('3.0.0a0'): driver = [(2, 'zarr'), (3, 'zarr3')] else: driver = [(2, 'zarr')] @pytest.mark.parametrize(('zarr_version', 'zarr_driver'), driver) def test_fill_tensorstore(tmp_path, zarr_version, zarr_driver): 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 file_path = str(tmp_path / 'labels.zarr') labels_temp = zarr.open( store=file_path, mode='w', shape=labels.shape, dtype=np.uint32, chunks=(1, 1, 8, 9), zarr_version=zarr_version, ) labels_temp[:] = labels labels_ts_spec = { 'driver': zarr_driver, 'kvstore': {'driver': 'file', 'path': file_path}, 'path': '', } 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(Dims(ndim=4, ndisplay=3, point=(1, 0, 0, 0))) 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(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 0))) 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( Dims(ndim=4, ndisplay=3, order=(3, 0, 1, 2), point=(0, 0, 0, 1)) ) 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( Dims(ndim=4, ndisplay=3, order=(0, 1, 3, 2), point=(1, 0, 0, 0)) ) 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(Dims(ndim=4, ndisplay=2, point=(1, 10, 0, 0))) 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(Dims(ndim=4, ndisplay=3, point=(1, 0, 0, 0))) # 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(Dims(ndim=4, 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(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 1))) 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(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 1))) 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._direct_colormap.color_dict assert layer._is_default_colors(current_color) # setting color to default colors doesn't update color mode layer.colormap = DirectLabelColormap(color_dict=current_color) assert isinstance(layer.colormap, CyclicLabelColormap) # new colors are not default new_color = {0: 'white', 1: 'red', 3: 'green', None: 'blue'} assert not layer._is_default_colors(new_color) # setting the color with non-default colors updates color mode layer.colormap = DirectLabelColormap(color_dict=new_color) assert isinstance(layer.colormap, DirectLabelColormap) def test_large_labels_direct_color(): """Make sure direct color works with large label ranges""" pytest.importorskip('numba') data = np.array([[0, 1], [2**16, 2**20]], dtype=np.uint32) colors = {1: 'white', 2**16: 'green', 2**20: 'magenta'} layer = Labels( data, colormap=DirectLabelColormap( color_dict=defaultdict(lambda: 'black', colors) ), ) np.testing.assert_allclose(layer.get_color(2**20), [1.0, 0.0, 1.0, 1.0]) def test_invalidate_cache_when_change_color_mode( direct_colormap, random_colormap ): """Checks if the cache is invalidated when color mode is changed.""" data = np.zeros((4, 10), dtype=np.int32) data[1, :] = np.arange(0, 10) layer = Labels(data) layer.selected_label = 0 gt_auto = layer._raw_to_displayed(layer._slice.image.raw) assert gt_auto.dtype == np.uint8 layer.colormap = direct_colormap layer._cached_labels = None assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.uint8 layer.colormap = random_colormap # If the cache is not invalidated, it returns colors for # the direct color mode instead of the color for the auto mode assert np.allclose( layer._raw_to_displayed(layer._slice.image.raw), gt_auto ) def test_color_mapping_when_color_is_changed(): """Checks if the color mapping is computed correctly when the color palette is changed.""" data = np.zeros((4, 5), dtype=np.int32) data[1, :] = np.arange(0, 5) layer = Labels( data, colormap=DirectLabelColormap( color_dict={1: 'green', 2: 'red', 3: 'white', None: 'black'} ), ) gt_direct_3colors = layer._raw_to_displayed(layer._slice.image.raw) layer = Labels( data, colormap=DirectLabelColormap( color_dict={1: 'green', 2: 'red', None: 'black'} ), ) assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.uint8 layer.colormap = DirectLabelColormap( color_dict={1: 'green', 2: 'red', 3: 'white', None: 'black'} ) assert np.allclose( layer._raw_to_displayed(layer._slice.image.raw), gt_direct_3colors ) def test_color_mapping_with_show_selected_label(): """Checks if the color mapping is computed correctly when show_selected_label is activated.""" data = np.arange(5, dtype=np.int32)[:, np.newaxis].repeat(5, axis=1) layer = Labels(data) mapped_colors_all = layer.colormap.map(data) layer.show_selected_label = True for selected_label in range(5): layer.selected_label = selected_label label_mask = data == selected_label mapped_colors = layer.colormap.map(data) npt.assert_allclose( mapped_colors[label_mask], mapped_colors_all[label_mask] ) npt.assert_allclose(mapped_colors[np.logical_not(label_mask)], 0) layer.show_selected_label = False assert np.allclose(layer.colormap.map(data), mapped_colors_all) def test_color_mapping_when_seed_is_changed(): """Checks if the color mapping is updated when the color palette seed is changed.""" np.random.seed(0) layer = Labels(np.random.randint(50, size=(10, 10))) mapped_colors1 = layer.colormap.map( layer._to_vispy_texture_dtype(layer._slice.image.raw) ) layer.new_colormap() mapped_colors2 = layer.colormap.map( layer._to_vispy_texture_dtype(layer._slice.image.raw) ) assert not np.allclose(mapped_colors1, mapped_colors2) @pytest.mark.parametrize('num_colors', [49, 50, 254, 255, 60000, 65534]) def test_color_shuffling_above_num_colors(num_colors): r"""Check that the color shuffle does not result in the same collisions. See https://github.com/napari/napari/issues/6448. Note that we don't support more than 2\ :sup:`16` colors, and behavior with more colors is undefined, so we don't test it here. """ labels = np.arange(1, 1 + 2 * (num_colors - 1)).reshape((2, -1)) layer = Labels(labels, colormap=label_colormap(num_colors - 1)) colors0 = layer.colormap.map(labels) assert np.all(colors0[0] == colors0[1]) layer.new_colormap() colors1 = layer.colormap.map(labels) assert not np.all(colors1[0] == colors1[1]) 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(Dims(ndim=3, 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)) 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(Dims(ndim=3, point=(1, 0, 0))) # This used to fail when negative values were used to index into _all_vals. 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() 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 def test_copy(): l1 = Labels(np.zeros((2, 4, 5), dtype=np.uint8)) l2 = copy.copy(l1) l3 = Labels.create(*l1.as_layer_data_tuple()) assert l1.data is not l2.data assert l1.data is l3.data @pytest.mark.parametrize( ('colormap', 'expected'), [ (label_colormap(49, 0.5), [0, 1]), ( DirectLabelColormap( color_dict={ 0: np.array([0, 0, 0, 0]), 1: np.array([1, 0, 0, 1]), None: np.array([1, 1, 0, 1]), } ), [1, 2], ), ], ids=['auto', 'direct'], ) def test_draw(colormap, expected): labels = Labels(np.zeros((30, 30), dtype=np.uint32)) labels.mode = 'paint' labels.colormap = colormap labels.selected_label = 1 npt.assert_array_equal(np.unique(labels._slice.image.raw), [0]) npt.assert_array_equal(np.unique(labels._slice.image.view), expected[:1]) labels._draw(1, (15, 15), (15, 15)) npt.assert_array_equal(np.unique(labels._slice.image.raw), [0, 1]) npt.assert_array_equal(np.unique(labels._slice.image.view), expected) 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', 'color', 'seed_rng'}, ) def test_docstring(): validate_all_params_in_docstring(Labels) validate_kwargs_sorted(Labels) def test_new_colormap_int8(): """Check that int8 labels colors can be shuffled without overflow. See https://github.com/napari/napari/issues/7277. """ data = np.arange(-128, 128, dtype=np.int8).reshape((16, 16)) layer = Labels(data) layer.new_colormap(seed=0) napari-0.5.6/napari/layers/labels/_tests/test_labels_key_bindings.py000066400000000000000000000015101474413133200257070ustar00rootroot00000000000000import numpy as np import pytest from napari.layers import Labels from napari.layers.labels._labels_key_bindings import ( new_label, swap_selected_and_background_labels, ) @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_swap_background_label(labels_data_4d): labels = Labels(labels_data_4d) labels.selected_label = 10 swap_selected_and_background_labels(labels) assert labels.selected_label == labels.colormap.background_value swap_selected_and_background_labels(labels) assert labels.selected_label == 10 napari-0.5.6/napari/layers/labels/_tests/test_labels_mouse_bindings.py000066400000000000000000000365631474413133200262670ustar00rootroot00000000000000import numpy as np from scipy import ndimage as ndi from napari.components.dims import Dims 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(Dims(ndim=3, 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(Dims(ndim=3, 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(Dims(ndim=3, 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.6/napari/layers/labels/_tests/test_labels_multiscale.py000066400000000000000000000067441474413133200254220ustar00rootroot00000000000000import numpy as np from napari.components.dims import Dims 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], [s - 1 for s in shapes[0]] ) 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], [s - 1 for s in shapes[0]] ) assert layer._data_view.ndim == 2 def test_3D_multiscale_labels_in_2D(): """Test instantiating Labels layer with 3D data, 2D dims.""" data_multiscale, layer = instantiate_3D_multiscale_labels() assert layer.data == data_multiscale assert layer.multiscale is True assert layer.editable is False assert layer.ndim == len(data_multiscale[0].shape) np.testing.assert_array_equal( layer.extent.data[1], np.array(data_multiscale[0].shape) - 1 ) assert layer._data_view.ndim == 2 # check corner pixels, should be tuple of highest resolution level assert layer.get_value([0, 0, 0]) == ( layer.data_level, data_multiscale[0][0, 0, 0], ) def test_3D_multiscale_labels_in_3D(): """Test instantiating Labels layer with 3D data, 3D dims.""" data_multiscale, layer = instantiate_3D_multiscale_labels() # use 3D dims layer._slice_dims(Dims(ndim=3, ndisplay=3)) assert layer._data_view.ndim == 3 # check corner pixels, should be value of lowest resolution level # [0,0,0] has value 0, which is transparent, so the ray will hit the next point # which is [1, 0, 0] and has value 4 # the position array is in original data coords (no downsampling) assert ( layer.get_value( [0, 0, 0], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) == 4 ) assert ( layer.get_value( [0, 0, 0], view_direction=[-1, 0, 0], dims_displayed=[0, 1, 2] ) == 4 ) assert ( layer.get_value( [0, 1, 1], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) == 4 ) assert ( layer.get_value( [0, 5, 5], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) == 3 ) assert ( layer.get_value( [5, 0, 5], view_direction=[0, 0, -1], dims_displayed=[0, 1, 2] ) == 5 ) def instantiate_3D_multiscale_labels(): lowest_res_scale = np.arange(8).reshape(2, 2, 2) middle_res_scale = ( lowest_res_scale.repeat(2, axis=0).repeat(2, axis=1).repeat(2, axis=2) ) highest_res_scale = ( middle_res_scale.repeat(2, axis=0).repeat(2, axis=1).repeat(2, axis=2) ) data_multiscale = [highest_res_scale, middle_res_scale, lowest_res_scale] return data_multiscale, Labels(data_multiscale, multiscale=True) napari-0.5.6/napari/layers/labels/_tests/test_labels_utils.py000066400000000000000000000106151474413133200244100ustar00rootroot00000000000000import numpy as np from napari.components.dims import Dims 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], ] ) np.testing.assert_array_equal(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) is np.dtype(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(Dims(ndim=3, 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.6/napari/layers/labels/labels.py000066400000000000000000001637331474413133200206420ustar00rootroot00000000000000import typing import warnings from collections import deque from collections.abc import Sequence from contextlib import contextmanager from typing import ( Any, Callable, ClassVar, Optional, Union, ) import numpy as np import numpy.typing as npt import pandas as pd from scipy import ndimage as ndi from skimage.draw import polygon2mask from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData from napari.layers._scalar_field.scalar_field import ScalarFieldBase 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._slice import _ImageSliceResponse from napari.layers.labels._labels_constants import ( IsoCategoricalGradientMode, LabelColorMode, LabelsRendering, Mode, ) from napari.layers.labels._labels_mouse_bindings import ( BrushSizeOnMouseMove, draw, pick, ) from napari.layers.labels._labels_utils import ( expand_slice, get_contours, indices_in_shape, interpolate_coordinates, sphere_indices, ) from napari.layers.utils.layer_utils import _FeatureTable from napari.utils._dtype import normalize_dtype, vispy_texture_dtype from napari.utils._indexing import elements_in_slice, index_in_slice from napari.utils.colormaps import ( direct_colormap, label_colormap, ) from napari.utils.colormaps.colormap import ( CyclicLabelColormap, LabelColormapBase, _normalize_label_colormap, ) from napari.utils.colormaps.colormap_utils import shuffle_and_extend_colormap from napari.utils.events import EmitterGroup, Event from napari.utils.events.custom_types import Array from napari.utils.misc import StringEnum, _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 __all__ = ('Labels',) class Labels(ScalarFieldBase): """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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. colormap : CyclicLabelColormap or DirectLabelColormap or None Colormap to use for the labels. If None, a random colormap will be used. depiction : str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. 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. 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. iso_gradient_mode : str Method for calulating the gradient (used to get the surface normal) in the 'iso_categorical' rendering mode. Must be one of {'fast', 'smooth'}. 'fast' uses a simple finite difference gradient in x, y, and z. 'smooth' uses an isotropic Sobel gradient, which is smoother but more computationally expensive. The default value is 'fast'. metadata : dict Layer metadata. 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. name : str Name of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. 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'}. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimensions 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. 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'. 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 : tuple of float Scale factors for the layer. 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 : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. 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. axis_labels : tuple of str Dimension names of the layer data. 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. DEPRECATED: set ``colormap`` directly, using `napari.utils.colormaps.label_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. DEPRECATED: set ``colormap`` directly, using `napari.utils.colormaps.DirectLabelColormap`. seed : float Seed for colormap random generator. DEPRECATED: set ``colormap`` directly, using `napari.utils.colormaps.label_colormap`. 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. Must be >= 0. brush_size : float Size of the paint brush in data coordinates. iso_gradient_mode : str Method for calulating the gradient (used to get the surface normal) in the 'iso_categorical' rendering mode. Must be one of {'fast', 'smooth'}. 'fast' uses a simple finite difference gradient in x, y, and z. 'smooth' uses an isotropic Sobel gradient, which is smoother but more computationally expensive. 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. units: tuple of pint.Unit Units of the layer data in world coordinates. 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. """ events: EmitterGroup _colormap: LabelColormapBase _modeclass = Mode _drag_modes: ClassVar[dict[Mode, Callable[['Labels', Event], None]]] = { # type: ignore[assignment] Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.PICK: pick, Mode.PAINT: draw, Mode.FILL: draw, Mode.ERASE: draw, Mode.POLYGON: no_op, # the overlay handles mouse events in this mode } brush_size_on_mouse_move = BrushSizeOnMouseMove(min_brush_size=1) _move_modes: ClassVar[ dict[StringEnum, Callable[['Labels', Event], None]] ] = { # type: ignore[assignment] Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.PICK: no_op, Mode.PAINT: brush_size_on_mouse_move, Mode.FILL: no_op, Mode.ERASE: brush_size_on_mouse_move, Mode.POLYGON: no_op, # the overlay handles mouse events in this mode } _cursor_modes: ClassVar[dict[Mode, str]] = { # type: ignore[assignment] Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.PICK: 'cross', Mode.PAINT: 'circle', Mode.FILL: 'cross', Mode.ERASE: 'circle', Mode.POLYGON: 'cross', } _history_limit = 100 def __init__( self, data, *, affine=None, axis_labels=None, blending='translucent', cache=True, colormap=None, depiction='volume', experimental_clipping_planes=None, features=None, iso_gradient_mode=IsoCategoricalGradientMode.FAST.value, metadata=None, multiscale=None, name=None, opacity=0.7, plane=None, projection_mode='none', properties=None, rendering='iso_categorical', rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, ) -> None: if name is None and data is not None: name = magic_name(data) self._seed = 0.5 # We use 50 colors (49 + transparency) by default for historical # consistency. This may change in future versions. self._random_colormap = label_colormap( 49, self._seed, background_value=0 ) self._original_random_colormap = self._random_colormap self._direct_colormap = direct_colormap( {0: 'transparent', None: 'black'} ) self._colormap = self._random_colormap self._color_mode = LabelColorMode.AUTO self._show_selected_label = False self._contour = 0 data = self._ensure_int_labels(data) super().__init__( data, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, depiction=depiction, experimental_clipping_planes=experimental_clipping_planes, rendering=rendering, metadata=metadata, multiscale=multiscale, name=name, scale=scale, shear=shear, plane=plane, opacity=opacity, projection_mode=projection_mode, rotate=rotate, translate=translate, units=units, visible=visible, ) self.events.add( brush_shape=Event, brush_size=Event, colormap=Event, contiguous=Event, contour=Event, features=Event, iso_gradient_mode=Event, labels_update=Event, n_edit_dimensions=Event, paint=Event, preserve_labels=Event, properties=Event, selected_label=Event, show_selected_label=Event, ) from napari.components.overlays.labels_polygon import ( LabelsPolygonOverlay, ) self._overlays.update({'polygon': LabelsPolygonOverlay()}) 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._iso_gradient_mode = IsoCategoricalGradientMode(iso_gradient_mode) self._selected_label = 1 self.colormap.selection = self._selected_label self.colormap.use_selection = self._show_selected_label self._prev_selected_label = None self._selected_color = self.get_color(self._selected_label) self._updated_slice = None if colormap is not None: self._set_colormap(colormap) self._status = self.mode self._preserve_labels = False def _post_init(self): 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 iso_gradient_mode(self) -> str: """Return current gradient mode for isosurface rendering. Selects the finite-difference gradient method for the isosurface shader. Options include: * ``fast``: use a simple finite difference gradient along each axis * ``smooth``: use an isotropic Sobel gradient, smoother but more computationally expensive Returns ------- str The current gradient mode """ return str(self._iso_gradient_mode) @iso_gradient_mode.setter def iso_gradient_mode(self, value: Union[IsoCategoricalGradientMode, str]): self._iso_gradient_mode = IsoCategoricalGradientMode(value) self.events.iso_gradient_mode() @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: """int: displays contours of labels instead of shaded regions.""" return self._contour @contour.setter def contour(self, contour: int) -> None: if contour < 0: raise ValueError('contour value must be >= 0') self._contour = int(contour) self.events.contour() self.refresh(extent=False) @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) def new_colormap(self, seed: Optional[int] = None): if seed is None: seed = np.random.default_rng().integers(2**32 - 1) orig = self._original_random_colormap self.colormap = shuffle_and_extend_colormap( self._original_random_colormap, seed ) self._original_random_colormap = orig @property def colormap(self) -> LabelColormapBase: return self._colormap @colormap.setter def colormap(self, colormap: LabelColormapBase): self._set_colormap(colormap) def _set_colormap(self, colormap): colormap = _normalize_label_colormap(colormap) if isinstance(colormap, CyclicLabelColormap): self._random_colormap = colormap self._original_random_colormap = colormap self._colormap = self._random_colormap color_mode = LabelColorMode.AUTO else: self._direct_colormap = colormap # `self._direct_colormap.color_dict` may contain just the default None and background label # colors, in which case we need to be in AUTO color mode. Otherwise, # `self._direct_colormap.color_dict` 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(self._direct_colormap.color_dict): color_mode = LabelColorMode.AUTO self._colormap = self._random_colormap else: color_mode = LabelColorMode.DIRECT self._colormap = self._direct_colormap self._cached_labels = None # invalidate the cached color mapping self._selected_color = self.get_color(self.selected_label) self._color_mode = color_mode self.events.colormap() # Will update the LabelVispyColormap shader self.events.selected_label() self.refresh(extent=False) @property def data(self) -> Union[LayerDataProtocol, MultiScaleData]: """array: Image data.""" return self._data @data.setter def data(self, data: Union[LayerDataProtocol, MultiScaleData]): data = self._ensure_int_labels(data) self._data = data self._ndim = len(self._data.shape) 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 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.colormap.background_value`. Parameters ---------- color : Dict Dictionary of label value to color array Returns ------- bool True if color contains only default colors, otherwise False. """ return ( {None, self.colormap.background_value} == set(color.keys()) and np.allclose(color[None], [0, 0, 0, 1]) and np.allclose( color[self.colormap.background_value], [0, 0, 0, 0] ) ) 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.view(np.uint8)) else: int_data.append(data_level) data = int_data if not looks_multiscale: data = data[0] return data def _get_state(self) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() state.update( { 'multiscale': self.multiscale, 'properties': self.properties, 'rendering': self.rendering, 'iso_gradient_mode': self.iso_gradient_mode, 'depiction': self.depiction, 'plane': self.plane.dict(), 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], 'data': self.data, 'features': self.features, 'colormap': self.colormap, } ) 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._prev_selected_label = self.selected_label self.colormap.selection = selected_label self._selected_label = selected_label self._selected_color = self.get_color(selected_label) self.events.selected_label() if self.show_selected_label: self.refresh(extent=False) def swap_selected_and_background_labels(self): """Swap between the selected label and the background label.""" if self.selected_label != self.colormap.background_value: self.selected_label = self.colormap.background_value else: self.selected_label = self._prev_selected_label @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, show_selected): self._show_selected_label = show_selected self.colormap.use_selection = show_selected self.colormap.selection = self.selected_label self.events.show_selected_label(show_selected_label=show_selected) self.refresh(extent=False) # Only overriding to change the docstring @property 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 Layer.mode.fget(self) # Only overriding to change the docstring of the setter above @mode.setter def mode(self, mode): Layer.mode.fset(self, mode) def _mode_setter_helper(self, mode): mode = super()._mode_setter_helper(mode) if mode == self._mode: return mode self._overlays['polygon'].enabled = mode == Mode.POLYGON 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) 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() @staticmethod def _to_vispy_texture_dtype(data): """Convert data to a dtype that can be used as a VisPy texture. Labels layers allow all integer dtypes for data, but only a subset are supported by VisPy textures. For now, we convert all data to float32 as it can represent all input values (though not losslessly, see https://github.com/napari/napari/issues/6084). """ return vispy_texture_dtype(data) def _update_slice_response(self, response: _ImageSliceResponse) -> None: """Override to convert raw slice data to displayed label colors.""" response = response.to_displayed(self._raw_to_displayed) super()._update_slice_response(response) def _partial_labels_refresh(self): """Prepares and displays only an updated part of the labels.""" if self._updated_slice is None or not self.loaded: return dims_displayed = self._slice_input.displayed raw_displayed = self._slice.image.raw # Keep only the dimensions that correspond to the current view updated_slice = tuple( self._updated_slice[index] for index in dims_displayed ) offset = [axis_slice.start for axis_slice in updated_slice] if self.contour > 0: colors_sliced = self._raw_to_displayed( raw_displayed, data_slice=updated_slice ) else: colors_sliced = self._slice.image.view[updated_slice] # The next line is needed to make the following tests pass in # napari/_vispy/_tests/: # - test_vispy_labels_layer.py::test_labels_painting # - test_vispy_labels_layer.py::test_labels_fill_slice # See https://github.com/napari/napari/pull/6112/files#r1291613760 # and https://github.com/napari/napari/issues/6185 self._slice.image.view[updated_slice] = colors_sliced self.events.labels_update(data=colors_sliced, offset=offset) self._updated_slice = None def _calculate_contour( self, labels: np.ndarray, data_slice: tuple[slice, ...] ) -> Optional[np.ndarray]: """Calculate the contour of a given label array within the specified data slice. Parameters ---------- labels : np.ndarray The label array. data_slice : Tuple[slice, ...] The slice of the label array on which to calculate the contour. Returns ------- Optional[np.ndarray] The calculated contour as a boolean mask array. Returns None if the contour parameter is less than 1, or if the label array has more than 2 dimensions. """ if self.contour < 1: return None if labels.ndim > 2: warnings.warn( trans._( 'Contours are not displayed during 3D rendering', deferred=True, ) ) return None expanded_slice = expand_slice(data_slice, labels.shape, 1) sliced_labels = get_contours( labels[expanded_slice], self.contour, self.colormap.background_value, ) # Remove the latest one-pixel border from the result delta_slice = tuple( slice(s1.start - s2.start, s1.stop - s2.start) for s1, s2 in zip(data_slice, expanded_slice) ) return sliced_labels[delta_slice] def _raw_to_displayed( self, raw, data_slice: Optional[tuple[slice, ...]] = None ) -> np.ndarray: """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. data_slice : numpy array slice Slice that specifies the portion of the input image that should be computed and displayed. If None, the whole input image will be processed. Returns ------- mapped_labels : array Encoded colors mapped between 0 and 1 to be displayed. """ if data_slice is None: data_slice = tuple(slice(0, size) for size in raw.shape) labels = raw # for readability sliced_labels = self._calculate_contour(labels, data_slice) # lookup function -> self._as_type if sliced_labels is None: sliced_labels = labels[data_slice] return self.colormap._data_to_texture(sliced_labels) def _update_thumbnail(self): """Update the thumbnail with current data and colormap. This is overridden from _ImageBase because we don't need to do things like adjusting gamma or changing the data based on the contrast limits. """ 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.raw if self._slice_input.ndisplay == 3 and self.ndim > 2: # we are only using the current slice so `image` will never be # bigger than 3. If we are in this clause, it is exactly 3, so we # use max projection. For labels, ideally we would use "first # nonzero projection", but we leave that for a future PR. (TODO) image = np.max(image, axis=0) imshape = np.array(image.shape[:2]) thumbshape = np.array(self._thumbnail_shape[:2]) raw_zoom_factor = np.min(thumbshape / imshape) new_shape = np.clip( raw_zoom_factor * imshape, a_min=1, a_max=thumbshape ) zoom_factor = tuple(new_shape / imshape) downsampled = ndi.zoom(image, zoom_factor, prefilter=False, order=0) color_array = self.colormap.map(downsampled) color_array[..., 3] *= self.opacity self.thumbnail = color_array def get_color(self, label): """Return the color corresponding to a specific label.""" if label == self.colormap.background_value: col = None elif label is None or ( self.show_selected_label and label != self.selected_label ): col = self.colormap.map(self.colormap.background_value) else: col = self.colormap.map(label) return col 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.colormap.background_value ): 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._partial_labels_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, dims_to_paint = self._get_shape_and_dims_to_paint() 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] 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 ) self._paint_indices( mask_indices, new_label, shape, dims_to_paint, slice_coord, refresh ) def paint_polygon(self, points, new_label): """Paint a polygon over existing labels with a new label. Parameters ---------- points : list of coordinates List of coordinates of the vertices of a polygon. new_label : int Value of the new label to be filled in. """ shape, dims_to_paint = self._get_shape_and_dims_to_paint() if len(dims_to_paint) != 2: raise NotImplementedError( 'Polygon painting is implemented only in 2D.' ) points = np.array(points, dtype=int) slice_coord = points[0].tolist() points2d = points[:, dims_to_paint] polygon_mask = polygon2mask(shape, points2d) mask_indices = np.argwhere(polygon_mask) self._paint_indices( mask_indices, new_label, shape, dims_to_paint, slice_coord, refresh=True, ) def _paint_indices( self, mask_indices, new_label, shape, dims_to_paint, slice_coord=None, refresh=True, ): """Paint over existing labels with a new label, using the selected mask indices, either only on the visible slice or in all n dimensions. Parameters ---------- mask_indices : numpy array of integer coordinates Mask to paint represented by an array of its coordinates. new_label : int Value of the new label to be filled in. shape : list The label data shape upon which painting is performed. dims_to_paint : list List of dimensions of the label data that are used for painting. refresh : bool Whether to refresh view slice or not. Set to False to batch paint calls. """ dims_not_painted = sorted( self._slice_input.order[: -self.n_edit_dimensions] ) # 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 = list(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.colormap.background_value: keep_coords = self.data[slice_coord] == self.selected_label else: keep_coords = ( self.data[slice_coord] == self.colormap.background_value ) slice_coord = tuple(sc[keep_coords] for sc in slice_coord) self.data_setitem(slice_coord, new_label, refresh) def _get_shape_and_dims_to_paint(self) -> tuple[list, list]: dims_to_paint = sorted(self._get_dims_to_paint()) shape = list(self.data.shape) if self.n_edit_dimensions < self.ndim: shape = [shape[i] for i in dims_to_paint] return shape, dims_to_paint def _get_dims_to_paint(self) -> list: return list(self._slice_input.order[-self.n_edit_dimensions :]) def _get_pt_not_disp(self) -> dict[int, int]: """ Get indices of current visible slice. """ slice_input = self._slice.slice_input point = np.round( self.world_to_data(slice_input.world_slice.point) ).astype(int) return {dim: point[dim] for dim in slice_input.not_displayed} def data_setitem(self, indices, value, refresh=True): """Set `indices` in `data` to `value`, while writing to edit history. Parameters ---------- indices : tuple of arrays of int Indices in data to overwrite. Must be a tuple of arrays of length equal to the number of data dimensions. (Fancy indexing in [2]_). 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 ---------- .. [2] https://numpy.org/doc/stable/user/basics.indexing.html """ changed_indices = self.data[indices] != value indices = tuple(x[changed_indices] for x in indices) if isinstance(value, Sequence): value = np.asarray(value, dtype=self._slice.image.raw.dtype) else: value = self._slice.image.raw.dtype.type(value) # Resize value array to remove unchanged elements if isinstance(value, np.ndarray): value = value[changed_indices] if not indices or indices[0].size == 0: return self._save_history( ( indices, np.array(self.data[indices], copy=True), value, ) ) # update the labels image self.data[indices] = value pt_not_disp = self._get_pt_not_disp() displayed_indices = index_in_slice( indices, pt_not_disp, self._slice.slice_input.order ) if isinstance(value, np.ndarray): visible_values = value[elements_in_slice(indices, pt_not_disp)] else: visible_values = value if not ( # if not a numpy array or numpy-backed xarray isinstance(self.data, np.ndarray) or isinstance(getattr(self.data, 'data', None), np.ndarray) ): # In the absence of slicing, the current slice becomes # invalidated by data_setitem; only in the special case of a NumPy # array, or a NumPy-array-backed Xarray, is the slice a view and # therefore updated automatically. # For other types, we update it manually here. self._slice.image.raw[displayed_indices] = visible_values # tensorstore and xarray do not return their indices in # np.ndarray format, so they need to be converted explicitly if not isinstance(self.data, np.ndarray): indices = [np.array(x).flatten() for x in indices] updated_slice = tuple( [ slice(min(axis_indices), max(axis_indices) + 1) for axis_indices in indices ] ) if self.contour > 0: # Expand the slice by 1 pixel as the changes can go beyond # the original slice because of the morphological dilation # (1 pixel because get_contours always applies 1 pixel dilation) updated_slice = expand_slice(updated_slice, self.data.shape, 1) else: # update data view self._slice.image.view[displayed_indices] = ( self.colormap._data_to_texture(visible_values) ) if self._updated_slice is None: self._updated_slice = updated_slice else: self._updated_slice = tuple( [ slice(min(s1.start, s2.start), max(s1.stop, s2.stop)) for s1, s2 in zip(updated_slice, self._updated_slice) ] ) if refresh is True: self._partial_labels_refresh() def _calculate_value_from_ray(self, values): non_bg = values != self.colormap.background_value if not np.any(non_bg): return None return values[np.argmax(np.ravel(non_bg))] def get_status( self, position: Optional[npt.ArrayLike] = None, *, view_direction: Optional[npt.ArrayLike] = 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() pos = position if pos is not None: pos = np.asarray(pos)[-self.ndim :] source_info['coordinates'] = generate_layer_coords_status(pos, value) # if this labels layer has properties properties = self._get_properties( position, view_direction=np.asarray(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: int = typing.cast( int, 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])) ] 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 except ModuleNotFoundError: pass else: return tuple(xr.DataArray(i) for i in indices) return tuple(indices) napari-0.5.6/napari/layers/points/000077500000000000000000000000001474413133200170635ustar00rootroot00000000000000napari-0.5.6/napari/layers/points/__init__.py000066400000000000000000000005331474413133200211750ustar00rootroot00000000000000from 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.6/napari/layers/points/_points_constants.py000066400000000000000000000062731474413133200232140ustar00rootroot00000000000000from collections import OrderedDict from enum import auto from typing import Union 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 = { '>': Symbol.ARROW, '+': Symbol.CROSS, 'o': Symbol.DISC, '-': Symbol.HBAR, 's': Symbol.SQUARE, '*': Symbol.STAR, '->': Symbol.TAILED_ARROW, 'v': Symbol.TRIANGLE_DOWN, '^': Symbol.TRIANGLE_UP, '|': 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()} SYMBOL_DICT: dict[Union[str, Symbol], Symbol] = {x: x for x in Symbol} SYMBOL_DICT.update({str(x): x for x in Symbol}) SYMBOL_DICT.update(SYMBOL_TRANSLATION_INVERTED) # type: ignore[arg-type] SYMBOL_DICT.update(SYMBOL_ALIAS) # type: ignore[arg-type] 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, } class PointsProjectionMode(StringEnum): """ Projection mode for aggregating a thick nD slice onto displayed dimensions. * NONE: ignore slice thickness, only using the dims point * ALL: project all points in the slice onto displayed dimensions """ NONE = auto() ALL = auto() napari-0.5.6/napari/layers/points/_points_key_bindings.py000066400000000000000000000101041474413133200236310ustar00rootroot00000000000000from __future__ import annotations from typing import Callable 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 ) -> Callable[[Callable], Callable]: return register_layer_action(Points, description, repeatable) def register_points_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Points, description, 'mode') @register_points_mode_action(trans._('Transform')) def activate_points_transform_mode(layer: Points) -> None: layer.mode = Mode.TRANSFORM @register_points_mode_action(trans._('Pan/zoom')) def activate_points_pan_zoom_mode(layer: Points) -> None: layer.mode = Mode.PAN_ZOOM @register_points_mode_action(trans._('Add points')) def activate_points_add_mode(layer: Points) -> None: layer.mode = Mode.ADD @register_points_mode_action(trans._('Select points')) def activate_points_select_mode(layer: Points) -> None: 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, overwrite=True) def copy(layer: Points) -> None: """Copy any selected points.""" layer._copy_data() @Points.bind_key(KeyMod.CtrlCmd | KeyCode.KeyV, overwrite=True) def paste(layer: Points) -> None: """Paste any copied points.""" layer._paste_data() @register_points_action( trans._('Select/Deselect all points in the current view slice'), ) def select_all_in_slice(layer: Points) -> None: 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, ) ) @register_points_action( trans._('Select/Deselect all points in the layer'), ) def select_all_data(layer: Points) -> None: # 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, ) ) @register_points_action(trans._('Delete selected points')) def delete_selected_points(layer: Points) -> None: """Delete all selected points.""" layer.remove_selected() napari-0.5.6/napari/layers/points/_points_mouse_bindings.py000066400000000000000000000202201474413133200241710ustar00rootroot00000000000000from typing import TypeVar import numpy as np from napari.layers.base import ActionType 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: # only emit just before moving if not is_moving: layer.events.data( value=layer.data, action=ActionType.CHANGING, data_indices=tuple( layer.selected_data, ), vertex_indices=((),), ) 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(layer.selected_data, 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.""" start_pos = event.pos dist = 0 yield while event.type == 'mouse_move': dist = np.linalg.norm(start_pos - event.pos) if dist < DRAG_DIST_THRESHOLD: # prevent vispy from moving the canvas if we're below threshold event.handled = True yield # in some weird cases you might have press and release without move, # so we just make 100% sure dist is correct 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() _T = TypeVar('_T') def _toggle_selected(selection: set[_T], value: _T) -> set[_T]: """Add or remove value from the selection set. This function returns a copy of the existing selection. Parameters ---------- selection : set Set of selected data points to be modified. value : int Index of point to add or remove from selected data set. Returns ------- selection: set Updated selection. """ selection = set(selection) if value in selection: selection.remove(value) else: selection.add(value) return selection 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.6/napari/layers/points/_points_utils.py000066400000000000000000000227171474413133200223410ustar00rootroot00000000000000from collections.abc import Sequence from typing import Optional, Union import numpy as np import numpy.typing as npt from napari.layers.points._points_constants import ( SYMBOL_ALIAS, SYMBOL_DICT, 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: npt.NDArray) -> npt.NDArray: """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: npt.NDArray, sizes: npt.NDArray) -> npt.NDArray: """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 the bounding 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 symbol_conversion(symbol: Union[str, Symbol]) -> Symbol: """ Convert a string or Symbol to a Symbol instance. """ if isinstance(symbol, str): symbol = SYMBOL_ALIAS.get(symbol, symbol) return Symbol(symbol) def fast_dict_get(symbols: Union[np.ndarray, list], d: dict) -> np.ndarray: """ Get the values from a dictionary using a list of keys. """ # 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. return np.vectorize(d.__getitem__, otypes=[object])(symbols) def coerce_symbols( symbol: Union[str, Symbol, Sequence[Union[str, Symbol]]], ) -> np.ndarray: """ Parse an array of symbols and convert it to the correct strings. If single value is given, it is converted to single element array. Ensures that all strings are valid symbols and convert aliases. Parameters ---------- symbol : str or Symbol or Sequence of str or Symbol data to be convert to array of Symbols. Returns ------- symbols : np.ndarray array of Symbols """ # if a symbol is a unique string or Symbol instance, convert it to a # proper Symbol instance if isinstance(symbol, (str, Symbol)): return np.array([symbol_conversion(symbol)], dtype=object) if not isinstance(symbol, np.ndarray): symbol = np.array(symbol) return fast_dict_get(symbol, SYMBOL_DICT) napari-0.5.6/napari/layers/points/_slice.py000066400000000000000000000115551474413133200207020ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Any import numpy as np import numpy.typing as npt from napari.layers.base._slice import _next_request_id from napari.layers.points._points_constants import PointsProjectionMode from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice @dataclass(frozen=True) class _PointSliceResponse: """Contains all the output data of slicing an points 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. slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. """ indices: np.ndarray = field(repr=False) scale: Any = field(repr=False) slice_input: _SliceInput request_id: int @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. data_slice : _ThickNDSlice The slicing coordinates and margins in data space. size : array like Size of each point. This is used in calculating visibility. others See the corresponding attributes in `Layer` and `Points`. """ slice_input: _SliceInput data: Any = field(repr=False) data_slice: _ThickNDSlice = field(repr=False) projection_mode: PointsProjectionMode size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) def __call__(self) -> _PointSliceResponse: # Return early if no data if len(self.data) == 0: return _PointSliceResponse( indices=np.array([], dtype=int), scale=np.empty(0), slice_input=self.slice_input, request_id=self.id, ) not_disp = list(self.slice_input.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, slice_input=self.slice_input, request_id=self.id, ) slice_indices, scale = self._get_slice_data(not_disp) return _PointSliceResponse( indices=slice_indices, scale=scale, slice_input=self.slice_input, request_id=self.id, ) def _get_slice_data(self, not_disp: list[int]) -> tuple[npt.NDArray, int]: data = self.data[:, not_disp] scale = 1 point, m_left, m_right = self.data_slice[not_disp].as_array() if self.projection_mode == 'none': low = point.copy() high = point.copy() else: low = point - m_left high = point + m_right # assume slice thickness of 1 in data pixels # (same as before thick slices were implemented) too_thin_slice = np.isclose(high, low) low[too_thin_slice] -= 0.5 high[too_thin_slice] += 0.5 inside_slice = np.all((data >= low) & (data <= high), axis=1) slice_indices = np.where(inside_slice)[0].astype(int) if self.out_of_slice_display and self.slice_input.ndim > 2: sizes = self.size[:, np.newaxis] / 2 # add out of slice points with progressively lower sizes dist_from_low = np.abs(data - low) dist_from_high = np.abs(data - high) distances = np.minimum(dist_from_low, dist_from_high) # anything inside the slice is at distance 0 distances[inside_slice] = 0 # display points that "spill" into the slice matches = np.all(distances <= sizes, axis=1) if not np.any(matches): return np.empty(0, dtype=int), 1 size_match = sizes[matches] # rescale size of spilling points based on how much they do scale_per_dim = (size_match - distances[matches]) / size_match scale = np.prod(scale_per_dim, axis=1) slice_indices = np.where(matches)[0].astype(int) return slice_indices, scale napari-0.5.6/napari/layers/points/_tests/000077500000000000000000000000001474413133200203645ustar00rootroot00000000000000napari-0.5.6/napari/layers/points/_tests/test_points.py000066400000000000000000002471251474413133200233240ustar00rootroot00000000000000from copy import copy from itertools import cycle, islice from unittest.mock import Mock import numpy as np import pandas as pd import pytest from psygnal.containers import Selection from vispy.color import get_colormap from napari._pydantic_compat import ValidationError from napari._tests.utils import ( assert_colors_equal, assert_layer_state_equal, check_layer_world_data_extent, ) from napari.components.dims import Dims from napari.layers import Points from napari.layers.base._base_constants import ActionType from napari.layers.points._points_constants import Mode from napari.layers.points._points_utils import points_to_squares from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice 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._test_utils import ( validate_all_params_in_docstring, validate_docstring_parent_class_consistency, validate_kwargs_sorted, ) 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) assert pts.ndim == 2 def test_3d_empty_points(): pts = Points(np.empty((0, 3))) assert pts.ndim == 3 def test_empty_points_with_features(): """See the following for the issues this covers: https://github.com/napari/napari/issues/5632 https://github.com/napari/napari/issues/5634 """ points = Points( features={'a': np.empty(0, int)}, feature_defaults={'a': 0}, face_color='a', face_color_cycle=list('rgb'), ) points.add([0, 0]) points.feature_defaults['a'] = 1 points.add([50, 50]) points.feature_defaults = {'a': 2} points.add([100, 100]) assert_colors_equal(points.face_color, list('rgb')) 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_border_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, border_color='point_type', border_colormap='gray', ) assert layer.border_color_mode == 'colormap' # verify the current_face_color is correct border_color = np.array([1, 1, 1, 1]) np.testing.assert_allclose(layer._border.current_color, border_color) @pytest.mark.parametrize('feature_name', ['border', 'face']) def test_set_current_properties_on_empty_layer_with_color_cycle(feature_name): """Test setting current_properties an empty layer where the face/border 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.array_equal(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=shape) layer = Points(data) assert np.array_equal(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.array_equal(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.array_equal(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.array_equal(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.array_equal(layer.extent.data, np.zeros((2, 3))) assert np.array_equal(layer.extent.world, np.zeros((2, 3))) assert np.array_equal(layer.extent.step, np.ones(3)) 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.array_equal(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.array_equal(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(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(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.array_equal(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.array_equal(layer.data[11:, :], coords) assert layer.selected_data == {11, 12} # test that the last added points can be deleted layer.remove_selected() np.testing.assert_equal(layer.data, np.vstack((data, coord))) def test_points_selection_with_setter(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) coords = [[10, 10], [15, 15]] layer.data = np.append(layer.data, np.atleast_2d(coords), axis=0) assert len(layer.data) == 12 assert layer.selected_data == set() 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.array_equal(layer.data[0], coord) assert layer.selected_data == {0} 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, *range(4, 10)] assert np.array_equal(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) old_data = layer.data layer.events.data = Mock() # set the value layer._value = 3 layer._value_stored = 3 selection = {0, 5, 6, 7} layer.selected_data = selection layer.remove_selected() assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.REMOVING, 'data_indices': tuple(selection), 'vertex_indices': ((),), } assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.REMOVED, 'data_indices': tuple(selection), 'vertex_indices': ((),), } 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, border_width=size, symbol=symbol, features={'feature': feature}, face_color=color, border_color=color, text=text, shown=shown, ) layer_expected = Points( data[1:], size=size[1:], symbol=symbol[1:], border_width=size[1:], features={'feature': feature[1:]}, feature_defaults={'feature': feature[0]}, face_color=color[1:], border_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) layer.events.data = Mock() # 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.array_equal(layer.data[0], unmoved[0] + [10, 10]) assert np.array_equal(layer.data[1:], unmoved[1:]) assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': (0,), 'vertex_indices': ((),), } # 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 layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': (1, 2), 'vertex_indices': ((),), } assert np.array_equal(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.mouse_pan is True layer.mode = 'add' assert layer.mode == 'add' layer.mode = 'select' assert layer.mode == 'select' assert layer.mouse_pan is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.mouse_pan is True with pytest.raises(ValueError, match='not a valid Mode'): 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.array_equiv(layer.symbol, 'disc') layer.symbol = 'cross' assert np.array_equiv(layer.symbol, 'cross') symbol = ['o', 's'] * 5 expected = ['disc', 'square'] * 5 layer.symbol = symbol assert np.array_equal(layer.symbol, expected) with pytest.raises( ValueError, match='Symbol array must be the same length as data' ): layer.symbol = symbol[1:5] layer = Points(data, symbol='star') assert np.array_equiv(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.array_equal(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.array_equal(layer.properties['point_type'], add_annotations) # test copy/paste layer.selected_data = {0, 1} layer._copy_data() assert np.array_equal( layer._clipboard['features']['point_type'], ['A', 'B'] ) layer._paste_data() paste_annotations = np.concatenate((add_annotations, ['A', 'B']), axis=0) assert np.array_equal(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', ['border', '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) annotations = {'point_type': np.array(['A', 'B'])} # try adding properties with the wrong number of properties with pytest.raises( ValueError, match='(does not match length)|(indices imply)' ): Points(data, properties=copy(annotations)) def test_border_width(): """Test setting border width.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) np.testing.assert_array_equal(layer.border_width, 0.05) layer.border_width = 0.5 np.testing.assert_array_equal(layer.border_width, 0.5) # fail outside of range 0, 1 if relative is enabled (default) with pytest.raises(ValueError, match='must be between 0 and 1'): layer.border_width = 2 layer.border_width_is_relative = False layer.border_width = 2 np.testing.assert_array_equal(layer.border_width, 2) # fail if we try to come back again with pytest.raises(ValueError, match='between 0 and 1'): layer.border_width_is_relative = True # all should work on instantiation too layer = Points(data, border_width=3, border_width_is_relative=False) np.testing.assert_array_equal(layer.border_width, 3) assert layer.border_width_is_relative is False with pytest.raises(ValueError, match='must be > 0'): layer.border_width = -2 @pytest.mark.parametrize( 'border_width', [1, float(1), np.array([1, 2, 3, 4, 5]), [1, 2, 3, 4, 5]], ) def test_border_width_types(border_width): """Test border_width dtypes with valid values""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points( data, border_width=border_width, border_width_is_relative=False ) np.testing.assert_array_equal(layer.border_width, border_width) @pytest.mark.parametrize( 'border_width', [-1, float(-1), np.array([-1, 2, 3, 4, 5]), [-1, 2, 3, 4, 5]], ) def test_border_width_types_negative(border_width): """Test negative values in all border_width dtypes""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) with pytest.raises(ValueError, match='must be > 0'): Points(data, border_width=border_width, border_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', ['border', '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 border_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 border 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, border_colors shouldn't change setattr(layer, f'{attribute}_color_mode', 'direct') new_border_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(new_border_color, color) @pytest.mark.parametrize('attribute', ['border', '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, match='must be a valid Points.properties'): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize('attribute', ['border', '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), pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize('attribute', ['border', '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', ['border', 'face']) def test_add_point_direct(attribute: str): """Test adding points to layer directly""" layer = Points() old_data = layer.data assert len(getattr(layer, f'{attribute}_color')) == 0 layer.events.data = Mock() setattr(layer, f'current_{attribute}_color', 'red') coord = [18, 18] layer.add(coord) assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.ADDING, 'data_indices': (-1,), 'vertex_indices': ((),), } assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.ADDED, 'data_indices': (-1,), 'vertex_indices': ((),), } np.testing.assert_allclose( [[1, 0, 0, 1]], getattr(layer, f'{attribute}_color') ) @pytest.mark.parametrize('attribute', ['border', '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.border_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 border 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', ['border', 'face']) @pytest.mark.parametrize( 'color_cycle', [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): """Test setting border/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', ['border', 'face']) def test_color_cycle_dict(attribute): """Test setting border/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', ['border', 'face']) def test_add_color_cycle_to_empty_layer(attribute): """Test adding a point to an empty layer when border/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_border_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', ['border', '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/border_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', ['border', 'face']) def test_color_colormap(attribute): """Test setting border/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.array_equal(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.array_equal(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 == (10,) 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,) 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,) 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,) assert np.unique(layer.size[:11])[0] == 10 assert np.array_equal(layer.size[11], 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,) 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 @pytest.mark.parametrize('ndim', [2, 3]) def test_size_with_arrays(ndim): """Test setting size with arrays.""" shape = (10, ndim) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) sizes = 5 * np.random.random(10) layer.size = sizes assert np.array_equal(layer.size, sizes) # Un-broadcastable array should raise an exception sizes = [5, 5] with pytest.raises(ValueError, match='not compatible for broadcasting'): layer.size = sizes # Create new layer with new size array data sizes = 5 * np.random.random(10) layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == (10,) np.testing.assert_array_equal(layer.size, sizes) # Add new point, should have new size coord = [18] * ndim layer.current_size = 13 layer.add(coord) assert layer.size.shape == (11,) np.testing.assert_array_equal(layer.size[:10], sizes[:10]) assert layer.size[10] == 13 # Select data and change size layer.selected_data = {0, 1} # current_size does not change because idx 0 and 1 are different sizes assert layer.current_size == 13 layer.current_size = 16 assert layer.size.shape == (11,) np.testing.assert_array_equal(layer.size[2:10], sizes[2:10]) np.testing.assert_array_equal(layer.size[:2], 16) # check that current size is correctly set if all points are the same size layer.selected_data = {10} assert layer.current_size == 13 layer.selected_data = {0, 1} assert layer.current_size == 16 # Check removing data adjusts sizes correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == 9 assert len(layer.size) == 9 assert layer.size[0] == 16 assert layer.size[1] == sizes[3] 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.array_equal(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.array_equal(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(Dims(ndim=4, 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 isinstance(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) layer._slice_input = _SliceInput( ndisplay=3, world_slice=_ThickNDSlice.make_full(ndim=2), order=(0, 1, 2), ) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) assert isinstance(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(Dims(ndim=3, 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(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_data, coords[np.ix_([0, 1], [1, 2])]) layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_data, coords[np.ix_([2], [1, 2])]) layer._slice_dims(Dims(ndim=3, point=(1, 0, 0), ndisplay=3)) assert np.array_equal(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], [4, 3, 3]]) sizes = np.array([5, 5, 3, 3]) layer = Points(coords, size=sizes, out_of_slice_display=False) layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_size, sizes[[0, 1]]) layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_size, sizes[[2]]) layer.out_of_slice_display = True # NOTE: since a dims slice of thickness 0 defaults back to 1, # out_of_slice_display actually compares the half-size with # distance + 0.5, not just distance assert len(layer._view_size) == 3 # test a slice with no points layer.out_of_slice_display = False layer._slice_dims(Dims(ndim=3, point=(2, 0, 0))) assert np.array_equal(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]] ) border_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, border_color=border_color) layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_face_color, face_color[[0, 1]]) assert np.array_equal(layer._view_border_color, border_color[[0, 1]]) layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_face_color, face_color[[2]]) assert np.array_equal(layer._view_border_color, border_color[[2]]) # view colors should return empty array if there are no points layer._slice_dims(Dims(ndim=3, point=(2, 0, 0))) assert len(layer._view_face_color) == 0 assert len(layer._view_border_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)) 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, match='dimensions must be equal to ndim'): 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, 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, match='(does not match length)|(indices imply)' ): 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_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) # Hide the last point layer.shown[-1] = False assert np.all(layer.shown[:-1]) 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]) assert layer.shown[-2] == False # noqa assert layer.shown[-1] == True # noqa 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_equiv(layer._view_size, [3, 2]) # 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, [2]) # 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), ('face_color', np.asarray([0.0, 0.0, 1.0, 1.0])), ('border_color', np.asarray([0.0, 0.0, 1.0, 1.0])), ('border_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, np.nan, np.nan), [2]), ((10, np.nan, np.nan), [0, 1, 3, 4]), ((10 + 2 * 1e-12, np.nan, np.nan), [0, 1, 3, 4]), ((10.1, np.nan, np.nan), [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) data_slice = _ThickNDSlice.make_full(point=dims_indices) request = layer._make_slice_request_internal( layer._slice_input, data_slice ) 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 def test_point_selection_remains_evented_after_update(): """Existing evented selection model should be updated rather than replaced.""" data = np.empty((3, 2)) layer = Points(data) assert isinstance(layer.selected_data, Selection) layer.selected_data = {0, 1} assert isinstance(layer.selected_data, Selection) def test_points_data_setter_emits_event(): data = np.random.random((5, 2)) emitted_events = Mock() layer = Points(data) layer.events.data.connect(emitted_events) layer.data = np.random.random((5, 2)) assert emitted_events.call_count == 2 def test_points_add_delete_only_emit_two_events(): data = np.random.random((5, 2)) emitted_events = Mock() layer = Points(data) layer.events.data.connect(emitted_events) layer.add(np.random.random(2)) assert emitted_events.call_count == 2 layer.selected_data = {3} layer.remove_selected() assert emitted_events.call_count == 4 def test_data_setter_events(): data = np.random.random((5, 2)) layer = Points(data) layer.events.data = Mock() layer.data = [] assert layer.events.data.call_args_list[0][1] == { 'value': data, 'action': ActionType.REMOVING, 'data_indices': tuple(i for i in range(len(data))), 'vertex_indices': ((),), } # Avoid truth value of empty array error assert np.array_equal( layer.events.data.call_args_list[1][1]['value'], np.empty((0, 2)) ) assert ( layer.events.data.call_args_list[1][1]['action'] == ActionType.REMOVED ) assert layer.events.data.call_args_list[1][1]['data_indices'] == () assert layer.events.data.call_args_list[1][1]['vertex_indices'] == ((),) layer.data = data assert np.array_equal( layer.events.data.call_args_list[2][1]['value'], np.empty((0, 2)) ) assert ( layer.events.data.call_args_list[2][1]['action'] == ActionType.ADDING ) assert layer.events.data.call_args_list[2][1]['data_indices'] == tuple( i for i in range(len(data)) ) assert layer.events.data.call_args_list[2][1]['vertex_indices'] == ((),) assert layer.events.data.call_args_list[3][1] == { 'value': data, 'action': ActionType.ADDED, 'data_indices': tuple(i for i in range(len(data))), 'vertex_indices': ((),), } layer.data = data assert layer.events.data.call_args_list[4][1] == { 'value': data, 'action': ActionType.CHANGING, 'data_indices': tuple(i for i in range(len(layer.data))), 'vertex_indices': ((),), } assert layer.events.data.call_args_list[5][1] == { 'value': data, 'action': ActionType.CHANGED, 'data_indices': tuple(i for i in range(len(layer.data))), 'vertex_indices': ((),), } def test_thick_slice(): data = np.array([[0, 0, 0], [10, 10, 10]]) layer = Points(data) # only first point shown layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) np.testing.assert_array_equal(layer._view_data, data[:1, -2:]) layer.projection_mode = 'all' np.testing.assert_array_equal(layer._view_data, data[:1, -2:]) # if margin is thick enough and projection is `all`, # it will take in the other point layer._slice_dims(Dims(ndim=3, point=(0, 0, 0), margin_right=(10, 0, 0))) np.testing.assert_array_equal(layer._view_data, data[:, -2:]) @pytest.mark.parametrize( ('old_name', 'new_name', 'value'), [ ('edge_width', 'border_width', 0.9), ('edge_width_is_relative', 'border_width_is_relative', False), ('current_edge_width', 'current_border_width', 0.9), ('edge_color', 'border_color', 'blue'), ('current_edge_color', 'current_border_color', 'pink'), ], ) def test_events_callback(old_name, new_name, value): data = np.array([[0, 0, 0], [10, 10, 10]]) layer = Points(data) old_name_callback = Mock() new_name_callback = Mock() with pytest.warns(FutureWarning): getattr(layer.events, old_name).connect(old_name_callback) getattr(layer.events, new_name).connect(new_name_callback) setattr(layer, new_name, value) new_name_callback.assert_called_once() old_name_callback.assert_called_once() def test_changing_symbol(): """Changing the symbol should update the UI""" layer = Points(np.random.rand(2, 2)) assert layer.symbol[1].value == 'disc' assert layer.current_symbol.value == 'disc' # select a point and change its symbol layer.selected_data = {1} layer.current_symbol = 'square' assert layer.symbol[1].value == 'square' # add a point and check that it has the new symbol layer.add([1, 1]) assert layer.symbol[2].value == 'square' assert layer.symbol[0].value == 'disc' def test_docstring(): validate_all_params_in_docstring(Points) validate_kwargs_sorted(Points) validate_docstring_parent_class_consistency(Points) @pytest.mark.parametrize( 'key', [ 'edge_width', 'edge_width_is_relative', 'edge_color', 'edge_color_cycle', 'edge_colormap', 'edge_contrast_limits', ], ) def test_as_layer_data_tuple_read_deprecated_attr(key: str): _, attrs, _ = Points().as_layer_data_tuple() with pytest.warns(FutureWarning, match='is deprecated since'): attrs[key] napari-0.5.6/napari/layers/points/_tests/test_points_key_bindings.py000066400000000000000000000073201474413133200260400ustar00rootroot00000000000000import pytest from napari.layers.points import Points, _points_key_bindings as key_bindings @pytest.mark.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' @pytest.mark.key_bindings 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 @pytest.mark.key_bindings 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 @pytest.mark.key_bindings 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 @pytest.mark.key_bindings 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 @pytest.mark.key_bindings 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.6/napari/layers/points/_tests/test_points_mouse_bindings.py000066400000000000000000000662121474413133200264050ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Optional, Union from unittest.mock import MagicMock import numpy as np import pytest from napari.components.dims import Dims from napari.layers import Points from napari.layers._tests._utils import compare_dicts from napari.layers.base import ActionType 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 = field( default_factory=lambda: 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]) def read_only_event(*args, **kwargs): return ReadOnlyWrapper(Event(*args, **kwargs), exceptions=('handled',)) @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 = [10, 11] n_points = len(data) layer = Points(data, size=1) np.testing.assert_array_equal(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(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(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 = read_only_event(type='mouse_press') mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=known_non_point) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_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 = read_only_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' # Simulate click event = read_only_event( type='mouse_press', position=known_non_point, pos=np.array([0, 0]) ) mouse_press_callbacks(layer, event) known_non_point_end = [40, 60] # Simulate drag end event = read_only_event( type='mouse_move', is_dragging=True, position=known_non_point_end, pos=np.array([4, 4]), ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event( type='mouse_press', position=position, view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event( type='mouse_press', modifiers=['Shift'], position=position ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event( type='mouse_press', modifiers=['Shift'], position=position ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=known_non_point) mouse_press_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_event(type='mouse_press', position=known_non_point) mouse_press_callbacks(layer, event) # Simulate release event = read_only_event(type='mouse_release', position=known_non_point) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 0 # check that this also works with scaled data and position near a point (see #5737) # we are taking the first point and shifting *slightly* more than the point size layer.scale = 100, 100 pos = np.array(layer.data[0]) pos[1] += layer.size[0] * 2 event = read_only_event(type='mouse_press', position=pos) mouse_press_callbacks(layer, event) event = read_only_event(type='mouse_release', position=pos) 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' # drag a box that includes all the points box_drag_begin = (20, 20) box_drag_end = (0, 0) # Simulate click event = read_only_event(type='mouse_press', position=box_drag_begin) mouse_press_callbacks(layer, event) # Simulate drag start event = read_only_event( type='mouse_move', is_dragging=True, position=box_drag_begin ) mouse_move_callbacks(layer, event) # Simulate drag end event = read_only_event( type='mouse_move', is_dragging=True, position=box_drag_end ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_event( type='mouse_release', is_dragging=True, position=box_drag_end ) 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 = read_only_event(type='mouse_press', position=known_non_point) mouse_press_callbacks(layer, event) # Simulate drag start event = read_only_event( type='mouse_move', is_dragging=True, position=known_non_point ) mouse_move_callbacks(layer, event) # Simulate drag end event = read_only_event( type='mouse_move', is_dragging=True, position=(50, 60) ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 = read_only_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 initial_position = tuple(layer.data[0]) if on_point else (20, 20) 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 = read_only_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 = read_only_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 = read_only_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 = read_only_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: pytest.fail('Unreachable code') assert layer._drag_box is None assert layer._drag_start is None def test_drag_point_with_mouse(create_known_points_layer_2d): layer, n_points, _ = create_known_points_layer_2d layer.events.data = MagicMock() layer.mode = 'select' old_data = ( layer.data.copy() ) # ensure you have old data, not updated in place layer.selected_data = {1} initial_position = tuple(layer.data[1]) new_position = [0, 0] modifier = [] event = read_only_event( type='mouse_press', position=initial_position, modifiers=modifier ) mouse_press_callbacks(layer, event) # Required to assert before the changing event as otherwise layer.data for changing is updated in place. changing_event = { 'value': old_data, 'action': ActionType.CHANGING, 'data_indices': (1,), 'vertex_indices': ((),), } def side_effect(*args, **kwargs): if kwargs['action'] == ActionType.CHANGING: assert compare_dicts(kwargs, changing_event) layer.events.data.side_effect = side_effect event = read_only_event( type='mouse_move', is_dragging=True, position=new_position, modifiers=modifier, ) mouse_move_callbacks(layer, event) event = read_only_event( type='mouse_release', is_dragging=False, modifiers=modifier ) mouse_release_callbacks(layer, event) changed_event = { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': (1,), 'vertex_indices': ((),), } assert not np.array_equal(layer.data, old_data) assert compare_dicts(layer.events.data.call_args[1], changed_event) napari-0.5.6/napari/layers/points/_tests/test_points_utils.py000066400000000000000000000017421474413133200245350ustar00rootroot00000000000000import 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.6/napari/layers/points/points.py000066400000000000000000002713401474413133200207600ustar00rootroot00000000000000import numbers import warnings from collections.abc import Sequence from copy import copy, deepcopy from itertools import cycle from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Literal, Optional, Union, ) import numpy as np import numpy.typing as npt import pandas as pd from psygnal.containers import Selection from scipy.stats import gmean from napari.layers.base import Layer, no_op from napari.layers.base._base_constants import ActionType from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.points._points_constants import ( Mode, PointsProjectionMode, 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, _ThickNDSlice 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.events.migrations import deprecation_warning_event from napari.utils.geometry import project_points_onto_plane, rotate_points from napari.utils.migrations import add_deprecated_property, rename_argument from napari.utils.status_messages import generate_layer_coords_status from napari.utils.transforms import Affine from napari.utils.translations import trans if TYPE_CHECKING: from napari.components.dims import Dims 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. 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. antialiasing: float Amount of antialiasing in canvas pixels. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. border_color : str, array-like, dict Color of the point marker border. Numeric color values should be RGB(A). border_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a categorical attribute is used color the vectors. border_colormap : str, napari.utils.Colormap Colormap to set border_color if a continuous attribute is used to set face_color. border_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()) border_width : float, array Width of the symbol border in pixels. border_width_is_relative : bool If enabled, border_width is interpreted as a fraction of the point size. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. canvas_size_limits : tuple of float Lower and upper limits for the size of points in canvas pixels. 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. 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()) feature_defaults : dict[str, Any] or DataFrame The default value of each feature in a table with one row. features : dict[str, array-like] or DataFrame Features table where each row corresponds to a point and each column is a feature. metadata : dict Layer metadata. 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. If not provided then will be guessed using heuristics. opacity : float Opacity of the layer visual, between 0.0 and 1.0. 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. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass. 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. 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 : tuple of float Scale factors for the layer. 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 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. shown : 1-D array of bool Whether to show each point. 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. 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. 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. translate : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. Attributes ---------- data : array (N, D) Coordinates for N points in D dimensions. axis_labels : tuple of str Dimension names of the layer data. 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,) Array of sizes for each point. Must have the same shape as the layer `data`. border_width : array (N,) Width of the marker borders in pixels for all points border_width : array (N,) Width of the marker borders for all points as a fraction of their size. border_color : Nx4 numpy array Array of border color RGBA values, one for each point. border_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a categorical attribute is used color the vectors. border_colormap : str, napari.utils.Colormap Colormap to set border_color if a continuous attribute is used to set face_color. border_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_border_width : float Border width of the marker for the next point to be added or the currently selected point. current_border_color : str Border color of the marker border for the next point to be added or the currently selected point. current_face_color : str Face color of the marker border 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 : Selection 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 border_color_mode : str Border 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. units: tuple of pint.Unit Units of the layer data in world coordinates. Notes ----- _view_data : array (M, D) 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_border_width : array (M, ) Border 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 _projectionclass = PointsProjectionMode _drag_modes: ClassVar[dict[Mode, Callable[['Points', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.ADD: add, Mode.SELECT: select, } _move_modes: ClassVar[dict[Mode, Callable[['Points', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.ADD: no_op, Mode.SELECT: highlight, } _cursor_modes: ClassVar[dict[Mode, str]] = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.ADD: 'crosshair', Mode.SELECT: 'standard', } # TODO write better documentation for border_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 @rename_argument( 'edge_width', 'border_width', since_version='0.5.0', version='0.6.0' ) @rename_argument( 'edge_width_is_relative', 'border_width_is_relative', since_version='0.5.0', version='0.6.0', ) @rename_argument( 'edge_color', 'border_color', since_version='0.5.0', version='0.6.0' ) @rename_argument( 'edge_color_cycle', 'border_color_cycle', since_version='0.5.0', version='0.6.0', ) @rename_argument( 'edge_colormap', 'border_colormap', since_version='0.5.0', version='0.6.0', ) @rename_argument( 'edge_contrast_limits', 'border_contrast_limits', since_version='0.5.0', version='0.6.0', ) def __init__( self, data=None, ndim=None, *, affine=None, antialiasing=1, axis_labels=None, blending='translucent', border_color='dimgray', border_color_cycle=None, border_colormap='viridis', border_contrast_limits=None, border_width=0.05, border_width_is_relative=True, cache=True, canvas_size_limits=(2, 10000), experimental_clipping_planes=None, face_color='white', face_color_cycle=None, face_colormap='viridis', face_contrast_limits=None, feature_defaults=None, features=None, metadata=None, n_dimensional=None, name=None, opacity=1.0, out_of_slice_display=False, projection_mode='none', properties=None, property_choices=None, rotate=None, scale=None, shading='none', shear=None, shown=True, size=10, symbol='o', text=None, translate=None, units=None, visible=True, ) -> None: if ndim is None: if scale is not None: ndim = len(scale) elif ( data is not None and hasattr(data, 'shape') and len(data.shape) == 2 ): ndim = data.shape[1] data, ndim = fix_data_points(data, ndim) # Indices of selected points 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 = {} super().__init__( data, ndim, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, name=name, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) self.events.add( size=Event, current_size=Event, border_width=Event, current_border_width=Event, border_width_is_relative=Event, face_color=Event, current_face_color=Event, border_color=Event, current_border_color=Event, properties=Event, current_properties=Event, symbol=Event, current_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, ) deprecated_events = {} for attr in [ '{}_width', 'current_{}_width', '{}_width_is_relative', '{}_color', 'current_{}_color', ]: old_attr = attr.format('edge') new_attr = attr.format('border') old_emitter = deprecation_warning_event( 'layer.events', old_attr, new_attr, since_version='0.5.0', version='0.6.0', ) getattr(self.events, new_attr).connect(old_emitter) deprecated_events[old_attr] = old_emitter self.events.add(**deprecated_events) # Save the point coordinates self._data = np.asarray(data) self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, properties=properties, property_choices=property_choices, num_data=len(self.data), ) self._text = TextManager._from_layer( text=text, features=self.features, ) self._border_width_is_relative = False self._shown = np.empty(0).astype(bool) # Indices of selected points self._selected_data: Selection[int] = Selection() 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_border_width = ( np.asarray(border_width) if np.isscalar(border_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._feature_table.properties() if self._data.size > 0 else self._feature_table.currents() ) self._border = ColorManager._from_layer_kwargs( n_colors=len(data), colors=border_color, continuous_colormap=border_colormap, contrast_limits=border_contrast_limits, categorical_colormap=border_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.border_width = border_width self.border_width_is_relative = border_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(extent=False) @classmethod def _add_deprecated_properties(cls) -> None: """Adds deprecated properties to class.""" deprecated_properties = [ 'edge_width', 'edge_width_is_relative', 'current_edge_width', 'edge_color', 'edge_color_cycle', 'edge_colormap', 'edge_contrast_limits', 'current_edge_color', 'edge_color_mode', ] for old_property in deprecated_properties: new_property = old_property.replace('edge', 'border') add_deprecated_property( cls, old_property, new_property, since_version='0.5.0', version='0.6.0', ) @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]) -> None: """Set the data array and emit a corresponding event.""" prior_data = len(self.data) > 0 data_not_empty = ( data is not None and (isinstance(data, np.ndarray) and data.size > 0) ) or (isinstance(data, list) and len(data) > 0) kwargs = { 'value': self.data, 'vertex_indices': ((),), 'data_indices': tuple(i for i in range(len(self.data))), } if prior_data and data_not_empty: kwargs['action'] = ActionType.CHANGING elif data_not_empty: kwargs['action'] = ActionType.ADDING kwargs['data_indices'] = tuple(i for i in range(len(data))) else: kwargs['action'] = ActionType.REMOVING self.events.data(**kwargs) self._set_data(data) kwargs['data_indices'] = tuple(i for i in range(len(self.data))) kwargs['value'] = self.data if prior_data and data_not_empty: kwargs['action'] = ActionType.CHANGED elif data_not_empty: kwargs['data_indices'] = tuple(i for i in range(len(data))) kwargs['action'] = ActionType.ADDED else: kwargs['action'] = ActionType.REMOVED self.events.data(**kwargs) def _set_data(self, data: Optional[np.ndarray]) -> None: """Set the .data array attribute, without emitting an event.""" 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(), self._border.events.blocker_all(), 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._border.colors) > len(data): self._border._remove( np.arange(len(data), len(self._border.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._border_width = self._border_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 size = np.repeat(self.current_size, adding, axis=0) if len(self._border_width) > 0: new_border_width = copy(self._border_width[-1]) else: new_border_width = self.current_border_width border_width = np.repeat([new_border_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, updating the current property value before # to handle any in-place modification of feature_defaults. # Also see: https://github.com/napari/napari/issues/5634 current_properties = self._feature_table.currents() self._border._update_current_properties(current_properties) self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) 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.border_width = np.concatenate( (self._border_width, border_width), axis=0 ) self.symbol = np.concatenate((self._symbol, symbol), axis=0) self._update_dims() self._reset_editable() def _on_selection(self, selected: bool) -> None: if selected: self._set_highlight() else: self._highlight_box = None self._highlight_index = [] self.events.highlight() @property def features(self) -> pd.DataFrame: """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._border, self._feature_table, 'border_color' ) self.text.refresh(self.features) self.events.properties() self.events.features() @property def feature_defaults(self) -> pd.DataFrame: """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 @feature_defaults.setter def feature_defaults( self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) current_properties = self.current_properties self._border._update_current_properties(current_properties) self._face._update_current_properties(current_properties) self.events.current_properties() self.events.feature_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] ) -> 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._border._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) -> None: """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.astype(float) @property def _extent_data_augmented(self) -> npt.NDArray: # _extent_data is a property that returns a new/copied array, which # is safe to modify below extent = self._extent_data if len(self.size) == 0: return extent max_point_size = np.max(self.size) extent[0] -= max_point_size / 2 extent[1] += max_point_size / 2 return extent @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(extent=False) @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: coerced_symbols = coerce_symbols(symbol) # If a single symbol has been converted, this will broadcast it to # the number of points in the data. If symbols is already an array, # this will check that it is the correct length. if coerced_symbols.size == 1: coerced_symbols = np.full( self.data.shape[0], coerced_symbols[0], dtype=object ) else: coerced_symbols = np.array(coerced_symbols) if coerced_symbols.size != self.data.shape[0]: raise ValueError( 'Symbol array must be the same length as data.' ) self._symbol = coerced_symbols 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() self.events.current_symbol() @property def size(self) -> np.ndarray: """(N,) array: size of all N points.""" return self._size @size.setter def size(self, size: Union[float, np.ndarray, list]) -> None: try: self._size = np.broadcast_to(size, len(self.data)).copy() except ValueError as e: # deprecated anisotropic sizes; extra check should be removed in future version 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 else: self._size = np.mean(size, axis=1) warnings.warn( trans._( 'Since 0.4.18 point sizes must be isotropic; the average from each dimension will be' ' used instead. This will become an error in version 0.6.0.', deferred=True, ), category=DeprecationWarning, stacklevel=2, ) # TODO: technically not needed to cleat the non-augmented extent... maybe it's fine like this to avoid complexity self.refresh(highlight=False) @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, (list, tuple, np.ndarray)): warnings.warn( trans._( 'Since 0.4.18 point sizes must be isotropic; the average from each dimension will be used instead. ' 'This will become an error in version 0.6.0.', deferred=True, ), category=DeprecationWarning, stacklevel=2, ) size = size[-1] if not isinstance(size, numbers.Number): raise TypeError( trans._( 'currrent size must be a number', deferred=True, ) ) if size < 0: raise ValueError( trans._( 'current_size value must be positive.', deferred=True, ), ) self._current_size = size if self._update_properties and len(self.selected_data) > 0: idx = np.fromiter(self.selected_data, dtype=int) self.size[idx] = size # TODO: also here technically no need to clear base extent self.refresh(highlight=False) self.events.size() self.events.current_size() @property def antialiasing(self) -> float: """Amount of antialiasing in canvas pixels.""" return self._antialiasing @antialiasing.setter def antialiasing(self, value: float) -> None: """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) -> npt.NDArray: """ 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(extent=False, highlight=False) @property def border_width(self) -> np.ndarray: """(N, D) array: border_width of all N points.""" return self._border_width @border_width.setter def border_width( self, border_width: Union[float, np.ndarray, list] ) -> None: # broadcast to np.array border_width = np.broadcast_to(border_width, self.data.shape[0]).copy() # border width cannot be negative if np.any(border_width < 0): raise ValueError( trans._( 'All border_width must be > 0', deferred=True, ) ) # if relative border width is enabled, border_width must be between 0 and 1 if self.border_width_is_relative and np.any(border_width > 1): raise ValueError( trans._( 'All border_width must be between 0 and 1 if border_width_is_relative is enabled', deferred=True, ) ) self._border_width = border_width self.events.border_width(value=border_width) self.refresh(extent=False) @property def border_width_is_relative(self) -> bool: """bool: treat border_width as a fraction of point size.""" return self._border_width_is_relative @border_width_is_relative.setter def border_width_is_relative(self, border_width_is_relative: bool) -> None: if border_width_is_relative and np.any( (self.border_width > 1) | (self.border_width < 0) ): raise ValueError( trans._( 'border_width_is_relative can only be enabled if border_width is between 0 and 1', deferred=True, ) ) self._border_width_is_relative = border_width_is_relative self.events.border_width_is_relative() @property def current_border_width(self) -> Union[int, float]: """float: border_width of marker for the next added point.""" return self._current_border_width @current_border_width.setter def current_border_width(self, border_width: Union[None, float]) -> None: self._current_border_width = border_width if self._update_properties and len(self.selected_data) > 0: idx = np.fromiter(self.selected_data, dtype=int) self.border_width[idx] = border_width self.refresh(highlight=False) self.events.border_width() self.events.current_border_width() @property def border_color(self) -> np.ndarray: """(N x 4) np.ndarray: Array of RGBA border colors for each point""" return self._border.colors @border_color.setter def border_color(self, border_color): self._border._set_color( color=border_color, n_colors=len(self.data), properties=self.properties, current_properties=self.current_properties, ) self.events.border_color() @property def border_color_cycle(self) -> np.ndarray: """Union[list, np.ndarray] : Color cycle for border_color. Can be a list of colors defined by name, RGB or RGBA """ return self._border.categorical_colormap.fallback_color.values @border_color_cycle.setter def border_color_cycle( self, border_color_cycle: Union[list, np.ndarray] ) -> None: self._border.categorical_colormap = border_color_cycle @property def border_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the border color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._border.continuous_colormap @border_colormap.setter def border_colormap(self, colormap: ValidColormapArg) -> None: self._border.continuous_colormap = colormap @property def border_contrast_limits(self) -> tuple[float, float]: """None, (float, float): contrast limits for mapping the border_color colormap property to 0 and 1 """ return self._border.contrast_limits @border_contrast_limits.setter def border_contrast_limits( self, contrast_limits: Union[None, tuple[float, float]] ) -> None: self._border.contrast_limits = contrast_limits @property def current_border_color(self) -> str: """str: border color of marker for the next added point or the selected point(s).""" hex_ = rgb_to_hex(self._border.current_color)[0] return hex_to_name.get(hex_, hex_) @current_border_color.setter def current_border_color(self, border_color: ColorType) -> None: if self._update_properties and len(self.selected_data) > 0: update_indices = list(self.selected_data) else: update_indices = [] self._border._update_current_color( border_color, update_indices=update_indices ) self.events.current_border_color() @property def border_color_mode(self) -> str: """str: border 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._border.color_mode @border_color_mode.setter def border_color_mode( self, border_color_mode: Union[str, ColorMode] ) -> None: self._set_color_mode(border_color_mode, 'border') @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] ) -> None: 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) -> None: 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]] ) -> None: 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: Literal['border', 'face'], ) -> None: """Set the face_color_mode or border_color_mode property Parameters ---------- color_mode : str, ColorMode The value for setting border or face_color_mode. If color_mode is a string, it should be one of: 'direct', 'cycle', or 'colormap' attribute : str in {'border', 'face'} The name of the attribute to set the color of. Should be 'border' for border_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) -> None: """Calculate and update face and border 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._border._refresh_colors(self.properties, update_color_mapping) self._face._refresh_colors(self.properties, update_color_mapping) def _get_state(self) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() state.update( { 'symbol': ( self.symbol if self.data.size else [self.current_symbol] ), 'border_width': self.border_width, 'border_width_is_relative': self.border_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.dict(), 'face_contrast_limits': self.face_contrast_limits, 'border_color': ( self.border_color if self.data.size else [self.current_border_color] ), 'border_color_cycle': self.border_color_cycle, 'border_colormap': self.border_colormap.dict(), 'border_contrast_limits': self.border_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, 'feature_defaults': self.feature_defaults, 'shading': self.shading, 'antialiasing': self.antialiasing, 'canvas_size_limits': self.canvas_size_limits, 'shown': self.shown, } ) return state @property def selected_data(self) -> Selection[int]: """set: set of currently selected points.""" return self._selected_data @selected_data.setter def selected_data(self, selected_data: Sequence[int]) -> None: self._selected_data.clear() self._selected_data.update(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) with self.block_update_properties(): if ( unique_border_color := _unique_element( self.border_color[index] ) ) is not None: self.current_border_color = unique_border_color if ( unique_face_color := _unique_element(self.face_color[index]) ) is not None: self.current_face_color = unique_face_color if (unique_size := _unique_element(self.size[index])) is not None: self.current_size = unique_size if ( unique_border_width := _unique_element( self.border_width[index] ) ) is not None: self.current_border_width = unique_border_width if ( unique_symbol := _unique_element(self.symbol[index]) ) is not None: 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()): self.current_properties = unique_properties self._set_highlight() def interaction_box(self, index: list[int]) -> 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.mouse_pan = 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,) np.ndarray Array of sizes for the N points in view """ if len(self._indices_view) > 0: sizes = self.size[self._indices_view] * 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_border_width(self) -> np.ndarray: """Get the border_width of the points in view Returns ------- view_border_width : (N,) np.ndarray Array of border_widths for the N points in view """ return self.border_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_border_color(self) -> np.ndarray: """Get the border colors of the points in view Returns ------- view_border_color : (N x 4) np.ndarray RGBA color array for the border colors of the N points in view. If there are no points in view, returns array of length 0. """ return self.border_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 _update_draw( self, scale_factor, corner_pixels_displayed, shape_threshold ): prev_scale = self.scale_factor super()._update_draw( scale_factor, corner_pixels_displayed, shape_threshold ) # update highlight only if scale has changed, otherwise causes a cycle self._set_highlight(force=(prev_scale != self.scale_factor)) def _get_value(self, position) -> Optional[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 ] # positions are scaled anisotropically by scale, but sizes are not, # so we need to calculate the ratio to correctly map to screen coordinates scale_ratio = ( self.scale[self._slice_input.displayed] / self.scale[-1] ) # 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. sizes = np.expand_dims(self._view_size, axis=1) / scale_ratio / 2 distances = abs(view_data - displayed_position) in_slice_matches = np.all( distances <= sizes, 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], ) -> Optional[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 : 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) # positions are scaled anisotropically by scale, but sizes are not, # so we need to calculate the ratio to correctly map to screen coordinates scale_ratio = self.scale[self._slice_input.displayed] / self.scale[-1] # find the points the click intersects sizes = np.expand_dims(self._view_size, axis=1) / scale_ratio / 2 distances = abs(rotated_points - rotated_click_point) in_slice_matches = np.all( distances <= sizes, 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 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) -> None: """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._data_slice ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims: 'Dims') -> _PointSliceRequest: """Make a Points slice request based on the given dims and these data.""" slice_input = self._make_slice_input(dims) # See Image._make_slice_request to understand why we evaluate this here # instead of using `self._data_slice`. data_slice = slice_input.data_slice(self._data_to_world.inverse) return self._make_slice_request_internal(slice_input, data_slice) def _make_slice_request_internal( self, slice_input: _SliceInput, data_slice: _ThickNDSlice ) -> _PointSliceRequest: return _PointSliceRequest( slice_input=slice_input, data=self.data, data_slice=data_slice, projection_mode=self.projection_mode, out_of_slice_display=self.out_of_slice_display, size=self.size, ) def _update_slice_response(self, response: _PointSliceResponse) -> None: """Handle a slicing response.""" self._slice_input = response.slice_input 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: bool = False) -> None: """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.array_equal(self._drag_box, self._drag_box_stored) ) and not force: return self._selected_data_stored = Selection(self.selected_data) self._value_stored = copy(self._value) self._drag_box_stored = copy(self._drag_box) if self._highlight_visible and ( 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._highlight_visible and 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[[*range(4), 0]] else: pos = None self._highlight_box = pos self.events.highlight() def _update_thumbnail(self) -> None: """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((*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. """ cur_points = len(self.data) self.events.data( value=self.data, action=ActionType.ADDING, data_indices=(-1,), vertex_indices=((),), ) self._set_data(np.append(self.data, np.atleast_2d(coords), axis=0)) self.events.data( value=self.data, action=ActionType.ADDED, data_indices=(-1,), vertex_indices=((),), ) self.selected_data = set(np.arange(cur_points, len(self.data))) def remove_selected(self) -> None: """Removes selected points if any.""" index = list(self.selected_data) index.sort() if len(index): self.events.data( value=self.data, action=ActionType.REMOVING, data_indices=tuple( self.selected_data, ), vertex_indices=((),), ) 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._border_width = np.delete(self._border_width, index, axis=0) with self._border.events.blocker_all(): self._border._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._set_data(np.delete(self.data, index, axis=0)) self.events.data( value=self.data, action=ActionType.REMOVED, data_indices=tuple( self.selected_data, ), vertex_indices=((),), ) 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, action=ActionType.CHANGED, data_indices=tuple(selection_indices), vertex_indices=((),), ) 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) -> None: """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._data_slice[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._border_width = np.append( self.border_width, deepcopy(self._clipboard['border_width']), axis=0, ) self._border._paste( colors=self._clipboard['border_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.update( set(range(totpoints, totpoints + len(self._clipboard['data']))) ) self.refresh() def _copy_data(self) -> None: """Copy selected points to clipboard.""" if len(self.selected_data) > 0: index = list(self.selected_data) self._clipboard = { 'data': deepcopy(self.data[index]), 'border_color': deepcopy(self.border_color[index]), 'face_color': deepcopy(self.face_color[index]), 'shown': deepcopy(self.shown[index]), 'size': deepcopy(self.size[index]), 'symbol': deepcopy(self.symbol[index]), 'border_width': deepcopy(self.border_width[index]), 'features': deepcopy(self.features.iloc[index]), 'indices': self._data_slice, 'text': self.text._copy(index), } else: self._clipboard = {} def to_mask( self, *, shape: tuple, data_to_world: Optional[Affine] = None, isotropic_output: bool = True, ) -> npt.NDArray: """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. radii = self.size / 2 # Scale each radius by the geometric mean scale of the Points layer to # keep the balls isotropic when visualized in world coordinates. # 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 = radii[:, np.newaxis] * 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, ) -> str: """ 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])) ] Points._add_deprecated_properties() napari-0.5.6/napari/layers/shapes/000077500000000000000000000000001474413133200170325ustar00rootroot00000000000000napari-0.5.6/napari/layers/shapes/__init__.py000066400000000000000000000005331474413133200211440ustar00rootroot00000000000000from 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.6/napari/layers/shapes/_accelerated_triangulate.py000066400000000000000000000536771474413133200244200ustar00rootroot00000000000000"""Triangulation utilities""" from __future__ import annotations import numpy as np from numba import njit @njit(cache=True, inline='always') def _dot(v0: np.ndarray, v1: np.ndarray) -> float: """Return the dot product of two 2D vectors. If the vectors have norm 1, this is the cosine of the angle between them. """ return v0[0] * v1[0] + v0[1] * v1[1] @njit(cache=True, inline='always') def _cross_z(v0: np.ndarray, v1: np.ndarray) -> float: """Return the z-magnitude of the cross-product between two vectors. If the vectors have norm 1, this is the sine of the angle between them. """ return v0[0] * v1[1] - v0[1] * v1[0] @njit(cache=True, inline='always') def _sign_abs(f: float) -> tuple[float, float]: """Return 1, -1, or 0 based on the sign of f, as well as the abs of f. The order of if-statements shows what the function is optimized for given the early returns. In this case, an array with many positive values will execute more quickly than one with many negative values. """ if f > 0: return 1.0, f if f < 0: return -1.0, -f return 0.0, 0.0 @njit(cache=True, inline='always') def _orthogonal_vector(v: np.ndarray, ccw: bool = True) -> np.ndarray: """Return an orthogonal vector to v (2D). Parameters ---------- v : np.ndarray of float, shape (2,) The input vector. ccw : bool Whether you want the orthogonal vector in the counterclockwise direction (in the napari reference frame) or clockwise. Returns ------- np.ndarray, shape (2,) A vector orthogonal to the input vector, and of the same magnitude. """ if ccw: return np.array([v[1], -v[0]]) return np.array([-v[1], v[0]]) @njit(cache=True, inline='always') def _calc_output_size( direction_vectors: np.ndarray, closed: bool, cos_miter_limit: float, bevel: bool, ) -> int: """Calculate the size of the output arrays for the triangulation. Parameters ---------- direction_vectors : np.ndarray Nx2 array of direction vectors along the path. The direction vectors have norm 1, so computing the cosine between them is just the dot product. closed : bool True if shape edge is a closed path. cos_miter_limit : float Miter limit which determines when to switch from a miter join to a bevel join bevel : bool If True, a bevel join is always used. If False, a bevel join will be used when the miter limit is exceeded. Returns ------- int number of points in the output arrays Notes ----- We use a cosine miter limit instead of maximum miter length for performance reasons. The cosine miter limit is related to the miter length by the following equation: .. math:: c = \frac{1}{2 (l/2)^2} - 1 = \frac{2}{l^2} - 1 """ n = len(direction_vectors) # for every miter join, we have two points on either side of the path # vertex; if the path is closed, we add two more points — repeats of the # start of the path point_count = 2 * n + 2 * closed if bevel: # if every join is a bevel join, the computation is trivial — we need # one more point at each path vertex, removing the first and last # vertices if the path is not closed... bevel_count = n - 2 * (not closed) # ... and we can return early return point_count + bevel_count # Otherwise, we use the norm-1 direction vectors to quickly check the # cosine of each angle in the path. If the angle is too sharp, we get a # negative cosine greater than some limit, and we add a bevel point for # that position. bevel_count = 0 # We are effectively doing a sliding window of three points along the path # of n points. If the path is closed, we start with indices (-1, 0, 1) and # end with indices (-2, -1, 0). In contrast, in an open path, we start with # (0, 1, 2) and end with (-3, -2, -1). # In the following loop, i represents the center point of the sliding # window. Therefore, and keeping in mind that the stop value of a range in # Python is exclusive, if closed we want i in the range [0, n), while if # open we want i in the range [1, n-1). This can be accomplished by: start = 1 - closed stop = n - 1 + closed for i in range(start, stop): cos_angle = _dot(direction_vectors[i - 1], direction_vectors[i]) if cos_angle < cos_miter_limit: bevel_count += 1 return point_count + bevel_count @njit(cache=True, inline='always') def _set_centers_and_offsets( centers: np.ndarray, offsets: np.ndarray, triangles: np.ndarray, vertex: np.ndarray, vec1: np.ndarray, vec2: np.ndarray, half_vec1_len: float, half_vec2_len: float, j: int, cos_limit: float, always_bevel: bool, ) -> int: """Set the centers, offsets, and triangles for a given path and position. This function computes the positions of the vertices of the edge triangles at the given path vertex and towards the next path vertex, including, if needed, the miter join triangle overlapping the path vertex. If a miter join is needed, this function will add three triangles. Otherwise, it will add two triangles (dividing parallelogram of the next edge into two triangles). The positions of the triangle vertices are given as the path vertex (repeated once for each triangle vertex), and offset vectors from that vertex. The added triangles "optimistically" index into vertices past the vertices added in this iteration (indices j+3 and j+4). Parameters ---------- centers : np.ndarray of float Mx2 output array of central coordinates of path triangles. offsets : np.ndarray of float Mx2 output array of the offsets from the central coordinates. Offsets 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 of int (M-2)x3 output array of the indices of the vertices that will form the triangles of the triangulation. vertex : np.ndarray The path vertex for which the centers, offsets and triangles are being calculated. vec1 : np.ndarray The norm-1 direction vector from the previous path vertex to the current path vertex. vec2 : np.ndarray The norm-1 direction vector from the current path vertex to the next path vertex. half_vec1_len : float Half the length of the segment between the previous path vertex and the current path vertex (used for bevel join calculation). half_vec2_len : float Half the length of the segment between the current path vertex and the next path vertex (used for bevel join calculation). j : int The current index in the ouput arrays. cos_limit : float Miter limit which determines when to switch from a miter join to a bevel join, to avoid very sharp shape angles. always_bevel : bool If True, a bevel join is always used. If False, a bevel join will only be used when the miter limit is exceeded. Returns ------- int in {2, 3} The number of triangles, centers and offsets added to the arrays """ cos_angle = _dot(vec1, vec2) sin_angle = _cross_z(vec1, vec2) bevel = always_bevel or cos_angle < cos_limit for i in range(2 + bevel): centers[j + i] = vertex if sin_angle == 0: # if the vectors are collinear, the miter join points are exactly # perpendicular to the path — we can construct this vector from vec1. miter = np.array([vec1[1], -vec1[0]], dtype=np.float32) * 0.5 else: # otherwise, we use the line intercept theorem to calculate the miter # direction as (vec1 - vec2) * 0.5 — these are the # `miter_helper_vectors` in examples/dev/triangle_edge.py — and scale. # If there is a bevel join, we have to make sure that the miter points # to the inside of the angle, *and*, we have to make sure that it does # not exceed the length of either incoming edge. # See also: # https://github.com/napari/napari/pull/7268#user-content-miter scale_factor = 1 / sin_angle if bevel: # There is a case of bevels join, and # there is a need to check if the miter length is not too long. # For performance reasons here, the miter length is estimated # by the inverse of the sin of the angle between the two vectors. # See https://github.com/napari/napari/pull/7268#user-content-bevel-cut sign, mag = _sign_abs(scale_factor) scale_factor = sign * min(mag, half_vec1_len, half_vec2_len) miter = (vec1 - vec2) * 0.5 * scale_factor if bevel: # add three vertices using offset vectors orthogonal to the path as # well as the miter vector. # the order in which the vertices and triangles are added depends on # whether the turn is clockwise or counterclockwise. clockwise = sin_angle < 0 counterclockwise = not clockwise invert = -1.0 if counterclockwise else 1.0 offsets[j + counterclockwise] = invert * miter offsets[j + clockwise] = 0.5 * _orthogonal_vector( vec1, ccw=counterclockwise ) offsets[j + 2] = 0.5 * _orthogonal_vector(vec2, ccw=counterclockwise) triangles[j] = [j, j + 1, j + 2] triangles[j + 1] = [j + counterclockwise, j + 2, j + 3] triangles[j + 2] = [j + 1 + clockwise, j + 3, j + 4] return 3 # bevel join added 3 triangles # otherwise, we just use the miter vector in either direction and add two # triangles offsets[j] = miter offsets[j + 1] = -miter triangles[j] = [j, j + 1, j + 2] triangles[j + 1] = [j + 1, j + 2, j + 3] return 2 # miter join added 2 triangles @njit(cache=True, inline='always') def _normalize_triangle_orientation( triangles: np.ndarray, centers: np.ndarray, offsets: np.ndarray ) -> None: """Ensure vertices of all triangles are listed in the same orientation. In terms of napari's preferred coordinate frame (axis 0, y, is pointing down, axis 1, x is pointing right), this orientation is positive for anti-clockwise and negative for clockwise. This function normalises all triangle data in-place to have positive orientation. The orientation is useful to check if a point is inside a triangle. Parameters ---------- triangles : np.ndarray (M-2)x3 array of the indices of the vertices that will form the triangles of the triangulation centers : np.ndarray Mx2 array central coordinates of path triangles. offsets : np.ndarray Mx2 array of the offsets to the central coordinates. Offsets need to be scaled by the line width and then added to the centers to generate the actual vertices of the triangulation """ for i in range(len(triangles)): ti = triangles[i] p1 = centers[ti[0]] + offsets[ti[0]] p2 = centers[ti[1]] + offsets[ti[1]] p3 = centers[ti[2]] + offsets[ti[2]] if _orientation(p1, p2, p3) < 0: triangles[i] = [ti[2], ti[1], ti[0]] @njit(cache=True, inline='always') def _orientation(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> float: """Compute the orientation of three points. In terms of napari's preferred coordinate frame (axis 0, y, is pointing down, axis 1, x is pointing right), this orientation is positive for anti-clockwise and negative for clockwise. Parameters ---------- p1, p2, p3: np.ndarray, shape (2,) The three points to check. Returns ------- float Positive if anti-clockwise, negative if clockwise, 0 if collinear. """ # fmt: off return ( (p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0]) ) # fmt: on @njit(cache=True, inline='always') def _direction_vec_and_half_length( path: np.ndarray, closed: bool ) -> tuple[np.ndarray, np.ndarray]: """Calculate the normal vector and its half-length. Parameters ---------- path : np.ndarray Mx2 array representing path for which to calculate the direction vectors and the length of each segment. This function assumes that there is no point repetition in the path: consecutive points should not have identical coordinates. closed : bool True if the input path is closed (forms a loop): the edge from the last node in the path back to the first one is automatically inserted if so. Returns ------- np.ndarray Mx2 array representing containing normal vectors of the path. If closed is False, the last vector has unspecified value. np.ndarray The array of limit of length of inner vectors in bevel joins. To reduce the graphical artifacts, the inner vectors are limited to half of the length of the adjacent egdes in path. """ normals = np.empty_like(path) vec_len_arr = np.empty((len(path)), dtype=np.float32) for i in range(1 - closed, len(path)): vec_diff = path[i] - path[i - 1] vec_len_arr[i - 1] = np.sqrt(vec_diff[0] ** 2 + vec_diff[1] ** 2) normals[i - 1] = vec_diff / vec_len_arr[i - 1] return normals, vec_len_arr * 0.5 @njit(cache=True, inline='always') def _generate_2D_edge_meshes_loop( path: np.ndarray, closed: bool, cos_limit: float, bevel: bool, direction_vectors: np.ndarray, bevel_limit_array: np.ndarray, centers: np.ndarray, offsets: np.ndarray, triangles: np.ndarray, ) -> None: """Main loop for :py:func:`generate_2D_edge_meshes`. Parameters ---------- path : np.ndarray Nx2 array of central coordinates of path to be triangulated closed : bool Bool which determines if the path is closed or not cos_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 direction_vectors : np.ndarray Nx2 array of normal vectors of the path bevel_limit_array : np.ndarray The array of limit of length of inner vectors in bevel joins. To reduce the graphical artifacts, the inner vectors are limited to half of the length of the adjacent edges in path. centers : np.ndarray Mx2 array to put central coordinates of path triangles. offsets : np.ndarray Mx2 array to put 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 (M-2)x3 array to put the indices of the vertices that will form the triangles of the triangulation """ j = 0 if not closed: centers[:2] = path[0] offsets[0] = 0.5 * _orthogonal_vector(direction_vectors[0], ccw=True) offsets[1] = -offsets[0] triangles[0] = [0, 1, 2] triangles[1] = [1, 2, 3] j = 2 for i in range(1 - closed, len(direction_vectors) - 1 + closed): j += _set_centers_and_offsets( centers, offsets, triangles, path[i], direction_vectors[i - 1], direction_vectors[i], bevel_limit_array[i - 1], bevel_limit_array[i], j, cos_limit, bevel, ) if closed: centers[j] = centers[0] centers[j + 1] = centers[1] offsets[j] = offsets[0] offsets[j + 1] = offsets[1] else: centers[j] = path[-1] centers[j + 1] = path[-1] offsets[j] = 0.5 * _orthogonal_vector(direction_vectors[-2]) offsets[j + 1] = -offsets[j] @njit(cache=True, inline='always') def _cut_end_if_repetition(path: np.ndarray) -> np.ndarray: """Cut the last point of the path if it is the same as the second to last point. Parameters ---------- path : np.ndarray Nx2 array of central coordinates of path to be triangulated Returns ------- np.ndarray Nx2 or (N-1)x2 array of central coordinates of path to be triangulated """ path_ = np.asarray(path, dtype=np.float32) if np.all(path_[-1] == path_[-2]): return path_[:-1] return path_ # Note: removing this decorator will double execution time. @njit(cache=True) def generate_2D_edge_meshes( path: np.ndarray, closed: bool = False, limit: float = 3.0, bevel: bool = False, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """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`. By using the `centers` and `offsets` representation, the computed triangulation can be independent of the line width. Parameters ---------- path : np.ndarray Nx2 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 array central coordinates of path triangles. offsets : np.ndarray Mx2 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 (M-2)x3 array of the indices of the vertices that will form the triangles of the triangulation """ path = _cut_end_if_repetition(path) if len(path) < 2: centers = np.empty((4, 2), dtype=np.float32) centers[0] = path[0] centers[1] = path[0] centers[2] = path[0] centers[3] = path[0] triangles = np.empty((2, 3), dtype=np.int32) triangles[0] = [0, 1, 3] triangles[1] = [1, 3, 2] return (centers, np.zeros((4, 2), dtype=np.float32), triangles) # why cos_limit is calculated this way is explained in the note in # https://github.com/napari/napari/pull/7268#user-content-bevel-limit cos_limit = 1 / (2 * (limit / 2) ** 2) - 1.0 direction_vectors, bevel_limit_array = _direction_vec_and_half_length( path, closed ) point_count = _calc_output_size( direction_vectors, closed, cos_limit, bevel ) centers = np.empty((point_count, 2), dtype=np.float32) offsets = np.empty((point_count, 2), dtype=np.float32) triangles = np.empty((point_count - 2, 3), dtype=np.int32) _generate_2D_edge_meshes_loop( path, closed, cos_limit, bevel, direction_vectors, bevel_limit_array, centers, offsets, triangles, ) # We fix triangle orientation to speed up checks for # whether points are in triangle. # Without the fix, the code is not robust to orientation. _normalize_triangle_orientation(triangles, centers, offsets) return centers, offsets, triangles @njit(cache=True) def remove_path_duplicates(path: np.ndarray, closed: bool) -> np.ndarray: """Remove consecutive duplicates from a path. Parameters ---------- path : np.ndarray Nx2 or Nx3 array of central coordinates of path to be deduplicated Returns ------- np.ndarray Nx2 or Nx3 array of central coordinates of deduplicated path """ if len(path) <= 2: # part of ugly hack to keep lasso tools working # should be removed after fixing the lasso tool return path dup_count = 0 # We would like to use len(path) - 1 as the range. # To keep the lasso tool working, we need to allow # duplication of the last point. # If the lasso tool is refactored, update to use the preferred range. for i in range(len(path) - 2): if np.all(path[i] == path[i + 1]): dup_count += 1 if closed and np.all(path[0] == path[-1]): dup_count += 1 if dup_count == 0: return path target_len = len(path) - dup_count new_path = np.empty((target_len, path.shape[1]), dtype=path.dtype) new_path[0] = path[0] index = 0 for i in range(1, len(path)): if index == target_len - 1: break if np.any(new_path[index] != path[i]): new_path[index + 1] = path[i] index += 1 return new_path @njit(cache=True) def create_box_from_bounding(bounding_box: np.ndarray) -> np.ndarray: """Creates the axis aligned interaction box of a bounding box Parameters ---------- bounding_box : np.ndarray 2x2 array of the bounding box. The first row is the minimum values and the second row is the maximum values 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 """ x_min = bounding_box[0, 0] x_max = bounding_box[1, 0] y_min = bounding_box[0, 1] y_max = bounding_box[1, 1] result = np.empty((9, 2), dtype=np.float32) result[0] = [x_min, y_min] result[1] = [(x_min + x_max) / 2, y_min] result[2] = [x_max, y_min] result[3] = [x_max, (y_min + y_max) / 2] result[4] = [x_max, y_max] result[5] = [(x_min + x_max) / 2, y_max] result[6] = [x_min, y_max] result[7] = [x_min, (y_min + y_max) / 2] result[8] = [(x_min + x_max) / 2, (y_min + y_max) / 2] return result napari-0.5.6/napari/layers/shapes/_mesh.py000066400000000000000000000061051474413133200205010ustar00rootroot00000000000000import 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: int = 2) -> None: self._ndisplay = ndisplay self.clear() def clear(self) -> None: """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: """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay: int) -> None: if self.ndisplay == ndisplay: return self._ndisplay = ndisplay self.clear() napari-0.5.6/napari/layers/shapes/_shape_list.py000066400000000000000000001441721474413133200217070ustar00rootroot00000000000000import typing from collections.abc import Generator, Iterable, Sequence from contextlib import contextmanager from functools import cached_property, wraps from typing import Literal, Union import numpy as np import numpy.typing as npt 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 def _batch_dec(meth): """ Decorator to apply `self.batched_updates` to the current method. """ @wraps(meth) def _wrapped(self, *args, **kwargs): with self.batched_updates(): return meth(self, *args, **kwargs) return _wrapped 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: typing.Iterable[Shape] = (), ndisplay: int = 2 ) -> None: self._ndisplay = ndisplay self.shapes: list[Shape] = [] self._displayed = np.array([]) self._slice_key = np.array([]) self.displayed_vertices = np.array([]) self.displayed_index = np.array([]) 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)) # counter for the depth of re entrance of the context manager. self.__batched_level = 0 self.__batch_force_call = False # Counter of number of time _update_displayed has been requested self.__update_displayed_called = 0 for d in data: self.add(d) @contextmanager def batched_updates(self) -> Generator[None, None, None]: """ Reentrant context manager to batch the display update `_update_displayed()` is called at _most_ once on exit of the context manager. There are two reason for this: 1. Some updates are triggered by events, but sometimes multiple pieces of data that trigger events must be set before the data can be recomputed. For example changing number of dimension cause broacast error on partially update structures. 2. Performance. Ideally we want to update the display only once. If no direct or indirect call to `_update_displayed()` are made inside the context manager, no the update logic is not called on exit. """ assert self.__batched_level >= 0 self.__batched_level += 1 try: yield finally: if self.__batched_level == 1 and self.__update_displayed_called: self.__batch_force_call = True self._update_displayed() self.__batch_force_call = False self.__update_displayed_called = 0 self.__batched_level -= 1 assert self.__batched_level >= 0 @property def data(self) -> list[npt.NDArray]: """list of (M, D) array: data arrays for each shape.""" return [s.data for s in self.shapes] @property def ndisplay(self) -> int: """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay: int) -> None: 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) -> npt.NDArray: """(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[str]: """list of str: shape types for each shape.""" return [s.name for s in self.shapes] @property def edge_color(self) -> npt.NDArray: """(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: npt.NDArray) -> None: self._set_color(edge_color, 'edge') @property def face_color(self) -> npt.NDArray: """(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: npt.NDArray) -> None: self._set_color(face_color, 'face') @_batch_dec def _set_color( self, colors: npt.NDArray, attribute: Literal['edge', 'face'] ) -> None: """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.array_equal(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[float]: """list of float: edge width for each shape.""" return [s.edge_width for s in self.shapes] @property def z_indices(self) -> list[int]: """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 @_batch_dec def slice_key(self, slice_key): slice_key = list(slice_key) if not np.array_equal(self._slice_key, slice_key): self._slice_key = slice_key self._clear_cache() self._update_displayed() def _update_displayed(self) -> None: """Update the displayed data based on the slice key. This method must be called from within the `batched_updates` context manager: """ assert self.__batched_level >= 1, ( 'call _update_displayed from within self.batched_updates context manager' ) if not self.__batch_force_call: self.__update_displayed_called += 1 return # 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( np.abs(self.slice_keys - slice_key) < 0.5, axis=(1, 2) ) else: self._displayed = np.array([]) 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 TypeError( 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 TypeError( 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() self._clear_cache() 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() self._clear_cache() @_batch_dec 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() self._clear_cache() @_batch_dec 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() self._clear_cache() @_batch_dec 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 isinstance(new_type, str): shape_type = ShapeType(new_type) if shape_type in shape_classes: 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) @_batch_dec 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() @_batch_dec 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() @_batch_dec 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() @_batch_dec 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 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() self._clear_cache() def outline( self, indices: Union[int, Sequence[int]] ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Finds outlines of shapes listed in indices Parameters ---------- indices : int | Sequence[int] Location in list of the shapes to be outline. If sequence, all elements should be ints 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 isinstance(indices, Sequence) and len(indices) == 1: indices = indices[0] if not isinstance(indices, Sequence): shape = self.shapes[indices] return ( shape._edge_vertices, shape._edge_offsets, shape._edge_triangles, ) return self.outlines(indices) def outlines( self, indices: Sequence[int] ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Finds outlines of shapes listed in indices Parameters ---------- indices : Sequence[int] Location in list of the shapes to be outline. 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 """ shapes_list = [self.shapes[i] for i in indices] offsets = np.vstack([s._edge_offsets for s in shapes_list]) centers = np.vstack([s._edge_vertices for s in shapes_list]) vert_count = np.cumsum( [0] + [len(s._edge_vertices) for s in shapes_list] ) triangles = np.vstack( [s._edge_triangles + c for s, c in zip(shapes_list, vert_count)] ) 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 @cached_property def _visible_shapes(self): slice_key = self.slice_key if len(slice_key): return [ (i, s) for i, s in enumerate(self.shapes) if s.slice_key[0] <= slice_key <= s.slice_key[1] ] return list(enumerate(self.shapes)) @cached_property def _bounding_boxes(self): data = np.array([s[1].bounding_box for s in self._visible_shapes]) if data.size == 0: return np.empty((0, self.ndisplay)), np.empty((0, self.ndisplay)) return data[:, 0], data[:, 1] 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. """ if not self.shapes: return None bounding_boxes = self._bounding_boxes in_bbox = np.all( (bounding_boxes[0] <= coord) * (bounding_boxes[1] >= coord), axis=1, ) inside_indices = np.flatnonzero(in_bbox) if inside_indices.size == 0: return None try: z_index = [ self._visible_shapes[i][1].z_index for i in inside_indices ] pos = np.argsort(z_index) return self._visible_shapes[ next( inside_indices[p] for p in pos[::-1] if np.any( inside_triangles( self._visible_shapes[inside_indices[p]][ 1 ]._all_triangles() - coord ) ) ) ][0] except StopIteration: 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: return None, None 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 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(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(int) colors = np.zeros((*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[: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 def _clear_cache(self): self.__dict__.pop('_bounding_boxes', None) self.__dict__.pop('_visible_shapes', None) napari-0.5.6/napari/layers/shapes/_shapes_constants.py000066400000000000000000000050511474413133200231230ustar00rootroot00000000000000import sys from enum import auto from typing import ClassVar 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_POLYLINE, 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_POLYLINE = auto() ADD_PATH = auto() ADD_POLYGON = auto() ADD_POLYGON_LASSO = 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: ClassVar[list[int]] = [0, 1, 2, 3, 4, 5, 6, 7, 9] LINE_HANDLE: ClassVar[list[int]] = [7, 6, 4, 2, 0, 7, 8] LINE: ClassVar[list[int]] = [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, str(ShapeType.RECTANGLE): Rectangle, str(ShapeType.ELLIPSE): Ellipse, str(ShapeType.LINE): Line, str(ShapeType.PATH): Path, str(ShapeType.POLYGON): Polygon, } napari-0.5.6/napari/layers/shapes/_shapes_key_bindings.py000066400000000000000000000137211474413133200235570ustar00rootroot00000000000000from collections.abc import Generator from typing import Callable import 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_active_element_under_cursor, ) 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, overwrite=True) def hold_to_lock_aspect_ratio(layer: Shapes) -> Generator[None, None, None]: """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_active_element_under_cursor(layer, layer._moving_coordinates) yield # on key release layer._fixed_aspect = False def register_shapes_action( description: str, repeatable: bool = False ) -> Callable[[Callable], Callable]: return register_layer_action(Shapes, description, repeatable) def register_shapes_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Shapes, description, 'mode') @register_shapes_mode_action(trans._('Transform')) def activate_shapes_transform_mode(layer: Shapes) -> None: layer.mode = Mode.TRANSFORM @register_shapes_mode_action(trans._('Pan/zoom')) def activate_shapes_pan_zoom_mode(layer: Shapes) -> None: layer.mode = Mode.PAN_ZOOM @register_shapes_mode_action(trans._('Add rectangles')) def activate_add_rectangle_mode(layer: Shapes) -> None: """Activate add rectangle tool.""" layer.mode = Mode.ADD_RECTANGLE @register_shapes_mode_action(trans._('Add ellipses')) def activate_add_ellipse_mode(layer: Shapes) -> None: """Activate add ellipse tool.""" layer.mode = Mode.ADD_ELLIPSE @register_shapes_mode_action(trans._('Add lines')) def activate_add_line_mode(layer: Shapes) -> None: """Activate add line tool.""" layer.mode = Mode.ADD_LINE @register_shapes_mode_action(trans._('Add polylines')) def activate_add_polyline_mode(layer: Shapes) -> None: """Activate add polyline tool.""" layer.mode = Mode.ADD_POLYLINE @register_shapes_mode_action(trans._('Add path')) def activate_add_path_mode(layer: Shapes) -> None: """Activate add path tool.""" layer.mode = Mode.ADD_PATH @register_shapes_mode_action(trans._('Add polygons')) def activate_add_polygon_mode(layer: Shapes) -> None: """Activate add polygon tool.""" layer.mode = Mode.ADD_POLYGON @register_shapes_mode_action(trans._('Add polygons lasso')) def activate_add_polygon_lasso_mode(layer: Shapes) -> None: """Activate add polygon tool.""" layer.mode = Mode.ADD_POLYGON_LASSO @register_shapes_mode_action(trans._('Select vertices')) def activate_direct_mode(layer: Shapes) -> None: """Activate vertex selection tool.""" layer.mode = Mode.DIRECT @register_shapes_mode_action(trans._('Select shapes')) def activate_select_mode(layer: Shapes) -> None: """Activate shape selection tool.""" layer.mode = Mode.SELECT @register_shapes_mode_action(trans._('Insert vertex')) def activate_vertex_insert_mode(layer: Shapes) -> None: """Activate vertex insertion tool.""" layer.mode = Mode.VERTEX_INSERT @register_shapes_mode_action(trans._('Remove vertex')) def activate_vertex_remove_mode(layer: Shapes) -> None: """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_polyline_mode, Mode.ADD_POLYLINE), (activate_add_path_mode, Mode.ADD_PATH), (activate_add_polygon_mode, Mode.ADD_POLYGON), (activate_add_polygon_lasso_mode, Mode.ADD_POLYGON_LASSO), (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) -> None: """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) -> None: """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) -> None: """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) -> None: """.""" if not layer._is_creating: layer.remove_selected() @register_shapes_action(trans._('Move to front')) def move_shapes_selection_to_front(layer: Shapes) -> None: layer.move_to_front() @register_shapes_action(trans._('Move to back')) def move_shapes_selection_to_back(layer: Shapes) -> None: 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) -> None: """Finish any drawing, for example when using the path or polygon tool.""" layer._finish_drawing() napari-0.5.6/napari/layers/shapes/_shapes_models/000077500000000000000000000000001474413133200220175ustar00rootroot00000000000000napari-0.5.6/napari/layers/shapes/_shapes_models/__init__.py000066400000000000000000000006741474413133200241370ustar00rootroot00000000000000from 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.6/napari/layers/shapes/_shapes_models/_polygon_base.py000066400000000000000000000102651474413133200252150ustar00rootroot00000000000000import numpy as np from scipy.interpolate import splev, splprep from napari.layers.shapes._shapes_models.shape import ( Shape, remove_path_duplicates, ) from napari.layers.shapes._shapes_utils import ( create_box_from_bounding, ) 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(np.float32) 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 unique vertices, {number} provided.', deferred=True, number=len(data), ) ) self._data = data self._bounding_box = np.array( [ np.min(data, axis=0), np.max(data, axis=0), ] ) self._update_displayed_data() def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" self._clean_cache() # 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: data_spline = remove_path_duplicates(data, closed=True) if 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].astype(np.float32) # For path connect every all data self._set_meshes(data, face=self._filled, closed=self._closed) bbox = self._bounding_box[:, self.dims_displayed] self._box = create_box_from_bounding(bbox) self.slice_key = np.rint( self._bounding_box[:, self.dims_not_displayed] ).astype(int) napari-0.5.6/napari/layers/shapes/_shapes_models/_tests/000077500000000000000000000000001474413133200233205ustar00rootroot00000000000000napari-0.5.6/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py000066400000000000000000000216041474413133200275620ustar00rootroot00000000000000import sys import numpy as np import numpy.testing as npt import pytest from vispy.geometry import PolygonData from napari.layers.shapes._shapes_models import ( Ellipse, Line, Path, Polygon, Rectangle, ) from napari.layers.shapes._shapes_utils import triangulate_face BETTER_TRIANGULATION = ( 'triangle' in sys.modules or 'PartSegCore_compiled_backend' in sys.modules ) def test_rectangle1(): """Test creating Rectangle by four corners.""" np.random.seed(0) data = np.array([(10, 10), (20, 10), (20, 20), (10, 20)], dtype=np.float32) shape = Rectangle(data) np.testing.assert_array_equal(shape.data, data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) def test_rectangle2(): """Test creating Rectangle by upper left and bottom right.""" # If given two corners, representation will be expanded to four data = np.array([[-10, -10], [20, 20]], dtype=np.float32) shape = Rectangle(data) assert len(shape.data) == 4 assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) def test_rectangle_bounding_box(): """Test that the bounding box is correctly updated based on edge width.""" data = [[10, 10], [20, 20]] shape = Rectangle(data) npt.assert_array_equal( shape.bounding_box, np.array([[9.5, 9.5], [20.5, 20.5]]) ) shape.edge_width = 2 npt.assert_array_equal(shape.bounding_box, np.array([[9, 9], [21, 21]])) shape.edge_width = 4 npt.assert_array_equal(shape.bounding_box, np.array([[8, 8], [22, 22]])) def test_rectangle_shift(): shape = Rectangle(np.array([[0, 0], [1, 0], [1, 1], [0, 1]])) npt.assert_array_equal( shape.bounding_box, np.array([[-0.5, -0.5], [1.5, 1.5]]) ) shape.shift((1, 1)) npt.assert_array_equal( shape.data, np.array([[1, 1], [2, 1], [2, 2], [1, 2]]) ) npt.assert_array_equal( shape.bounding_box, np.array([[0.5, 0.5], [2.5, 2.5]]) ) def test_rectangle_rotate(): shape = Rectangle(np.array([[1, 2], [-1, 2], [-1, -2], [1, -2]])) npt.assert_array_equal( shape.bounding_box, np.array([[-1.5, -2.5], [1.5, 2.5]]) ) shape.rotate(-90) npt.assert_array_almost_equal( shape.data, np.array([[-2, 1], [-2, -1], [2, -1], [2, 1]]) ) npt.assert_array_almost_equal( shape.bounding_box, np.array([[-2.5, -1.5], [2.5, 1.5]]) ) def test_nD_rectangle(): """Test creating Shape with a single four corner planar 3D rectangle.""" data = np.array( [[0, -10, -10], [0, -10, 20], [0, 20, 20], [0, 20, -10]], dtype=np.float32, ) shape = Rectangle(data) np.testing.assert_array_equal(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_data_triangle(): data = np.array( [ [10.97627008, 14.30378733], [12.05526752, 10.89766366], [8.47309599, 12.91788226], [8.75174423, 17.83546002], [19.27325521, 7.66883038], [15.83450076, 10.5778984], ] ) vertices, _triangles = PolygonData(vertices=data).triangulate() assert vertices.shape == (8, 2) def test_polygon_data_triangle_module(): pytest.importorskip('triangle') data = np.array( [ [10.97627008, 14.30378733], [12.05526752, 10.89766366], [8.47309599, 12.91788226], [8.75174423, 17.83546002], [19.27325521, 7.66883038], [15.83450076, 10.5778984], ] ) vertices, _triangles = triangulate_face(data) assert vertices.shape == (6, 2) def test_polygon(): """Test creating Shape with a random polygon.""" # Test a single non convex six vertex polygon data = np.array( [ [10.97627008, 14.30378733], [12.05526752, 10.89766366], [8.47309599, 12.91788226], [8.75174423, 17.83546002], [19.27325521, 7.66883038], [15.83450076, 10.5778984], ], dtype=np.float32, ) shape = Polygon(data) np.testing.assert_array_equal(shape.data, data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 0) # should get few triangles expected_face = (6, 2) if BETTER_TRIANGULATION else (8, 2) assert shape._edge_vertices.shape == (16, 2) assert shape._face_vertices.shape == expected_face def test_polygon2(): data = np.array([[0, 0], [0, 1], [1, 1], [1, 0]]) shape = Polygon(data, interpolation_order=3) # should get many triangles expected_face = (249, 2) assert shape._edge_vertices.shape == (500, 2) assert shape._face_vertices.shape == expected_face def test_polygon3(): 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)).astype(np.float32) data[:, 0] = 0 shape = Polygon(data) np.testing.assert_array_equal(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)).astype(np.float32) shape = Path(data) np.testing.assert_array_equal(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)).astype(np.float32) shape = Path(data) np.testing.assert_array_equal(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 2D Line.""" np.random.seed(0) data = np.array([[10, 10], [20, 20]], dtype=np.float32) shape = Line(data) np.testing.assert_array_equal(shape.data, data) assert shape.data_displayed.shape == (2, 2) assert shape.slice_key.shape == (2, 0) def test_nD_line(): """Test creating Line in 3d""" data = np.array([[10, 10, 10], [20, 20, 20]], dtype=np.float32) shape = Line(data) np.testing.assert_array_equal(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_ellipse1(): """Test creating Ellipse by four corners.""" data = np.array([(10, 10), (20, 10), (20, 20), (10, 20)], dtype=np.float32) shape = Ellipse(data) np.testing.assert_array_equal(shape.data, data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) def test_ellipse2(): """Test creating Ellipse by upper left and lower right corners.""" data = np.array([[10, 10], [20, 20]], dtype=np.float32) 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 data = np.array( [(0, -10, -10), (0, -10, 20), (0, 20, 20), (0, 20, -10)], dtype=np.float32, ) shape = Ellipse(data) np.testing.assert_array_equal(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_ellipse_shift(): shape = Ellipse(np.array([[0, 0], [1, 0], [1, 1], [0, 1]])) npt.assert_array_equal( shape.bounding_box, np.array([[-0.5, -0.5], [1.5, 1.5]]) ) shape.shift((1, 1)) npt.assert_array_equal( shape.data, np.array([[1, 1], [2, 1], [2, 2], [1, 2]]) ) npt.assert_array_equal( shape.bounding_box, np.array([[0.5, 0.5], [2.5, 2.5]]) ) def test_ellipse_rotate(): shape = Ellipse(np.array([[1, 2], [-1, 2], [-1, -2], [1, -2]])) npt.assert_array_equal( shape.bounding_box, np.array([[-1.5, -2.5], [1.5, 2.5]]) ) shape.rotate(-90) npt.assert_array_almost_equal( shape.data, np.array([[-2, 1], [-2, -1], [2, -1], [2, 1]]) ) npt.assert_array_almost_equal( shape.bounding_box, np.array([[-2.5, -1.5], [2.5, 1.5]]) ) napari-0.5.6/napari/layers/shapes/_shapes_models/ellipse.py000066400000000000000000000074211474413133200240320ustar00rootroot00000000000000import 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.0, 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(np.float32) 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._bounding_box = np.round( [ np.min(data, axis=0), np.max(data, axis=0), ] ) self._update_displayed_data() def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # Build boundary vertices with num_segments self._clean_cache() 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) self.slice_key = self._bounding_box[:, self.dims_not_displayed].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 self._bounding_box = np.array( [ np.min(self._data, axis=0), np.max(self._data, axis=0), ] ) self._clean_cache() napari-0.5.6/napari/layers/shapes/_shapes_models/line.py000066400000000000000000000042771474413133200233320ustar00rootroot00000000000000import 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(np.float32) 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._bounding_box = np.array( [ np.min(data, axis=0), np.max(data, axis=0), ] ) self._update_displayed_data() def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # For path connect every all data self._clean_cache() self._set_meshes(self.data_displayed, face=False, closed=False) self._box = create_box(self.data_displayed) self.slice_key = np.round( self._bounding_box[:, self.dims_not_displayed] ).astype('int') napari-0.5.6/napari/layers/shapes/_shapes_models/path.py000066400000000000000000000020371474413133200233270ustar00rootroot00000000000000from napari.layers.shapes._shapes_models._polygon_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.6/napari/layers/shapes/_shapes_models/polygon.py000066400000000000000000000020051474413133200240550ustar00rootroot00000000000000from napari.layers.shapes._shapes_models._polygon_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.6/napari/layers/shapes/_shapes_models/rectangle.py000066400000000000000000000052571474413133200243460ustar00rootroot00000000000000import 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(np.float32) 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: 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._bounding_box = np.array( [ np.min(data, axis=0), np.max(data, axis=0), ] ) self._update_displayed_data() def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # Add four boundary lines and then two triangles for each self._clean_cache() data_displayed = self.data_displayed self._set_meshes(data_displayed, face=False) self._face_vertices = data_displayed self._face_triangles = np.array([[0, 1, 2], [0, 2, 3]]) self._box = rectangle_to_box(data_displayed) self.slice_key = self._bounding_box[:, self.dims_not_displayed].astype( 'int' ) napari-0.5.6/napari/layers/shapes/_shapes_models/shape.py000066400000000000000000000506721474413133200235030ustar00rootroot00000000000000from __future__ import annotations from abc import ABC, abstractmethod from functools import cached_property import numpy as np import numpy.typing as npt from napari.layers.shapes._shapes_utils import ( is_collinear, path_to_mask, poly_to_mask, triangulate_edge, triangulate_face, ) from napari.settings import get_settings from napari.utils.misc import argsort from napari.utils.translations import trans try: from PartSegCore_compiled_backend.triangulate import ( triangulate_path_edge_numpy, triangulate_polygon_numpy_li, triangulate_polygon_with_edge_numpy_li, ) except ImportError: triangulate_path_edge_numpy = None triangulate_polygon_numpy_li = None triangulate_polygon_with_edge_numpy_li = None def _remove_path_duplicates_np(data: np.ndarray, closed: bool): # We add the first data point at the end to get the same length bool # array as the data, and also to work on closed shapes; the last value # in the diff array compares the last and first vertex. diff = np.diff(np.append(data, data[0:1, :], axis=0), axis=0) dup = np.all(diff == 0, axis=1) # if the shape is closed, check whether the first vertex is the same # as the last vertex, and count it as a duplicate if so if closed and dup[-1]: dup[0] = True # we allow repeated nodes at the end for the lasso tool, which # for an instant needs both the last placed point and the point at the # cursor to be the same; if the lasso implementation becomes cleaner, # remove this hardcoding dup[-2:] = False indices = np.arange(data.shape[0]) return data[indices[~dup]] try: from napari.layers.shapes._accelerated_triangulate import ( remove_path_duplicates, ) except ImportError: remove_path_duplicates = _remove_path_duplicates_np 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: npt.NDArray 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 = '' self._data: npt.NDArray self._bounding_box = np.empty((0, self.ndisplay)) def __new__(cls, *args, **kwargs): if ( get_settings().experimental.compiled_triangulation and triangulate_path_edge_numpy is not None ): cls._set_meshes = cls._set_meshes_compiled else: cls._set_meshes = cls._set_meshes_py return super().__new__(cls) @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) -> None: 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() @cached_property def dims_displayed(self): """tuple: Dimensions that are displayed.""" return self.dims_order[-self.ndisplay :] @property def bounding_box(self) -> np.ndarray: """(2, N) array, bounding box of the object.""" # We add +-0.5 to handle edge width return self._bounding_box[:, self.dims_displayed] + [ [-0.5 * self.edge_width], [0.5 * self.edge_width], ] @property def dims_not_displayed(self): """tuple: Dimensions that are not displayed.""" return self.dims_order[: -self.ndisplay] @cached_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_compiled( self, data: npt.NDArray, closed: bool = True, face: bool = True, edge: bool = True, ) -> None: """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 data.shape[1] == 3: self._set_meshes_py(data, closed=closed, face=face, edge=edge) return # if we are computing both edge and face triangles, we can do so # with a single call to the compiled backend if edge and face: (triangles, vertices), (centers, offsets, edge_triangles) = ( triangulate_polygon_with_edge_numpy_li([data]) ) self._edge_vertices = centers self._edge_offsets = offsets self._edge_triangles = edge_triangles self._face_vertices = vertices self._face_triangles = triangles return # otherwise, we make individual calls to specialized functions if edge: centers, offsets, triangles = triangulate_path_edge_numpy( 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: triangles, vertices = triangulate_polygon_numpy_li([data]) 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) def _set_meshes( # noqa: B027 self, data: npt.NDArray, closed: bool = True, face: bool = True, edge: bool = True, ) -> None: ... def _set_meshes_py( self, data: npt.NDArray, closed: bool = True, face: bool = True, edge: bool = True, ) -> None: """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 """ data = remove_path_duplicates(data, closed=closed) 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 = np.array([]) vertices = np.array([]) 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 _all_triangles(self): """Return all triangles for the shape Returns ------- np.ndarray Nx3 array of vertex indices that form the triangles for the shape """ return np.vstack( [ self._face_vertices[self._face_triangles], (self._edge_vertices + self.edge_width * self._edge_offsets)[ self._edge_triangles ], ] ) def transform(self, transform: npt.NDArray) -> None: """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 self.__dict__.pop('data_displayed', None) # clear cache points = self.data_displayed points = remove_path_duplicates(points, closed=self._closed) centers, offsets, triangles = triangulate_edge( points, closed=self._closed ) self._edge_vertices = centers self._edge_offsets = offsets self._edge_triangles = triangles self._bounding_box = np.array( [ np.min(self._data, axis=0), np.max(self._data, axis=0), ] ) self._clean_cache() def shift(self, shift: npt.NDArray) -> None: """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 self._bounding_box[:, self.dims_displayed] = ( self._bounding_box[:, self.dims_displayed] + shift ) self._clean_cache() 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: center = np.array(center) 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: center = np.array(center) 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: list[int | slice] = [0] * len(mask_shape) for i in range(len(mask_shape)): if i in self.dims_displayed: slice_key[i] = slice(None) elif self.slice_key is not None: slice_key[i] = slice( self.slice_key[0, i], self.slice_key[1, i] + 1 ) else: raise RuntimeError( 'Internal error: self.slice_key is None' ) displayed_order = argsort(self.dims_displayed) mask[tuple(slice_key)] = mask_p.transpose(displayed_order) else: mask = mask_p return mask def _clean_cache(self) -> None: if 'dims_displayed' in self.__dict__: del self.__dict__['dims_displayed'] if 'data_displayed' in self.__dict__: del self.__dict__['data_displayed'] napari-0.5.6/napari/layers/shapes/_shapes_mouse_bindings.py000066400000000000000000000747271474413133200241340ustar00rootroot00000000000000from __future__ import annotations from copy import copy from typing import TYPE_CHECKING import numpy as np from napari.layers.base._base_constants import ActionType 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 from napari.settings import get_settings if TYPE_CHECKING: from collections.abc import Generator from typing import Optional import numpy.typing as npt from vispy.app.canvas import MouseEvent from napari.layers.shapes.shapes import Shapes def highlight(layer: Shapes, event: MouseEvent) -> None: """Render highlights of shapes. Highlight hovered shapes, including boundaries, vertices, interaction boxes, and drag selection box when appropriate. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. Though not used here it is passed as argument by the shapes layer mouse move callbacks. Returns ------- None """ layer._set_highlight() def select(layer: Shapes, event: MouseEvent) -> Generator[None, None, None]: """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. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ 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_active_element_under_cursor(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: vertex_indices = tuple( tuple( vertex_index for vertex_index, coord in enumerate(layer.data[i]) ) for i in layer.selected_data ) layer.events.data( value=layer.data, action=ActionType.CHANGED, data_indices=tuple(layer.selected_data), vertex_indices=vertex_indices, ) # 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: Shapes, event: MouseEvent) -> Generator[None, None, None]: """Add a line. Adds a line by connecting 2 ndim points. On press one point is set under the mouse cursor and a second point is created with a very minor offset to the first point. If moving mouse while mouse is pressed the second point will track the cursor. The second point it set upon mouse release. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # full size is the initial offset of the second point compared to the first point of the line. size = layer._normalized_vertex_radius / 2 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 is first datapoint defining the line corner = np.array(coordinates) data = np.array([corner, corner + full_size]) # adds data to layer.data and handles mouse move (cursor tracking) and release event (setting second point) yield from _add_line_rectangle_ellipse( layer, event, data=data, shape_type='line' ) def add_ellipse( layer: Shapes, event: MouseEvent ) -> Generator[None, None, None]: """ Add an ellipse to the shapes layer. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ size = layer._normalized_vertex_radius / 2 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: Shapes, event: MouseEvent ) -> Generator[None, None, None]: """Add a rectangle to the shapes layer. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ size = layer._normalized_vertex_radius / 2 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: Shapes, event: MouseEvent, data: npt.NDArray, shape_type: str ) -> Generator[None, None, None]: """Helper function for adding a line, rectangle or ellipse. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. data : np.NDarray Array containing the initial datapoints of the shape in image data space. shape_type : str String indicating the type of shape to be added. """ # on press # reset layer._aspect_ratio for a new shape layer._aspect_ratio = 1 # Start drawing rectangle / ellipse / line layer.add(data, shape_type=shape_type, gui=True) 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_active_element_under_cursor(layer, coordinates) yield # on release layer._finish_drawing() def finish_drawing_shape(layer: Shapes, event: MouseEvent) -> None: """Finish drawing of shape. Calls the finish drawing method of the shapes layer which resets all the properties used for shape drawing and deletes the shape if the number of vertices do not meet the threshold of 3. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. Not used here, but passed as argument due to being a double click callback of the shapes layer. """ layer._finish_drawing() def initiate_polygon_draw( layer: Shapes, coordinates: tuple[float, ...] ) -> None: """Start drawing of polygon. Creates the polygon shape when initializing the draw, adding to layer and selecting the initiatlized shape and setting required layer attributes for drawing. Parameters ---------- layer : Shapes Napari shapes layer coordinates : Tuple[float, ...] A tuple with the coordinates of the initial vertex in image data space. """ layer._is_creating = True data = np.array([coordinates, coordinates]) layer.add(data, shape_type='path', gui=True) layer.selected_data = {layer.nshapes - 1} layer._value = (layer.nshapes - 1, 1) layer._moving_value = copy(layer._value) layer._set_highlight() def add_path_polygon_lasso( layer: Shapes, event: MouseEvent ) -> Generator[None, None, None]: """Add, draw and finish drawing of polygon. Initiates, draws and finishes the lasso polygon in drag mode (tablet) or initiates and finishes the lasso polygon when drawing with the mouse. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # on press coordinates = layer.world_to_data(event.position) if layer._is_creating is False: # Set last cursor position to initial position of the mouse when starting to draw the shape layer._last_cursor_position = np.array(event.pos) # Start drawing a path initiate_polygon_draw(layer, coordinates) yield while event.type == 'mouse_move': polygon_creating(layer, event) yield index = layer._moving_value[0] vertices = layer._data_view.shapes[index].data # If number of vertices is higher than 2, tablet draw mode is assumed and shape is finished upon mouse release if len(vertices) > 2: layer._finish_drawing() else: # This code block is responsible for finishing drawing in mouse draw mode layer._finish_drawing() def add_vertex_to_path( layer: Shapes, event: MouseEvent, index: int, coordinates: tuple[float, ...], new_type: Optional[str], ) -> None: """Add a vertex to an existing path or polygon and edit the layer view. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. index : int The index of the shape being added, e.g. first shape in the layer has index 0. coordinates : Tuple[float, ...] The coordinates of the vertex being added to the shape being drawn in image data space new_type : Optional[str] Type of the shape being added. """ vertices = layer._data_view.shapes[index].data vertices = np.concatenate((vertices, [coordinates]), axis=0) value = layer.get_value(event.position, world=True) # If there was no move event between two clicks value[1] is None # and needs to be taken care of. if value[1] is None: value = layer._moving_value 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) layer._last_cursor_position = np.array(event.pos) def polygon_creating(layer: Shapes, event: MouseEvent) -> None: """Let active vertex follow cursor while drawing polygon, adding it to polygon after a certain distance. When drawing a polygon in lasso mode or adding a path, a vertex follows the cursor, creating a polygon visually that is *not* the final polygon to be created: it is the polygon if the current mouse position were to be the last position added. After the mouse moves a distance of 10 screen pixels, a new vertex is automatically added and the last cursor position is set to the global screen coordinates at that moment. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ if layer._is_creating: coordinates = layer.world_to_data(event.position) move_active_vertex_under_cursor(layer, coordinates) if layer._mode in [Mode.ADD_POLYGON_LASSO, Mode.ADD_PATH]: index = layer._moving_value[0] position_diff = np.linalg.norm( event.pos - layer._last_cursor_position ) if ( position_diff > get_settings().experimental.lasso_vertex_distance ): add_vertex_to_path(layer, event, index, coordinates, None) def add_path_polygon(layer: Shapes, event: MouseEvent) -> None: """Add a path or polygon or add vertex to an existing one. When shape is not yet being created, initiates the drawing of a polygon on mouse press. Else, on subsequent mouse presses, add vertex to polygon being created. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # on press coordinates = layer.world_to_data(event.position) if layer._is_creating is False: # Set last cursor position to initial position of the mouse when starting to draw the shape layer._last_cursor_position = np.array(event.pos) # Start drawing a path initiate_polygon_draw(layer, coordinates) else: # Add to an existing path or polygon index = layer._moving_value[0] new_type = Polygon if layer._mode == Mode.ADD_POLYGON else None # Ensure the position of the new vertex is different from the previous # one before adding it. See napari/napari#6597 if not np.array_equal( np.array(event.pos), layer._last_cursor_position ): add_vertex_to_path(layer, event, index, coordinates, new_type) def move_active_vertex_under_cursor( layer: Shapes, coordinates: tuple[float, ...] ) -> None: """While a path or polygon is being created, move next vertex to be added. Parameters ---------- layer : Shapes Napari shapes layer coordinates : Tuple[float, ...] The coordinates in data space of the vertex to be potentially added, e.g. vertex tracks the mouse cursor position. """ if layer._is_creating: _move_active_element_under_cursor(layer, coordinates) def vertex_insert(layer: Shapes, event: MouseEvent) -> None: """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. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # 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 layer.events.data( value=layer.data, action=ActionType.CHANGING, data_indices=(index,), vertex_indices=((ind,),), ) # 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.events.data( value=layer.data, action=ActionType.CHANGED, data_indices=(index,), vertex_indices=((ind,),), ) layer.refresh() def vertex_remove(layer: Shapes, event: MouseEvent) -> None: """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. Parameters ---------- layer : Shapes Napari shapes layer event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ 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 layer.events.data( value=layer.data, action=ActionType.CHANGING, data_indices=(shape_under_cursor,), vertex_indices=((vertex_under_cursor,),), ) # 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 or (shape_type == Polygon and len(vertices) == 3): # 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) else: if shape_type == Rectangle: # noqa SIM108 # 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.events.data( value=layer.data, action=ActionType.CHANGED, data_indices=(shape_under_cursor,), vertex_indices=((vertex_under_cursor,),), ) layer.refresh() def _drag_selection_box(layer: Shapes, coordinates: tuple[float, ...]) -> None: """Drag a selection box. Parameters ---------- layer : napari.layers.Shapes Shapes layer. coordinates : Tuple[float, ...] The current position of the cursor during the mouse move event in image data space. """ # 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: Shapes, coordinates: tuple[float, ...] ) -> list[float]: """Indicate where in data space a drag event started. Sets the coordinates relative to the center of the bounding box of a shape and returns the position of where a drag event of a shape started. Parameters ---------- layer : Shapes The napari layer shape coordinates : Tuple[float, ...] The position in image data space where dragging started. Returns ------- coord: List[float, ...] The coordinates of where a shape drag event started. """ 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_active_element_under_cursor( layer: Shapes, coordinates: tuple[float, ...] ) -> None: """Moves object at given mouse position and set of indices. Parameters ---------- layer : napari.layers.Shapes Shapes layer. coordinates : Tuple[float, ...] 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] ): if layer._mode == Mode.SELECT and not layer._is_moving: vertex_indices = tuple( tuple( vertex_index for vertex_index, coord in enumerate(layer.data[i]) ) for i in layer.selected_data ) layer.events.data( value=layer.data, action=ActionType.CHANGING, data_indices=tuple(layer.selected_data), vertex_indices=vertex_indices, ) 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 # Also applies while drawing line, rectangle, ellipse 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) box_center = box[Box.CENTER] if layer._fixed_aspect and layer._fixed_index % 2 == 0: # corner # ensure line rotates through 45 degree steps if aspect ratio is 1 if layer._mode == Mode.ADD_LINE and layer._aspect_ratio == 1: new_offset = coord - layer._fixed_vertex angle_rad = np.arctan2(new_offset[0], -new_offset[1]) angle_rad = np.round(angle_rad / (np.pi / 4)) * (np.pi / 4) new = ( np.array([np.sin(angle_rad), -np.cos(angle_rad)]) * np.linalg.norm(new - box_center) + box_center ) else: new = (box[vertex] - box_center) / np.linalg.norm( box[vertex] - box_center ) * np.linalg.norm(new - box_center) + box_center if layer._fixed_index % 2 == 0: # corner selected drag_scale = (inv_rot @ (new - fixed)) / ( inv_rot @ (box[vertex] - fixed) ) elif layer._fixed_index % 4 == 3: # top or bottom selected drag_scale = np.array( [ (inv_rot @ (new - fixed))[0] / (inv_rot @ (box[vertex] - fixed))[0], 1, ] ) else: # left or right selected drag_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_LEFT] - box_center),) if ( np.linalg.norm(size * drag_scale) < layer._normalized_vertex_radius ): drag_scale[:] = 1 # on vertical/horizontal drags we get scale of 0 # when we actually simply don't want to scale drag_scale[drag_scale == 0] = 1 # check orientation of box if abs(handle_offset_norm[0]) == 1: for index in layer.selected_data: layer._data_view.scale( index, drag_scale, center=layer._fixed_vertex ) layer._scale_box(drag_scale, center=layer._fixed_vertex) else: scale_mat = np.array([[drag_scale[0], 0], [0, drag_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 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_POLYLINE, Mode.ADD_POLYGON, Mode.ADD_POLYGON_LASSO, ] and 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: # TODO: Implement DIRECT vertex moving of ellipse pass else: new_type = Polygon if shape_type == Rectangle else 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() def _set_highlight(layer: Shapes, event: MouseEvent) -> None: if event.type in {'mouse_press', 'mouse_wheel'}: layer._set_highlight() napari-0.5.6/napari/layers/shapes/_shapes_utils.py000066400000000000000000001227631474413133200222610ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING 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 if TYPE_CHECKING: import numpy.typing as npt try: # see https://github.com/vispy/vispy/issues/1029 from triangle import triangulate except ModuleNotFoundError: triangulate = None try: from napari.layers.shapes._accelerated_triangulate import ( create_box_from_bounding as acc_create_box_from_bounding, generate_2D_edge_meshes as acc_generate_2D_edge_meshes, ) except ImportError: acc_generate_2D_edge_meshes = None acc_create_box_from_bounding = None def _is_convex(poly: npt.NDArray) -> bool: """Check whether a polygon is convex. Parameters ---------- poly: numpy array of floats, shape (N, 3) Polygon vertices, in order. Returns ------- bool True if the given polygon is convex. """ fst = poly[:-2] snd = poly[1:-1] thrd = poly[2:] orn_set = np.unique(orientation(fst.T, snd.T, thrd.T)) if orn_set.size != 1: return False return (orn_set[0] == orientation(poly[-2], poly[-1], poly[0])) and ( orn_set[0] == orientation(poly[-1], poly[0], poly[1]) ) def _fan_triangulation(poly: npt.NDArray) -> tuple[npt.NDArray, npt.NDArray]: """Return a fan triangulation of a given polygon. https://en.wikipedia.org/wiki/Fan_triangulation Parameters ---------- poly: numpy array of float, shape (N, 3) Polygon vertices, in order. Returns ------- vertices : numpy array of float, shape (N, 3) The vertices of the triangulation. In this case, the input array. triangles : numpy array of int, shape (N, 3) The triangles of the triangulation, as triplets of indices into the vertices array. """ vertices = np.copy(poly) triangles = np.zeros((len(poly) - 2, 3), dtype=np.uint32) triangles[:, 1] = np.arange(1, len(poly) - 1) triangles[:, 2] = np.arange(2, len(poly)) return vertices, triangles 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 = ABAM >= 0 c2 = ABAM <= ABAB c3 = BCBM >= 0 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): # noqa: SIM103 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: npt.NDArray) -> bool: """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 return all(orientation(points[0], points[1], p) == 0 for p in points[2:]) 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 (2D cross-product) line_dist = abs( unit_lines[..., 0] * point_vectors[..., 1] - unit_lines[..., 1] * point_vectors[..., 0] ) # 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_from_bounding(bounding_box: npt.NDArray) -> npt.NDArray: """Creates the axis aligned interaction box of a bounding box Parameters ---------- bounding_box : np.ndarray 2x2 array of the bounding box. The first row is the minimum values and the second row is the maximum values 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 """ tl = bounding_box[(0, 0), (0, 1)] br = bounding_box[(1, 1), (0, 1)] tr = bounding_box[(1, 0), (0, 1)] bl = bounding_box[(0, 1), (0, 1)] return np.array( [ tl, (tl + tr) / 2, tr, (tr + br) / 2, br, (br + bl) / 2, bl, (bl + tl) / 2, (tl + br) / 2, ] ) def create_box(data: npt.NDArray) -> npt.NDArray: """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: npt.NDArray) -> npt.NDArray: """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: npt.NDArray) -> npt.NDArray: """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: npt.NDArray, radii: npt.NDArray ) -> npt.NDArray: """Expands a center and radii into a four corner rectangle Parameters ---------- center : np.ndarray Length 2 array of the center coordinates. radii : np.ndarray Length 2 array 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: npt.NDArray, num_segments: int = 100 ) -> tuple[npt.NDArray, npt.NDArray]: """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 whether 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: npt.NDArray) -> tuple[npt.NDArray, npt.NDArray]: """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({'vertices': data, 'segments': edges}, 'p') vertices, triangles = res['vertices'], res['triangles'] elif _is_convex(data): vertices, triangles = _fan_triangulation(data) else: vertices, triangles = PolygonData(vertices=data).triangulate() triangles = triangles.astype(np.uint32) return vertices, triangles def triangulate_edge( path: npt.NDArray, closed: bool = False ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]: """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( np.asarray(clean_path, dtype=np.float32), closed=closed ) else: centers, offsets, triangles = generate_tube_meshes( clean_path, closed=closed ) # offsets[2,1] = -0.5 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]) if x.shape[1] == y.shape[1] == 3: return _sign_nonzero(np.cross(x, y)) 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: idx_offset = (miter_signs[idx_bevel] < 0).astype(int) # outside and inside offsets are treated differently (only outside offsets get beveled) # See drawing at: # https://github.com/napari/napari/pull/6706#discussion_r1528790407 idx_bevel_outside = 2 * idx_bevel + idx_offset idx_bevel_inside = 2 * idx_bevel + (1 - idx_offset) sign_bevel = np.expand_dims(miter_signs[idx_bevel], -1) # adjust offset of outer offset offsets[idx_bevel_outside] = ( -0.5 * full_normals[:-1][idx_bevel] * sign_bevel ) # adjust/normalize length of inner offset offsets[idx_bevel_inside] /= np.sqrt( miter_lengths_squared[idx_bevel, np.newaxis] ) # special cases for the last vertex _nonspecial = idx_bevel != len(path) - 1 idx_bevel = idx_bevel[_nonspecial] idx_bevel_outside = idx_bevel_outside[_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 a new center/bevel 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.array_equal(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: npt.NDArray, vertices: npt.NDArray ) -> npt.NDArray[np.bool_]: """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: npt.ArrayLike, vertices: npt.ArrayLike ) -> npt.NDArray[np.bool_]: """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 = d[0] * (points[:, 1] - vertices[i, 1]) > 0 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), ) ) def perpendicular_distance( point: npt.NDArray, line_start: npt.NDArray, line_end: npt.NDArray ) -> float: """Calculate the perpendicular distance of a point to a given euclidean line. Calculates the shortest distance of a point to a euclidean line defined by a line_start point and a line_end point. Works up to any dimension. Parameters --------- point : np.ndarray A point defined by a numpy array of shape (viewer.ndims,) which is part of a polygon shape. line_start : np.ndarray A point defined by a numpy array of shape (viewer.ndims,) used to define the starting point of a line. line_end : np.ndarray A point defined by a numpy array of shape (viewer.ndims,) used to define the end point of a line. Returns ------- float A float number representing the distance of point to a euclidean line defined by line_start and line_end. """ if np.array_equal(line_start, line_end): return float(np.linalg.norm(point - line_start)) t = np.dot(point - line_end, line_start - line_end) / np.dot( line_start - line_end, line_start - line_end ) return float( np.linalg.norm(t * (line_start - line_end) + line_end - point) ) def rdp(vertices: npt.NDArray, epsilon: float) -> npt.NDArray: """Reduce the number of vertices that make up a polygon. Implementation of the Ramer-Douglas-Peucker algorithm based on: https://github.com/fhirschmann/rdp/blob/master/rdp. This algorithm reduces the amounts of points in a polyline or in this case reduces the number of vertices in a polygon shape. Parameters ---------- vertices : np.ndarray A numpy array of shape (n, viewer.ndims) containing the vertices of a polygon shape. epsilon : float A float representing the maximum distance threshold. When the perpendicular distance of a point to a given line is higher, subsequent refinement occurs. Returns ------- np.ndarray A numpy array of shape (n, viewer.ndims) containing the vertices of a polygon shape. """ max_distance_index = -1 max_distance = 0.0 for i in range(1, vertices.shape[0]): d = perpendicular_distance(vertices[i], vertices[0], vertices[-1]) if d > max_distance: max_distance_index = i max_distance = d if epsilon != 0: if max_distance > epsilon and epsilon: l1 = rdp(vertices[: max_distance_index + 1], epsilon) l2 = rdp(vertices[max_distance_index:], epsilon) return np.vstack((l1[:-1], l2)) # This part of the algorithm is actually responsible for removing the datapoints. return np.vstack((vertices[0], vertices[-1])) # When epsilon is 0, avoid removing datapoints return vertices if acc_generate_2D_edge_meshes is not None: _generate_2D_edge_meshes = acc_generate_2D_edge_meshes else: # pragma: no cover _generate_2D_edge_meshes = generate_2D_edge_meshes if acc_create_box_from_bounding is not None: create_box_from_bounding = acc_create_box_from_bounding napari-0.5.6/napari/layers/shapes/_tests/000077500000000000000000000000001474413133200203335ustar00rootroot00000000000000napari-0.5.6/napari/layers/shapes/_tests/conftest.py000066400000000000000000000317711474413133200225430ustar00rootroot00000000000000import numpy as np import pytest @pytest.fixture def single_four_corner() -> list[np.ndarray]: return [ np.array( [ [6.5957985, 7.1852345], [2.87612, 2.0895412], [1.973858, 10.891626], [11.863327, 14.566921], ], dtype=np.float32, ) ] @pytest.fixture def ten_four_corner() -> list[np.ndarray]: return [ np.array( [ [17.50093, 0.59361523], [18.020409, 18.638197], [14.526781, 14.467463], [9.254028, 18.727674], ], dtype=np.float32, ), np.array( [ [7.278204, 5.099733], [18.39801, 19.622711], [19.648762, 9.590886], [11.369405, 1.8619729], ], dtype=np.float32, ), np.array( [ [18.940228, 9.202899], [14.660338, 12.172355], [3.0547895, 10.981356], [1.7724335, 10.323669], ], dtype=np.float32, ), np.array( [ [3.7452614, 1.2272043], [17.442095, 16.222443], [15.570847, 12.4132], [8.985382, 4.0625296], ], dtype=np.float32, ), np.array( [ [9.748284, 4.3844504], [7.651263, 16.566586], [15.874295, 14.766316], [6.1144648, 9.241833], ], dtype=np.float32, ), np.array( [ [11.028608, 19.633142], [16.842916, 19.298588], [1.1768825, 3.404611], [17.53738, 14.9563875], ], dtype=np.float32, ), np.array( [ [12.355914, 17.984932], [6.8080034, 17.108805], [4.950972, 8.947148], [6.0480304, 11.312822], ], dtype=np.float32, ), np.array( [ [9.633244, 1.0103394], [17.489294, 6.927368], [16.326223, 0.42313808], [11.682402, 4.273177], ], dtype=np.float32, ), np.array( [ [6.587939, 3.057046], [9.089723, 10.495151], [2.997726, 0.76716715], [14.590194, 19.604425], ], dtype=np.float32, ), np.array( [ [8.401338, 11.062277], [4.8243365, 11.910896], [18.186176, 10.091279], [6.704347, 5.4453797], ], dtype=np.float32, ), ] @pytest.fixture def twenty_four_corner() -> list[np.ndarray]: return [ np.array( [ [3.915969, 7.8135843], [8.393104, 18.920172], [14.069965, 3.7124484], [3.4207442, 10.474266], ], dtype=np.float32, ), np.array( [ [13.093829, 10.628601], [13.130707, 6.009158], [13.47422, 19.55084], [11.290086, 1.8060791], ], dtype=np.float32, ), np.array( [ [15.458012, 17.083729], [18.065348, 13.092547], [6.021884, 7.284472], [14.465336, 14.867735], ], dtype=np.float32, ), np.array( [ [8.356629, 11.802527], [10.49842, 10.772692], [1.9225302, 15.309828], [2.7022123, 5.877228], ], dtype=np.float32, ), np.array( [ [0.0472871, 6.1385994], [13.490301, 6.684538], [16.91262, 9.216312], [6.5908933, 17.177036], ], dtype=np.float32, ), np.array( [ [13.509915, 3.2456365], [7.561683, 16.553562], [0.92989004, 5.3060174], [2.2870188, 19.118221], ], dtype=np.float32, ), np.array( [ [15.1053095, 4.8142157], [0.8535362, 14.753784], [10.039892, 17.245684], [19.919659, 12.106256], ], dtype=np.float32, ), np.array( [ [3.1561623, 6.8480477], [4.6244307, 13.403254], [15.039071, 0.5711754], [6.0181017, 10.039466], ], dtype=np.float32, ), np.array( [ [5.8035297, 1.1744947], [9.812645, 17.92537], [2.8984253, 1.9077939], [4.551977, 15.917308], ], dtype=np.float32, ), np.array( [ [19.832373, 12.937964], [5.653778, 3.7855823], [19.92601, 8.508228], [11.112172, 18.182554], ], dtype=np.float32, ), np.array( [ [3.234961, 5.392519], [9.195639, 11.232752], [7.030445, 2.1683385], [15.3207245, 11.744694], ], dtype=np.float32, ), np.array( [ [13.027798, 4.9237623], [1.8797916, 15.584881], [4.975614, 0.21063913], [15.884403, 2.5303805], ], dtype=np.float32, ), np.array( [ [14.246391, 17.44541], [16.53483, 16.567772], [15.474697, 9.554304], [0.9726791, 0.38446364], ], dtype=np.float32, ), np.array( [ [15.69831, 4.518636], [11.4159565, 16.977335], [10.6060295, 3.4504724], [11.813847, 19.47234], ], dtype=np.float32, ), np.array( [ [13.263935, 4.0687637], [14.042292, 4.803806], [2.8870966, 3.990361], [19.149946, 9.0334635], ], dtype=np.float32, ), np.array( [ [11.913461, 17.762383], [7.0338235, 0.88027215], [8.051452, 12.320653], [19.27673, 4.0772495], ], dtype=np.float32, ), np.array( [ [11.44268, 7.145645], [5.5853043, 4.4647007], [11.857039, 6.1128635], [19.915642, 9.154013], ], dtype=np.float32, ), np.array( [ [6.1400304, 8.206569], [7.1725554, 5.842869], [7.1434703, 2.830281], [16.632149, 0.33894876], ], dtype=np.float32, ), np.array( [ [15.774475, 6.7334757], [8.424132, 17.798683], [2.3209176, 2.4583323], [11.784108, 5.82434], ], dtype=np.float32, ), np.array( [ [10.666719, 19.523237], [11.11067, 5.5118046], [7.602155, 16.304794], [3.6093395, 19.374704], ], dtype=np.float32, ), ] @pytest.fixture def single_two_corners() -> list[np.ndarray]: return [ np.array( [[19.630909, 4.094504], [6.050901, 4.739327]], dtype=np.float32 ) ] @pytest.fixture def ten_two_corners() -> list[np.ndarray]: return [ np.array( [[13.568503, 3.6805813], [19.67907, 2.7589796]], dtype=np.float32 ), np.array( [[0.771308, 7.7354484], [4.8191123, 2.6181173]], dtype=np.float32 ), np.array( [[15.471089, 18.194923], [9.326611, 6.637912]], dtype=np.float32 ), np.array( [[3.8954687, 1.0359402], [11.732754, 2.2416632]], dtype=np.float32 ), np.array( [[4.812966, 1.8201057], [8.283552, 10.809121]], dtype=np.float32 ), np.array( [[18.000807, 17.218533], [8.915606, 16.599905]], dtype=np.float32 ), np.array( [[18.782663, 7.1298103], [18.03142, 16.10713]], dtype=np.float32 ), np.array( [[8.764693, 9.151221], [0.23011725, 11.056407]], dtype=np.float32 ), np.array( [[5.1388326, 14.443498], [4.202188, 14.370723]], dtype=np.float32 ), np.array( [[18.97423, 14.230529], [9.725716, 5.771379]], dtype=np.float32 ), ] @pytest.fixture( params=[ 'single_four_corner', 'ten_four_corner', 'single_two_corners', 'ten_two_corners', ] ) def two_and_four_corners(request): return request.getfixturevalue(request.param) @pytest.fixture(params=['single_two_corners', 'ten_two_corners']) def two_corners(request): return request.getfixturevalue(request.param) @pytest.fixture def polygons(): return [ np.array( [ [4.3472557, 19.092707], [12.385868, 9.441998], [11.030792, 7.6805634], [5.285246, 10.27396], [2.7348402, 3.5166616], [3.7063575, 6.323633], ], dtype=np.float32, ), np.array( [ [4.4513884, 1.3679211], [6.0738254, 18.56807], [6.5996447, 12.0634985], [0.18547149, 17.87575], ], dtype=np.float32, ), np.array( [ [12.3054905, 9.864174], [19.879269, 1.6925181], [14.934044, 12.565186], [15.530991, 19.44206], [11.512451, 14.002723], [16.356024, 18.321203], [16.751368, 2.385293], [0.87240964, 2.6350331], ], dtype=np.float32, ), np.array( [ [7.100853, 2.7780702], [4.5400105, 19.321873], [16.720345, 5.207758], [4.631408, 15.734205], [16.353329, 11.723293], [2.6555562, 9.734464], [19.614532, 15.008726], ], dtype=np.float32, ), np.array( [ [5.2782, 3.0509028], [11.8710575, 14.706998], [7.360063, 11.472012], [2.41257, 14.442611], [16.697138, 14.617584], [7.5786357, 6.9946175], [14.939011, 5.434313], [0.37513006, 13.553444], [2.1920238, 9.2616825], [14.726601, 1.4248564], ], dtype=np.float32, ), np.array( [[2.2108445, 8.827738], [4.913185, 4.5486608]], dtype=np.float32 ), np.array( [ [13.347389, 0.15637287], [4.0802207, 2.606402], [9.446134, 3.0769732], [18.885344, 6.4319353], [7.6073093, 7.5026965], [5.6101704, 9.132152], [11.165885, 16.490229], [16.560513, 1.9102397], [12.203619, 9.196948], [10.25309, 4.242064], ], dtype=np.float32, ), np.array( [ [18.879221, 10.407496], [12.269095, 14.931961], [13.592372, 7.0003676], [4.6837134, 8.655116], [13.341547, 12.509719], [18.894463, 4.2734575], ], dtype=np.float32, ), np.array( [ [4.0373907, 8.009811], [18.000036, 7.472767], [0.10347261, 16.277378], [10.2881565, 19.298697], [6.6568017, 18.500061], [10.836271, 19.892216], [16.511509, 19.442808], [16.949703, 14.658965], [7.315138, 18.384237], [17.577011, 15.8563175], ], dtype=np.float32, ), np.array( [ [5.0307846, 13.863499], [12.189665, 6.997516], [8.854627, 15.04776], [12.708408, 14.246945], [6.940458, 17.575687], [6.773896, 16.208294], [19.53695, 4.133974], [0.81575483, 15.194465], ], dtype=np.float32, ), ] napari-0.5.6/napari/layers/shapes/_tests/test_shape_list.py000066400000000000000000000126441474413133200241060ustar00rootroot00000000000000import numpy as np import numpy.testing as npt 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_reset_bounding_box_rotation(): """Test if rotating shape resets bounding box.""" shape = Rectangle(np.array([[0, 0], [10, 10]])) shape_list = ShapeList() shape_list.add(shape) npt.assert_array_almost_equal( shape_list._bounding_boxes, np.array([[[-0.5, -0.5]], [[10.5, 10.5]]]) ) shape_list.rotate(0, 45, (5, 5)) p = 5 * np.sqrt(2) + 0.5 npt.assert_array_almost_equal( shape.bounding_box, np.array([[5 - p, 5 - p], [5 + p, 5 + p]]) ) npt.assert_array_almost_equal( shape_list._bounding_boxes, shape.bounding_box[:, np.newaxis, :] ) def test_reset_bounding_box_shift(): """Test if shifting shape resets bounding box.""" shape = Rectangle(np.array([[0, 0], [10, 10]])) shape_list = ShapeList() shape_list.add(shape) npt.assert_array_almost_equal( shape_list._bounding_boxes, shape.bounding_box[:, np.newaxis, :] ) shape_list.shift(0, np.array([5, 5])) npt.assert_array_almost_equal( shape.bounding_box, np.array([[4.5, 4.5], [15.5, 15.5]]) ) npt.assert_array_almost_equal( shape_list._bounding_boxes, shape.bounding_box[:, np.newaxis, :] ) def test_reset_bounding_box_scale(): """Test if scaling shape resets the bounding box.""" shape = Rectangle(np.array([[0, 0], [10, 10]])) shape_list = ShapeList() shape_list.add(shape) npt.assert_array_almost_equal( shape_list._bounding_boxes, shape.bounding_box[:, np.newaxis, :] ) shape_list.scale(0, 2, (5, 5)) npt.assert_array_almost_equal( shape.bounding_box, np.array([[-5.5, -5.5], [15.5, 15.5]]) ) npt.assert_array_almost_equal( shape_list._bounding_boxes, shape.bounding_box[:, np.newaxis, :] ) def test_shape_list_outline(): """Test ShapeList outline method.""" np.random.seed(0) data = 20 * np.random.random((4, 2)) shape = Rectangle(data) shape_list = ShapeList() shape_list.add(shape) # Check passing an int outline_by_index = shape_list.outline(0) assert isinstance(outline_by_index, tuple) # Check passing a list outline_by_index_list = shape_list.outline([0]) assert isinstance(outline_by_index_list, tuple) # Check return value for `int` and `list` are the same for value_by_idx, value_by_idx_list in zip( outline_by_index, outline_by_index_list ): assert np.array_equal(value_by_idx, value_by_idx_list) # Check passing a `numpy.int_` (`numpy.int32/64` depending on platform) outline_by_index_np = shape_list.outline(np.int_(0)) assert isinstance(outline_by_index_np, tuple) # Check return value for `int` and `numpy.int_` are the same for value_by_idx, value_by_idx_np in zip( outline_by_index, outline_by_index_np ): assert np.array_equal(value_by_idx, value_by_idx_np) def test_shape_list_outline_two_shapes(): shape1 = Polygon([[0, 0], [0, 10], [10, 10], [10, 0]]) shape2 = Polygon([[20, 20], [20, 30], [30, 30], [30, 20]]) shape_list = ShapeList() shape_list.add([shape1, shape2]) # check if the outline contains triangle with vertex of number 16 triangles = shape_list.outline([0, 1])[2] assert np.any(triangles == 16) 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, match='must have shape'): setattr(shape_list, f'{attribute}_color', bad_color_array) def test_inside(): shape1 = Polygon(np.array([[0, 0, 0], [0, 1, 0], [0, 1, 1], [0, 0, 1]])) shape2 = Polygon(np.array([[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]])) shape3 = Polygon(np.array([[2, 0, 0], [2, 1, 0], [2, 1, 1], [2, 0, 1]])) shape_list = ShapeList() shape_list.add([shape1, shape2, shape3]) shape_list.slice_key = (1,) assert shape_list.inside((0.5, 0.5)) == 1 napari-0.5.6/napari/layers/shapes/_tests/test_shapes.py000066400000000000000000002341451474413133200232400ustar00rootroot00000000000000from copy import copy from itertools import cycle, islice from unittest.mock import Mock import numpy as np import pandas as pd import pytest from napari._pydantic_compat import ValidationError from napari._tests.utils import ( assert_colors_equal, check_layer_world_data_extent, ) from napari.components import ViewerModel from napari.components.dims import Dims from napari.layers import Shapes from napari.layers.base._base_constants import ActionType from napari.layers.utils._text_constants import Anchor from napari.layers.utils.color_encoding import ConstantColorEncoding from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) 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() def test_empty_shapes_with_features(): """See the following for the points issues this covers: https://github.com/napari/napari/issues/5632 https://github.com/napari/napari/issues/5634 """ shapes = Shapes( features={'a': np.empty(0, int)}, feature_defaults={'a': 0}, face_color='a', face_color_cycle=list('rgb'), ) shapes.add_rectangles([[0, 0], [1, 1]]) shapes.feature_defaults['a'] = 1 shapes.add_rectangles([[1, 1], [2, 2]]) shapes.feature_defaults = {'a': 2} shapes.add_rectangles([[2, 2], [3, 3]]) assert_colors_equal(shapes.face_color, list('rgb')) 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.array_equal(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.array_equal(layer.properties['shape_type'], add_properties) # test copy/paste layer.selected_data = {0, 1} layer._copy_data() assert np.array_equal( layer._clipboard['features']['shape_type'], ['A', 'B'] ) layer._paste_data() paste_properties = np.concatenate((add_properties, ['A', 'B']), axis=0) assert np.array_equal(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) layer.events.data = Mock() # 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']) @pytest.mark.parametrize('prepend', [(), (7,), (8, 9)]) def test_nd_text(prepend): """Test slicing of text coords with nD shapes We can prepend as many dimensions as we want it should not change the result """ shapes_data = [ [ prepend + (0, 10, 10, 10), prepend + (0, 10, 20, 20), prepend + (0, 10, 10, 20), prepend + (0, 10, 20, 10), ], [ prepend + (1, 20, 30, 30), prepend + (1, 20, 50, 50), prepend + (1, 20, 50, 30), prepend + (1, 20, 30, 50), ], ] layer = Shapes(shapes_data) assert layer.ndim == 4 + len(prepend) layer._slice_dims( Dims( ndim=layer.ndim, ndisplay=2, range=((0, 100, 1),) * layer.ndim, point=prepend + (0, 10, 0, 0), ) ) np.testing.assert_equal(layer._indices_view, [0]) np.testing.assert_equal(layer._view_text_coords[0], [[15, 15]]) # TODO: 1st bug #6205, ndisplay 3 is buggy in 5+ dimensions # may need to call _update_dims layer._slice_dims( Dims( ndim=layer.ndim, ndisplay=3, range=((0, 100, 1),) * layer.ndim, point=prepend + (1, 0, 0, 0), ) ) 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 def test_rectangles(two_and_four_corners): """Test instantiating Shapes layer with a random 2D rectangles.""" # Test instantiating with data layer = Shapes(two_and_four_corners) assert layer.nshapes == len(two_and_four_corners) # 4 corner rectangle(s) passed, assert vertices the same if two_and_four_corners[0].shape[0] == 4: assert np.all( [ layer.data[i] == two_and_four_corners[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 == two_and_four_corners[0].shape[1] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_rectangles_add_method(two_and_four_corners): # Test adding via add_rectangles method layer = Shapes(two_and_four_corners) layer2 = Shapes() layer2.add_rectangles(two_and_four_corners) 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, match='invalid number of vertices'): layer.add_rectangles(data) # multiple rectangles, 5 vertices data = 20 * np.random.random((5, 5, 2)) with pytest.raises(ValueError, match='invalid number of vertices'): layer.add_rectangles(data) def test_rectangle_with_shape_type(single_four_corner: list[np.ndarray]): """Test instantiating rectangles with shape_type in data""" # Test (rectangle, shape_type) tuple data = (single_four_corner, 'rectangle') layer = Shapes(data) assert layer.nshapes == 1 assert np.array_equiv(layer.data[0], data[0]) assert layer.ndim == 2 assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_rectangles_with_shape_type(ten_four_corner: list[np.ndarray]): # Test (list of rectangles, shape_type) tuple data = (ten_four_corner, 'rectangle') layer = Shapes(data) assert layer.nshapes == len(ten_four_corner) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_four_corner)] ) assert layer.ndim == ten_four_corner[0].shape[1] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_rectangles_with_shape_type_per_element( ten_four_corner: list[np.ndarray], ): # Test list of (rectangle, shape_type) tuples data = [(el, 'rectangle') for el in ten_four_corner] layer = Shapes(data) assert layer.nshapes == len(ten_four_corner) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_four_corner)] ) assert layer.ndim == ten_four_corner[0].shape[1] 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(ten_four_corner): """Test instantiating rectangles with negative data.""" data = [x - 10 for x in ten_four_corner] layer = Shapes(data) assert layer.nshapes == len(data) assert np.all([np.array_equal(ld, d) for ld, d in zip(layer.data, data)]) assert layer.ndim == data[0].shape[1] 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) data = np.empty(shape) layer = Shapes(data) assert layer.nshapes == 0 assert len(layer.data) == 0 assert layer.ndim == 2 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, dtype=np.float32) layer = Shapes(data) assert layer.nshapes == len(data) assert np.all([np.array_equal(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.array_equal(ld, ld2) for ld, ld2 in zip(layer.data, layer2.data)] ) assert np.all([s == 'rectangle' for s in layer2.shape_type]) def test_ellipses(two_and_four_corners): """Test instantiating Shapes layer with 2D ellipses.""" # Test instantiating with data layer = Shapes(two_and_four_corners, shape_type='ellipse') assert layer.nshapes == len(two_and_four_corners) # 4 corner bounding box passed, assert vertices the same if two_and_four_corners[0].shape[0] == 4: assert np.all( [ layer.data[i] == two_and_four_corners[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 == two_and_four_corners[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_ellipses_add_method(two_and_four_corners): # Test adding via add_ellipses method layer = Shapes(two_and_four_corners, shape_type='ellipse') layer2 = Shapes() layer2.add_ellipses(two_and_four_corners) 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, match='invalid number of vertices'): layer.add_ellipses(data) # multiple ellipses, 5 vertices data = 20 * np.random.random((5, 5, 2)) with pytest.raises(ValueError, match='invalid number of vertices'): layer.add_ellipses(data) def test_single_ellipses_with_shape_type(single_four_corner): """Test instantiating ellipses with shape_type in data""" # Test single four corner (vertices, shape_type) tuple data = (single_four_corner, 'ellipse') layer = Shapes(data) assert layer.nshapes == len(single_four_corner) assert np.array_equiv(layer.data[0], data[0]) assert layer.ndim == single_four_corner[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_ten_ellipses_with_shape_type(ten_four_corner): # Test multiple four corner (list of vertices, shape_type) tuple data = (ten_four_corner, 'ellipse') layer = Shapes(data) assert layer.nshapes == len(ten_four_corner) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_four_corner)] ) assert layer.ndim == ten_four_corner[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_ten_ellipses_with_shape_type_per_shape(ten_four_corner): # Test list of four corner (vertices, shape_type) tuples data = [(el, 'ellipse') for el in ten_four_corner] layer = Shapes(data) assert layer.nshapes == len(ten_four_corner) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_four_corner)] ) assert layer.ndim == ten_four_corner[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_single_ellipses_two_corner_with_shape_type(single_two_corners): # Test single (center-radii, shape_type) ellipse data = (single_two_corners, 'ellipse') layer = Shapes(data) assert layer.nshapes == 1 assert len(layer.data[0]) == 4 assert layer.ndim == single_two_corners[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_ellipses_two_corner_with_shape_type(ten_two_corners): # Test (list of center-radii, shape_type) tuple data = (ten_two_corners, 'ellipse') layer = Shapes(data) assert layer.nshapes == len(ten_two_corners) assert np.all([len(ld) == 4 for ld in layer.data]) assert layer.ndim == ten_two_corners[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_ellipses_two_corner_with_shape_type_per_shape(ten_two_corners): # Test list of (center-radii, shape_type) tuples data = [(el, 'ellipse') for el in ten_two_corners] layer = Shapes(data) assert layer.nshapes == len(ten_two_corners) assert np.all([len(ld) == 4 for ld in layer.data]) assert layer.ndim == ten_two_corners[0].shape[1] 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.array_equal(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.array_equal(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)]) def test_lines(two_corners): """Test instantiating Shapes layer with a random 2D lines.""" # Test instantiating with data layer = Shapes(two_corners, shape_type='line') assert layer.nshapes == len(two_corners) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, two_corners)] ) assert layer.ndim == two_corners[0].shape[1] assert np.all([s == 'line' for s in layer.shape_type]) def test_lines_add_method(two_corners): # Test adding using add_lines layer = Shapes(two_corners, shape_type='line') layer2 = Shapes() layer2.add_lines(two_corners) 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, match='invalid number of vertices'): 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, match='invalid number of vertices'): layer.add_lines(data) def test_single_lines_with_shape_type(single_two_corners): """Test instantiating lines with shape_type""" # Test (single line, shape_type) tuple data = (single_two_corners, 'line') layer = Shapes(data) assert layer.nshapes == len(single_two_corners) assert np.array_equal(layer.data[0], single_two_corners[0]) assert layer.ndim == single_two_corners[0].shape[1] assert np.all([s == 'line' for s in layer.shape_type]) def test_ten_lines_with_shape_type(ten_two_corners): # Test (multiple lines, shape_type) tuple data = (ten_two_corners, 'line') layer = Shapes(data) assert layer.nshapes == len(ten_two_corners) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_two_corners)] ) assert layer.ndim == ten_two_corners[0].shape[1] assert np.all([s == 'line' for s in layer.shape_type]) def test_ten_lines_with_shape_type_per_shape(ten_two_corners): # Test list of (line, shape_type) tuples data = [(el, 'line') for el in ten_two_corners] layer = Shapes(data) assert layer.nshapes == len(ten_two_corners) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_two_corners)] ) assert layer.ndim == ten_two_corners[0].shape[1] 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).astype(np.float32)] layer = Shapes(data, shape_type='path') assert layer.nshapes == len(data) assert np.all([np.array_equal(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, match='invalid number of vertices'): layer.add_paths(data) # multiple paths data = 20 * np.random.random((10, 1, 2)) with pytest.raises(ValueError, match='invalid number of vertices'): 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).astype(np.float32) data = (path_points, 'path') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.array_equal(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)).astype(np.float32) for i in range(10) ] data = (path_points, 'path') layer = Shapes(data) assert layer.nshapes == len(path_points) assert np.all( [np.array_equal(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.array_equal(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.array_equal(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).astype(np.float32)] layer = Shapes(data, shape_type='polygon') assert layer.nshapes == len(data) assert np.all([np.array_equal(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.events.data = Mock() 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]) # Avoid a.any(), a.all() assert layer2.events.data.call_args_list[0][1] == { 'value': [], 'action': ActionType.ADDING, 'data_indices': (-1,), 'vertex_indices': ((),), } assert np.array_equal( layer2.events.data.call_args_list[1][1]['value'], layer.data ) assert ( layer2.events.data.call_args_list[0][1]['action'] == ActionType.ADDING ) assert layer2.events.data.call_args_list[0][1]['data_indices'] == (-1,) assert layer2.events.data.call_args_list[0][1]['vertex_indices'] == ((),) 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, match='invalid number of vertices'): 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, match='invalid number of vertices'): 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).astype(np.float32) data = (vertices, 'polygon') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.array_equal(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)).astype(np.float32) for i in range(10) ] data = (polygons, 'polygon') layer = Shapes(data) assert layer.nshapes == len(polygons) assert np.all( [np.array_equal(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.array_equal(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.array_equal(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)).astype(np.float32) for i in range(5) ] + list(np.random.random((5, 4, 2)).astype(np.float32)) 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.array_equal(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.array_equal(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)).astype(np.float32) for i in range(5) ] + list(np.random.random((5, 4, 2)).astype(np.float32)) 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.array_equal(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(ten_four_corner, twenty_four_corner): """Test changing Shapes data.""" layer = Shapes(ten_four_corner) assert layer.nshapes == len(ten_four_corner) layer.data = twenty_four_corner assert layer.nshapes == len(twenty_four_corner) assert np.all( [ np.array_equal(ld, d) for ld, d in zip(layer.data, twenty_four_corner) ] ) assert layer.ndim == twenty_four_corner[0].shape[1] assert np.all([s == 'rectangle' for s in layer.shape_type]) # setting data with shape type data_a = (ten_four_corner, 'ellipse') layer.data = data_a assert layer.nshapes == len(ten_four_corner) assert np.all( [np.array_equal(ld, d) for ld, d in zip(layer.data, ten_four_corner)] ) assert layer.ndim == ten_four_corner[0].shape[1] assert np.all([s == 'ellipse' for s in layer.shape_type]) # setting data with fewer shapes smaller_data = ten_four_corner[: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 == len(smaller_data) 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 = twenty_four_corner layer.data = bigger_data assert layer.nshapes == len(bigger_data) 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(polygons, ten_four_corner): """Test adding shapes.""" # Start with polygons with different numbers of points # shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer = Shapes(polygons, shape_type='polygon') new_shape_type = ['rectangle'] * 6 + ['ellipse'] * 4 layer.add(ten_four_corner, shape_type=new_shape_type) all_data = polygons + ten_four_corner all_shape_type = ['polygon'] * len(polygons) + new_shape_type assert layer.nshapes == len(all_data) assert np.all( [np.array_equal(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)]) def test_adding_shapes_per_shape(polygons, ten_four_corner): # test adding data with shape_type layer = Shapes(polygons, shape_type='polygon') new_shape_type = ['ellipse'] * 6 + ['rectangle'] * 4 new_data = list(zip(ten_four_corner, new_shape_type)) layer.add(new_data) all_vertices = polygons + ten_four_corner all_shape_type = ['polygon'] * len(polygons) + new_shape_type assert layer.nshapes == len(all_vertices) assert np.all( [np.array_equal(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)).astype(np.float32) for i in range(5) ] + list(np.random.random((5, 4, 2)).astype(np.float32)) shape_type = ['path'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer.add(data, shape_type=shape_type) assert layer.nshapes == len(data) assert np.all([np.array_equal(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) layer.events.data = Mock() old_data = layer.data assert layer.nshapes == 10 layer.data = [] assert layer.nshapes == 0 assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.REMOVING, 'data_indices': tuple(i for i in range(len(old_data))), 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { 'value': layer.data, 'action': ActionType.REMOVED, 'data_indices': (), 'vertex_indices': ((),), } 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) layer.events.data = Mock() old_data = layer.data assert layer.nshapes == 10 layer.data = np.empty((0, 2)) assert layer.nshapes == 0 assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.REMOVING, 'data_indices': tuple(i for i in range(len(old_data))), 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { 'value': layer.data, 'action': ActionType.REMOVED, 'data_indices': (), 'vertex_indices': ((),), } def test_removing_selected_shapes(): """Test removing selected shapes.""" np.random.seed(0) data = [ 20 * np.random.random((np.random.randint(2, 12), 2)).astype(np.float32) for i in range(5) ] + list(np.random.random((5, 4, 2)).astype(np.float32)) shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer = Shapes(data, shape_type=shape_type) layer.events.data = Mock() old_data = layer.data # With nothing selected no points should be removed layer.remove_selected() layer.events.data.assert_not_called() assert len(layer.data) == len(data) # Select three shapes and remove them selection = {1, 7, 8} layer.selected_data = selection layer.remove_selected() assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.REMOVING, 'data_indices': tuple( selection, ), 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { 'value': layer.data, 'action': ActionType.REMOVED, 'data_indices': tuple( selection, ), 'vertex_indices': ((),), } keep = [0, *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.array_equal(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.mouse_pan is True modes = [ 'select', 'direct', 'vertex_insert', 'vertex_remove', 'add_rectangle', 'add_ellipse', 'add_line', 'add_polyline', 'add_path', 'add_polygon', 'add_polygon_lasso', ] for mode in modes: layer.mode = mode assert layer.mode == mode assert layer.mouse_pan is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.mouse_pan 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.array_equal(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.array_equal(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, match='must be a valid Shapes.properties'): 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), 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, match='does not match number of shapes'): 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, match='does not match number of shapes'): 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.array_equal(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.array_equal(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]] assert Shapes([]).get_value((0,) * 2) == (None, None) 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) def test_value_non_convex(): """Test getting the value of the data at the current coordinates.""" data = [ [[0, 0], [10, 10], [20, 0], [10, 5]], ] layer = Shapes(data, shape_type='polygon') assert layer.get_value((1,) * 2) == (0, None) assert layer.get_value((10, 3)) == (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(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 0))) 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 isinstance(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 isinstance(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.array_equal(labels.shape, labels_shape) assert np.array_equal(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)) 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(Dims(ndim=3, 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 ] def test_shapes_data_setter_emits_event(): data = np.random.random((4, 2)) emitted_events = Mock() layer = Shapes(data) layer.events.data.connect(emitted_events) layer.data = np.random.random((4, 2)) assert emitted_events.call_count == 2 def test_shapes_add_delete_only_emit_two_events(): data = np.random.random((4, 2)) emitted_events = Mock() layer = Shapes(data) layer.events.data.connect(emitted_events) layer.add(np.random.random((4, 2))) assert emitted_events.call_count == 2 layer.selected_data = {1} layer.remove_selected() assert emitted_events.call_count == 4 def test_clean_selection_on_set_data(): data = [[[0, 0], (10, 10)], [[0, 15], [10, 25]]] layer = Shapes(data) layer.selected_data = {0} layer.data = [[[0, 0], (10, 10)]] assert layer.selected_data == set() def test_docstring(): validate_all_params_in_docstring(Shapes) validate_kwargs_sorted(Shapes) napari-0.5.6/napari/layers/shapes/_tests/test_shapes_key_bindings.py000066400000000000000000000067271474413133200257700ustar00rootroot00000000000000import 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_polyline_mode(layer) assert layer.mode == 'add_polyline' 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.6/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py000066400000000000000000001054261474413133200263240ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np import pytest from napari.layers import Shapes from napari.layers.base._base_constants import ActionType from napari.layers.shapes.shapes import Mode from napari.settings import get_settings from napari.utils._test_utils import read_only_mouse_event from napari.utils.interactions import ( mouse_double_click_callbacks, mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) @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) # very zoomed in, guaranteed no overlap between vertices layer.scale_factor = 0.001 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): """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 = read_only_mouse_event( type='mouse_press', ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', ) 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): """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 = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) known_non_shape_end = [40, 60] # Simulate drag end event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=known_non_shape_end, ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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 # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} def test_line_fixed_angles(create_known_shapes_layer): """Draw line with fixed angles.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.mode = 'add_line' # set _fixed_aspect, like Shift would layer._fixed_aspect = True # Simulate click with shift event = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) known_non_shape_end = [40, 60] # Simulate drag with shift event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=known_non_shape_end, ) mouse_move_callbacks(layer, event) # Simulate release with shift event = read_only_mouse_event( type='mouse_release', position=known_non_shape_end, ) mouse_release_callbacks(layer, event) new_line = layer.data[-1][-1] - layer.data[-1][0] # Check new shape added at coordinates assert len(layer.data) == n_shapes + 1 # start should match mouse event assert np.allclose( layer.data[-1][0], np.asarray(known_non_shape).astype(float) ) # With _fixed_aspect, the line end won't be at the end event assert not np.allclose( layer.data[-1][-1], np.asarray(known_non_shape_end).astype(float) ) # with _fixed_aspect the angle of the line should be 45 degrees theta = np.degrees(np.arctan2(new_line[1], new_line[0])) assert np.allclose(theta, 45.0) def test_path_tablet(create_known_shapes_layer): layer, n_shapes, known_non_shape = create_known_shapes_layer desired_shape = np.array([[20, 30], [10, 50], [60, 40], [80, 20]]) layer.mode = 'add_path' event = read_only_mouse_event( type='mouse_press', is_dragging=True, position=desired_shape[0], pos=desired_shape[0], ) mouse_press_callbacks(layer, event) assert layer.shape_type[-1] == 'path' for coord in desired_shape[1:]: event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=coord, pos=coord, ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_release', is_dragging=True, position=desired_shape[-1], pos=desired_shape[-1], ) mouse_release_callbacks(layer, event) assert len(layer.data) == n_shapes + 1 assert np.array_equal(desired_shape, layer.data[-1]) assert layer.shape_type[-1] == 'path' assert not layer._is_creating # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} def test_polyline_mouse(create_known_shapes_layer): layer, n_shapes, known_non_shape = create_known_shapes_layer desired_shape = np.array([[20, 30], [10, 50], [60, 40], [80, 20]]) layer.mode = 'add_path' event = read_only_mouse_event( type='mouse_press', position=desired_shape[0], pos=desired_shape[0], ) mouse_press_callbacks(layer, event) assert layer.shape_type[-1] == 'path' for coord in desired_shape[1:]: event = read_only_mouse_event( type='mouse_move', position=coord, pos=coord, ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_press', position=desired_shape[-1], pos=desired_shape[-1], ) mouse_press_callbacks(layer, event) assert len(layer.data) == n_shapes + 1 assert np.array_equal(desired_shape, layer.data[-1]) assert layer.shape_type[-1] == 'path' assert not layer._is_creating # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} def test_polygon_lasso_tablet(create_known_shapes_layer): """Draw polygon with tablet simulated by mouse drag event.""" layer, n_shapes, known_non_shape = create_known_shapes_layer desired_shape = np.array([[20, 30], [10, 50], [60, 40], [80, 20]]) get_settings().experimental.rdp_epsilon = 0 layer.mode = 'add_polygon_lasso' event = read_only_mouse_event( type='mouse_press', is_dragging=True, position=desired_shape[0], pos=desired_shape[0], ) mouse_press_callbacks(layer, event) assert layer.shape_type[-1] != 'polygon' for coord in desired_shape[1:]: event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=coord, pos=coord, ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_release', is_dragging=True, position=desired_shape[-1], pos=desired_shape[-1], ) mouse_release_callbacks(layer, event) assert len(layer.data) == n_shapes + 1 assert np.array_equal(desired_shape, layer.data[-1]) assert layer.shape_type[-1] == 'polygon' assert not layer._is_creating # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} def test_polygon_lasso_mouse(create_known_shapes_layer): """Draw polygon with mouse. Events in sequence are mouse press, release, move, press, release""" layer, n_shapes, known_non_shape = create_known_shapes_layer desired_shape = np.array([[20, 30], [10, 50], [60, 40], [80, 20]]) get_settings().experimental.rdp_epsilon = 0 layer.mode = 'add_polygon_lasso' event = read_only_mouse_event( type='mouse_press', position=desired_shape[0], pos=desired_shape[0], ) mouse_press_callbacks(layer, event) assert layer.shape_type[-1] != 'polygon' for coord in desired_shape[1:]: event = read_only_mouse_event( type='mouse_move', position=coord, pos=coord, ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_press', position=desired_shape[-1], pos=desired_shape[-1], ) mouse_press_callbacks(layer, event) assert len(layer.data) == n_shapes + 1 assert np.array_equal(desired_shape, layer.data[-1]) assert layer.shape_type[-1] == 'polygon' assert not layer._is_creating # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} def test_distance_polygon_creating(create_known_shapes_layer): """Test that distance threshold in polygon creating works as intended""" layer, n_shapes, known_non_shape = create_known_shapes_layer # While drawing only 2 of the vertices should be added to shape data because distance threshold is 10 vertices = [[x, 0] for x in range(11)] layer.mode = 'add_polygon_lasso' event = read_only_mouse_event( type='mouse_press', position=vertices[0], pos=vertices[0], ) mouse_press_callbacks(layer, event) for coord in vertices[1:]: event = read_only_mouse_event( type='mouse_move', position=coord, pos=coord, ) mouse_move_callbacks(layer, event) assert len(layer.data[-1] == 2) @pytest.mark.parametrize('shape_type', ['polyline', 'polygon']) def test_add_complex_shape(shape_type, create_known_shapes_layer): """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 = read_only_mouse_event( type='mouse_move', position=coord, pos=np.array(coord, dtype=float), ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_press', position=coord, pos=np.array(coord, dtype=float), ) mouse_press_callbacks(layer, event) event = read_only_mouse_event( type='mouse_release', position=coord, pos=np.array(coord, dtype=float), ) mouse_release_callbacks(layer, event) # finish drawing end_click = read_only_mouse_event( type='mouse_double_click', 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) shape_type = shape_type if shape_type == 'polygon' else 'path' assert layer.shape_type[-1] == shape_type # Ensure it's selected, accounting for zero-indexing assert len(layer.selected_data) == 1 assert layer.selected_data == {n_shapes} @pytest.mark.parametrize( 'shape_type_vertices', [ ('polyline', [[20, 30], [20, 30]]), ('polygon', [[20, 30], [10, 50], [10, 50]]), ], ) def test_add_invalid_shape(shape_type_vertices, create_known_shapes_layer): """Check invalid shape clicking behavior in add polygon mode.""" layer, n_shapes, known_non_shape = create_known_shapes_layer # Add shape at location where non exists shape_type, shape_vertices = shape_type_vertices layer.mode = f'add_{shape_type}' for coord in shape_vertices: # Simulate move, click, and release event = read_only_mouse_event( type='mouse_move', position=coord, pos=np.array(coord, dtype=float), ) mouse_move_callbacks(layer, event) event = read_only_mouse_event( type='mouse_press', position=coord, pos=np.array(coord, dtype=float), ) mouse_press_callbacks(layer, event) event = read_only_mouse_event( type='mouse_release', position=coord, pos=np.array(coord, dtype=float), ) mouse_release_callbacks(layer, event) # Although the shape/polygon being created is invalid, three shapes should # be available until it is marked as finished via mouse double click assert len(layer.data) == n_shapes + 1 # finish drawing causing the removal of the in progress shape since is invalid end_click = read_only_mouse_event( type='mouse_double_click', position=coord, pos=np.array(coord, dtype=float), ) assert layer.mouse_double_click_callbacks mouse_double_click_callbacks(layer, end_click) # Ensure no data is selected, number of shapes is the initial one and # last cursor position variable was reset assert len(layer.selected_data) == 0 assert len(layer.data) == n_shapes assert layer._last_cursor_position is None def test_vertex_insert(create_known_shapes_layer): """Add vertex to shape.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.events.data = Mock() n_coord = len(layer.data[0]) layer.mode = 'vertex_insert' layer.selected_data = {0} old_data = layer.data # Simulate click event = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) # Simulate drag end event = read_only_mouse_event( type='mouse_move', is_dragging=True, 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 assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.CHANGING, 'data_indices': tuple(layer.selected_data), 'vertex_indices': ((2,),), } assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': tuple(layer.selected_data), 'vertex_indices': ((2,),), } 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): """Remove vertex from shape.""" layer, n_shapes, known_non_shape = create_known_shapes_layer old_data = layer.data layer.events.data = Mock() n_coord = len(layer.data[0]) layer.mode = 'vertex_remove' select = {0} layer.selected_data = select position = tuple(layer.data[0][0]) # Simulate click event = read_only_mouse_event( type='mouse_press', position=position, ) mouse_press_callbacks(layer, event) assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.CHANGING, 'data_indices': tuple( select, ), 'vertex_indices': ((0,),), } assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': tuple( select, ), 'vertex_indices': ((0,),), } 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): """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 = read_only_mouse_event( type='mouse_press', position=position, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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): """Select and drag vertex.""" layer, n_shapes, _ = create_known_shapes_layer layer.events.data = Mock() old_data = layer.data layer.mode = 'select' 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 = read_only_mouse_event( type='mouse_press', position=position, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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 = read_only_mouse_event( type='mouse_press', is_dragging=True, position=position, ) mouse_press_callbacks(layer, event) # start drag event event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=position, ) mouse_move_callbacks(layer, event) position = tuple(np.add(position, [10, 5])) # Simulate move, click, and release event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=position, ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=True, position=position, ) mouse_release_callbacks(layer, event) # Check clicked shape selected vertex_indices = (tuple(range(len(layer.data[0]))),) assert len(layer.selected_data) == 1 assert layer.selected_data == {0} assert layer.events.data.call_args_list[0][1] == { 'value': old_data, 'action': ActionType.CHANGING, 'data_indices': (0,), 'vertex_indices': vertex_indices, } assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': (0,), 'vertex_indices': vertex_indices, } np.testing.assert_allclose(layer.data[0], orig_data + np.array([10, 5])) def test_rotate_shape(create_known_shapes_layer): """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 = read_only_mouse_event( type='mouse_press', is_dragging=True, position=position, ) mouse_press_callbacks(layer, event) # start drag event event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=position, ) mouse_move_callbacks(layer, event) # drag in the handle to bottom midpoint vertex to rotate 180 degrees position = tuple(layer._selected_box[3]) # Simulate move, click, and release event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=position, ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=True, 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): """Select and drag vertex.""" layer, n_shapes, _ = create_known_shapes_layer layer.events.data = Mock() layer.mode = 'direct' layer.selected_data = {0} old_position = tuple(layer.data[0][0]) # Simulate click event = read_only_mouse_event( type='mouse_press', position=old_position, ) mouse_press_callbacks(layer, event) new_position = [0, 0] assert np.all(new_position != old_position) # Simulate move, click, and release event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=new_position, ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=True, position=new_position, ) mouse_release_callbacks(layer, event) # Check clicked shape selected vertex_indices = (tuple(range(len(layer.data[0]))),) assert len(layer.selected_data) == 1 assert layer.selected_data == {0} assert layer.events.data.call_args[1] == { 'value': layer.data, 'action': ActionType.CHANGED, 'data_indices': (0,), 'vertex_indices': vertex_indices, } np.testing.assert_allclose(layer.data[0][0], [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): """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 = read_only_mouse_event( type='mouse_press', position=position, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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', [ 'add_polygon', 'add_path', ], ) def test_clicking_the_same_point_is_not_crashing( mode, create_known_shapes_layer ): layer, n_shapes, _ = create_known_shapes_layer layer.mode = mode position = tuple(layer.data[0][0]) for _ in range(2): event = read_only_mouse_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) event = read_only_mouse_event(type='mouse_release', position=position) mouse_release_callbacks(layer, event) # If there was no move event between the two clicks, we expect value[1] must be None assert layer.get_value(event.position, world=True)[1] is None @pytest.mark.parametrize( 'mode', [ 'add_polygon', 'add_polyline', ], ) def test_is_creating_is_false_on_creation(mode, create_known_shapes_layer): layer, n_shapes, _ = create_known_shapes_layer layer.mode = mode position = tuple(layer.data[0][0]) def is_creating_is_True(event): assert event.source._is_creating def is_creating_is_False(event): assert not event.source._is_creating assert not layer._is_creating layer.events.set_data.connect(is_creating_is_True) event = read_only_mouse_event(type='mouse_press', position=position) mouse_press_callbacks(layer, event) assert layer._is_creating event = read_only_mouse_event(type='mouse_release', position=position) mouse_release_callbacks(layer, event) assert layer._is_creating layer.events.set_data.disconnect(is_creating_is_True) layer.events.set_data.connect(is_creating_is_False) end_click = read_only_mouse_event( type='mouse_double_click', position=position, ) mouse_double_click_callbacks(layer, end_click) assert not layer._is_creating @pytest.mark.parametrize('mode', ['select', 'direct']) def test_unselect_select_shape(mode, create_known_shapes_layer): """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 = read_only_mouse_event( type='mouse_press', position=position, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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): """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 = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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): """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 = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', 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): """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 = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) # Simulate drag start event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=known_non_shape, ) mouse_move_callbacks(layer, event) # Simulate drag end event = read_only_mouse_event(type='mouse_move', is_dragging=True) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=True, ) 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): """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 = read_only_mouse_event( type='mouse_press', position=known_non_shape, ) mouse_press_callbacks(layer, event) # Simulate drag start event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=known_non_shape, ) mouse_move_callbacks(layer, event) # Simulate drag end event = read_only_mouse_event( type='mouse_move', is_dragging=True, position=(50, 60), ) mouse_move_callbacks(layer, event) # Simulate release event = read_only_mouse_event( type='mouse_release', is_dragging=True, 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} == set(Mode.keys()) @pytest.mark.parametrize( ('pre_selection', 'on_point', 'modifier'), [ (set(), True, []), ({1}, True, []), ], ) def test_drag_start_selection( create_known_shapes_layer, 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 = read_only_mouse_event( type='mouse_press', is_dragging=True, modifiers=modifier, position=initial_position, ) 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 = read_only_mouse_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 = read_only_mouse_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 = read_only_mouse_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: pytest.fail('Unreachable code') assert layer._drag_box is None assert layer._drag_start is None napari-0.5.6/napari/layers/shapes/_tests/test_shapes_utils.py000066400000000000000000000275721474413133200244640ustar00rootroot00000000000000import 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, perpendicular_distance, rdp, ) W_DATA = [[0, 3], [1, 0], [2, 3], [5, 0], [2.5, 5]] line_points = [ (np.array([0, 0]), np.array([0, 3]), np.array([1, 0])), (np.array([0, 0, 0]), np.array([0, 0, 3]), np.array([1, 0, 0])), ( np.array([0, 0, 0, 0]), np.array([0, 0, 3, 0]), np.array([1, 0, 0, 0]), ), (np.array([0, 0, 0]), np.array([0, 0, 0]), np.array([1, 0, 0])), ] 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, 0.5], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], [-0.29235514, 0.40562109], [-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, 0.5], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], [-0.29235514, 0.40562109], [-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, 0.5], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], [0.11487646, -0.48662449], [-0.29235514, 0.40562109], [-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.47952713, -0.14161119], [-0.31234752, 0.3904344], [-0.0, 0.5], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], [0.11487646, -0.48662449], [-0.29235514, 0.40562109], [-0.35355339, -0.35355339], [-0.10726172, -0.48835942], [0.4472136, 0.2236068], [0.47952713, -0.14161119], [-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.fixture def create_complex_shape(): shape = np.array( [ [136.74888492, -279.3367529], [144.05664585, -286.64451383], [154.10481713, -295.77921499], [162.32604817, -303.08697591], [170.54727921, -307.65432649], [179.68198037, -306.74085638], [187.90321142, -300.34656557], [193.38403211, -291.21186441], [195.21097235, -282.07716325], [196.12444246, -272.94246209], [200.69179304, -264.72123104], [207.08608385, -255.58652988], [214.39384478, -246.45182872], [218.04772525, -237.31712756], [212.56690455, -229.09589652], [207.99955397, -220.87466548], [205.25914362, -209.91302409], [203.43220339, -200.77832293], [203.43220339, -189.81668153], [199.77832293, -179.76851026], [189.73015165, -171.54727921], [179.68198037, -166.97992864], [169.6338091, -164.23951829], [160.49910794, -166.06645852], [149.53746655, -169.72033898], [140.40276539, -176.11462979], [134.00847458, -185.24933095], [126.70071365, -195.29750223], [121.21989295, -204.43220339], [118.4794826, -213.56690455], [114.82560214, -222.70160571], [115.73907226, -232.74977698], [118.4794826, -241.88447814], [123.9603033, -251.0191793], [129.441124, -259.24041035], ] ) return shape @pytest.mark.parametrize( ('path', 'closed', 'limit', 'bevel', 'expected'), cases, ) def test_generate_2D_edge_meshes( path, closed, limit, bevel, expected, ): 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' def test_rdp(create_complex_shape): # Rational of test is more vertices should be removed as epsilon gets higher. shape = create_complex_shape rdp_shape = rdp(shape, 0) assert len(shape) == len(rdp_shape) rdp_shape = rdp(shape, 1) assert len(rdp_shape) < len(shape) rdp_shape_lt = rdp(shape, 2) assert len(rdp_shape_lt) < len(rdp_shape) @pytest.mark.parametrize(('start', 'end', 'point'), line_points) def test_perpendicular_distance(start, end, point): # check whether math is correct and works higher than 2D / 3d distance = perpendicular_distance(point, start, end) assert distance == 1 napari-0.5.6/napari/layers/shapes/_tests/test_triangulation.py000066400000000000000000000105541474413133200246310ustar00rootroot00000000000000import importlib from unittest.mock import patch import numpy as np import numpy.testing as npt import pytest ac = pytest.importorskip('napari.layers.shapes._accelerated_triangulate') @pytest.fixture def _disable_jit(monkeypatch): """Fixture to temporarily disable numba JIT during testing. This helps to measure coverage and in debugging. *However*, reloading a module can cause issues with object instance / class relationships, so the `_accelerated_cmap` module should be as small as possible and contain no class definitions, only functions. """ pytest.importorskip('numba') with patch('numba.core.config.DISABLE_JIT', True): importlib.reload(ac) yield importlib.reload(ac) @pytest.mark.parametrize( ('path', 'closed', 'bevel', 'expected'), [ ([[0, 0], [0, 10], [10, 10], [10, 0]], True, False, 10), ([[0, 0], [0, 10], [10, 10], [10, 0]], False, False, 8), ([[0, 0], [0, 10], [10, 10], [10, 0]], True, True, 14), ([[0, 0], [0, 10], [10, 10], [10, 0]], False, True, 10), ([[2, 10], [0, -5], [-2, 10], [-2, -10], [2, -10]], True, False, 15), ([[0, 0], [0, 10]], False, False, 4), ([[0, 0], [0, 10], [0, 20]], False, False, 6), ([[0, 0], [0, 2], [10, 1]], True, False, 9), ([[0, 0], [10, 1], [9, 1.1]], False, False, 7), ([[9, 0.9], [10, 1], [0, 2]], False, False, 7), ([[0, 0], [-10, 1], [-9, 1.1]], False, False, 7), ([[-9, 0.9], [-10, 1], [0, 2]], False, False, 7), ], ) @pytest.mark.usefixtures('_disable_jit') def test_generate_2D_edge_meshes(path, closed, bevel, expected): centers, offsets, triangles = ac.generate_2D_edge_meshes( np.array(path, dtype='float32'), closed=closed, bevel=bevel ) assert centers.shape == offsets.shape assert centers.shape[0] == expected assert triangles.shape[0] == expected - 2 @pytest.mark.parametrize( ('data', 'expected', 'closed'), [ ( np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), True, ), ( np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), False, ), ( np.array( [[0, 0], [1, 0], [1, 0], [1, 1], [0, 1]], dtype='float32' ), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), True, ), ( np.array( [[0, 0], [1, 0], [1, 0], [1, 1], [0, 1]], dtype='float32' ), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), False, ), ( np.array( [[0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 1], [0, 1]], dtype='float32', ), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), False, ), ( np.array( [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype='float32' ), np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype='float32'), True, ), ( np.array( [ [0, 0], [1, 0], [1, 1], [0, 1], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], ], dtype='float32', ), np.array( [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype='float32' ), True, ), ], ) @pytest.mark.usefixtures('_disable_jit') def test_remove_path_duplicates(data, expected, closed): result = ac.remove_path_duplicates(data, closed=closed) assert np.all(result == expected) @pytest.mark.usefixtures('_disable_jit') def test_create_box_from_bounding(): bounding = np.array([[0, 0], [2, 2]], dtype='float32') box = ac.create_box_from_bounding(bounding) assert box.shape == (9, 2) npt.assert_array_equal( box, [ [0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 2], [0, 2], [0, 1], [1, 1], ], ) napari-0.5.6/napari/layers/shapes/shapes.py000066400000000000000000003601041474413133200206730ustar00rootroot00000000000000import warnings from contextlib import contextmanager from copy import copy, deepcopy from itertools import cycle from typing import ( Any, Callable, ClassVar, Optional, Union, ) import numpy as np import numpy.typing as npt import pandas as pd from vispy.color import get_color_names from napari.layers.base import Layer, no_op from napari.layers.base._base_constants import ActionType 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 ( _set_highlight, add_ellipse, add_line, add_path_polygon, add_path_polygon_lasso, add_rectangle, finish_drawing_shape, highlight, polygon_creating, 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, rdp, 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.settings import get_settings 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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. 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()) 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. 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. 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()) feature_defaults : dict[str, Any] or Dataframe-like The default value of each feature in a table with one row. features : dict[str, array-like] or Dataframe-like Features table where each row corresponds to a shape and each column is a feature. metadata : dict Layer metadata. name : str Name of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimenions. 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. 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 : tuple of float Scale factors for the layer. 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. 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. 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. translate : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. 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. 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. axis_labels : tuple of str Dimension names of the layer data. 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_POLYLINE, ADD_PATH, and ADD_POLYGON modes all allow for their corresponding shape type to be added. units: tuple of pint.Unit Units of the layer data in world coordinates. 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 _face_color_property: str _edge_color_property: str _face_color_cycle: npt.NDArray _edge_color_cycle: npt.NDArray _face_color_cycle_values: npt.NDArray _edge_color_cycle_values: npt.NDArray _face_color_mode: str _edge_color_mode: str # If more shapes are present then they are randomly subsampled # in the thumbnail _max_shapes_thumbnail = 100 _drag_modes: ClassVar[dict[Mode, Callable[['Shapes', Event], Any]]] = { 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_lasso, Mode.ADD_POLYLINE: add_path_polygon, Mode.ADD_POLYGON: add_path_polygon, Mode.ADD_POLYGON_LASSO: add_path_polygon_lasso, } _move_modes: ClassVar[dict[Mode, Callable[['Shapes', Event], Any]]] = { 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_POLYLINE: polygon_creating, Mode.ADD_PATH: polygon_creating, Mode.ADD_POLYGON: polygon_creating, Mode.ADD_POLYGON_LASSO: polygon_creating, } _double_click_modes: ClassVar[ dict[Mode, Callable[['Shapes', Event], Any]] ] = { 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: no_op, Mode.ADD_POLYLINE: finish_drawing_shape, Mode.ADD_POLYGON: finish_drawing_shape, Mode.ADD_POLYGON_LASSO: no_op, } _cursor_modes: ClassVar[dict[Mode, str]] = { 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_POLYLINE: 'cross', Mode.ADD_PATH: 'cross', Mode.ADD_POLYGON: 'cross', Mode.ADD_POLYGON_LASSO: 'cross', } _interactive_modes: ClassVar[set[Mode]] = { Mode.PAN_ZOOM, } def __init__( self, data=None, ndim=None, *, affine=None, axis_labels=None, blending='translucent', cache=True, edge_color='#777777', edge_color_cycle=None, edge_colormap='viridis', edge_contrast_limits=None, edge_width=1, experimental_clipping_planes=None, face_color='white', face_color_cycle=None, face_colormap='viridis', face_contrast_limits=None, feature_defaults=None, features=None, metadata=None, name=None, opacity=0.7, projection_mode='none', properties=None, property_choices=None, rotate=None, scale=None, shape_type='rectangle', shear=None, text=None, translate=None, units=None, visible=True, z_index=0, ) -> None: if data is None or len(data) == 0: 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, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, name=name, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) 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, feature_defaults=feature_defaults, 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._data_slice.point)[ self._slice_input.not_displayed ] self._value = (None, None) self._value_stored = (None, None) self._moving_value: tuple[Optional[int], Optional[int]] = (None, None) self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() self._selected_box = None self._last_cursor_position = 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: dict[str, Shapes] = {} 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.mouse_wheel_callbacks.append(_set_highlight) self.mouse_drag_callbacks.append(_set_highlight) 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.feature_defaults[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.feature_defaults[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() self.selected_data = set() prior_data = len(self.data) > 0 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'), ) ) data_not_empty = ( data is not None and (isinstance(data, np.ndarray) and data.size > 0) ) or (isinstance(data, list) and len(data) > 0) kwargs = { 'value': self.data, 'vertex_indices': ((),), 'data_indices': tuple(i for i in range(len(self.data))), } if prior_data and data_not_empty: kwargs['action'] = ActionType.CHANGING elif data_not_empty: kwargs['action'] = ActionType.ADDING kwargs['data_indices'] = tuple(i for i in range(len(data))) else: kwargs['action'] = ActionType.REMOVING self.events.data(**kwargs) self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) self._data_view.slice_key = np.array(self._data_slice.point)[ self._slice_input.not_displayed ] self._add_shapes( data, shape_type=shape_type, edge_width=edge_widths, edge_color=edge_color, face_color=face_color, z_index=z_indices, n_new_shapes=n_new_shapes, ) self._update_dims() kwargs['data_indices'] = tuple(i for i in range(len(data))) kwargs['value'] = self.data if prior_data and data_not_empty: kwargs['action'] = ActionType.CHANGED elif data_not_empty: kwargs['action'] = ActionType.ADDED else: kwargs['action'] = ActionType.REMOVED self.events.data(**kwargs) 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 @feature_defaults.setter def feature_defaults( self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.current_properties() self.events.feature_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.""" ndim = self.ndim if self.nshapes == 0 else 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( [d._bounding_box[1] for d in self._data_view.shapes], axis=0 ) mins = np.min( [d._bounding_box[0] for d in self._data_view.shapes], 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(np.asarray(edge_color_cycle), 'edge') @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_colormap @edge_colormap.setter def edge_colormap(self, colormap: ValidColormapArg): self._edge_colormap = ensure_colormap(colormap) @property def edge_contrast_limits(self) -> Union[tuple[float, float], None]: """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) -> 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: Union[np.ndarray, cycle], 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') ) 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') ) 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 self._set_highlight() @property def _is_moving(self) -> bool: 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 _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.isin(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): return color in self.properties if isinstance(color, (list, np.ndarray)): return False 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) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() face_color = self.face_color edge_color = self.edge_color if not face_color.size: face_color = self._current_face_color if not edge_color.size: edge_color = self._current_edge_color 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': face_color, 'face_color_cycle': self.face_color_cycle, 'face_colormap': self.face_colormap.dict(), 'face_contrast_limits': self.face_contrast_limits, 'edge_color': edge_color, 'edge_color_cycle': self.edge_color_cycle, 'edge_colormap': self.edge_colormap.dict(), 'edge_contrast_limits': self.edge_contrast_limits, 'data': self.data, 'features': self.features, 'feature_defaults': self.feature_defaults, } ) 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 ] # TODO: fix types here with np.asarray(sliced_in_view_coords) # but blocked by https://github.com/napari/napari/issues/6294 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) @property 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_POLYLINE, 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, val: Union[str, Mode]): mode = self._mode_setter_helper(val) 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 if not (mode in draw_modes and self._mode in draw_modes): # Shapes._finish_drawing() calls Shapes.refresh() via Shapes._update_dims() # so we need to block thumbnail update from here # TODO: this is not great... ideally we should no longer need this blocking system # but maybe follow up PR with self.block_thumbnail_update(): self._finish_drawing() else: self.refresh(data_displayed=False, extent=False, thumbnail=False) 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 in 2D a (2, 2) array of 2 vertices that are the 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 in 2D a (2, 2) array of center position and radii magnitudes. 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, gui=False, ): """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. gui : bool Whether the shape is drawn by drawing in the gui. """ data, shape_type = extract_shape_type(data, shape_type) n_new_shapes = number_of_shapes(data) if n_new_shapes > 0: self.events.data( value=self.data, action=ActionType.ADDING, data_indices=(-1,), vertex_indices=((),), ) self._add_shapes( data, shape_type=shape_type, edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, n_new_shapes=n_new_shapes, ) # This should only emit when programmatically adding as with drawing this leads to premature emit. if not gui: self.events.data( value=self.data, action=ActionType.ADDED, data_indices=(-1,), vertex_indices=((),), ) 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, n_new_shapes=n_shapes, ) 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, n_new_shapes=0, ): """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. n_new_shapes : int The number of new shapes to be added to the Shapes layer. """ if n_new_shapes > 0: total_shapes = n_new_shapes + self.nshapes self._feature_table.resize(total_shapes) if hasattr(self, 'text'): self.text.apply(self.features) 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 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: ShapeList): """Build new shapes and add them to the _data_view""" shape_inputs = tuple(shape_inputs) # build all shapes sh_inp = tuple( ( shape_classes[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) @property def _normalized_scale_factor(self): """Scale factor accounting for layer scale. This is often needed when calculating screen-space sizes and distances of vertices for interactivity (rescaling, adding vertices, etc). """ return self.scale_factor / self.scale[-1] @property def _normalized_vertex_radius(self): """Vertex radius normalized to screen space.""" return self._vertex_size * self._normalized_scale_factor / 2 def _set_view_slice(self): """Set the view given the slicing indices.""" with self._data_view.batched_updates(): ndisplay = self._slice_input.ndisplay if ndisplay != self._ndisplay_stored: self.selected_data = set() self._data_view.ndisplay = min(self.ndim, ndisplay) self._ndisplay_stored = ndisplay self._clipboard = {} if 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._data_slice.point)[ self._slice_input.not_displayed ] if not np.array_equal(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 bounding 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[next(iter(index))]._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._normalized_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._highlight_visible and 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._normalized_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 self._highlight_visible and len(self.selected_data) > 0: if self._mode == Mode.SELECT: # If in select mode just show the interaction bounding box # including its vertices and the rotation handle box = self._selected_box[Box.WITH_HANDLE] if self._value[0] is None or 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_POLYGON_LASSO, Mode.ADD_RECTANGLE, Mode.ADD_ELLIPSE, Mode.ADD_LINE, Mode.ADD_POLYLINE, 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_POLYLINE: vertices = vertices[:-1] if self._value[0] is None or 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._highlight_visible and 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) -> None: """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.array_equal(self._value, self._value_stored) and np.array_equal(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) -> None: """Reset properties used in shape drawing.""" index = copy(self._moving_value[0]) self._is_moving = False 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) self._last_cursor_position = None if self._is_creating is True: if self._mode in {Mode.ADD_PATH, Mode.ADD_POLYLINE}: vertices = self._data_view.shapes[index].data if len(vertices) <= 2: self._data_view.remove(index) # Clear selected data to prevent issues. # See https://github.com/napari/napari/pull/6912#discussion_r1601169680 self.selected_data.clear() else: self._data_view.edit(index, vertices[:-1]) if self._mode in {Mode.ADD_POLYGON, Mode.ADD_POLYGON_LASSO}: vertices = self._data_view.shapes[index].data if len(vertices) <= 3: self._data_view.remove(index) # Clear selected data to prevent issues. # See https://github.com/napari/napari/pull/6912#discussion_r1601169680 self.selected_data.clear() elif self._mode == Mode.ADD_POLYGON: self._data_view.edit(index, vertices[:-1]) else: vertices = rdp( vertices, epsilon=get_settings().experimental.rdp_epsilon, ) self._data_view.edit( index, vertices[:-1], new_type=shape_classes[ShapeType.POLYGON], ) # handles the case that if index is not None: self.events.data( value=self.data, action=ActionType.ADDED, data_indices=(-1,), vertex_indices=((),), ) 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) -> None: """Remove any selected shapes.""" index = list(self.selected_data) to_remove = sorted(index, reverse=True) if len(index) > 0: self.events.data( value=self.data, action=ActionType.REMOVING, data_indices=tuple( index, ), vertex_indices=((),), ) for ind in to_remove: self._data_view.remove(ind) 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.events.data( value=self.data, action=ActionType.REMOVED, data_indices=tuple( index, ), vertex_indices=((),), ) self.selected_data.clear() self._finish_drawing() 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.array_equal(box[Box.TOP_CENTER], box[Box.HANDLE]): r = self._rotation_handle_length * self._normalized_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.array_equal(box[Box.TOP_CENTER], box[Box.HANDLE]): r = self._rotation_handle_length * self._normalized_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: self.scale[self._slice_input.displayed] # Get the vertex sizes. They need to be rescaled by a few parameters: # - scale_factor, because vertex sizes are zoom-invariant # - scale, because vertex sizes are not affected by scale (unlike in Points) # - 2, because the radius is what we need 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) # Check if any matching vertices matches = np.all( distances <= self._normalized_vertex_radius, 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) # Check if any matching vertices matches = np.all( distances <= self._normalized_vertex_radius, 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], 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], Union[npt.NDArray, 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) -> None: """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(extent=False, highlight=False) def move_to_back(self) -> None: """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(extent=False, highlight=False) def _copy_data(self) -> None: """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._data_slice.point, 'text': self.text._copy(index), } else: self._clipboard = {} def _paste_data(self) -> None: """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._data_slice.point[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.6/napari/layers/surface/000077500000000000000000000000001474413133200171775ustar00rootroot00000000000000napari-0.5.6/napari/layers/surface/__init__.py000066400000000000000000000001111474413133200213010ustar00rootroot00000000000000from napari.layers.surface.surface import Surface __all__ = ['Surface'] napari-0.5.6/napari/layers/surface/_surface_constants.py000066400000000000000000000020701474413133200234330ustar00rootroot00000000000000from 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.6/napari/layers/surface/_surface_key_bindings.py000066400000000000000000000020501474413133200240620ustar00rootroot00000000000000from typing import Callable from 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 ) -> Callable[[Callable], Callable]: return register_layer_action(Surface, description, repeatable) def register_surface_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Surface, description, 'mode') @register_surface_mode_action(trans._('Transform')) def activate_surface_transform_mode(layer: Surface) -> None: layer.mode = str(Mode.TRANSFORM) @register_surface_mode_action(trans._('Pan/zoom')) def activate_surface_pan_zoom_mode(layer: Surface) -> None: layer.mode = str(Mode.PAN_ZOOM) surface_fun_to_mode = [ (activate_surface_pan_zoom_mode, Mode.PAN_ZOOM), (activate_surface_transform_mode, Mode.TRANSFORM), ] napari-0.5.6/napari/layers/surface/_surface_utils.py000066400000000000000000000024261474413133200225640ustar00rootroot00000000000000import 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.6/napari/layers/surface/_tests/000077500000000000000000000000001474413133200205005ustar00rootroot00000000000000napari-0.5.6/napari/layers/surface/_tests/test_surface.py000066400000000000000000000372561474413133200235560ustar00rootroot00000000000000import copy import numpy as np import pandas as pd import pytest from napari._tests.utils import check_layer_world_data_extent from napari.components.dims import Dims from napari.layers import Surface from napari.layers.surface.normals import SurfaceNormals from napari.layers.surface.wireframe import SurfaceWireframe from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) 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.array_equal(ld, d) for ld, d in zip(layer.data, data)]) assert np.array_equal(layer.vertices, vertices) assert np.array_equal(layer.faces, faces) assert np.array_equal(layer.vertex_values, values) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 def test_random_surface_features(): """Test instantiating surface layer with features.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) features = pd.DataFrame({'feature': np.random.random(10)}) data = (vertices, faces, values) layer = Surface(data, features=features) assert 'feature' in layer.features.columns def test_set_features_and_defaults(): """Test setting features and defaults.""" 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.features.shape[1] == layer.feature_defaults.shape[1] == 0 features = pd.DataFrame( { 'str': ('a', 'b') * 5, 'float': np.random.random(10), } ) feature_defaults = pd.DataFrame( { 'str': ('b',), 'float': (0.5,), } ) layer.features = features layer.feature_defaults = feature_defaults pd.testing.assert_frame_equal(layer.features, features) pd.testing.assert_frame_equal(layer.feature_defaults, feature_defaults) 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.array_equal(ld, d) for ld, d in zip(layer.data, data)]) assert np.array_equal(layer.vertices, vertices) assert np.array_equal(layer.faces, faces) assert np.array_equal(layer.vertex_values, np.ones(len(vertices))) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 def test_random_surface_clearing_vertex_values(): """Test setting `vertex_values=None` resets values to uniform ones.""" 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 np.array_equal(layer.vertex_values, values) layer.vertex_values = None assert np.array_equal(layer.vertex_values, np.ones(len(vertices))) 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.array_equal(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(Dims(ndim=3, 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.array_equal(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(Dims(ndim=4, 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.array_equal(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] == 21 layer._slice_dims(Dims(ndim=4, 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(Dims(ndim=4, 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.array_equal(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] == 15 assert layer.extent.data[1][1] == 21 layer._slice_dims(Dims(ndim=5, 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.array_equal(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(Dims(ndim=3, 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)) 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 def test_texture(): """Test setting texture""" 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) texture = np.random.random((32, 32, 3)).astype(np.float32) texcoords = vertices[:, :2] layer = Surface(data, texture=texture, texcoords=texcoords) np.testing.assert_allclose(layer.texture, texture) np.testing.assert_allclose(layer.texcoords, texcoords) assert layer._has_texture layer.texture, layer.texcoords = None, texcoords assert not layer._has_texture layer.texture, layer.texcoords = texture, None assert not layer._has_texture layer.texture, layer.texcoords = None, None assert not layer._has_texture layer.texture, layer.texcoords = texture, texcoords assert layer._has_texture def test_vertex_colors(): """Test setting vertex colors""" 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) vertex_colors = np.random.random((len(vertices), 3)) layer = Surface(data, vertex_colors=vertex_colors) np.testing.assert_allclose(layer.vertex_colors, vertex_colors) layer.vertex_colors = vertex_colors**2 np.testing.assert_allclose(layer.vertex_colors, vertex_colors**2) @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(Dims(ndim=3, 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(Dims(ndim=4, 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) def test_surface_normals(): """Ensure that normals can be set both with dict and SurfaceNormals. The model should internally always use SurfaceNormals. """ 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]) normals = {'face': {'visible': True, 'color': 'red'}} surface_layer = Surface((vertices, faces, values), normals=normals) assert isinstance(surface_layer.normals, SurfaceNormals) assert surface_layer.normals.face.visible is True assert np.array_equal(surface_layer.normals.face.color, (1, 0, 0, 1)) surface_layer = Surface( (vertices, faces, values), normals=SurfaceNormals(**normals) ) assert isinstance(surface_layer.normals, SurfaceNormals) assert surface_layer.normals.face.visible is True assert np.array_equal(surface_layer.normals.face.color, (1, 0, 0, 1)) def test_surface_wireframe(): """Ensure that wireframe can be set both with dict and SurfaceWireframe. The model should internally always use SurfaceWireframe. """ 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]) wireframe = {'visible': True, 'color': 'red'} surface_layer = Surface((vertices, faces, values), wireframe=wireframe) assert isinstance(surface_layer.wireframe, SurfaceWireframe) assert surface_layer.wireframe.visible is True assert np.array_equal(surface_layer.wireframe.color, (1, 0, 0, 1)) surface_layer = Surface( (vertices, faces, values), wireframe=SurfaceWireframe(**wireframe) ) assert isinstance(surface_layer.wireframe, SurfaceWireframe) assert surface_layer.wireframe.visible is True assert np.array_equal(surface_layer.wireframe.color, (1, 0, 0, 1)) def test_surface_copy(): 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]) l1 = Surface((vertices, faces, values)) l2 = copy.copy(l1) assert l1.data[0] is not l2.data[0] def test_surface_with_no_visible_faces(): points = np.array([[0, 0.0, 0.0, 0.0], [0, 1.0, 0, 0], [0, 1, 1, 0]]) faces = np.array([[0, 1, 2]]) layer = Surface((points, faces)) # the following with throw an exception when _view_faces # is non-integer values. with pytest.raises( ValueError, match='operands could not be broadcast together' ): layer._get_value_3d( np.array([1, 0, 0, 0]), np.array([1, 1, 0, 0]), [1, 2, 3] ) def test_docstring(): validate_all_params_in_docstring(Surface) validate_kwargs_sorted(Surface) napari-0.5.6/napari/layers/surface/_tests/test_surface_utils.py000066400000000000000000000015271474413133200247660ustar00rootroot00000000000000import 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.6/napari/layers/surface/normals.py000066400000000000000000000024631474413133200212310ustar00rootroot00000000000000from enum import Enum, auto from napari._pydantic_compat import Field from napari.utils.color import ColorValue from napari.utils.events import EventedModel class NormalMode(Enum): FACE = auto() VERTEX = auto() _DEFAULT_COLOR = ColorValue('black') 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 = Field(default_factory=lambda: _DEFAULT_COLOR) 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.6/napari/layers/surface/surface.py000066400000000000000000000727011474413133200212100ustar00rootroot00000000000000import copy import warnings from typing import Any, Optional, Union import numpy as np import pandas as pd 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 _FeatureTable, calc_data_range from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.events import Event from napari.utils.events.event_utils import connect_no_arg 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. Surfaces may be colored by: * setting `vertex_values`, which colors the surface with the selected `colormap` (default is uniform ones) * setting `vertex_colors`, which replaces/overrides any color from `vertex_values` * setting both `texture` and `texcoords`, which blends a the value from a texture (image) with the underlying color from `vertex_values` or `vertex_colors`. Blending is achieved by multiplying the texture color by the underlying color - an underlying value of "white" will result in the unaltered texture color. 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 (vertex_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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. 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. 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. feature_defaults : dict[str, Any] or Dataframe-like The default value of each feature in a table with one row. features : dict[str, array-like] or Dataframe-like Features table where each row corresponds to a shape and each column is a feature. gamma : float Gamma correction for determining colormap linearity. Defaults to 1. metadata : dict Layer metadata. name : str Name of the layer. normals : None, dict or SurfaceNormals Whether and how to display the face and vertex normals of the surface mesh. opacity : float Opacity of the layer visual, between 0.0 and 1.0. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimenions. 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 : tuple of float Scale factors for the layer. 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'``. 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. texcoords: (N, 2) array 2D coordinates for each vertex, mapping into the texture. The number of texture coords must match the number of vertices (N). Coordinates should be in [0.0, 1.0] and are scaled to sample the 2D texture. Coordinates outside this range will wrap, but this behavior should be considered an implementation detail: there are no plans to change it, but it's a feature of the underlying vispy visual. texture: (I, J) or (I, J, C) array A 2D texture to be mapped onto the mesh using `texcoords`. C may be 3 (RGB) or 4 (RGBA) channels for a color texture. translate : tuple of float Translation values for the layer units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. vertex_colors: (N, C) or (K0, ..., KL, N, C) array of color values Take care that the (optional) L additional dimensions match those of vertex_values for proper slicing. C may be 3 (RGB) or 4 (RGBA) channels.. visible : bool Whether the layer visual is currently being displayed. wireframe : None, dict or SurfaceWireframe Whether and how to display the edges of the surface mesh with a wireframe. 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. axis_labels : tuple of str Dimension names of the layer data. 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. features : DataFrame-like Features table where each row corresponds to a vertex and each column is a feature. feature_defaults : DataFrame-like Stores the default value of each feature in a table with one row. 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. units: tuple of pint.Unit Units of the layer data in world coordinates. 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, *, affine=None, axis_labels=None, blending='translucent', cache=True, colormap='gray', contrast_limits=None, experimental_clipping_planes=None, feature_defaults=None, features=None, gamma=1.0, metadata=None, name=None, normals=None, opacity=1.0, projection_mode='none', rotate=None, scale=None, shading='flat', shear=None, texcoords=None, texture=None, translate=None, units=None, vertex_colors=None, visible=True, wireframe=None, ) -> None: ndim = data[0].shape[1] super().__init__( data, ndim, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, metadata=metadata, name=name, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) self.events.add( interpolation=Event, rendering=Event, shading=Event, wireframe=Event, normals=Event, texture=Event, texcoords=Event, features=Event, feature_defaults=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)) self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, num_data=len(data[0]), ) self._texture = texture self._texcoords = texcoords self._vertex_colors = vertex_colors # Set contrast_limits and colormaps self._gamma = gamma if contrast_limits is not None: self._contrast_limits_range = contrast_limits else: self._contrast_limits_range = calc_data_range(self._vertex_values) self._contrast_limits = 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), dtype=int) self._view_vertex_values: Union[list[Any], np.ndarray] = [] self._view_vertex_colors: Union[list[Any], np.ndarray] = [] # 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 # initialize normals and wireframe self._wireframe = SurfaceWireframe() self._normals = SurfaceNormals() connect_no_arg(self.wireframe.events, self.events, 'wireframe') connect_no_arg(self.normals.events, self.events, 'normals') self.wireframe = wireframe self.normals = normals def _calc_data_range(self, mode='data'): return calc_data_range(self.vertex_values) @property def dtype(self) -> np.dtype: 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) -> None: """Array of values (n, 1) used to color vertices with a colormap.""" if vertex_values is None: vertex_values = np.ones(len(self._vertices)) self._vertex_values = vertex_values self._update_dims() self.events.data(value=self.data) self._reset_editable() @property def vertex_colors(self) -> Optional[np.ndarray]: return self._vertex_colors @vertex_colors.setter def vertex_colors(self, vertex_colors: Optional[np.ndarray]) -> None: """Values used to directly color vertices. Note that dims sliders for this layer are based on vertex_values, so make sure the shape of vertex_colors matches the shape of vertex_values for proper slicing. That is: vertex_colors should be None, one set (N, C), or completely match the dimensions of vertex_values (K0, ..., KL, N, C). """ if vertex_colors is not None and not isinstance( vertex_colors, np.ndarray ): msg = ( f'texture should be None or ndarray; got {type(vertex_colors)}' ) raise ValueError(msg) self._vertex_colors = vertex_colors 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) -> None: """Array of indices of mesh triangles.""" self.faces = faces self.refresh(extent=False) self.events.data(value=self.data) self._reset_editable() def _get_ndim(self) -> int: """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 = [n - 1 for n in self.vertex_values.shape[:-1]] + list( maxs ) extrema = np.vstack([mins, maxs]) return extrema @property def features(self) -> pd.DataFrame: """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[0])) self.events.features() @property def feature_defaults(self) -> pd.DataFrame: """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 @feature_defaults.setter def feature_defaults( self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.feature_defaults() @property def shading(self) -> str: return str(self._shading) @shading.setter def shading(self, shading: Union[str, Shading]) -> None: if isinstance(shading, Shading): self._shading = shading else: self._shading = Shading(shading) self.events.shading(value=self._shading) @property def wireframe(self) -> SurfaceWireframe: return self._wireframe @wireframe.setter def wireframe( self, wireframe: Union[dict, SurfaceWireframe, None] ) -> None: if wireframe is None: self._wireframe.reset() elif isinstance(wireframe, (SurfaceWireframe, dict)): self._wireframe.update(wireframe) else: raise ValueError( f'wireframe should be None, a dict, or SurfaceWireframe; got {type(wireframe)}' ) self.events.wireframe(value=self._wireframe) @property def normals(self) -> SurfaceNormals: return self._normals @normals.setter def normals(self, normals: Union[dict, SurfaceNormals, None]) -> None: if normals is None: self._normals.reset() elif not isinstance(normals, (SurfaceNormals, dict)): raise ValueError( f'normals should be None, a dict, or SurfaceNormals; got {type(normals)}' ) else: if isinstance(normals, SurfaceNormals): normals = {k: dict(v) for k, v in normals.dict().items()} # ignore modes, they are unmutable cause errors for norm_type in ('face', 'vertex'): normals.get(norm_type, {}).pop('mode', None) self._normals.update(normals) self.events.normals(value=self._normals) @property def texture(self) -> Optional[np.ndarray]: return self._texture @texture.setter def texture(self, texture: np.ndarray) -> None: if texture is not None and not isinstance(texture, np.ndarray): msg = f'texture should be None or ndarray; got {type(texture)}' raise ValueError(msg) self._texture = texture self.events.texture(value=self._texture) @property def texcoords(self) -> Optional[np.ndarray]: return self._texcoords @texcoords.setter def texcoords(self, texcoords: np.ndarray) -> None: if texcoords is not None and not isinstance(texcoords, np.ndarray): msg = f'texcoords should be None or ndarray; got {type(texcoords)}' raise ValueError(msg) self._texcoords = texcoords self.events.texcoords(value=self._texcoords) @property def _has_texture(self) -> bool: """Whether the layer has sufficient data for texturing""" return bool( self.texture is not None and self.texcoords is not None and len(self.texcoords) ) def _get_state(self) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() state.update( { 'colormap': self.colormap.dict(), 'contrast_limits': self.contrast_limits, 'gamma': self.gamma, 'shading': self.shading, 'data': self.data, 'features': self.features, 'feature_defaults': self.feature_defaults, 'wireframe': self.wireframe.dict(), 'normals': self.normals.dict(), 'texture': self.texture, 'texcoords': self.texcoords, 'vertex_colors': self.vertex_colors, } ) return state def _slice_associated_data( self, data: np.ndarray, vertex_ndim: int, dims: int = 1, ) -> Union[list[Any], np.ndarray]: """Return associated layer data (e.g. vertex values, colors) within the current slice. """ if data is None: return [] data_ndim = data.ndim - 1 if data_ndim >= dims: # Get indices for axes corresponding to data dimensions data_indices: tuple[Union[int, slice], ...] = tuple( slice(None) if np.isnan(idx) else int(np.round(idx)) for idx in self._data_slice.point[:-vertex_ndim] ) data = data[data_indices] if data.ndim > dims: warnings.warn( trans._( 'Assigning multiple data per vertex after slicing ' 'is not allowed. All dimensions corresponding to ' 'vertex data must be non-displayed dimensions. Data ' 'may not be visible.', deferred=True, ), category=UserWarning, stacklevel=2, ) return [] return data 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 self._view_vertex_values = self._slice_associated_data( self.vertex_values, vertex_ndim, ) self._view_vertex_colors = self._slice_associated_data( self.vertex_colors, vertex_ndim, dims=2, ) if len(self._view_vertex_values) == 0: self._data_view = np.zeros((0, self._slice_input.ndisplay)) self._view_faces = np.zeros((0, 3), dtype=int) return if values_ndim > 0: indices = np.array(self._data_slice.point[-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: indices = np.array(self._data_slice.point) 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), dtype=int) 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), dtype=int) 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) -> None: """Update thumbnail with current surface.""" def _get_value(self, position) -> None: """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 def _get_value_3d( self, start_point: Optional[np.ndarray], end_point: Optional[np.ndarray], dims_displayed: list[int], ) -> tuple[Union[None, float, int], Optional[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. vertex : None Index of vertex if any that is at the coordinates. """ 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 or intersection 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 def __copy__(self): """Create a copy of this layer. Returns ------- layer : napari.layers.Layer Copy of this layer. Notes ----- This method is defined for purpose of asv memory benchmarks. The copy of data is intentional for properly estimating memory usage for layer. If you want a to copy a layer without coping the data please use `layer.create(*layer.as_layer_data_tuple())` If you change this method, validate if memory benchmarks are still working properly. """ data, meta, layer_type = self.as_layer_data_tuple() return self.create( tuple(copy.copy(x) for x in self.data), meta=meta, layer_type=layer_type, ) napari-0.5.6/napari/layers/surface/wireframe.py000066400000000000000000000012471474413133200215360ustar00rootroot00000000000000from napari._pydantic_compat import Field from napari.utils.color import ColorValue from napari.utils.events import EventedModel _DEFAULT_COLOR = ColorValue('black') 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 = Field(default_factory=lambda: _DEFAULT_COLOR) width: float = 1 napari-0.5.6/napari/layers/tracks/000077500000000000000000000000001474413133200170365ustar00rootroot00000000000000napari-0.5.6/napari/layers/tracks/__init__.py000066400000000000000000000001051474413133200211430ustar00rootroot00000000000000from napari.layers.tracks.tracks import Tracks __all__ = ['Tracks'] napari-0.5.6/napari/layers/tracks/_tests/000077500000000000000000000000001474413133200203375ustar00rootroot00000000000000napari-0.5.6/napari/layers/tracks/_tests/test_tracks.py000066400000000000000000000214601474413133200232420ustar00rootroot00000000000000import numpy as np import pandas as pd import pytest from napari.layers import Tracks from napari.layers.tracks._track_utils import TrackManager from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) # 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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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 with pytest.raises(ValueError, match='not a valid property'): 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 np.testing.assert_array_equal(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, match='must be an integer'): 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, match='node 2 not found'): 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]] np.testing.assert_array_equal(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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(sorted_track_ids, layer.features['track_id']) def test_changing_data_inplace() -> None: """Test if layer can be refreshed after changing data in place.""" data = np.ones((100, 4)) data[:, 1] = np.arange(100) layer = Tracks(data) # Change data in place # coordinates layer.data[50:, -1] = 2 layer.refresh() # time layer.data[50:, 1] = np.arange(100, 150) layer.refresh() # track_id layer.data[50:, 0] = 2 layer.refresh() def test_track_connex_validity() -> None: """Test if track_connex is valid (i.e if the value False appears as many times as there are tracks.""" data = np.zeros((11, 4)) # Track ids data[:-1, 0] = np.repeat(np.arange(1, 6), 2) # create edge case where a track has length one data[-1, 0] = 6 # Time data[:-1, 1] = np.array([0, 1] * 5) data[-1, 1] = 0 layer = Tracks(data) # number of tracks n_tracks = 6 # the number of 'False' in the track_connex array should be equal to the number of tracks assert np.sum(~layer._manager.track_connex) == n_tracks def test_docstring(): validate_all_params_in_docstring(Tracks) validate_kwargs_sorted(Tracks) napari-0.5.6/napari/layers/tracks/_track_utils.py000066400000000000000000000354641474413133200221070ustar00rootroot00000000000000from typing import Optional, Union import numpy as np import numpy.typing as npt 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 class TrackManager: """Manage track data and simplify interactions with the Tracks layer. Parameters ---------- data : array See attribute doc below. 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, data: np.ndarray) -> None: # store the raw data here self.data = data self._feature_table = _FeatureTable() self._data: npt.NDArray self._order: list[int] self._kdtree: cKDTree self._points: npt.NDArray self._points_id: npt.NDArray self._points_lookup: dict[int, slice] self._ordered_points_idx: npt.NDArray self._track_vertices: npt.NDArray | None = None self._track_connex: npt.NDArray | None = None self._graph: Optional[dict[int, list[int]]] = None self._graph_vertices = None self._graph_connex: npt.NDArray | None = 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]) -> None: """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) -> pd.DataFrame: """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]) -> None: """set track properties""" self.features = properties @property def graph(self) -> Optional[dict[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]]]) -> None: """set the track graph""" self._graph = self._normalize_track_graph(graph) @property def track_ids(self) -> npt.NDArray[np.uint32]: """return the track identifiers""" return self.data[:, 0].astype(np.uint32) @property def unique_track_ids(self) -> npt.NDArray[np.uint32]: """return the unique track identifiers""" return np.unique(self.track_ids) def __len__(self) -> int: """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) -> npt.NDArray: """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.array_equal(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 _normalize_track_graph( self, graph: dict[int, Union[int, list[int]]] ) -> dict[int, list[int]]: """validate the track graph""" new_graph: dict[int, list[int]] = {} # check that graph nodes are of correct format for node_idx, parents_idx in graph.items(): # make sure parents are always a list if isinstance(parents_idx, list): new_graph[node_idx] = parents_idx else: new_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 new_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 new_graph def build_tracks(self) -> None: """build the tracks""" # Track ids associated to all vertices, sorted by time points_id = self.data[:, 0][self._ordered_points_idx] # Coordinates of all vertices track_vertices = self.data[:, 1:] # Indices in the data array just before the track id changes indices_new_id = np.where(np.diff(self.data[:, 0]))[0] # Define track_connex as an array full of 'True', then set to 'False' # at the indices just before the track id changes track_connex = np.ones(self.data.shape[0], dtype=bool) track_connex[indices_new_id] = False # Add 'False' for the last entry too (end of the last track) track_connex[-1] = False self._points_id = points_id self._track_vertices = track_vertices self._track_connex = track_connex def build_graph(self) -> None: """build the track graph""" graph_vertices = [] graph_connex = [] assert self.graph is not None 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: npt.NDArray) -> Optional[npt.NDArray]: """use a kd-tree to lookup the ID of the nearest tree""" if self._kdtree is None: return None # 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 None # 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) -> Optional[int]: """Determine the maximum timestamp of the dataset""" if self.track_times is not None: return int(np.max(self.track_times)) return None @property def track_vertices(self) -> Optional[np.ndarray]: """return the track vertices""" return self._track_vertices @property def track_connex(self) -> Optional[np.ndarray]: """vertex connections for drawing track lines""" return self._track_connex @property def graph_vertices(self) -> Optional[np.ndarray]: """return the graph vertices""" return self._graph_vertices @property def graph_connex(self) -> Optional[npt.NDArray]: """vertex connections for drawing the graph""" return self._graph_connex @property def track_times(self) -> Optional[np.ndarray]: """time points associated with each track vertex""" if self.track_vertices is not None: return self.track_vertices[:, 0] return None @property def graph_times(self) -> Optional[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 ) -> Union[tuple[None, None], tuple[list[str], np.ndarray]]: """return track labels at the current time""" if self._points_id is None: return None, None # this is the slice into the time ordered points array if current_time not in self._points_lookup: lbl = [] pos = np.array([]) else: 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.6/napari/layers/tracks/_tracks_key_bindings.py000066400000000000000000000020301474413133200235560ustar00rootroot00000000000000from typing import Callable from 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 ) -> Callable[[Callable], Callable]: return register_layer_action(Tracks, description, repeatable) def register_tracks_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Tracks, description, 'mode') @register_tracks_mode_action(trans._('Transform')) def activate_tracks_transform_mode(layer: Tracks) -> None: layer.mode = str(Mode.TRANSFORM) @register_tracks_mode_action(trans._('Pan/zoom')) def activate_tracks_pan_zoom_mode(layer: Tracks) -> None: layer.mode = str(Mode.PAN_ZOOM) tracks_fun_to_mode = [ (activate_tracks_pan_zoom_mode, Mode.PAN_ZOOM), (activate_tracks_transform_mode, Mode.TRANSFORM), ] napari-0.5.6/napari/layers/tracks/tracks.py000066400000000000000000000557371474413133200207200ustar00rootroot00000000000000# from napari.layers.base.base import Layer # from napari.utils.events import Event # from napari.utils.colormaps import AVAILABLE_COLORMAPS from typing import Any, Optional, 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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. color_by : str Track property (from property keys) by which to color vertices. 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. 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. features : Dataframe-like Features table where each row corresponds to a point and each column is a feature. 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 head_length : float Length of the positive (forward in time) tails in units of time. metadata : dict Layer metadata. name : str Name of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimenions. 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. 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 : tuple of float Scale factors for the layer. 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. tail_length : float Length of the positive (backward in time) tails in units of time. tail_width : float Width of the track tails in pixels. translate : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. visible : bool Whether the layer visual is currently being displayed. """ # 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, *, affine=None, axis_labels=None, blending='additive', cache=True, color_by='track_id', colormap='turbo', colormaps_dict=None, experimental_clipping_planes=None, features=None, graph=None, head_length: int = 0, metadata=None, name=None, opacity=1.0, projection_mode='none', properties=None, rotate=None, scale=None, shear=None, tail_length: int = 30, tail_width: int = 2, translate=None, units=None, visible=True, ) -> None: # if not provided with any data, set up an empty layer in 2D+t # otherwise convert the data to an np.ndarray data = np.empty((0, 4)) if data is None else np.asarray(data) # set the track data dimensions (remove ID from data) ndim = data.shape[1] - 1 super().__init__( data, ndim, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, name=name, metadata=metadata, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) 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(data) self._track_colors: Optional[np.ndarray] = 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) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any 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) -> None: """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) -> Optional[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. """ val = self._manager.get_value(np.array(position)) if val is None: return None return int(val) def _update_thumbnail(self) -> None: """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 = np.array(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 assert self.track_times is not None assert self.current_time is not None 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 colormapped[np.isnan(colormapped)] = 0 self.thumbnail = colormapped.astype(np.uint8) @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 None 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 return data[:, (2, 1, 0)] # z, y, x -> x, y, z @property def current_time(self) -> Optional[int]: """current time according to the first dimension""" # TODO(arl): get the correct index here time_step = self._data_slice.point[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) -> None: """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) -> pd.DataFrame: """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._check_color_by_in_features() self.events.properties() @property def properties(self) -> dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}: Properties for each track.""" return self._manager.properties @properties.setter def properties(self, properties: dict[str, np.ndarray]) -> None: """set track properties""" self.features = properties @property def properties_to_color_by(self) -> list[str]: """track properties that can be used for coloring etc...""" return list(self.properties.keys()) @property def graph(self) -> Optional[dict[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]]]) -> None: """Set the track graph.""" # Ignored type, because mypy can't handle different signatures # on getters and setters; see https://github.com/python/mypy/issues/3004 self._manager.graph = graph # type: ignore[assignment] self._manager.build_graph() self.events.rebuild_graph() @property def tail_width(self) -> float: """float: Width for all vectors in pixels.""" return self._tail_width @tail_width.setter def tail_width(self, tail_width: float) -> None: self._tail_width: float = np.clip(tail_width, 0.5, self._max_width) self.events.tail_width() @property def tail_length(self) -> int: """float: Width for all vectors in pixels.""" return self._tail_length @tail_length.setter def tail_length(self, tail_length: int) -> None: if tail_length > self._max_length: self._max_length = tail_length self._tail_length: int = tail_length self.events.tail_length() @property def head_length(self) -> int: return self._head_length @head_length.setter def head_length(self, head_length: int) -> None: if head_length > self._max_length: self._max_length = head_length self._head_length: int = 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) -> None: self._display_id = value self.events.display_id() # TODO: this refresh is only here to trigger setting the id text... # a bit overkill? But maybe for a future PR. self.refresh(extent=False, thumbnail=False) @property def display_tail(self) -> bool: """display the track tail""" return self._display_tail @display_tail.setter def display_tail(self, value: bool) -> None: 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) -> None: 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) -> None: """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) -> None: """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 # Ignored type because mypy doesn't recognise colormaps_dict as a property # TODO: investigate and fix this - not sure why this is the case? @colormaps_dict.setter # type: ignore[attr-defined] def colomaps_dict(self, colormaps_dict: dict[str, Colormap]) -> None: # validate the dictionary entries? self._colormaps_dict = colormaps_dict def _recolor_tracks(self) -> None: """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) -> Optional[np.ndarray]: """vertex connections for drawing track lines""" return self._manager.track_connex @property def track_colors(self) -> Optional[np.ndarray]: """return the vertex colors according to the currently selected property""" return self._track_colors @property def graph_connex(self) -> Optional[np.ndarray]: """vertex connections for drawing the graph""" return self._manager.graph_connex @property def track_times(self) -> Optional[np.ndarray]: """time points associated with each track vertex""" return self._manager.track_times @property def graph_times(self) -> Optional[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""" assert self.current_time is not None 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) -> None: 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.6/napari/layers/utils/000077500000000000000000000000001474413133200167075ustar00rootroot00000000000000napari-0.5.6/napari/layers/utils/__init__.py000066400000000000000000000000001474413133200210060ustar00rootroot00000000000000napari-0.5.6/napari/layers/utils/_color_manager_constants.py000066400000000000000000000006151474413133200243260ustar00rootroot00000000000000from napari.utils.compat import StrEnum class ColorMode(StrEnum): """ 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.6/napari/layers/utils/_link_layers.py000066400000000000000000000236131474413133200217410ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator, Iterable from contextlib import contextmanager from functools import partial from itertools import combinations, permutations, product from typing import ( TYPE_CHECKING, Callable, Optional, ) from weakref import ReferenceType, ref if TYPE_CHECKING: from collections import abc from napari.layers import Layer from napari.utils.events import Event from collections import defaultdict 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] = {} _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 set() refs = set.union(*(_LINKED_LAYERS.get(ref(x), set()) for x in layers)) linked_layers = {x() for x in refs} return {x for x in linked_layers 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: Layer = lay1, l2: Layer = lay2, attr: str = attribute ) -> Callable: # get a suitable equality operator for this attribute type eq_op = pick_equality_operator(getattr(l1, attr)) def setter(event: Optional[Event] = None) -> 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 # actually 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] = () ) -> None: """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] = () ) -> Generator[None, None, None]: """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: abc.Set[str] = frozenset( ( 'thumbnail', 'status', 'name', 'mode', 'data', 'features', 'properties', 'size', 'symbol', 'edge_width', 'border_width', 'edge_color', 'face_color', 'border_color', 'extent', 'loaded', ) ), with_private: bool = 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. 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]) -> None: """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() -> defaultdict[ ReferenceType[Layer], set[ReferenceType[Layer]] ]: links = defaultdict(set) for l1, l2, _attr in _UNLINKERS: links[l1].add(l2) return links napari-0.5.6/napari/layers/utils/_slice_input.py000066400000000000000000000173321474413133200217440ustar00rootroot00000000000000from __future__ import annotations import warnings from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, TypeVar, 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 if TYPE_CHECKING: import numpy.typing as npt from napari.components.dims import Dims _T = TypeVar('_T') @dataclass(frozen=True) class _ThickNDSlice(Generic[_T]): """Holds the point and the left and right margins of a thick nD slice.""" point: tuple[_T, ...] margin_left: tuple[_T, ...] margin_right: tuple[_T, ...] @property def ndim(self): return len(self.point) @classmethod def make_full( cls, point=None, margin_left=None, margin_right=None, ndim=None, ): """ Make a full slice based on minimal input. If ndim is provided, it will be used to crop or prepend zeros to the given values. Values not provided will be filled zeros. """ for val in (point, margin_left, margin_right): if val is not None: val_ndim = len(val) break else: if ndim is None: raise ValueError( 'ndim must be provided if no other value is given' ) val_ndim = ndim ndim = val_ndim if ndim is None else ndim # not provided arguments are just all zeros point = (0,) * ndim if point is None else tuple(point) margin_left = ( (0,) * ndim if margin_left is None else tuple(margin_left) ) margin_right = ( (0,) * ndim if margin_right is None else tuple(margin_right) ) # prepend zeros if ndim is bigger than the given values prepend = max(ndim - val_ndim, 0) point = (0,) * prepend + point margin_left = (0,) * prepend + margin_left margin_right = (0,) * prepend + margin_right # crop to ndim in case given values are longer (keeping last dims) return cls( point=point[-ndim:], margin_left=margin_left[-ndim:], margin_right=margin_right[-ndim:], ) @classmethod def from_dims(cls, dims: Dims): """Generate from a Dims object's point and margins.""" return cls.make_full(dims.point, dims.margin_left, dims.margin_right) def copy_with( self, point=None, margin_left=None, margin_right=None, ndim=None, ): """Create a copy, but modifying the given fields.""" return self.make_full( point=point or self.point, margin_left=margin_left or self.margin_left, margin_right=margin_right or self.margin_right, ndim=ndim or self.ndim, ) def as_array(self) -> npt.NDArray: """Return point and left and right margin as a (3, D) array.""" return np.array([self.point, self.margin_left, self.margin_right]) @classmethod def from_array(cls, arr: npt.NDArray) -> _ThickNDSlice: """Construct from a (3, D) array of point, left margin and right margin.""" return cls( point=tuple(arr[0]), margin_left=tuple(arr[1]), margin_right=tuple(arr[2]), ) def __getitem__(self, key): # this allows to use numpy-like slicing on the whole object return _ThickNDSlice( point=tuple(np.array(self.point)[key]), margin_left=tuple(np.array(self.margin_left)[key]), margin_right=tuple(np.array(self.margin_right)[key]), ) def __iter__(self): # iterate all three fields dimension per dimension yield from zip(self.point, self.margin_left, self.margin_right) @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 thick slice in world coordinates. # Only the elements in the non-displayed dimensions have meaningful values. world_slice: _ThickNDSlice[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 world_slice = self.world_slice.copy_with(ndim=ndim) if old_ndim > ndim: order = reorder_after_dim_reduction(self.order[-ndim:]) elif old_ndim < ndim: order = tuple(range(ndim - old_ndim)) + tuple( o + ndim - old_ndim for o in self.order ) else: order = self.order return _SliceInput( ndisplay=self.ndisplay, world_slice=world_slice, order=order ) def data_slice( self, world_to_data: Affine, ) -> _ThickNDSlice[Union[float, int]]: """Transforms this thick_slice into data coordinates with only relevant dimensions. 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_slice_not_disp = self.world_slice[self.not_displayed].as_array() data_slice = slice_world_to_data(world_slice_not_disp) full_data_slice = np.full((3, self.ndim), np.nan) for i, ax in enumerate(self.not_displayed): # we cannot have nan in non-displayed dims, so we default to 0 full_data_slice[:, ax] = np.nan_to_num(data_slice[:, i], nan=0) return _ThickNDSlice.from_array(full_data_slice) 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.6/napari/layers/utils/_tests/000077500000000000000000000000001474413133200202105ustar00rootroot00000000000000napari-0.5.6/napari/layers/utils/_tests/__init__.py000066400000000000000000000000001474413133200223070ustar00rootroot00000000000000napari-0.5.6/napari/layers/utils/_tests/test_color_encoding.py000066400000000000000000000147771474413133200246250ustar00rootroot00000000000000import 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.6/napari/layers/utils/_tests/test_color_manager.py000066400000000000000000000533341474413133200244410ustar00rootroot00000000000000import json from itertools import cycle, islice import numpy as np import pytest from napari._pydantic_compat 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.6/napari/layers/utils/_tests/test_color_manager_utils.py000066400000000000000000000030751474413133200256560ustar00rootroot00000000000000import 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) categorical_annotation_3 = np.arange(20, dtype=int) assert guess_continuous(categorical_annotation_3) 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.6/napari/layers/utils/_tests/test_color_transforms.py000066400000000000000000000056271474413133200252270ustar00rootroot00000000000000from 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.6/napari/layers/utils/_tests/test_interactivity_utils.py000066400000000000000000000020511474413133200257350ustar00rootroot00000000000000import 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), ], ) 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.6/napari/layers/utils/_tests/test_layer_utils.py000066400000000000000000000361701474413133200241640ustar00rootroot00000000000000import 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) np.testing.assert_array_equal(clim, (0, 1)) # all ones should return [0, 1] by default data = np.ones((10, 10)) clim = calc_data_range(data) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(clim, (0, 2)) # Try large data data = np.zeros((1000, 2000)) data[0, 0] = 0 data[0, 1] = 2 clim = calc_data_range(data) np.testing.assert_array_equal(clim, (0, 2)) # Try large data multidimensional data = np.zeros((3, 1000, 1000)) data[0, 0, 0] = 0 data[0, 0, 1] = 2 clim = calc_data_range(data) np.testing.assert_array_equal(clim, (0, 2)) data = np.zeros((10_000, 10_000)) data[0, 0] = -1 data[-1, -1] = 10 clim = calc_data_range(data) np.testing.assert_array_equal(clim, (-1, 10)) @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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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, match='should have length 1'): _ = 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_empty_int_features(): feature_table = _FeatureTable.from_layer( features={'a': np.empty(0, dtype=np.int64)} ) assert feature_table.values['a'].dtype == np.int64 assert len(feature_table.values['a']) == 0 assert feature_table.defaults['a'].dtype == np.int64 assert feature_table.defaults['a'][0] == 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]), 'varying_length_prop': np.array( [[0], [0, 0, 0], [0, 0], [0]], dtype=object ), } feature_table = _FeatureTable.from_layer(properties=properties, num_data=4) features = feature_table.values assert features.shape == (4, 3) np.testing.assert_array_equal(features['class'], properties['class']) np.testing.assert_array_equal( features['confidence'], properties['confidence'] ) np.testing.assert_array_equal( features['varying_length_prop'], properties['varying_length_prop'] ) defaults = feature_table.defaults assert defaults.shape == (1, 3) assert defaults['class'][0] == properties['class'][-1] assert defaults['confidence'][0] == properties['confidence'][-1] assert ( defaults['varying_length_prop'][0] == properties['varying_length_prop'][-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) @pytest.fixture def feature_table(): return _FeatureTable(TEST_FEATURES.copy(deep=True), num_data=4) def test_feature_table_resize_smaller(feature_table: _FeatureTable): 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: _FeatureTable): 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: _FeatureTable): 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: _FeatureTable): 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_feature_table_set_defaults_with_same_columns(feature_table): defaults = {'class': 'building', 'confidence': 1} assert feature_table.defaults['class'][0] != defaults['class'] assert feature_table.defaults['confidence'][0] != defaults['confidence'] feature_table.set_defaults(defaults) assert feature_table.defaults['class'][0] == defaults['class'] assert feature_table.defaults['confidence'][0] == defaults['confidence'] def test_feature_table_set_defaults_with_extra_column(feature_table): defaults = {'class': 'building', 'confidence': 0, 'cat': 'kermit'} assert 'cat' not in feature_table.values.columns with pytest.raises( ValueError, match='extra columns not in feature values' ): feature_table.set_defaults(defaults) def test_feature_table_set_defaults_with_missing_column(feature_table): defaults = {'class': 'building'} assert len(feature_table.values.columns) > 1 with pytest.raises( ValueError, match='missing some columns in feature values' ): feature_table.set_defaults(defaults) 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.6/napari/layers/utils/_tests/test_link_layers.py000066400000000000000000000137721474413133200241470ustar00rootroot00000000000000import 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, match='not shared by all layers'): link_layers([l1, l2], ('rendering',)) def test_adding_points_to_linked_layer(): """Test that points can be added to a Points layer that is linked""" l1 = layers.Points(None) l2 = layers.Points(None) link_layers([l1, l2]) l2.add([20, 20]) assert len(l2.data) def test_linking_layers_with_different_modes(): """Test that layers with different modes can be linked""" l1 = layers.Image(np.empty((10, 10))) l2 = layers.Labels(np.empty((10, 10), dtype=np.uint8)) link_layers([l1, l2]) l2.mode = 'paint' assert l1.mode == 'pan_zoom' assert l2.mode == 'paint' 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 other layers 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 opacity 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' def test_link_layers_with_images_then_loaded_not_linked(): """See https://github.com/napari/napari/issues/6372""" l1 = layers.Image(np.zeros((5, 5))) l2 = layers.Image(np.ones((5, 5))) assert l1.loaded assert l2.loaded link_layers([l1, l2]) l1._set_loaded(False) assert not l1.loaded assert l2.loaded napari-0.5.6/napari/layers/utils/_tests/test_plane.py000066400000000000000000000070111474413133200227170ustar00rootroot00000000000000import numpy as np import pytest from napari._pydantic_compat 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_shift_along_normal_vector(): plane = Plane(position=(0, 0, 0), normal=(1, 0, 0)) plane.shift_along_normal_vector(0.5) assert plane.position == (0.5, 0, 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 def test_clipping_plane_list_add_plane(): plane_list = ClippingPlaneList() plane_list.add_plane() assert len(plane_list) == 1 assert plane_list[0].enabled pos = (0, 0, 0) norm = (0, 0, 1) plane_list.add_plane(position=pos, normal=norm, enabled=False) assert len(plane_list) == 2 assert not plane_list[1].enabled assert plane_list[1].position == pos assert plane_list[1].normal == norm napari-0.5.6/napari/layers/utils/_tests/test_stack_utils.py000066400000000000000000000210401474413133200241430ustar00rootroot00000000000000import numpy as np import pytest from napari.layers import Image from napari.layers.utils.stack_utils import ( images_to_stack, merge_rgb, split_channels, split_rgb, 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) is 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 = [] data.append(np.zeros((3, 128, 128))) data.append(np.zeros((3, 64, 64))) data.append(np.zeros((3, 32, 32))) data.append(np.zeros((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) is 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) is 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) is 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) is 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] def test_split_and_merge_rgb(): """Test merging 3 images with RGB colormaps into single RGB image.""" # Make an RGB data = np.random.randint(0, 100, (10, 128, 128, 3)) stack = Image(data) assert stack.rgb is True # split the RGB into 3 images images = split_rgb(stack) assert len(images) == 3 colormaps = {image.colormap.name for image in images} assert colormaps == {'red', 'green', 'blue'} # merge the 3 images back into an RGB rgb_image = merge_rgb(images) assert rgb_image.rgb is True @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 dict(request.param) def test_split_channels(kwargs): """Test split_channels with shape (3,128,128) expecting 3 (128,128)""" data = np.zeros((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 = [] data.append(np.zeros((3, 128, 128))) data.append(np.zeros((3, 64, 64))) data.append(np.zeros((3, 32, 32))) data.append(np.zeros((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.zeros((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.zeros((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.zeros((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.zeros((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.zeros((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.6/napari/layers/utils/_tests/test_string_encoding.py000066400000000000000000000130621474413133200247770ustar00rootroot00000000000000import 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, match='unmatched'): 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_format_with_index(features): encoding = FormatStringEncoding(format='{index}: {confidence:.2f}') values = encoding(features) np.testing.assert_array_equal(values, ['0: 0.50', '1: 1.00', '2: 0.25']) def test_format_with_index_column(features): features['index'] = features['class'] encoding = FormatStringEncoding(format='{index}: {confidence:.2f}') values = encoding(features) np.testing.assert_array_equal(values, ['a: 0.50', 'b: 1.00', 'c: 0.25']) 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_str = '{class}: {score:.2f}' argument = {'format': format_str} expected = FormatStringEncoding(format=format_str) actual = StringEncoding.validate(argument) assert actual == expected napari-0.5.6/napari/layers/utils/_tests/test_style_encoding.py000066400000000000000000000214461474413133200246360ustar00rootroot00000000000000""" 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 napari._pydantic_compat 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.6/napari/layers/utils/_tests/test_text_manager.py000066400000000000000000000576601474413133200243150ustar00rootroot00000000000000from itertools import permutations import numpy as np import pandas as pd import pytest from napari._pydantic_compat import ValidationError from napari._tests.utils import assert_colors_equal from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice 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 text_manager_1 == text_manager_2 text_manager_2.color = 'blue' assert text_manager_1 != text_manager_2 assert 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(5)) text_manager = TextManager(color=color, features=features) # Use an index of 4 to ensure that constant color values, which are # RGBA 4-vectors, are handled correctly when copied, unlike in a # related bug: # https://github.com/napari/napari/issues/5786 copied = text_manager._copy([0, 4]) 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 (3, 3, 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 (3, 3, 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, world_slice=_ThickNDSlice.make_full(ndim=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.6/napari/layers/utils/_tests/test_text_utils.py000066400000000000000000000101231474413133200240220ustar00rootroot00000000000000import 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.6/napari/layers/utils/_text_constants.py000066400000000000000000000012251474413133200225000ustar00rootroot00000000000000from 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.6/napari/layers/utils/_text_utils.py000066400000000000000000000127511474413133200216320ustar00rootroot00000000000000from typing import Union import numpy as np import numpy.typing as npt 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, ) -> tuple[np.ndarray, str, str]: # 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: """ Calculate the bounding box of the given centers, Parameters ---------- view_data : np.ndarray | list of ndarray if an ndarray, return the center across the 0-th axis. if a list, return the bbox center for each items. Returns ------- An ndarray of the centers. """ if isinstance(view_data, np.ndarray): if view_data.ndim == 2: # shape[1] is 2 for a 2D center, 3 for a 3D center. # It should work is N > 3 Dimension, but this catches mistakes # when the caller passed a transposed view_data assert view_data.shape[1] in (2, 3), view_data.shape # if the data are a list of coordinates, just return the coord (e.g., points) bbox_centers = view_data else: assert view_data.ndim == 3 bbox_centers = np.mean(view_data, axis=0) elif isinstance(view_data, list): for coord in view_data: assert coord.shape[1] in (2, 3), coord.shape 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], ) -> tuple[npt.NDArray, npt.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.6/napari/layers/utils/color_encoding.py000066400000000000000000000200021474413133200222370ustar00rootroot00000000000000from typing import ( Any, Literal, Optional, Protocol, Union, runtime_checkable, ) import numpy as np from napari._pydantic_compat 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, allow_reuse=True) def _check_colormap(cls, colormap: ValidColormapArg) -> Colormap: return ensure_colormap(colormap) @validator('contrast_limits', pre=True, always=True, allow_reuse=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.6/napari/layers/utils/color_manager.py000066400000000000000000000546761474413133200221130ustar00rootroot00000000000000from copy import deepcopy from dataclasses import dataclass from typing import Any, Optional, Union import numpy as np from napari._pydantic_compat 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]) 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, allow_reuse=True) def _ensure_continuous_colormap(cls, v): return ensure_colormap(v) @validator('colors', pre=True, allow_reuse=True) def _ensure_color_array(cls, v): if len(v) > 0: return transform_color(v) return np.empty((0, 4)) @validator('current_color', pre=True, allow_reuse=True) def _coerce_current_color(cls, v): if v is None: return v if len(v) == 0: return None return transform_color(v)[0] @root_validator(allow_reuse=True) 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) else: # color_mode == ColorMode.DIRECT: colors = values['colors'] # set the current color to the last color/property value # if it wasn't already set if values.get('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: new_color = self.current_color if color is None else 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: Optional[list] = None ): """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. """ if update_indices is None: update_indices = [] 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.6/napari/layers/utils/color_manager_utils.py000066400000000000000000000111041474413133200233060ustar00rootroot00000000000000from typing import Any, Union import numpy as np from napari.utils.colormaps import Colormap from napari.utils.translations import trans def guess_continuous(color_map: 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 ---------- color_map : 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 return issubclass(color_map.dtype.type, np.floating) or ( len(np.unique(color_map)) > 16 and issubclass(color_map.dtype.type, np.integer) ) def is_color_mapped(color, properties): """determines if the new color argument is for directly setting or cycle/colormap""" if isinstance(color, str): return color in properties if isinstance(color, dict): return True if isinstance(color, (list, np.ndarray)): return False 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.6/napari/layers/utils/color_transformations.py000066400000000000000000000116701474413133200237150ustar00rootroot00000000000000"""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 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: ColorType, elem_name: str, default: str ) -> tuple['cycle[np.ndarray]', np.ndarray]: """Helper method to return an Nx4 np.array from an arbitrary user input. Parameters ---------- color_cycle : ColorType 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 shape (4,) 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: np.ndarray ) -> 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 : np.ndarray 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.6/napari/layers/utils/interaction_box.py000066400000000000000000000102221474413133200224450ustar00rootroot00000000000000from __future__ import annotations from functools import lru_cache from typing import TYPE_CHECKING, Optional 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_augmented(list(dims_displayed)) # 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 = int(np.argmax(close_to_vertex)) return InteractionBoxHandle(idx) if np.all((position >= top_left) & (position <= bot_right)): return InteractionBoxHandle.INSIDE return None napari-0.5.6/napari/layers/utils/interactivity_utils.py000066400000000000000000000152401474413133200234010ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Union import numpy as np import numpy.typing as npt 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: npt.NDArray, end_point: npt.NDArray, dims_displayed: Union[list[int], npt.NDArray], ) -> tuple[npt.NDArray, npt.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: npt.NDArray, end_position: npt.NDArray, view_direction: npt.NDArray, vector: npt.NDArray, ) -> npt.NDArray: """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 ) -> None: """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() if viewer is None: return # 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=np.asarray(viewer.cursor.position), dims_displayed=layer._slice_input.displayed, ) view_direction = layer._world_to_displayed_data_ray( np.asarray(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_normal( np.asarray(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.6/napari/layers/utils/layer_utils.py000066400000000000000000001066221474413133200216240ustar00rootroot00000000000000from __future__ import annotations import functools import inspect import warnings from collections.abc import Sequence from typing import ( TYPE_CHECKING, Any, Callable, NamedTuple, Optional, 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 if TYPE_CHECKING: from collections.abc import Mapping import numpy.typing as npt from napari.layers._data_protocols import LayerDataProtocol class Extent(NamedTuple): """Extent of coordinates in a local data space and world space. Each extent is a (2, D) array that stores the minimum and maximum coordinate values in each of D dimensions. Both the minimum and maximum coordinates are inclusive so form an axis-aligned, closed interval or a D-dimensional box around all the coordinates. Attributes ---------- data : (2, D) array of floats The minimum and maximum raw data coordinates ignoring any transforms like translation or scale. world : (2, D) array of floats The minimum and maximum world coordinates after applying a transform to the raw data coordinates that brings them into a potentially shared world space. step : (D,) array of floats The step in each dimension that when taken from the minimum world coordinate, should form a regular grid that eventually hits the maximum world coordinate. """ data: np.ndarray world: np.ndarray step: np.ndarray def register_layer_action( keymapprovider, description: str, repeatable: bool = False, shortcuts: Optional[Union[str, list[str]]] = None, ) -> Callable[[Callable], Callable]: """ 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: Callable) -> Callable: 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, ) -> Callable[[Callable], Callable]: """ 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: Callable) -> Callable: 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): obj = args[0] if args else 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_value = np.min(array) if not np.isfinite(min_value): masked = array[np.isfinite(array)] if masked.size == 0: return 0 min_value = np.min(masked) return min_value def _nanmax(array): """ call np.max but fall back to avoid nan and inf if necessary """ max_value = np.max(array) if not np.isfinite(max_value): masked = array[np.isfinite(array)] if masked.size == 0: return 1 max_value = np.max(masked) return max_value def calc_data_range( data: LayerDataProtocol, rgb: bool = False ) -> tuple[float, float]: """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 : pair of floats Minimum and maximum values in that order. 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 isinstance(data, np.ndarray) and data.ndim < 3: min_val = _nanmin(data) max_val = _nanmax(data) if min_val == max_val: min_val = min(min_val, 0) max_val = max(max_val, 1) return float(min_val), float(max_val) center: Union[int, list[int]] reduced_data: Union[list, LayerDataProtocol] 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 = min(min_val, 0) max_val = max(max_val, 1) return (float(min_val), float(max_val)) def segment_normal(a, b, p=(0, 0, 1)) -> np.ndarray: """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 norm: Any # float or array or float, mypy has some difficulties. if d.ndim == 1: normal = np.array([d[1], -d[0]]) if len(d) == 2 else 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 return normal / norm def convert_to_uint8(data: np.ndarray) -> np.ndarray: """ Convert array content to uint8, always returning a copy. Based on skimage.util.dtype._convert but limited to an output type uint8, so should be equivalent to skimage.util.dtype.img_as_ubyte. If all negative, values are clipped to 0. If values are integers and below 256, this simply casts. Otherwise the maximum value for the input data type is determined and output values are proportionally scaled by this value. Binary images are converted so that False -> 0, True -> 255. Float images are multiplied by 255 and then cast to 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) image_out = np.nan_to_num(image_out, copy=False) 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 ) 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 ) raise NotImplementedError 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, 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: Mapping[ str, Union[float, str, int, bool, list, tuple, npt.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)) level = locations[-1][0] if len(locations) > 0 else 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: Union[npt.ArrayLike, Affine], *, ndim: int, name: Optional[str] = None, ) -> Affine: """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_arr = np.array(order) if offset <= 0: order = list(range(-offset)) + list(order_arr - offset) else: order = list(order_arr[order_arr >= 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: npt.NDArray, data_to_world: Affine, centered: Optional[Any] = None, ) -> npt.NDArray: """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. Returns ------- extent_world : array, shape (2, D) """ if centered is not None: warnings.warn( trans._( 'The `centered` argument is deprecated. ' 'Extents are now always centered on data points.', deferred=True, ), stacklevel=2, ) D = data_extent.shape[1] full_data_extent = np.array(np.meshgrid(*data_extent.T)).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. defaults: Optional[Union[Dict[str, Any], pd.DataFrame]] The default feature values, which if specified should have the same keys as the values provided. If None, will be inferred from the values. """ def __init__( self, values: Optional[Union[dict[str, np.ndarray], pd.DataFrame]] = None, *, num_data: Optional[int] = None, defaults: Optional[Union[dict[str, Any], pd.DataFrame]] = None, ) -> None: self._values = _validate_features(values, num_data=num_data) self._defaults = _validate_feature_defaults(defaults, self._values) @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 = _validate_feature_defaults(None, self._values) @property def defaults(self) -> pd.DataFrame: """The default values one-row table.""" return self._defaults def set_defaults( self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: """Sets the feature default values.""" self._defaults = _validate_feature_defaults(defaults, self._values) 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, npt.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.loc[update_indices, k] = 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, feature_defaults: Optional[Union[dict[str, Any], 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, defaults=feature_defaults, 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] elif isinstance(column.dtype, np.dtype) and np.issubdtype( column.dtype, np.integer ): # For numpy backed columns that store integers there's no way to # store missing values, so passing None creates an np.float64 series # containing NaN. Therefore, use a default of 0 instead. value = 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 feature values 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.asarray(value) 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 _validate_feature_defaults( defaults: Optional[Union[dict[str, Any], pd.DataFrame]], values: pd.DataFrame, ) -> pd.DataFrame: """Validates and coerces feature default values into a pandas DataFrame. See Also -------- :class:`_FeatureTable` : See initialization for parameter descriptions. """ if defaults is None: defaults = {c: _get_default_column(values[c]) for c in values.columns} else: default_columns = set(defaults.keys()) value_columns = set(values.keys()) extra_defaults = default_columns - value_columns if len(extra_defaults) > 0: raise ValueError( trans._( 'Feature defaults contain some extra columns not in feature values: {extra_defaults}', deferred=True, extra_defaults=extra_defaults, ) ) missing_defaults = value_columns - default_columns if len(missing_defaults) > 0: raise ValueError( trans._( 'Feature defaults is missing some columns in feature values: {missing_defaults}', deferred=True, missing_defaults=missing_defaults, ) ) # Convert to series first to capture the per-column dtype from values, # since the DataFrame initializer does not support passing multiple dtypes. defaults = { c: pd.Series( defaults[c], dtype=values.dtypes[c], index=range(1), ) for c in defaults } return pd.DataFrame(defaults, index=range(1)) 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.6/napari/layers/utils/plane.py000066400000000000000000000157761474413133200204000ustar00rootroot00000000000000import sys from typing import Any, cast import numpy as np import numpy.typing as npt from napari._pydantic_compat 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 if sys.version_info < (3, 10): # Once 3.12+ there is a new syntax, type Foo = Bar[...], but # we are not there yet. from typing_extensions import TypeAlias else: from typing import TypeAlias Point3D: TypeAlias = tuple[float, float, float] 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: Point3D = (1, 0, 0) position: Point3D = (0, 0, 0) @validator('normal', allow_reuse=True) def _normalise_vector(cls, v: npt.NDArray) -> Point3D: return cast(Point3D, tuple(v / np.linalg.norm(v))) @validator('normal', 'position', pre=True, allow_reuse=True) def _ensure_tuple(cls, v: Any) -> Point3D: return cast(Point3D, tuple(v)) def shift_along_normal_vector(self, distance: float) -> None: """Shift the plane along its normal vector by a given distance.""" assert len(self.position) == len(self.normal) == 3 self.position = cast( Point3D, tuple( p + (distance * n) for p, n in zip(self.position, 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: npt.NDArray, b: npt.NDArray, c: npt.NDArray, enabled: bool = True, ) -> 'Plane': """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.vstack((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) -> npt.NDArray: """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: npt.NDArray, enabled: bool = True) -> 'Plane': """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) -> int: 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) -> npt.NDArray: """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: npt.NDArray, enabled: bool = True ) -> 'ClippingPlaneList': """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: Point3D, dimensions: Point3D, enabled: bool = True ) -> 'ClippingPlaneList': """ 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) def add_plane(self, **kwargs: Any) -> None: """Add a clipping plane to the list.""" self.append(ClippingPlane(**kwargs)) napari-0.5.6/napari/layers/utils/stack_utils.py000066400000000000000000000303071474413133200216110ustar00rootroot00000000000000from __future__ import annotations import itertools from typing import TYPE_CHECKING import numpy as np import pint 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 = { 'axis_labels', 'scale', 'translate', 'contrast_limits', 'metadata', 'plane', 'experimental_clipping_planes', 'custom_interpolation_kernel_2d', 'units', } # 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, ) ) elif key == 'affine' and isinstance(val, np.ndarray): # affine may be Affine or np.ndarray object that is not # iterable, but it is not now a problem as we use it only to warning # if a provided object is a sequence and channel_axis is not provided kwargs[key] = itertools.repeat(val, n_channels) else: kwargs[key] = iter(ensure_iterable(val)) layerdata_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: FullLayerData = (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['axis_labels'] = None meta['units'] = 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)) if not all(isinstance(layer, Image) for layer in images): non_image_layers = [ (layer.name, type(layer).__name__) for layer in images if not isinstance(layer, Image) ] raise ValueError( trans._( 'All selected layers to be merged must be Image layers. ' 'The following layers are not Image layers: ' f'{", ".join(f"{name} ({layer_type})" for name, layer_type in non_image_layers)}' ) ) data, meta, _ = images[0].as_layer_data_tuple() # RGB images do not need extra dimensions inserted into metadata if 'rgb' not in kwargs: 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) # RGB images do not need extra dimensions inserted into metadata # They can use the meta dict from one of the source image layers if not meta['rgb']: meta['units'] = (pint.get_application_registry().pixel,) + meta[ 'units' ] meta['axis_labels'] = (f'axis -{data.ndim + 1}',) + meta['axis_labels'] return Image(new_data, **meta) def merge_rgb(images: list[Image]) -> 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._( 'Merging to RGB requires exactly 3 Image layers', deferred=True ) ) if not all(image.data.shape == images[0].data.shape for image in images): all_shapes = [(image.name, image.data.shape) for image in images] raise ValueError( trans._( 'Shape mismatch! To merge to RGB, all selected Image layers (with R, G, and B colormaps) must have the same shape. ' 'Mismatched shapes: ' f'{", ".join(f"{name} (shape: {shape})" for name, shape in all_shapes)}' ) ) # we will check for the presence of R G B colormaps to determine how to merge colormaps = {image.colormap.name for image in images} r_g_b = ['red', 'green', 'blue'] if colormaps != set(r_g_b): missing_colormaps = set(r_g_b) - colormaps raise ValueError( trans._( 'Missing colormap(s): {missing_colormaps}! To merge layers to RGB, ensure you have red, green, and blue as layer colormaps.', missing_colormaps=missing_colormaps, deferred=True, ) ) # use the R G B colormaps to order the images for merging imgs = [ image for color in r_g_b for image in images if image.colormap.name == color ] return images_to_stack(imgs, axis=-1, rgb=True) napari-0.5.6/napari/layers/utils/string_encoding.py000066400000000000000000000152071474413133200224420ustar00rootroot00000000000000from collections.abc import Sequence from string import Formatter from typing import Any, Literal, Protocol, Union, runtime_checkable import numpy as np from napari._pydantic_compat 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() # Expose the dataframe index to the format string keys # unless a column exists with the name "index", which takes precedence. with_index = False if 'index' not in feature_names: feature_names = ['index'] + feature_names with_index = True values = [ self.format.format(**dict(zip(feature_names, row))) for row in features.itertuples(index=with_index, 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.6/napari/layers/utils/style_encoding.py000066400000000000000000000220061474413133200222670ustar00rootroot00000000000000import warnings from abc import ABC, abstractmethod from typing import ( Any, Generic, 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.6/napari/layers/utils/text_manager.py000066400000000000000000000346551474413133200217540ustar00rootroot00000000000000import warnings from collections.abc import Sequence from copy import deepcopy from typing import Any, Optional, Union import numpy as np import pandas as pd from napari._pydantic_compat 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.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_name='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, value_ndim=1), } 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, allow_reuse=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.6/napari/layers/vectors/000077500000000000000000000000001474413133200172345ustar00rootroot00000000000000napari-0.5.6/napari/layers/vectors/__init__.py000066400000000000000000000001111474413133200213360ustar00rootroot00000000000000from napari.layers.vectors.vectors import Vectors __all__ = ['Vectors'] napari-0.5.6/napari/layers/vectors/_slice.py000066400000000000000000000116661474413133200210560ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Any, Union import numpy as np import numpy.typing as npt from napari.layers.base._slice import _next_request_id from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.vectors._vectors_constants import VectorsProjectionMode @dataclass(frozen=True) class _VectorSliceResponse: """Contains all the output data of slicing an Vectors layer. Attributes ---------- indices : array like Indices of the sliced Vectors data. alphas : array like or scalar Used to change the opacity of the sliced vectors for visualization. Should be broadcastable to indices. slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. """ indices: np.ndarray = field(repr=False) alphas: Union[np.ndarray, float] = field(repr=False) slice_input: _SliceInput request_id: int @dataclass(frozen=True) class _VectorSliceRequest: """A callable that stores all the input data needed to slice a Vectors 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 ---------- slice_input : _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. data_slice : _ThickNDSlice The slicing coordinates and margins in data space. others See the corresponding attributes in `Layer` and `Vectors`. """ slice_input: _SliceInput data: Any = field(repr=False) data_slice: _ThickNDSlice = field(repr=False) projection_mode: VectorsProjectionMode length: float = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) def __call__(self) -> _VectorSliceResponse: # Return early if no data if len(self.data) == 0: return _VectorSliceResponse( indices=np.empty(0, dtype=int), alphas=np.empty(0), slice_input=self.slice_input, request_id=self.id, ) not_disp = list(self.slice_input.not_displayed) if not not_disp: # If we want to display everything, then use all indices. # alpha is only impacted by not displayed data, therefore 1 return _VectorSliceResponse( indices=np.arange(len(self.data), dtype=int), alphas=1, slice_input=self.slice_input, request_id=self.id, ) slice_indices, alphas = self._get_slice_data(not_disp) return _VectorSliceResponse( indices=slice_indices, alphas=alphas, slice_input=self.slice_input, request_id=self.id, ) def _get_slice_data(self, not_disp: list[int]) -> tuple[npt.NDArray, int]: data = self.data[:, 0, not_disp] alphas = 1 point, m_left, m_right = self.data_slice[not_disp].as_array() if self.projection_mode == 'none': low = point.copy() high = point.copy() else: low = point - m_left high = point + m_right # assume slice thickness of 1 in data pixels # (same as before thick slices were implemented) too_thin_slice = np.isclose(high, low) low[too_thin_slice] -= 0.5 high[too_thin_slice] += 0.5 inside_slice = np.all((data >= low) & (data <= high), axis=1) slice_indices = np.where(inside_slice)[0].astype(int) if self.out_of_slice_display and self.slice_input.ndim > 2: projected_lengths = abs(self.data[:, 1, not_disp] * self.length) # add out of slice points with progressively lower sizes dist_from_low = np.abs(data - low) dist_from_high = np.abs(data - high) distances = np.minimum(dist_from_low, dist_from_high) # anything inside the slice is at distance 0 distances[inside_slice] = 0 # display vectors that "spill" into the slice matches = np.all(distances <= projected_lengths, axis=1) length_match = projected_lengths[matches] length_match[length_match == 0] = 1 # rescale alphas of spilling vectors based on how much they do alphas_per_dim = (length_match - distances[matches]) / length_match alphas_per_dim[length_match == 0] = 1 alphas = np.prod(alphas_per_dim, axis=1) slice_indices = np.where(matches)[0].astype(int) return slice_indices, alphas napari-0.5.6/napari/layers/vectors/_tests/000077500000000000000000000000001474413133200205355ustar00rootroot00000000000000napari-0.5.6/napari/layers/vectors/_tests/test_vectors.py000066400000000000000000000535111474413133200236400ustar00rootroot00000000000000import numpy as np import pandas as pd import pytest from vispy.color import get_colormap from napari._tests.utils import ( assert_colors_equal, check_layer_world_data_extent, ) from napari.components.dims import Dims from napari.layers import Vectors from napari.utils._test_utils import ( validate_all_params_in_docstring, validate_kwargs_sorted, ) 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) np.testing.assert_array_equal(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, match='must be equal to ndim'): 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) np.testing.assert_array_equal(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_features(): """See the following for the points issues this covers: https://github.com/napari/napari/issues/5632 https://github.com/napari/napari/issues/5634 """ vectors = Vectors( features={'a': np.empty(0, int)}, feature_defaults={'a': 0}, edge_color='a', edge_color_cycle=list('rgb'), ) vectors.data = np.concatenate((vectors.data, [[[0, 0], [1, 1]]])) vectors.feature_defaults['a'] = 1 vectors.data = np.concatenate((vectors.data, [[[1, 1], [2, 2]]])) vectors.feature_defaults = {'a': 2} vectors.data = np.concatenate((vectors.data, [[[2, 2], [3, 3]]])) assert_colors_equal(vectors.edge_color, list('rgb')) 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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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) np.testing.assert_array_equal(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, match='(does not match length)|(indices imply)' ): 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 np.testing.assert_array_equal(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, match='should be the name of a color'): 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)) np.testing.assert_array_equal(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)) np.testing.assert_array_equal(layer.edge_color, edge_color_array) # change the color cycle - edge_color should not change layer.edge_color_cycle = ['red', 'blue'] np.testing.assert_array_equal(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, match='must be a valid Points.properties'): layer.edge_color_mode = 'colormap' with pytest.raises(ValueError, match='must be a valid Points.properties'): 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(Dims(ndim=3, 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 isinstance(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)) 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 def test_docstring(): validate_all_params_in_docstring(Vectors) validate_kwargs_sorted(Vectors) napari-0.5.6/napari/layers/vectors/_vector_utils.py000066400000000000000000000067761474413133200225070ustar00rootroot00000000000000from typing import Optional import numpy as np import numpy.typing as npt from napari.utils.translations import trans def convert_image_to_coordinates(vectors: npt.NDArray) -> npt.NDArray: """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 = np.array([]) 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, ) ) return vectors, data_ndim napari-0.5.6/napari/layers/vectors/_vectors_constants.py000066400000000000000000000021631474413133200235300ustar00rootroot00000000000000from collections import OrderedDict from enum import auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class VectorStyle(StringEnum): """STYLE: Display style for the vectors. Selects a preset display style in that determines how vectors are displayed. VectorStyle.LINE Displays vectors as rectangular lines. VectorStyle.TRIANGLE Displays vectors as triangles. VectorStyle.ARROW Displays vectors as arrows. """ LINE = auto() TRIANGLE = auto() ARROW = auto() VECTORSTYLE_TRANSLATIONS = OrderedDict( [ (VectorStyle.LINE, trans._('line')), (VectorStyle.TRIANGLE, trans._('triangle')), (VectorStyle.ARROW, trans._('arrow')), ] ) class VectorsProjectionMode(StringEnum): """ Projection mode for aggregating a thick nD slice onto displayed dimensions. * NONE: ignore slice thickness, only using the dims point * ALL: project all vectors in the slice onto displayed dimensions """ NONE = auto() ALL = auto() napari-0.5.6/napari/layers/vectors/_vectors_key_bindings.py000066400000000000000000000020501474413133200241540ustar00rootroot00000000000000from typing import Callable from 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 ) -> Callable[[Callable], Callable]: return register_layer_action(Vectors, description, repeatable) def register_vectors_mode_action( description: str, ) -> Callable[[Callable], Callable]: return register_layer_attr_action(Vectors, description, 'mode') @register_vectors_mode_action(trans._('Transform')) def activate_vectors_transform_mode(layer: Vectors) -> None: layer.mode = str(Mode.TRANSFORM) @register_vectors_mode_action(trans._('Pan/zoom')) def activate_vectors_pan_zoom_mode(layer: Vectors) -> None: layer.mode = str(Mode.PAN_ZOOM) vectors_fun_to_mode = [ (activate_vectors_pan_zoom_mode, Mode.PAN_ZOOM), (activate_vectors_transform_mode, Mode.TRANSFORM), ] napari-0.5.6/napari/layers/vectors/vectors.py000066400000000000000000000750221474413133200213010ustar00rootroot00000000000000import warnings from copy import copy from typing import Any, 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._slice_input import _SliceInput, _ThickNDSlice 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._slice import ( _VectorSliceRequest, _VectorSliceResponse, ) from napari.layers.vectors._vector_utils import fix_data_vectors from napari.layers.vectors._vectors_constants import ( VectorsProjectionMode, VectorStyle, ) 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. 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. axis_labels : tuple of str, optional Dimension names of the layer data. If not provided, axis_labels will be set to (..., 'axis -2', 'axis -1'). 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'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. 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()) edge_width : float Width for all vectors in pixels. 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. feature_defaults : dict[str, Any] or DataFrame The default value of each feature in a table with one row. features : dict[str, array-like] or DataFrame Features table where each row corresponds to a vector and each column is a feature. length : float Multiplicative factor on projections for length of all vectors. metadata : dict Layer metadata. name : str Name of the layer. 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. opacity : float Opacity of the layer visual, between 0.0 and 1.0. 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. projection_mode : str How data outside the viewed dimensions but inside the thick Dims slice will be projected onto the viewed dimenions. 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. 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 : tuple of float Scale factors for the layer. 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 : tuple of float Translation values for the layer. units : tuple of str or pint.Unit, optional Units of the layer data in world coordinates. If not provided, the default units are assumed to be pixels. vector_style : str One of a list of preset display modes that determines how vectors are displayed. Allowed values are {'line', 'triangle', and 'arrow'}. visible : bool Whether the layer visual is currently being displayed. Attributes ---------- data : (N, 2, D) array The start point and projections of N vectors in D dimensions. axis_labels : tuple of str Dimension names of the layer data. 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. vector_style : VectorStyle Determines how vectors are displayed. * ``VectorStyle.LINE``: Vectors are displayed as lines. * ``VectorStyle.TRIANGLE``: Vectors are displayed as triangles. * ``VectorStyle.ARROW``: Vectors are displayed as arrows. 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. units: tuple of pint.Unit Units of the layer data in world coordinates. 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. """ _projectionclass = VectorsProjectionMode # 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, *, affine=None, axis_labels=None, blending='translucent', cache=True, edge_color='red', edge_color_cycle=None, edge_colormap='viridis', edge_contrast_limits=None, edge_width=1, experimental_clipping_planes=None, feature_defaults=None, features=None, length=1, metadata=None, name=None, ndim=None, opacity=0.7, out_of_slice_display=False, projection_mode='none', properties=None, property_choices=None, rotate=None, scale=None, shear=None, translate=None, units=None, vector_style='triangle', visible=True, ) -> None: if ndim is None and scale is not None: ndim = len(scale) data, ndim = fix_data_vectors(data, ndim) super().__init__( data, ndim, affine=affine, axis_labels=axis_labels, blending=blending, cache=cache, experimental_clipping_planes=experimental_clipping_planes, name=name, metadata=metadata, opacity=opacity, projection_mode=projection_mode, rotate=rotate, scale=scale, shear=shear, translate=translate, units=units, visible=visible, ) # events for non-napari calculations self.events.add( length=Event, edge_width=Event, edge_color=Event, vector_style=Event, edge_color_mode=Event, properties=Event, out_of_slice_display=Event, features=Event, feature_defaults=Event, ) # Save the vector style params self._vector_style = VectorStyle(vector_style) self._edge_width = edge_width self._out_of_slice_display = out_of_slice_display self._length = float(length) self._data = data self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, 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._feature_table.currents() ), ) # Data containing vectors in the currently viewed slice self._view_data = np.empty((0, 2, 2)) self._view_indices = np.array([], dtype=int) self._view_alphas: Union[float, np.ndarray] = 1.0 # 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(), 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._update_current_properties( self._feature_table.currents() ) 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 @feature_defaults.setter def feature_defaults( self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.feature_defaults() @property def property_choices(self) -> dict[str, np.ndarray]: return self._feature_table.choices() def _get_state(self) -> dict[str, Any]: """Get dictionary of layer state. Returns ------- state : dict of str to Any Dictionary of layer state. """ state = self._get_base_state() state.update( { 'length': self.length, 'edge_width': self.edge_width, 'vector_style': self.vector_style, '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.dict(), 'edge_contrast_limits': self.edge_contrast_limits, 'data': self.data, 'properties': self.properties, 'property_choices': self.property_choices, 'ndim': self.ndim, 'features': self.features, 'feature_defaults': self.feature_defaults, '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(extent=False) @property def edge_width(self) -> float: """float: Width for all vectors in pixels.""" return self._edge_width @edge_width.setter def edge_width(self, edge_width: float): self._edge_width = edge_width self.events.edge_width() self.refresh(extent=False) @property def vector_style(self) -> str: """Vectors display mode: Determines how vectors are displayed. VectorStyle.LINE Displays vectors as rectangular lines. VectorStyle.TRIANGLE Displays vectors as triangles. VectorStyle.ARROW Displays vectors as arrows. """ return str(self._vector_style) @vector_style.setter def vector_style(self, vector_style: str): old_vector_style = self._vector_style self._vector_style = VectorStyle(vector_style) if self._vector_style != old_vector_style: self.events.vector_style() self.refresh(extent=False, thumbnail=False) @property def length(self) -> float: """float: Multiplicative factor for length of all vectors.""" return self._length @length.setter def length(self, length: 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) -> 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 triangles""" # Create as many colors as there are visible vectors. # Using fancy array indexing implicitly creates a new # array rather than creating a view of the original one # in ColorManager face_color = self.edge_color[self._view_indices] face_color[:, -1] *= self._view_alphas # Generally, several triangles are drawn for each vector, # so we need to duplicate the colors accordingly if self.vector_style == 'line': # Line vectors are drawn with 2 triangles face_color = np.repeat(face_color, 2, axis=0) elif self.vector_style == 'triangle': # Triangle vectors are drawn with 1 triangle pass # No need to duplicate colors elif self.vector_style == 'arrow': # Arrow vectors are drawn with 3 triangles face_color = np.repeat(face_color, 3, axis=0) if self._slice_input.ndisplay == 3 and self.ndim > 2: face_color = np.vstack([face_color, face_color]) return face_color 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._data_slice ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims) -> _VectorSliceRequest: """Make a Vectors slice request based on the given dims and these data.""" slice_input = self._make_slice_input(dims) # 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_slice(self._data_to_world.inverse) return self._make_slice_request_internal(slice_input, slice_indices) def _make_slice_request_internal( self, slice_input: _SliceInput, data_slice: _ThickNDSlice ): return _VectorSliceRequest( slice_input=slice_input, data=self.data, data_slice=data_slice, projection_mode=self.projection_mode, out_of_slice_display=self.out_of_slice_display, length=self.length, ) def _update_slice_response(self, response: _VectorSliceResponse): """Handle a slicing response.""" self._slice_input = response.slice_input indices = response.indices alphas = response.alphas disp = self._slice_input.displayed self._view_indices = indices self._view_alphas = alphas self._view_data = self.data[np.ix_(list(indices), [0, 1], disp)] 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, dtype=np.uint8) 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] = (colormapped[..., 3] * self.opacity).astype( np.uint8 ) 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 napari-0.5.6/napari/plugins/000077500000000000000000000000001474413133200157315ustar00rootroot00000000000000napari-0.5.6/napari/plugins/__init__.py000066400000000000000000000037001474413133200200420ustar00rootroot00000000000000from functools import lru_cache 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__ = ('menu_item_template', 'plugin_manager') 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() -> None: _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) # 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.6/napari/plugins/_npe2.py000066400000000000000000000352271474413133200173170ustar00rootroot00000000000000from __future__ import annotations from collections import defaultdict from collections.abc import Iterator, Sequence from typing import ( TYPE_CHECKING, Optional, cast, ) from app_model import Action from app_model.types import SubmenuItem from npe2 import io_utils, plugin_manager as pm from npe2.manifest import contributions from napari.utils.translations import trans if TYPE_CHECKING: 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.""" # do nothing if `plugin` is not an npe2 reader if plugin: # user might have passed 'plugin.reader_contrib' as the command # so ensure we check vs. just the actual plugin name plugin_name = plugin.partition('.')[0] if plugin_name not in get_readers(): return None 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 ) except ValueError as e: # plugin wasn't passed and no reader was found if 'No readers returned data' not in str(e): raise else: return layer_data, _FakeHookimpl(reader.plugin_name) 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 ) except ValueError: return [], '' else: return paths, writer.plugin_name 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) assert subm is not None populate_qmenu(subm, subm_contrib.id) else: cmd = pm.get_command(item.command) action = menu.addAction(cmd.title) assert action is not None 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.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)) _safe_register_qt_actions(pm.get_manifest(plugin_name)) def on_plugins_registered(manifests: set[PluginManifest]): """Callback when any npe2 plugins are registered. 'Registered' means that a manifest has been provided or discovered. """ sorted_manifests = sorted( manifests, key=lambda mf: mf.display_name if mf.display_name else mf.name, ) for mf in sorted_manifests: if not pm.is_disabled(mf.name): _register_manifest_actions(mf) _safe_register_qt_actions(mf) def _register_manifest_actions(mf: 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_model app = get_app_model() actions, submenus = _npe2_manifest_to_actions(mf) context = pm.get_context(cast('PluginName', mf.name)) # Register and connect dispose callback to plugin deactivate ('unregistered') event if actions: context.register_disposable(app.register_actions(actions)) if submenus: context.register_disposable(app.menus.append_menu_items(submenus)) def _safe_register_qt_actions(mf: PluginManifest) -> None: """Register samples and widget `Actions` if Qt available.""" try: from napari._qt._qplugins import _register_qt_actions except ImportError: # pragma: no cover # if no Qt bindings are installed (PyQt/PySide), then trying to import # qtpy will raise an ImportError, *not* a ModuleNotFoundError pass else: _register_qt_actions(mf) 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 menu_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)) menu_cmds[item.command].append(rule) else: subitem = _npe2_submenu_to_app_model(item) submenus.append((menu_id, subitem)) # Filter sample data commands (not URIs) as they are registered via # `_safe_register_qt_actions` sample_data_ids = { contrib.command for contrib in mf.contributions.sample_data or () if hasattr(contrib, 'command') } # Filter widgets as are registered via `_safe_register_qt_actions` widget_ids = {widget.command for widget in mf.contributions.widgets or ()} # We want to register all `Actions` so they appear in the command palette actions: list[Action] = [] for cmd in mf.contributions.commands or (): if cmd.id not in sample_data_ids | widget_ids: actions.append( Action( id=cmd.id, title=f'{cmd.title} ({mf.display_name})', category=cmd.category, tooltip=cmd.short_title or cmd.title, icon=cmd.icon, enablement=cmd.enablement, callback=cmd.python_name or '', menus=menu_cmds.get(cmd.id), keybindings=[], ) ) return actions, submenus def _when_group_order( menu_item: contributions.MenuItem, ) -> dict: """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.6/napari/plugins/_plugin_manager.py000066400000000000000000000703711474413133200214420ustar00rootroot00000000000000import contextlib import warnings from collections.abc import Iterable, Iterator from functools import partial from pathlib import Path from types import FunctionType from typing import ( Any, Callable, Optional, Union, ) from warnings import warn from napari_plugin_engine import ( HookImplementation, PluginManager as PluginManager, ) from napari_plugin_engine.hooks import HookCaller from typing_extensions import TypedDict from napari._pydantic_compat import ValidationError from napari.plugins import hook_specifications from napari.settings import get_settings from napari.types import AugmentedWidget, LayerData, SampleDict, WidgetCallable from napari.utils.events import EmitterGroup, EventedSet from napari.utils.misc import camel_to_spaces 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]] = {} # TODO: remove once npe1 deprecated # appmodel sample menu actions/submenu unregister functions used in # `_rebuild_npe1_samples_menu` self._unreg_sample_submenus = None self._unreg_sample_actions = None # appmodel plugins menu actions/submenu unregister functions used in # `_rebuild_npe1_plugins_menu` self._unreg_plugin_submenus = None self._unreg_plugin_actions = None def _initialize(self) -> None: 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) self.events.unregistered(value=_name) return plugin def _on_blocked_change(self, event) -> None: # 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) -> None: """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 # The content of contribution dictionaries is name of plugin and # list of its names of widgets contributed by this plugin # as this order do not depend on the order of contributions in file # we sort it to make it easier searchable. dock_widgets = zip( repeat('dock'), ( (name, sorted(cont)) for name, cont in self._dock_widgets.items() ), ) func_widgets = zip( repeat('func'), ( (name, sorted(cont)) for name, cont in 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` is not installed or 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} is not installed or 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 = next(iter(plg_wdgs)) 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.6/napari/plugins/_tests/000077500000000000000000000000001474413133200172325ustar00rootroot00000000000000napari-0.5.6/napari/plugins/_tests/__init__.py000066400000000000000000000000001474413133200213310ustar00rootroot00000000000000napari-0.5.6/napari/plugins/_tests/_sample_manifest.yaml000066400000000000000000000024511474413133200234260ustar00rootroot00000000000000name: 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/file/new_layer: - 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/1024 napari-0.5.6/napari/plugins/_tests/test_exceptions.py000066400000000000000000000024331474413133200230260ustar00rootroot00000000000000import 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.6/napari/plugins/_tests/test_hook_specifications.py000066400000000000000000000062001474413133200246640ustar00rootroot00000000000000import 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_'), ( f"hook specification '{name}' does not start with 'napari_'" ) @pytest.mark.parametrize(('name', 'func'), HOOK_SPECIFICATIONS) def test_docstring_on_hook_specification(name, func): """All hook specifications should have documentation.""" assert func.__doc__, f"no docstring for '{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.6/napari/plugins/_tests/test_npe2.py000066400000000000000000000162571474413133200215220ustar00rootroot00000000000000from pathlib import Path from types import MethodType from typing import TYPE_CHECKING from unittest.mock import MagicMock import npe2 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() with pytest.raises(ValueError, match='No compatible readers'): _npe2.read(['some.randomext'], stack=False) mock_pm.commands.get.assert_not_called() mock_pm.commands.get.reset_mock() assert ( _npe2.read(['some.randomext'], stack=True, plugin='not-npe2-plugin') is None ) mock_pm.commands.get.assert_not_called() mock_pm.commands.get.reset_mock() _, hookimpl = _npe2.read( ['some.fzzy'], stack=False, plugin='my-plugin.some_reader' ) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') assert hookimpl.plugin_name == PLUGIN_NAME @pytest.mark.skipif( npe2.__version__ < '0.7.0', reason='Older versions of npe2 do not throw specific error.', ) def test_read_with_plugin_failure(mock_pm: 'TestPluginManager'): with pytest.raises(ValueError, match='is not a compatible reader'): _npe2.read(['some.randomext'], stack=True, plugin=PLUGIN_NAME) 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/file/new_layer') menu.addMenu.assert_called_once_with('My SubMenu') menu.addAction.assert_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) assert opener.__self__ is samples[0] opener, _ = _npe2.get_sample_data(PLUGIN_NAME, 'internet_image') assert isinstance(opener, MethodType) assert 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', mock_app_model): from napari._app_model import get_app_model from napari.plugins import _initialize_plugins app = get_app_model() # nothing yet registered with this menu assert 'napari/file/new_layer' not in app.menus # menus_items1 = list(app.menus.get_menu('napari/file/new_layer')) # 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 # now we have command registered menus_items2 = list(app.menus.get_menu('napari/file/new_layer')) assert 'my-plugin.hello_world' in app.commands assert len(menus_items2) == 2 # then disable and re-enable the plugin mock_pm.disable(PLUGIN_NAME) assert 'napari/file/new_layer' not in app.menus mock_pm.enable(PLUGIN_NAME) menus_items4 = list(app.menus.get_menu('napari/file/new_layer')) assert len(menus_items4) == 2 assert 'my-plugin.hello_world' in app.commands napari-0.5.6/napari/plugins/_tests/test_plugin_widgets.py000066400000000000000000000031471474413133200236740ustar00rootroot00000000000000import 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.6/napari/plugins/_tests/test_plugins_manager.py000066400000000000000000000065301474413133200240220ustar00rootroot00000000000000import 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 return None @napari_hook_implementation def napari_get_writer(path, *args): if path.endswith('.png'): return lambda x: None return 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, match='is not a valid reader'): tnpm.assign_reader_to_extensions('test_pldfdfugin', '.png') napari-0.5.6/napari/plugins/_tests/test_provide_theme.py000066400000000000000000000072321474413133200235010ustar00rootroot00000000000000"""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').to_rgb_dict() 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').to_rgb_dict() 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').to_rgb_dict() 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.6/napari/plugins/_tests/test_sample_data.py000066400000000000000000000051301474413133200231140ustar00rootroot00000000000000from 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 LOGO = str(Path(napari.__file__).parent / 'resources' / 'logo.png') 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})] 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)) def test_sample_uses_reader_plugin(builtins, tmp_plugin, tmp_path): viewer = ViewerModel() NAME = tmp_plugin.name tmp_plugin.manifest.contributions.sample_data = [ SampleDataURI( uri=LOGO, key='napari logo', display_name='Napari logo', reader_plugin='gibberish', ) ] # if we don't pass a plugin, the declared reader_plugin is tried with pytest.raises(ValueError, match='no registered plugin'): viewer.open_sample(NAME, 'napari logo') # if we pass a plugin, it overrides the declared one viewer.open_sample(NAME, 'napari logo', reader_plugin='napari') assert len(viewer.layers) == 1 # if we pass a plugin that fails, we get the right error message fake_uri = tmp_path / 'fakepath.png' fake_uri.touch() tmp_plugin.manifest.contributions.sample_data = [ SampleDataURI( uri=str(fake_uri), key='fake sample', display_name='fake sample', reader_plugin='gibberish', ) ] with pytest.raises(ValueError, match='failed to open sample'): viewer.open_sample(NAME, 'fake sample', reader_plugin='napari') napari-0.5.6/napari/plugins/_tests/test_save_layers.py000066400000000000000000000061431474413133200231640ustar00rootroot00000000000000import 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.6/napari/plugins/_tests/test_utils.py000066400000000000000000000210551474413133200220060ustar00rootroot00000000000000import os.path import sys from 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 os.path.realpath('/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(): # account for py313 change in https://github.com/python/cpython/pull/113829 if sys.platform.startswith('win') and sys.version_info >= (3, 13): relative_path = r'*\my-specific-folder\[nested]\*?.tif' absolute_path = r'\\my-specific-folder\[nested]\*?.tif' else: relative_path = '*/my-specific-folder/[nested]/*?.tif' absolute_path = '/my-specific-folder/[nested]/*?.tif' assert score_specificity(relative_path) == ( True, -3, [ MatchFlag.STAR, MatchFlag.NONE, MatchFlag.SET, MatchFlag.STAR | MatchFlag.ANY, ], ) assert score_specificity(absolute_path) == ( 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], ) # account for py313 change in https://github.com/python/cpython/pull/113829 if sys.platform.startswith('win') and sys.version_info >= (3, 13): absolute_path = r'\\abc*\*.tif' else: absolute_path = '/abc*/*.tif' assert score_specificity(absolute_path) == (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_preferred_reader_full_path(tmp_path, monkeypatch): (tmp_path / 'my_file.zarr').mkdir() zarr_path = str(tmp_path / 'my_file.zarr') assert get_preferred_reader(zarr_path) is None get_settings().plugins.extension2reader[f'{zarr_path}/'] = 'fake-plugin' assert get_preferred_reader(zarr_path) == 'fake-plugin' monkeypatch.chdir(tmp_path) assert get_preferred_reader('./my_file.zarr') == 'fake-plugin' 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.6/napari/plugins/exceptions.py000066400000000000000000000036701474413133200204720ustar00rootroot00000000000000from 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}: {err!s} {"-" * _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.6/napari/plugins/hook_specifications.py000066400000000000000000000515051474413133200223340ustar00rootroot00000000000000# mypy: disable-error-code=empty-body """ 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, Optional, 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.6/napari/plugins/io.py000066400000000000000000000432641474413133200167230ustar00rootroot00000000000000from __future__ import annotations import os import pathlib import warnings from collections.abc import Sequence from logging import getLogger from typing import TYPE_CHECKING, Any, Optional from napari_plugin_engine import HookImplementation, PluginCallError from napari.layers import Layer from napari.plugins import _npe2, plugin_manager from napari.types import LayerData, PathLike 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[PathLike], 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 list(ld_), hookimpl 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 = set(_npe2.get_readers().keys()).union( {i.plugin_name for i in hook_caller.get_hookimpls()} ) err_helper = ( trans._( 'No readers are available. ' 'Do you have any plugins installed?', deferred=True, ) if len(names) <= 1 else trans._( f'\nNames of plugins offering readers are: {names}.', deferred=True, ) ) raise ValueError( trans._( "There is no registered plugin named '{plugin}'. {err_helper}", deferred=True, plugin=plugin, err_helper=err_helper, ) ) 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: # 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 TypeError(msg) try: return ( writer_function(abspath_or_url(path), layer_data), implementation.plugin_name, ) except Exception as exc: 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.6/napari/plugins/npe2api.py000066400000000000000000000074561474413133200176550ustar00rootroot00000000000000""" These convenience functions will be useful for searching pypi for packages that match the plugin naming convention, and retrieving related metadata. """ import json from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from typing import ( Optional, TypedDict, cast, ) from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from npe2 import PackageMetadata from typing_extensions import NotRequired from napari.plugins.utils import normalized_name from napari.utils.notifications import show_warning 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_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 _ShortSummaryDict(TypedDict): """Objects returned at https://npe2api.vercel.app/api/extended_summary .""" name: NotRequired[PyPIname] version: str summary: str author: str license: str home_page: str class SummaryDict(_ShortSummaryDict): display_name: NotRequired[str] pypi_versions: NotRequired[list[str]] conda_versions: NotRequired[list[str]] def plugin_summaries() -> list[SummaryDict]: """Return PackageMetadata object for all known napari plugins.""" url = 'https://npe2api.vercel.app/api/extended_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, dict]]: """Iterator of tuples of ProjectInfo, Conda availability for all napari plugins.""" try: with ThreadPoolExecutor() as executor: data = executor.submit(plugin_summaries) _conda = executor.submit(conda_map) conda = _conda.result() data_set = data.result() except (HTTPError, URLError): show_warning( 'There seems to be an issue with network connectivity. ' 'Remote plugins cannot be installed, only local ones.\n' ) return conda_set = {normalized_name(x) for x in conda} for info in data_set: info_copy = dict(info) info_copy.pop('display_name', None) pypi_versions = info_copy.pop('pypi_versions') conda_versions = info_copy.pop('conda_versions') info_ = cast(_ShortSummaryDict, info_copy) # TODO: use this better. # this would require changing the api that qt_plugin_dialog expects to # receive # TODO: once the new version of npe2 is out, this can be refactored # to all the metadata includes the conda and pypi versions. extra_info = { 'home_page': info_.get('home_page', ''), 'display_name': info.get('display_name', ''), 'pypi_versions': pypi_versions, 'conda_versions': conda_versions, } info_['name'] = normalized_name(info_['name']) meta = PackageMetadata(**info_) # type:ignore[call-arg] yield meta, (info_['name'] in conda_set), extra_info napari-0.5.6/napari/plugins/utils.py000066400000000000000000000134641474413133200174530ustar00rootroot00000000000000import 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 Optional, Union from npe2 import PluginManifest from napari.plugins import _npe2, plugin_manager from napari.settings import get_settings from napari.types import PathLike 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: PathLike) -> list[tuple[str, str]]: """Given filepath, find matching readers from preferences. Parameters ---------- path : str Path of the file. Returns ------- filtered_preferences : List[Tuple[str, str]] Filtered patterns and their corresponding readers. """ path = os.path.realpath(str(path)) if osp.isdir(path) and not path.endswith(os.sep): path = path + os.sep reader_settings = get_settings().plugins.extension2reader def filter_fn(kv: tuple[str, str]) -> bool: return fnmatch(path, kv[0]) ret = list(filter(filter_fn, reader_settings.items())) return ret def get_preferred_reader(path: PathLike) -> 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: PathLike) -> 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.6/napari/qt/000077500000000000000000000000001474413133200146745ustar00rootroot00000000000000napari-0.5.6/napari/qt/__init__.py000066400000000000000000000011671474413133200170120ustar00rootroot00000000000000from napari._qt.qt_event_loop import get_app, get_qapp, 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__ = ( 'QtToolTipLabel', 'QtViewer', 'QtViewerButtons', 'Window', 'create_worker', 'get_app', 'get_current_stylesheet', 'get_qapp', 'get_stylesheet', 'run', 'thread_worker', ) napari-0.5.6/napari/qt/threading.py000066400000000000000000000007171474413133200172200ustar00rootroot00000000000000from 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__ = ( 'FunctionWorker', 'GeneratorWorker', 'GeneratorWorkerSignals', 'WorkerBase', 'WorkerBaseSignals', 'create_worker', 'thread_worker', ) napari-0.5.6/napari/resources/000077500000000000000000000000001474413133200162625ustar00rootroot00000000000000napari-0.5.6/napari/resources/__init__.py000066400000000000000000000003651474413133200203770ustar00rootroot00000000000000from napari.resources._icons import ( ICON_PATH, ICONS, LOADING_GIF_PATH, get_colorized_svg, get_icon_path, ) __all__ = [ 'ICONS', 'ICON_PATH', 'LOADING_GIF_PATH', 'get_colorized_svg', 'get_icon_path', ] napari-0.5.6/napari/resources/_icons.py000066400000000000000000000137321474413133200201140ustar00rootroot00000000000000import re from collections.abc import Iterable, Iterator from functools import lru_cache from itertools import product from pathlib import Path from typing import Optional, Union from napari.utils._appdirs import user_cache_dir from napari.utils.translations import trans LOADING_GIF_PATH = str((Path(__file__).parent / 'loading.gif').resolve()) 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: Optional[str] = None, opacity=1.0 ) -> 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), 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.6/napari/resources/icon.icns000066400000000000000000027201351474413133200201020ustar00rootroot00000000000000icns ]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.6/napari/resources/icon.ico000066400000000000000000004055771474413133200177300ustar00rootroot00000000000000 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.6/napari/resources/icons/000077500000000000000000000000001474413133200173755ustar00rootroot00000000000000napari-0.5.6/napari/resources/icons/2D.svg000066400000000000000000000010341474413133200203610ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/3D.svg000066400000000000000000000016331474413133200203670ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/add.svg000066400000000000000000000014171474413133200206510ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/check.svg000066400000000000000000000003411474413133200211710ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/chevron_down.svg000066400000000000000000000012651474413133200226150ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/chevron_left.svg000066400000000000000000000012651474413133200226000ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/chevron_up.svg000066400000000000000000000012521474413133200222660ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/circle.svg000066400000000000000000000003161474413133200213570ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/console.svg000066400000000000000000000015201474413133200215560ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/copy_to_clipboard.svg000066400000000000000000000011101474413133200236020ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/debug.svg000066400000000000000000000001051474413133200212000ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/delete.svg000066400000000000000000000013201474413133200213540ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/delete_shape.svg000066400000000000000000000011131474413133200225340ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/direct.svg000066400000000000000000000013611474413133200213710ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/down_arrow.svg000066400000000000000000000006241474413133200223010ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/drop_down.svg000066400000000000000000000006241474413133200221130ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/ellipse.svg000066400000000000000000000025421474413133200215560ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/erase.svg000066400000000000000000000007051474413133200212170ustar00rootroot00000000000000 Artboard 1 napari-0.5.6/napari/resources/icons/error.svg000066400000000000000000000007121474413133200212470ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/fill.svg000066400000000000000000000012151474413133200210430ustar00rootroot00000000000000 Artboard 1 napari-0.5.6/napari/resources/icons/grid.svg000066400000000000000000000014611474413133200210450ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/help.svg000066400000000000000000000024241474413133200210500ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/home.svg000066400000000000000000000013441474413133200210500ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/horizontal_separator.svg000066400000000000000000000014621474413133200243720ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/info.svg000066400000000000000000000007401474413133200210520ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/left_arrow.svg000066400000000000000000000006241474413133200222640ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/line.svg000066400000000000000000000014541474413133200210510ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/lock.svg000066400000000000000000000035231474413133200210510ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/lock_open.svg000066400000000000000000000047421474413133200220760ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/logo_silhouette.svg000066400000000000000000000035021474413133200233230ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/long_left_arrow.svg000066400000000000000000000010201474413133200232720ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/long_right_arrow.svg000066400000000000000000000007201474413133200234630ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/minus.svg000066400000000000000000000006301474413133200212500ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/move_back.svg000066400000000000000000000014401474413133200220430ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/move_front.svg000066400000000000000000000013671474413133200223030ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_image.svg000066400000000000000000000016321474413133200220530ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_labels.svg000066400000000000000000000012701474413133200222310ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_points.svg000066400000000000000000000011061474413133200223010ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_shapes.svg000066400000000000000000000006411474413133200222530ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_surface.svg000066400000000000000000000007331474413133200224220ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_tracks.svg000066400000000000000000000015001474413133200222520ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/new_vectors.svg000066400000000000000000000025751474413133200224650ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/none.svg000066400000000000000000000001051474413133200210510ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/paint.svg000066400000000000000000000016501474413133200212330ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/pan_arrows.svg000066400000000000000000000022751474413133200222770ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/path.svg000066400000000000000000000261351474413133200210610ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/picker.svg000066400000000000000000000043721474413133200214010ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/plus.svg000066400000000000000000000010231474413133200210750ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/polygon.svg000066400000000000000000000074061474413133200216140ustar00rootroot00000000000000 Layer 1 napari-0.5.6/napari/resources/icons/polygon_lasso.svg000066400000000000000000000075311474413133200230140ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/polyline.svg000066400000000000000000000025361474413133200217570ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/pop_out.svg000066400000000000000000000012461474413133200216060ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/rectangle.svg000066400000000000000000000023701474413133200220640ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/right_arrow.svg000066400000000000000000000006241474413133200224470ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/roll.svg000066400000000000000000000027351474413133200210750ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/select.svg000066400000000000000000000012661474413133200214020ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/shuffle.svg000066400000000000000000000013771474413133200215620ustar00rootroot00000000000000 Artboard 1 napari-0.5.6/napari/resources/icons/square.svg000066400000000000000000000006311474413133200214160ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/step_left.svg000066400000000000000000000010111474413133200220740ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/step_right.svg000066400000000000000000000007041474413133200222670ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/transform.svg000066400000000000000000000046451474413133200221420ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/transpose.svg000066400000000000000000000012421474413133200221330ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/up_arrow.svg000066400000000000000000000006241474413133200217560ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/vertex_insert.svg000066400000000000000000000016411474413133200230210ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/vertex_remove.svg000066400000000000000000000015721474413133200230150ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/vertical_separator.svg000066400000000000000000000014621474413133200240120ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/visibility.svg000066400000000000000000000012421474413133200223040ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/visibility_off.svg000066400000000000000000000014241474413133200231400ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/warning.svg000066400000000000000000000012231474413133200215610ustar00rootroot00000000000000 napari-0.5.6/napari/resources/icons/zoom.svg000066400000000000000000000013331474413133200211020ustar00rootroot00000000000000 napari-0.5.6/napari/resources/loading.gif000066400000000000000000013022201474413133200203660ustar00rootroot00000000000000GIF89ar2WIDZv*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.6/napari/resources/logo.png000066400000000000000000005340531474413133200177420ustar00rootroot00000000000000PNG  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(Vg U" K";A@E@ ˽WE/*z,Md_vL2]U?ɐ@B>99}tVzc`0((()k!fsυƆ((( },]ua (((CO>DQEQEQCoo/qmF/[I"*EYQ92a¸(((Jcccӥ7D >xBo[dH0_`1z1;)((@io 1IojJ% YKY5D6F(((JI)@@©1@gLka?474Ʌ)'ߵQEQEQEzLόygaN"bX3@|ȱ-gqOEQEQEGe}YqĦX =Q#Ic ||H.FQP(Zo~nG?)("]zٳTqm!bYʰMSZ< lI/; C1X| ~(68ȣxӁ>(l!ws/ws/zpy=/BVF @ #Xw  /ٳgs7_z׼{9K_o|}# @$hƺnFKTKjuze0rWK{룫e"B6s ]GwO}RCEQU~ 8GW\Q~EpI#9o+g|,Wo|G_^P~^g|#|'0>ugXbK,=;{#zy1gC(k[=(x9?%뉐c=| H$ km%2ef,cea 2m&MY۵Z N?4xʩT|( -|ӟ/7ȗO?y6Gͱ~K.o*{װnsT*oȵW_ȑ#(+V<ǹϏ~sAqEQ:[=(x, O9/r0HT*Tz{ZZ 59O=k\ew*ƈ˴O|}k;7{^{oq(CAoz;4 }݇F,Y @kk Zˮ|bL~{r!Xk>m:8pnY~ͥZE˗//?iP-+3110X4\6v̀`* d.a,Z޶5G*c #KEDQ4PMwGu(ƍ[}RIȜY-xhoYr%ҥ˘6ejyQʿƍˣ> Usc `v8aL."Fᇿ뮻~+(Pɋv]{Wнڲ݄HzZZOSkm4SinBCk UKϣL=i2S8O (e_W_O)(f HZ؈"3^ZŌ4P.^]w0av456/(f(YEQE֋ J5D.Gscsvڍ?V`ǹa-0eWc6}(((\dQS@ 2FG^NKS3I44AYA @tjF(6xQGΣ|XQEQ6Rn VQ*@S(j͗K>sw|XQEQEQ(((+nPEQEQeDQEQEQ EQEQEQf9DQEQEQ(((P(((VC((([  ((l5T(((BbǓw'2?S |֢aN8zfKDHvoa*#S꼏c42皯KeGh$q5k;SEQeD"f/;ov;`_]{m[*!m_oi>:?]lgB7ǙW˱ӊ`uqHKqe~5_ݏʳ7naĶO}zun~|`(j //c>={d-5sã๣5⬚y|.K:x8>'FLSq<ėc[Y9֠0?6Cv/n#JO n c]'͏۵|EQEQeS<<9yyo:^t9_s1ϣռ6a-ߴeRc͕_⍍`Zgqǟ/.'0^f8SyOKsgdT?qõ?/IIn'LbY?|nrVN?_-0x%qi[1f8G~jyHC;(> =G`h|kWz7ZĬBX̌7̢3,\9d%?hן`Fgwy\M7\ʯx;Sr7+~~:m70 ۽x˸+Sy&Lmp][ T˾CVgv7d?μ?9_W?<~.6gQEQ埈WM̓w o=Ӝ_g~|-v3x 6em_eŠd'>_2⊓9p•엏f']u;l`i.33*̚:!׿ڐ㾋Gyo/|q{Jniv;toioz#{{Wyw|#yeU7|>?n~uNN<ECgbwK,rOxg8>~Nm:xěn?>'??Imy?fC {2g}\}t3" ];gs${g~`Fqɧ~+;\rt>Cr>OY}Mf΍@}vij^c1 y&>:Gҁx0/awiޏM~~Yri|-#_yEQEobcX&sW2e\},e̤.Ǻn _u'f=M&Ao1<{ӭ 7qP/%Ba٫{|2wjߝ}׬ {O!̛۬ CC|,=?.ƸQU7_;# DS9nn}9:LO^u9 r_~sn_MO-%]3N<-r,ӎ=z?8Ie-=yγ?+_ ьf.ߺ.uJ><{u|=*Շ9͙<&?Nܶ#FONn>ɑG:usO䞶IfJ>ן^/8#'΅[??~1򌗃_|܁.x4<uVTogmZ+t]W983gE_3~g:ovo? ]vk䁟=sp^Y$0uxMhgM{qw4gxƪE֬#J6ʅ ƪp ww|~v]1&Zyo>g;pTLK@B'%6F-EQEQ^l5mЍ-x׮c5g@nf r=_/tu-?z> qCphxwF7,g$ n6.KF4eѤn\EQEQ6 Ϣ}?vI ئmqڨ!a踋13O|dP=b:[~$Ȟx]{ cvx$+T*+}eXTx9{ 7%rByqmA`;qe1 ًѷ;ܞwo7FFٓw2%_A,M,㽼of kJa{|*G9:̻ifml@]=k.LJ/EQEQ^+b"fp\; zVO >u 9enu#5 񹓿';x?5C*rw{xsض\3([Fr9Wsd~3_\r| Nݾ9q_yҡ۾Uƞt"{)3ڞ},ۀ#jǿq4k{W3ҭV\>T~ǷzZy\`Ïg8 >􊲱tlls鋦G&qdcyφ0U9 YSpVbXY})y~UQEQW(((VCCEQEQEjQEQEQeDQEQEQ EQEQEQ*@EQEQEjQEQEQeDQEQEQ&G(C|HQEQ 3f|HW@EQEQEzQEQEQeDQEQEQVba$jy}REQEQf k &M""}v;͚ɪUXY;rgEcӦM3=iYw$OjʰQ m]f vCPQEQ`Sy}YA%I8Sgڵ|~8}_PO~9&|,]lيla,;yDQEQekId)7p-rη̥L4^Ƞo4!~Q&NDyuaZ?c-'yF25> 0Ley,kaԩj0k,]#w`%˲&-oBu&o75н'_I4IS'ƺ%Ol]n0zZTO,^[emhnLi4==?y}k&o;:V< k.XǽI4a C((C& n[[xӁBüd}DZ䩃)b٢g8UOGH1}ɌX0Tnj#Z:S֋nI =r?kҦif%,~e n4F/XȚi8y~dutfLhC2z>GS/cZ3G|1Y(lɲɓ& /Xoˍ7e/_1Ǽ|,g] YN q a3VcmF~d(š裁m5к3X␒$е {OT]H[im1vnjQ+QqA k5rt*lCK ݴ=SwIes^3EQEQ!dHY|---D1tr>ig6;{2l.ǺfL?OQ2"=e] 8囻Dl;{Y1}qx1%EQEQaHgg]tuOÇ(ߧ+_5 E{cK$ɚm''VT@/F|wi$4}Q;45ZHO`ua #;h~2qBd+pdֱ\Sr"ꝬY$y6F IDAT|DQEQe$2Tttth_ѓwb1)EWw!%٢ y~t^?g ]3OEQEQMEW@EQEQEjQEQEQeDQEQEQ EQEQEQ*@EQEQEjQEQEQeDQEQEQ EQEQEQ*@EQEQEjQEQEQeDQEQEQF<`Zd؈+OHǺut|XQEQeyh((ʖDQEQEQeP(((V%(xAozU"`!m,.sƽj-T`ZR/NId.FM1@0 0@` d@!@0`Z| t!ā B Gއ8E`bC8Pwuj}52Ei1@6xq=a&&!jD." m^KbXlYp8""o_A !i6Dq$yH n&2DQFLf0R~}BLF}d6G`$&%#YzG,^[HK9ē #f1}Rh mZhZm+4Tڐzc$x22O+}gs܌.v.6<ɨ:Pv:bbi$ M ca!TfH+BBB"[t([OC@k"rQ㍴%؂QĞcH]Fҿ1Xb{i22RRu|8I^JoHXBFikKfqOM-B̕4Q%"$14R U,OGHq#}LChi ; 5DPxέ!j0AƨƨQ5 ^lK[ T(KM3ѻR<$TnY S8sH>:,X X( ax8,\9ġEb>w򁃀9zplyoe3ȬpzA ٮA[OdKZLjEG'c3@'20Pqj0֐XY%B%d̉|?#edԒ|̨51"b_`mGQJ-t8r"J ]mVcj4f|Q# :uRʌ8{ ݁L>`؟ 5S;& ) 3Kȋ;05bYņpya#K/dAL$䎌\hJd)X.&BJ Al$X9dFV= _C~[$@ Ή g\"I%ρ e!eʱk{E5RRE|i$d*&8Doe"c iʅGGRRӚ!A5}Q_f|;zYGpҦN11_P He#!Nԥ$%.sXl)5n#b|0ܨ]^ @ a91ZNoo^J^:e`֗aȠ#C]ftԉKU5+f ;E &U:qqb2 άA| # T&A2H`d|tAB(#"ȈG86"8_0&G2ȪQd# 1g2 $^ 2ِ;IsRaj9% y.?5"a8>(D2˞3z֔NuT8LDlc0ԝ3R!7aJk$FHIg"Tn@KGJ^Vԩ3 1jHLBW|VKD zCLj$ /q֕e>ga+ȫvNkiFCnAY`J1= #3F줼? f܆ٮ + BH=[+3#_^ 8ic A~gʾ_,yg m3Y1YQY/"$/"R2`!)X 82knFTHM0NL I*&2aQ7^!zd3YQv&H Ҙ睴M#7Q>Ԇ5RWŪbg4x(`FM1|OK.L@"Ž b?Ʈ/P-V'}۾|R<Cn#5IZ9) vTDlbZiC !WDe~"/nq&o+C Ey ^ QOZf32e3i=;c)g!OfrpC+0q+IEZKlc* SEZ X9T*dLf}xls'K1+28 1!)flbH#IsR 2` 3uU^Rq F$!՞ ^DM Qa;i-Ym}I'*wĂc'c+9.[+kHFO֋1Dp!eXً(_QVfJ drRnAo+Y9B"3#S8,sZ35Vդ!eL6FbtGЗ'I.&MTq4m?A^iHq^f B |%wĬ+Ž6Jn!4M\.@6MlI7C#=bu9w1Ya8of$> 2qRi>IaNJG~IC/`m*LγAdb7!,S1؊bzcNz ㄕ~Jz\4,PnۍCH M=1Iۊ(yp>Nc,*1J׽b%*P79/\qj#sY)J[٧Үkli~{ljb)`sJ[>ym S!5s e(']gC<L4z ,uO]&bPNFyW\W{oyead6S:!cM6DQDmbt|.؈Ò}!s2,f yz8LGB'BqL(D u h!OCo<7l-F(QFFKhK!pdEU7`$!3R.8j}$YBTS/AQ)`$]q u 8E{A$ )W16"u1JDe%?-b&?@@|6c$-;yXg6.CJSJJj$4_ڲFTb!eim*5Vo#+i ܉ !D^H"  }±h[%DF">IzboEJ/]y/i rc_b:n*!_-_JH$6H%V}7<̛-&DL*VC0J R?E^,y=vQr ȽʶmHHm..Pe詔^@B i. #cE&lli(DYJ# -3RiJ'r!rrh\[ EB&;&iT7׷;vL)>֬ijCsًnE6#cs7ɏK8Ad"$ 2k7g1M ȉP23pL`F|!Z8V:A,wH7g2qldg20/FL{\ BBB"DM u)ibW:nxb2Xf4mGdF&VrLHͺDY?|VωSbzq˘|/yJ=W˯ŋ7R!OTmkJ*](!=0LD{ِ_8>H\X¶JL5ӆlMuҐ|?D$ފfloE0zHCR tj8HRiF,w`=R ^r'/ oI֭K(r+iӏDR&1Ί:*l[q `J/ih&&TV_5,"B-fM_cI7]BH/ȓɄ$P۪ZV+T+YZ@ϟHΝǐN F`r+'P:6.td'$)Z0b/CBJ 6$ C@Br%şQ^ES@lyh|F<B%_M,OdYYxmؼL,|(Wꑉ#REY+&z);ȵpqsȄE<4"YŌ VyݗBJQ9rA!## T/c$>D5/aݦ$Jdu~f,ʨGuYjPrxP(Cv-Mr7YU, !si[Kcc#sS̸>`KG$8)ʝob nM{h&jZ^f3Cx Ip0"VlŖa+EHN_ Y(>"*}?L"8T |@>GiR59&ȽJR/qbI78F@ 0qnM^V3L bE'qd"$ -3f# eC# AB8h.BE(7!;DGr}c!0E[u,~P–|=m^ʳt>@_d.[4(8qm%gdϐAÀvb6Bf 69<8;s[8ma&l L_!q8z 5t9K.`$D1 yѦbbY Xb#H~]=)'i"a8!/,aJ:R>)cr%Wg$M&}]Cw6_2yyZrB@(C}[1V: HYydRwdsJ*/`'Hu+۝ Rn&vmg {'m,xS <3/w<`tAVQL6}^ĆM_,WQOQeܨLG"թQ U9 }wxit+yˊX8زB2'ypҖDQv s]24 JWw7Wn憛nyjaG#iHq!M~>pĂ 9>jFk2<'Syڍ1tmd1S: 7gό)CL02S걑;&AS5FVfMX ONi&#K FGhIaNcmc E o"aqv4[#Ql\ 8D"N.BRKQ6sdB>M͈P5تV:>36eCsLȃi'&j9jKk{RoI$TXl .29ɟ+V19lRNYcFWGYQ/,197 xd ,dD "(prYH_Z{g۲3\ksn5j$d W ,"JE\T?ďyv9~I (RRo{9{y 6q!R{ws^{9s9mWÃvS2p <3M/NmxqwooXO >ל/Ŵ~#g~}= IDAT ~wy?wKRI+hd܌8 0):ޮ%,SDV@d=Z)Or9a7eXEV\虄w')`UVXVKHFGJLw])a0n}5VB9uH,uڡ U!A W ˲rbh-Ui*Q_-E@7S`̢}CYmjM\q\<9:W}:]sWpux$ )PLgW/eoZ2h$LliJ8o0.nz(E=4\G#I=LkE2YQXT^xqas0}.lcɅ+qqzҖ*ڎB%a6D-TuE+s5wh }o/_:{]w; nr>Gϵ O&A)kdTܰ+#(]g\Vi@$Y`a;ˋЧQbt7YB" l:|"k4 T)#ؔ>ϖJ9%4Hbm%fSeyS?sm& @zz$rʵO{6+m15hhRJWM r%7 K&CѬӵ?x$7D]56Dd藞,`n<B&/T8/U*}uf6v߾K ?_O6>1׾}_9!<{=7~/O?|eYuIkwŬM#!NYiN)Md=(D֮ե @>ݪ&e)E ^b$vvt:p`bCf:X ֲC=' @Td׊.|k6s. e-ϓ<fz\L )!Eq(3~ ka0 "e {uY&~}v̆r]|R8KYjW|>Tt̡-EА9*u .b -tR7VN E-ɬ,= dj(H9tx"B)vn&GJq*8u%ta0Tb <M-Y<07fZ)5@V0Mr碝,U;Eڰ[WZoR68摇9f3)/3Ӝ7ס'hOYAZý2Zy_WnE؏Lk_ %jPs?sㅞwKRRy3/ iZk;+:Ι[dq;);.B)2Qf^p` %DsRǑgh/{V|Iia0h,9Z 9^c_S H*\b9lxZ47 b YymL]&ES=SgBE<*H5W@}jWX'>:k}=뜎0f6懦Do_lRmLoM 7\%;E`Y(D+R`8J@FyFH[*dJYl3IZJ9Є\RWZBUVs67֦c\j82])WbTޚ&U`4fCBgT3g+#{A\})* &%$ondά*ЂFoiK%xڞjvА-\N\BS;N:pCb{\j~dPmbMZXկB1iR\$#>WvkpU[sL'!oIBϿj!>4F` [M4pDC=Dg?PdN1 Ϭ l.9>?0M:C)šbddhKSBshfOrUAy0i~M!+tW"coNofeͷ= C =ѳޡL)[Y~YMGZ!(ԬH<# G7MLړKM}oL#rtfLyB{r]E)e+Zko:vkLXN+օQ18{Uڛ!LL cbͷdžn=Dg1E\26=au[9Da;~۽rIk/o~7R&j777gWW٥@[,hJZ 4nv ε9 |R 挔3~)Re1[⹳ӎvJjuh:Ǡp.0q&#6]V)" 7"V Ͱvˁ,h7q+ =n68G;rl7ʙ*Af@j,xO }`vUs\sf'n1k|f{ϰ؁D:+ Ԅ,s*ruG\Wĉvu\;,ڕCmz/+>>%P0)LńRxYXDgZ)ބ9}l2$C0sȃTb:qn9'ϩ9)?3VIco_gxmK|MY&@_e\{'5}{=RNRPsy`'4'ܺKhv0ޣ'zvy4~U-(ӎ2䤐.gCPHfd$笰A$s2Tjk-unMf)#a */LQxfC mjRVܢB׶\w%N쩹pt[kǺuM!^DCS̔4P7$ lpΐEA=%R s{\I38䁫Pq4} VʸyLI85э7UKv7L Sӽy{ݷ/υ`}.ϼj+Xy?՞>} _4uIeY8fO ?ɟ&_c"y3NK c%ܳnU^ i;\VGDзjj^b$c]BѭF_0I pNq~wR~6v!abn0k[̙d eK~ e%>R[mV2nUmwPSB49ڑrb3q6ti/K?XzhҪ+cz>w78&-+$y?A>a>丨\Vn鴇m]eB#$ qw&J}.= Ż5G)% [YiJ昛[ͮmYsfȮh|樹Qi_9~phX'e(gLڭ+=PQVzrIQ1Z%sەD/GR:\E<gdLk-]BwG0ئiSE YrY<'y%"Od UJgFSk-|z =Á ;]]q ^#!3E@84oеU5ɚcvjHfJxHV7&Ĝ3BՏ BsA?w{xR}Mp(E>7_n{vK/i?e~U- dMޝiMNs:jO\5V'x<6ijG9?cNό+bE)VlZf\swtJd9qqw^o{ho>M/{?޾2_[;$m#*mo5>\^+?9~Sk_:_<{v}[?mJ}PLqd0vr˖NlG,lv!3R$t)k }IsYڛ!SM a՟ʹK"|wkJThyg1?rdCv3Sʍ*X3T{'#.B~]Âi8#+:,m clZOg\z9_&-33UVޘEߏG+뙯M_SBz$7tSʍ(TlT#<($3ɖLˤt!)¢+N-'; e94.JRwVnl)/LklY%pY 9wxn! eu|w:hg$7e!;k&BL^{ƢW Key@ cL Gt.`mx=,lϧx|WT tQ|)]{g{bWDRIJܽ1 %fy{Cj7sc:+41eN\Ҕҽ+)>x+'6))y>%dR)CA˓Mz?TʍZ!$yX.osەEWG^Yo"aY')93>+;; [+EvOoy.ْz^hNM=!u^fQ! &`EJ.Eg<T(.Zf<3uH0Dhj˾@ITa/miZ=nn\ -g:lQ4SU<~9ϊ)AWqĉ%Nv򥚋m}}·5\!CZ.c Jk&0^N1֚N-)"@ݴt*\oVEL #m-%6y Pفm7_V ʽUsR T0jhePY޶ɪMcƋR2r"1r%?o &s^h} l- T!= lc(Ef9 CG O[ȭSR@+,<g"*_"Bk3̲v$Q`k9kw2DAYs3ӷ~#&Kg4W8SCtj5~ Vc{"^Ϣq,3ϳ3"ϷT>@e!#>NPH.m# [6 ؒ `%%v! XJɂC U-r%BV$wẃV} iB,%,,KxEb4 1 x6V@ =$\;@%8=vr,eS?̤Dvr\ajT3 =.oѬަ3O lFE `7w3AV-#,̞qsa̱y5/"!(?AkL|qc|GYSscJvEEEXUT`h>3!: |L?-+X2ZptnNɑFY/crh-ܟQo'X׏LYB;I <ΰG:PW?x()iRtԀy[ȱ~XJXz: ZJ'3g5GXc4u ;EϚNNvQ9a.y6x4˚2.$P@:i$/q*܌} )P%-5g="qbcYs~@~}Oވ yq *5QިoDLCwM;vܷן=_ .ծׄ尿1r<~|ҟ߼[~ix$jW~ŹY,;0S;ꐸ-/M>aa,v15~(u.JB x6i\UhNYAyFΝ8Zo;v i(d51^#^ƳE%@W^ Y Y1G  f&g zr'FtgI:75DmT}0!3/^`&bLz//9 :#ɦ\&nxSt/lCا%̺^`NKy:)^3|Zݫ8C@֛r+huלRtL{,:K5M<Qn]/+E3,ᴹq:Gyi ,_<˽BkRv3W3o]ĜA뢳ͶW:A{ k'P%+RJ |E*Y\9>J@#2y C$ZfW>R4LyJ_$]ym/+ 6J_ݖ^k{_W?Oٟ;mko'~?4?;tNR]jro^߱K؅~\7L̄`%K\E4Jog>Xz,5Ʊd7%+:fEMsH@2Wq²sZYi$Q‡Zoh)/#dC$y0YY]C`856H9gahi]=tާ-w+˵~ϵⵤƖ,kW^'=U .~ȮӺWja,pv`["3#1RJs`Krspj|\R4ې5,&Lm?KuGtZ` ̞*F eVef20L(hBtw^}׸x sǘz p<N:/b(i-r*j7R`9^so<\43.%` o2PmŬΜZ02 zOu h?5)ekvp[yx*Q>=dʉrL;D'4 )=hWNAۿ{ɗ{e%YVyһ8ىuJXh uW86FnTrUpuähǐu%,KPݱʙ]6!vH 8C%D3%\@!:)hY ub(0УC*DYc:,%a(9JM5{-uhّాe#B䔥7J`P(H @sHN }py0+`MlmMϩ6x'k-% DaMJ9>Nv*5lف@e0"E~d柄#GĚ* cd1˼<'R>މ,L{.}Ϩ'D%d>c^3FeiflG[92%A`,)vg OH_/&bR n\'}g%Ɔ藡uy ]Y4&t!;^O5 ^wޜC?pi:$k.ee˻<)J'2Rqx YϨ,b|Xc)(~3g*/|>DzÓEDfQ>g䐇}m$!Ed*~7 Lak՛4aU@Yl؎MXhefl)jcj}R}SJ؛j Gk6Ylx+bɅ[xò.]կ"FM03s@V97)G;L{o Iw|ō Mtr)i5Ťg&'p8p|xiؘY^&e"ڎ\xB(R Ƃ!{0U uxѨ9l&zY{_󐦳JI)^sV,rݦ.E%ST"q""t$Ǎ E ֶ^i< 0\g)I֖`KYe ,b+y0yr$ R<PޅY!}]h(+ Sٌ<#/vizrwnI}izsҹ+f cO0Ms鮹i4V1V[ih[h&@apD $J#4\{a>Жơx/ÚS@LO?czndޓXIVx4=5o2R7L@ IDATԚ׍C {̭$E]gvfm+%;c~ E3!'#k YfoƞSk/!rG'&yHEvܷv~(0+ MB+ ^>G[HP4@յV߽k \3>~8NScO\B$,93)KYD.־(!2k}>Iuv|2?2v͛M*8;0`4Eֻ8,}:uE4[nL0_\i"C m) 75Gݣ%mjtc5*oAp7yO4`={B’n]O{ v5L$ER)K|z.gtPXO=R6ԇ}k#*DYG xOO'R6Ez(Ekƅ]BiؽAG3L7 SRz) ˶ h%ɛ4a!mGW?v[A.k* ?8黹@C5&>)`tlCL+eUx"@7y(ud Ly{hлAlY|:ˮuԷ #5p^{ouYιw4=,dYX(aBIBc1 JL c Ʊye* ))'q `lɖ-ز$[Ac$Y̽~{Zz\%BC>ۻwս=w圝9:O:dG)9fFFѽk 6YGZi H4W29qpM|E~:Z?`Q|h맿{j~-(4{7hUHO[1؎|0s7ʵr]uyVy{bHXԉ)2;$K+M1Xecf}fSZlt9}C{ #nz,cyQ NVBL`ϹOt:6 mm \&aja)cg;KX[c=@RB]YHoBt:ǽE}xs=(]SĤo}*sThR!/'w@ur '#9yߊFc5W=sDYurSGWLYB7 Nj>T í)tn{QyCPuvmW;(/Kٔ&oO'J?R s$^[vOSSynN8^m~MJʪ]YR\UZcG?W4?kZ'j.zw /%1vEk@rm `]D`b5*@3v)RtDkcKXpWsx7QMOͅwOcC=ovRZk[=o^!ީ.i=S/q͔_1<'k>xy>n~&EG.㒃^*iI,{i c.k\(Y׃کE1c6ۄm M;Ew,_T=dIwXb; YVF  jfvH37S P}娾E3$L|BeH u<ݟ܊[,%dl3:"wA#V,5ƙϽr kzY"bߥ`hTI;I5?3n&cMk*Y8=Fx &$Lͬ 8:G<ª \%gǝ&C4~yﰄyL;˪:3@Im.iL9ָk.oVlw([r+>Y^65<@ғ-1L`#=1 X+δAOmȰ)h{O}B\OyF]#5 !|]74_X9]jk-i;8Vinꥰպh5%;lsjѶwRWu . pANja,1<Tҫ qL?$Ew{bh|دN7H]O?z&N6Mv@un!V9 9Ѷ,5{9W ˖N4Y9+MNzEۛl͗l1HR;@E\y)0=NfEF)rVJp:m|`46E}Gv8]Cw&ZZya\hznC<س˰5NKPWŤGa^h_Y93vc{<]2yVPmmxQw\+ \gE dIzٺ,#)|"%\3Rokٞ,L;2O o)/YRΚ+Օ)eN}"=z+uHJK(?BUV|)'QHxLTJVCݙ Hx kf혙1P8ڑCWяUa)ttLwfع-L@TY;œuz͊V tmZy=f}(dE47MmMTGh4crSj13,Ecѽ*c(C=򚙉O*!S@P=AFfmM|Gv֍4q.:454f- 9WnɴN5 mxU*Bߥ6i vvR&Se+2Z,Y7km`Ʊفl끛%z 4Nl]oRvc;vn yz*,\m7豭#SL6_Y:x.t3sYlh:}O }KT ͪ-XD-‹`Uk  ;'px<}R6BGMxg27^ ТluG=Ym[(IU݈~X5vLqkg%C A~ctg#y=wYO׉sWX`QuZmxzoFfpu=&yݦФԟeLCNw\}z.XmQĕ<&F{M ՞=33sܬ1#oK1OBq8ghmr't_Xp%5v EDZaXTNqF}O30v홎B@Iȅџ;[%M.k>u8l9 z!Ĥ i󸍲TxIqݔZǠ+5 yR֬]|^tJw$ Fsigv˵r]uyVgֵ&g[ǢL܌Hi)%A !:9Φl (.M#|&ZhMr2I3{Bqn3Nmi -l@W/hD4"s #iY;Kd5%ﮩ5Maj ib%>ˉ}WxՄK `Ch,zZNiڕ'S11G3M10l&>GoDl*lW4tćxj_[2s-c6nXx)!Q@&v[L6y:sJͅnm&G,J'*p\xe#51P2R[ 7in,aqW[Jɨ{tc!+t:/ؔƜ4mr{JYڲFǞ}*I܉~jictz1֔cS[ʐRapX\/n8AC 0n=G?+kmr}DB6 JqMFlhVJDL3#yN*`C"oF٣Ǹ58*p-p6cZG`⏡DG#gi8XcWV)L(2P]cP"cQ.[;k`ΌZ;/\+ \gE9Ql32+"G?!)|~i&XT5]y,eI ĸx0溎ژEU,e`ԏ+5 0w{hXMGrw@7YmM;e (@YsY8Gd%)40p ܰJE*B,D@T_(k/屉i۶8YI/R+%5~ J@g{_a aGtʞ*ꇬ>P@ kf="5, K7UgCo$Vxɦdht֔d@xOvEɩ2Ddz )f:pmHM]/m :MIݳBL|g~?smMYZuzQIѡy83o~鴔RӐeII1؂FXmb ;ia ;}7Af@!wś<+amN-۵a׳64 @4 VUO4ylԗ8Rgst]66ծM.qJTOK͋k1kicHW@`Bkq.k\ORy=_gg9#<?Az;.?_K2uhridݮ!4 sSҠ֢Mo = ,㚱t2Iq : p$D(֨S[r K@ykY=]0d}^&34e7@MpDBp"R e~3,]8㌹͂ 6, ̜ura1J.{3SIpUa5`2 qPbvxxbbdO6K(a ]NVM+:} mUO3ODl킆̆$mY F%ƱCa6/5=?MZSS;G$f5LmvA],3'2 I]Ϫ?O9-u3,rXяj<񌭆va]nj6Z{9ItPP~SuZr>V[YjeoB{B"28FCK)Q)~HKVʣ[^/,QB9 W-ƶ59)fVϮ6X@4CZ 1t4V &ŦG{Dk1b\mcKZ..w畟r^l(7o|zw[uHmwKkm@B% 5q9g5i$u _K0 !B Q/ pm}1N`4SnkrkK1;m+sE޴>hTINE;*0[B^S &+MՏuSYn眛7<N~BH\)'|W!C86RV ߹7&36gU9 wv \c8j[{P;W]WBu7=G ۲ (OEkdX\*?z/:x>DžnK)e`S@𺞨_QKjPqa'?Ϙ[EWKg6%WYalhmޖb8+@{ˣ3>#|$`JORLː fLBq̨-.@v Vh.aeCRad&cY`V )cвEagřwVys`>iOn:ߠ3%ӥѴl37{@j>-R>(OQ.Ie1nLĤ-M9\p+ZF2 3pDdyJ@d=ڑv^{^왘Ka30yB ;%sO09K]&#IkQ2B)mhޭrxg;>o&RcV\xŊh#RҕDGq)͖0 ;3 rʵr]],Ljx+?w]w~u](CXe bj'8kR C] q'@ݰ `k@},wkXrzf3>+zSB4ZIБ ѡBpD O:$H7PNR'IG DVֶ쳕vn:/L1ɪK,8LTu'"H-l[0lB IDATʗdBP(a_޻,:;[R@MH0y^LtH/: \M 0X}?Ƴl(>ssڮ1MHQ lb (&hL:,u-{?, }YmVWj>vE}*_{-4!eKv^!:zoN$~7LaS ]9 KfG)cHk-<>kYn}۶;|rb^04rU7)TR>kA]47GMjw/EsP&=4V*? G͏OBtcX#A0.Zg¥ #TwZYֽWJ03YBv-Sa]c@>y>ϱ0rnOhr#7ŏC:vlmF%:vC905_|fNj)5<m*r7;1^]Kቌ3k/m<ɺXr]Ry=𒗼OD= w ׭Of;^|؀l<) ;f'𖺗Գci71'wYA>\4ڙCN4&ĪnP 3 *0S˺4 g7,ag~7a[m]ܒ ꢃ5w7ۦ>-(,!o%(^Y?]ѧt56nڱ!-5;:ùaG;R6Zj+kC:$ )`ZJayi($툙i鮐1!\=#_藡Ptyy>煼͛і)*Z@м13s^gC6 )HP/w"C[::5393ZNYa|nu2dڦ<-7Lʐ P]u%{Է->[Y̯=| _.l^w>/Ex1u.wɏ/}٧ί?k>̟#o'whE+e>1IWЉ?syge[$oO--Z EYxF= X΍Blu{iA& 3L%<ll[M WXu~R`7Sˢ (7 KRawE]BPbkOd$>}'g\o(;L^u:(T[&j{؏\4MióRLmcjn[XNhݙfןBcO e`z__6.Lڎ1l_m!LgXj$nNDV. mJj`{/H&⍨N S! ~z5ӖіRP8zNG04/峙YX"~ cOXɣL!9Ih$-}%hl<ɪO)Y.g ?{m^[w>͉˵*g bW?̯{=|_mg7 ~_iyo|/?_wOSJ0KPY iHKf 4yVr}_,ڎg08q‚-,5}e)N>Wvwy m™}ίZ\ n.1ͅc#֢R\yN5{KiZgbzJAKyqn4>u_W\K≷?w/io_[Ÿvb|}7/h`7_{;gݠ%okKsm7./^s?ě~p7=?>_R7,!oz_~YVgWiy{<%m/㏾+>x'x7W-w(G_旾x%HHX`X b78:VOi!!-8ۂ0lץ$z>bizWh[-) ;JY3ѻum`{\OS&O$eI3KeQjYVڭq73Gd؅RVtx!!Zh/'7WH )X>xcMB;޲^Z dWѩ#TFJ 22%wy̍v N}#m83,Mty]'!:z_.I<yF￟>/^?/?<O=b]oO>8ܸ(=<%ci ϻIؐO30Vv`-v 4{T^:Pl) ,fS#6a}[a]  yw\|t=Sv]&a83r+Ldһm"CЪtM}-`qUy "%{M9::<Ӯ+<|p!oLrVhۤR4b0v-˖,m2P . ēfRֲY?)OMbW[j1Rcn NύjNl3 Osq) MFEΔ=(?S)'27^dT|:)Ĵe^beǍ~hYku3on ggrhy>|aE׻34E#C5#eFJ> -+91k'I4ʋAֳK~]Q@vsQM~xW*>{ y<ʛ_L8{1_?}}}x^+N/:knnU4:{syiYU"YTzL'6^R{mW2IX-,ϖR `P`5HHq an zʢ6gJХ'\>+D,WBZ[ V=ߩp$b`jS)% lRmP2@b cI>'F}w6۱PJ}#0~:X~A Y'Ѥ|CXؒlKޗO\LێB24orɥ0%F"#4͢ ]|ApIi ܌)JA3ViSZ*T0)o&@kir36Гg3D5݃xR Zo Qڱi [Et$zآ&%be9[XB&`\/8q&flSxf_G;5X4/[˛e=Fuf{Gjf_Kru7]~gy?t,*{>A䑇'P'yDŽ֎=Sn}<'yw}  -N)H9ē" 7ޞe)U ;;Zv8-⻔G,#O $GB%_["l"!\Re @zcS籪kZjK /;U+>\d ޽ZNad0ľy'ҥ<./5g\ڥBm޹!!/O@D[i8`Ҿ  P"c$y -t]%׬KP5~I?3U3!0 mi[>τΔhE]RX⯔U8YG-(`蚇c.M~ӧN; %37FdMS?mas2l&dmog;휹4Sq9r#smuc1 4Xօuչ-338R@Lƍ+&732!O7ċ~ŧWfy{5d^-ZݍrW$~hr߫_||mK_K9>}:?717o}?Axǻo;^2}&B+7Gk@||Ӂ݋|!~6G}G}S<{xfoLy/v_;w8?5y2~5n֟oY^kĝ}ꖬg-ε +4G6r">cjq_Ч&v *Ҏ >YwD9GVl5

Lt )'ڡžXq03)=tt4)][.7>‡ؼC = {2zPH 6fmujc o;`cg;lS0C+cewJNfm)|1~ס:I6@\ .uEåx\r䦳"<]fvAϺ9>h<´F7;'tH`_4ޮq[ y2,oc+k50[708'GZJFXZ?K)9sn'VOi7XX^Vgɲ[E/.bŋa2N5Rggոyaf)6G{Q Q"jf͟mrw$~4}= .'|(^:=?xsn;(k|?ޯ-~?;go~_g>~۷y|'> ^_'=28\O^yy衇x-y[w^R^=?|y{9OYl2S$z#oo?.eR&$hk@.ly ,Ȣ~X*ѹ~"XE!}$nZ j0g NȕqV׽BS 3MD ]6.&WOܜ+)NPRYC^ !8CP}fRf١kZ?% T21pzp@r[qԎ4K,2Ֆۥm< i,Sy7D"8빌j|W4ﭬc1C IDAT56Ј3ӻ]36%bDChR(?UzIj)t&TGϮDŰ5CǣM06暈  ܱϮP#foR"d1jo7!6ofַŒ&)^ֵ;Y3w f\/MRGq;pUGΐվS&)%M<8Lck462E |6jRuahދ/N)?[~So?'Es쒸'"r@ .+I",M &AL'^#y {;wB mn|ke PM)?l(ѰLY)c-y+aR2o-*oi\SJWL7c}֮SGmG&79υt8oi1ns .w.J7 rPP7g˼plG.P^mtȊpݭ;fdϏώ#c+y[<ܕ#S A|rUQqe' x֚< e6 ӞvO;{]1qOM nR!P $ͅUNA$4& 5~Ӟh_pX/BtG)"2ߺ9feھq/ I,!ZprlE;*sbbwܱ{I3 Ruc L5-\[4b2i)_*|'% E)LSW.Bᇚ=uƉkƪ~(G`P(JIԇt#unE/Gv)%9h 3Ƭ]ISJQ߱r(ģ=cKtfWwD۞ |ȁPOѐa ycvn!<Z6"_e,8kg̛^x3ēI>潻r#mnR+/GmRJ+PSxNX'*_vu$&dpɢs0Ry`1m H oGkSSߠsk'+x@>f)mV~ć~([6^_Y,>AΟހ'Z!ow^wlGJ8,LC?Hr m0 Ȼ?#_mw;"~<|gg]w8yo>č7t xw! M&5);ˣ#pg80I2+gnfIۖaٕa} P:-IJ ='d%L}Ll)bl y֟OO_ 'ߔ5 @ȳTrmAU D2''Se/O /dl'gճY{? Aף ̘xXgk -s0l ,Y4?;J YW);$R KV_U*3X(oDsA*'}N1,E8e0\4n@F" y5=ծxnJ*3j)Q<C!I B/8xy(J8ь7#/Ӕ2$tE2;x=|o i_2uFQyS9Us9醀zFLB\ l2JD^YVxKY E7+jlt~k3ΐ ZQCY9>A#p5JYj 6+#eS2Ph̬qnxU<~F*ϒst>PqYG͓DA +sgl{((k91\f=ېUPՔgL%‹C1%2TxF#8h7xL) )]my"i\f]YtyIHaܧr$Tu+LiȀa(kKRֽQh,lJm~gsZ+ ]!o[Mˉiǚ34 BH;+2׬=<6'݉nV@nLF Gh+ˡ#pLuՆ]3P:^z"혖Mpev#cxf>,-H-Xaze 6%cS Chc⯇ 3Xmp*,ExuR p~zwKj(ur@FЖly9=zߪ ^,CiLN!U@b"2 [kVؕš,,la C 4ӡ $F9DFNia6wOb'G:rCV[ W5Z{%0Yuyffų˳ ~~ .XbZ;[wI]+:3(!8 1+imEz+3uLVVa:R(dP2SSs{ځPV&#͓hG5YVb<+4YFMDզ}Z)[k%tpo)ZݝZ!ZNߓStLK=il];={Go5)CF׵++1C6Hyx7K3qfMycQIG!TyzIJ=I%LN( 'ECVTZyս3bڍbo EoϾ&BSMTN] FWDA>;* wvW]$<6JaoU1H'|؀33:I{{m]l,*3 =E?sŵ/&aCy2sHdj#!PݞaQ3!^hGRvU|`-oyÅݳv{Uڭ$=)ETQZamM7a0UQ+W$ `x6e6LAsVKYi'y6wAP@^-HRvco{ K.Sf20P2X5Fg5Tɉ&{Nh@"WQZa.35WYoZuxVjxV N<"Z9^SChIraGQ"8lL G3S/HI))K,0yH ޕPJ?|h J(5 ZY3vnuP6 ѵt> +J6y6KJ`A U sJ*HeY{֜yєvC꼢4D62Lw<9s.+릸&ߛAWiDsYjm[_g ڡx(KRg_ٟ}k&VWk(-=ɣRB-h%srτq!UN)e@dڳB)n3<+*1Rc)vW9,5f8$J<ڀssvz+:Kbg;Uu JUUWFeL8L;@w!g}:8> ky .}XnB[P*su,7&7BE1xJp4!EC1LZyA-4  /q]YjYn\,4F-coZX_$\L@ s'o(ӊPl&5)R٥l({IB匇r[фxN T݁#l|k.~/f&P,.t^ U rJbs*>sܜ7O7Lۻsx8fEJ7nD u yLgWъX&1)aAw#]BC}^eoht/Yn8 "2;ZS]unhUgqpO4.Sjn@<-`K#@hms lsy2RI&#OgJ#AlyNkhm7L{~F.rsJsf2z\Jq6[60R"Cbѐ+x%7G^eѹKπ3&̬}f>A{ndG%WKzz;* Ek{<ObY׷=›e]ޗx&6N۬;MdC=hdQ6V/h!0PL -,a$, a$R/S&%,[D\Tm'=عmn=# A;`oyڭ{WP3C9pg4v5Bv ۞;Yຕ]?ݺY)R eH㶱qnכ@*˵hΙ9f ΝqO׼FLDŽyGI4%ZϔvRH<;hl2TկA0Y Tl ӜelX[ǣӥ/@XLE朕t* u$wR%zX`-;* =:qwCCG!~KUjM6XI+Cc&{rA&"+;]L<@DS}ݪ\JH:;v\W8#kdv<8cɫ:d əvX C,-ݘECށZkFSއWƺ/I yRɐJ JӚQDҐ C dlٟ9%uW_4k[4g0I)p!qVR1_sp!S9cmIB 2g+ Hǎno胟O]^Z IDATF% R N^\~Oǘ/vtzim\z%mm[!L*|tt>LݐG 'oNr =1m+\z<>|A^)n{n]guOcvA@}sMsJ5 3;i4 \r Aj*'0ڥN[8*܋'RR9^3YrŽIhNt\Bm1mgkʗGW)#EM$Q(#e4glʨM&`3-(`ݻU&ykf,T-u^e%T[^ܔߑyT=IJD^s5d93SLl P.2DN:  w423hSL\L!Tkh~OttSת2)atpij2C;4YQ {AQ*q2x4;ͤXј2VyɃ7kDZ0ݚZ)נVVX67%IN$zy2`)mh^xÖP,$'0U4 [Q-?3 :D`VuF*## {)? MzENviʷ` VO|5l& `+dԘ_M g7T’$o$vi[ ES^a͕RRvXK{)hnTs!\I fyWRDg](d_c9OOlįp{,X&!5=JJcqTb:j Fn o[Wd K5\gtA4b)!w_ o5ZfOJ/^W1,$;5[mz $oocM<]۽49@{J'ΕeVnkR s_]Q*ܶax{h *ElG9+ڳ/w\毊?F?q0SbyJSߚ/RP\ ѤPd)EJա {p~wߓ=˃o|*n3}g)!. ]hCeb ).p(sNCu(4)!( $DݺM `fJT(3m8Di׭4=i(8%(f&7#Շhpƞ=K[87e:mv} j l{֣qw^\:r u֒@_X)ҶyaI璬SP6 u>=-Uޣ kƙY7P]|׼|xN7IBk*w)ODž޷jĥK(yxMq)}Dv6O̠k<: ŧ9FD3b*o:)+T=?{ yH uPBꓥkfCsW <֕5K?ǸZ5?^T3AlC [#'396@|-=&q{bOoxlv//~{mص_aW?/ _=k x;x;Ҟ|''t?]?zi\VQ̦þP'U I v=@RE`aLZn2^x[1Y].w3ҊLfɳ4Cq)!u qso@cUq.咷qb7$C= R{{ssvZf*uN ؁NF,D@@o`;ӫ+W#]uItm 1,z^FmA3Ӊ{9Bf/26 wk85f7&>A,Gs,mac^sO&gwR,·gY:~bPXMZ&_ߥ]jxl g1ϝ/Zmpit(l m^ ,\~d' (oN!`7y7Hq9nʚM& q%G+ k]ʎ{n32DT՘㰐 )*&消 ;1ߦyÑ90m~L?L vvC|F0Pgd՞Dj̍,OBCB~Qg.:h$bh^\&06LbP(:Pv 6Bm=:/ [j2p,⑽ qU 8J_KώZv;OnU|zS*M O9'ɜEk!v Ȕ\] ȁ3:wy}Op]v0x }3c\}?ū~r{ewOf~.YȯU@?w}S'?n{in!}3Dd>J$JjyJV成%)&7=CRi.xߔH rQPw %[Ikcq-.dQJXkHTĤ8r$L3;PDs[-8[ j)I(fx@0o(pn[؟ ~[[y{C9lD@ҺL 6i.4[($07dg9))np@g}$)HLU2M l(CBYDgD$Fn `1Mg~z&.~܀n *W2Z YRb&~TC`*4ĚmVd͏~o ^ao0= { ӕG~[u93yS-?[~_۾`W?/z!|OC{Շ{>S?{?i~ok{S^_?>g?o|2+wkܑλ6W~~??6,: $$n[hQY &y:OgJ-]AʍHz\Ap¾ d,נorwLһ!ja+IX_tFݛɢW":#N"dI3UO yylG;J)Q˼ݛP uFUjP,#?KnD`5OA W\ ASŲFa7#PIL7WK=+֫>9}RGp4AP:\aռYݐ$~8S5VT`̟|'0[*,^޷/i&붣{o#~mm= dn8 G 0]Ct,6-/ :dc񞐞vZUM4-7UΊн;|:U:{ߓ{5s/DѲpzׂXpS-}Ao\{ ?YЅK9IҰ}ߣn 'YSߙ\%P+)e UXc/,aYMkGP'Z.'6$Eqw{ڪF1lQ2ɝw9Dsd]sfKy?M9&̻Y!U{NE|}1xl7՞RWa蔈>݋ΝjwDg}*_^OE%V&湱^{5e|ͧ|%_]O?~k|pU?Ϋ?s=1;_ëo& y[G!׮Z%󿌿/^x[?ߵ~}dm_4u3;wdGH? ˾-$8!86c' %f[ȢEjH,XI|22U%I= ~)ZAHUjReL̮eZ(U4Y$[Xbk[߭[R-%1:wGK.#:OG7$GZ s(.elՠa ޒDό$pYu0Ȱ;z{9)IWQkFXaD&0|}uZ%W=m bCSL>>7f˫Ő|SLJQI1:-P# }JO$OI~9_̧=?үcKȷ4p~? 翑o;ޖ#Lno<+Y)K^'~_uo{'Խr]T@#O~=PՎe0ʹF6mIv gu#iw\Lxi,6> IDATu$(J*.Kf2L'0,K 9@yXH&}܄&CpJI,p=} zPKӁ[f-X Q=&E} PE7s\76\*΢l{ t^{fnhס 9țaXQm|<.OXƶ$I.)辿  t%;ͧs"Ͷ= aZTynrRoCöV[3&w-Y+O'S2W&`G=5\|y?|ӭg <83ׯһqSί|+ןǗ< ǟ͛U<o|>ph˄ʣ;2w{QP> I!3'Lx!KYYe(7!t2$L.RխI;6%Y4Xn0JEZ1.p)RÐ= VW r6lzIh26J\'|qlo u+Xz',EMƻʳZޑ'zf-ڡ7)xMmbEB29LR`"L`ENѧ>{i(UM||5qD`~gQfʂZLo+ YHŽҿ0͛Ha=R9AYI4_c,iSB;Bw&dqpO:P~_D39/;hy{A,x ^c cP*t r4Mt:Zm.6`!P[*h0ME4ϷcCcKsmkО2>teB<ټ$by19[3!5Zgm5gn"f;#C^OӸ2@ߟoh;Gf_c,}W30rݤl/e!g=͘}oS"8MLY49N;P^}_|_|c8-ms<_;y >?nr|y 0Ξy1DžywW'749~w!/~S^|O91W&iʟ+~7=^?/ڃ|xܼqߣ+5*GJrX{B ȝݺ+lyXs=}q 6kHM6#: .p3-%h. $vaj( PL礰]r ER؜-y ^$$]p!R# [hnX9+/-iu _ 0;@+,5B\ ^JjBPz?x$΂›'/ owYŇ>$-zoLNa / 1RTVE2i)4S'bJËRUnhtPhiα45^KvPi0F jnRk*|4by;ܨslN'CO6A3f:2$l iR˅C9PSdA<hl5[=7p>gcۮϘs9ǽ CH06ycP"P DZH $$AJG"TF4Ҧ85vbھ9{;B|͙瞻oךs1 %8 O<}*򄹉Wu9,еF9Afp@.P-hOLe"6;X1&[1ۦKt84ݎy?s0sO̗iܜ#lH  շ kZ[)uXt0WZz^صRH1I)(&N4y) H̰Z*,K~<02XBIੈb yl)<\{BʡʦTt<дE͠J}3yriݿ qe*6 gmyURKTm'ﯹ;o~/i/|Iϕ@-"x[l<r801fr_TNY5H*XF$̖u_du,5(ԭPyz|Do7)]  Jh)_ ǁq(zU /*})%Ċmޡ(!hv-U +*1:9d0NoCwExP6Jqf\ݭB:L.f,b+9sm(9>G` gp'&=;)42dmƲ[+;vc(`:vYQ9h} ˰j߱{u#/Bۍz{(-@by13g}gB{6Zn|^I hNlcEz2i9n!ɫ7tZTbThU*1)KT |r((q:(H4*%K ~TЩe,*=[yfblǴ} *!Aeq%nl!Q#xeu?񣪲,! Z(9֌u^%WYʂP "!+'8lp;,`|)4 Tu(,๕7$TVYaX,x{ܐ`G!<BWt8ܚBNea 8zٯ8) &1Fv ji]lϓ x6ahUM:Wi," AtE eV%3_Tsl##Jfϴ`W. s+$-D'ySs0)eݿ*OWzLjͱb# YQ?q}ߊٯнS TW({BI=C=Ӽ{w ,UzGv;v% "bxT{8c!Չ&WACvrxqT_] |7mF! 5p3%[4LlI1J&cnUi1Q-t K $\nF1ki>&\C4PG > XY(nLBB-g) MLe|wRpLc  hKc,-B zc2.@k  1jt4nTijQ:~P-XBD^d%Cߞa9Eϋ !V#&w+}LVј̀KHٱSNժʰ>ۘAm;vˎg< u&<)+&-Rp(KsJzfȨg+'@gVb|M< x]i ue>i ='v!E0^0]o4EUUdܮ^hZ378uf&/N}e:2L2b1003ca:84E4ISMN1cǞ=<2X+븲SM`kg*|nq L;h-v 1N}CFoy5)e([ 湑Jʜft^ -y]VE9Rep[Y~ݣv\vݞ2Ee,I(6ajh "=I xQhM ?8csYpE%6u&Fɾe?b3% IDAT֡H0[BS"Ԋ ،maK28E:o~ GV%|#t*"Z<0'+]s(”V /+& di^Ǒlسg@I-*jhhgZ7(㨲;)3Hx$ȃ,:柴 >(467̟b ( Rv@h%X ukhIe[|y]yw@x|Bfhw=gVL޼*~9+UjzVWKxq(pr[qy\j\fJٔ9M݋8FTnZ:;c9p 6, ѳy44Ys(Vy[M ֕q5F2D-O \B{C]إj1w=ǝ!z (k"!^B|L}jMFW"i*Uǎv/r_TgtBs*jzV7mh˪}{[*Gn V_v.XB WW}-m }>d)EȗI^cB}%PڜaLEƋat]+Sj.~<ضzEdͣ<ϽSXӞlGX13?"梐bдּ0N؅ 2h]0 pmUqቁ8۬;_QWZn=%ZO¢8MGUFlgtU@!0eMYKñ0Y袰$a5Vvuwe~Eh)ÅJ- _JQ2fS&s  @Vg|y-w-CˇXX>#tk@"a X7*:+z'ky#`"!jEacaPHV|9 tmGE;%Ri.a',T- [/'PU:ǙUbXaPE ,4^5CM/z%XemE*L@4gB`??B|d`KhMy9TE M?"tπ 8Qn3[YśР>(Ghs%Mv n5?XʂKHJ&*|Y)a4# )&ŋoxS"8y˕9Z'PP8ՊxZ/ccSN\fb@$GfLHÔ}COa[5*WnH Qg\ycpG%_JWU!ɨ-Vk, -f /϶)a暏jUF il333K[ ʼh?eP_04g3Dkmkˑm=#P`iJ_UZiCҐdR$32\H/eJ9l޸޿ZmRs\߱yk<#m~k͑BMCYn;ׅ}RɋVe$Y;81KF%Tv/L6RcOH)* %>dI&Ot1 PjR@ nGy\^H˵-K7AeF?&gHw&A wQ4djUx@:GT%fq)Q`[Xj 6CI~Jcͳ'XXڢV#NNC׬m^~](Qhē3!ުmIŊU )hA} E?*Qgo{nMѲV6<\R)E}~ % K,@? 4ZwQQ3θ~3ڨ5s(?+9f 3={G\rlx;rEcka'=j=&\(!~NՔ7㦽ɘ@@[H1ۼeWhyEp*՜ߘޯERz}-zKŅ YG j2Ԫ*p#0,b+mTݬP/+y1xZc`w)L6 e.qֹsՒny[5ê{bȳRʜ5b[[hG*ŶMro $;\M׊rA!C^ =5w>-y>mlԝηE.2nê [w4(0Ll߷\;E{Юv ?Vjpx?pMÏM֐)@Rlk%[2.Kn؍VvX?w\:8~z p(`ƫZƑ`8wz&Gݿ31`JdmC`=̲SZ|- iEB*l}\K aR>t<ӿapM3xBVZtF1ȸ(q OVq/X1Lc XlaQ'Es^ x+j ە henX5lo~ۖ-zΫ{8H܀Qsia!šOAf}'9&~URO1iM=O;`lBCV+fU(cNJDz\egL9vDfr o:{csX֐2hSZ/g"yBth#{O{Ѯv U]o43LxeL-yْ%['͌F0Uco-vY35(qPDgTS*KY#0B ܺ!w. OwyBXB;qgB:d՟ð\U0Xlw-ŋ#(L>1e53ōb 賆)*9 %OCUZ"I s﹝\ M9#JNlG:Tl:RaJV%$!^Kp"6ꬋ3;\JXwD3<x,0s'2S{[NݿP(&eju ~dpH<ac1 LĈMCv 8[΍qU*Oĝ+Y4h!ɰ'Rf'}]~~Bޯ yin80N#~x{\k':}+W0!?/o;?JKETzgCl<6i-iR[mB"Hy ʓ+XFᔳ#ʥ]¨5t)N^&|qh#cJw¼Ǚe榜Ŵ~kZh(r/{(6NDG3=#^mU+m &e*Ru %PԶy,K]'TG,IWZn=%Z"M}-WTq%`03) U`>Pvsm (l Wк.-Z֮_m0|,F pmq3)_ Y֠!߇ή혘x,6e}q SdOCC4na"f]O I ,eUIHGw~ [ 5KN! &ϋ{t?#E:D; WynXFn2F; .<#drsG8gs[N ;8L6хae%6-%v\|mzJ?s3:|a0npW2l=P_VumPX@Dl62)6VEۭzNIt?`S+ KTXHƧRP-VS54Xhѿە9LϦO-06lv%Fh>r ѽoqi/o.\ht C{]Ai[y; k,'yj_~/c?Rmm=i~V|4:RFتmw՗kgA uLĺfAQDcCJ涏7SL?2#EE 5{[Me@OgND7wgz跃T~Bڟ[(`%'fFSE2/yB %߈(TK*44k,y}}+RfRn80L^7YrI ć@[>Syb虸]LѴLg4kI R[\Ƽhqp@C@J!m+QZÓ<-6<rt˼wf [#-7 2IPLZ70StGȀTn*X5Zmhwz.k&SKVjd5T&[cͤT͝PU=q8._QJ4_OZ;ksooz'qGn?܇}4G~ފ$ۿU3>|\I= r,UW (@ 6잖TB C^Fe` (A@oˆޣ{Yd,=\/]mcqG&JL1 ʑ8rO3A%+´j4%!OIq3LF}si~V[y|}\gw h[jTY׺n>H9Ky4DWMo,}NKQg{ȭl<%fs mNKb"֠J*exL{LB, Ye;gn- M0)Wв/S0EIeb:-NG3"<6)c AIZ4˃Q3jVݢ<)dl{) 9׼|;ߣvo`=㵼Gnoȋcuί?vmJ6wƾTBf-vbo+  &9!a@ x5y dU0 Z*2)p"B ۀuIxlFtP-ڣM㴚 Ѡ\r ܕZCHfz9ZpV8sn\b=w;§,3᳍\K^0F`d$L"BA6v+0ấȐG?2ˑuQUn4 ӼH$f'Cxɟf?{e*KxX]7_ϣ|˷|/>Ы_~n~1oɋ3yo};x/>_;?''/Vzӏ_ŗ~ݗw$qc'?i?_b.|?s+O}>\pد~ǧ~&mc?'ѻϷy/[>7^xӾ?k7E_ϗ|=wӿՆO@[`-/6!Ap10mҬ,Խ9[f4}ϐ *.n!!C@@PZ-#kă+d0bTPok48$e¥3HAvgS,iczf磍\&%(JܞB@)}lHVA{+'%NiOK6 eNEûk4ԗ t]*ZRtu ~0;24g:9+=ύMi48݆=fbЍf&N;U27^fGqy#Ɛ7hCEL]kQLX =HZ:]VM9k螞6#BFFow A[!/6EzE>G nt=hnUG ȁGwW}x.mC0n~m/5Soz.׼ʞGox]~Qk?WP%|ǫ^74Bn_;}=?􋯽0xGͷ=8 ?l\??|+?oaa`_ȳƿwoat+üƛx+˯LJ׼W}ֳO=8q|9-?~cዾ+xϿ7>f~ձotc;?[|7}/ng/sʿz'{vu~O=}yԋo^RF8v?xxL(^TfV: ^ηp+ \ N )y @៙Ԭm]R̘MDKCbйD؅ {fvR}u%; EMHE;צ>ڐXlH3(!wvwQQ)&:2-ڠ0uZi5%=Gˎ'>={wd8iShsc ZӠ^ D˽g8͖W=N>Rz2S"~LP]3ɚVY=aꪒkW( ~^_͗}mbvx9ۋVT ?V '_ij일xwRKZk?O ?;> {~^ o./gw={g?*# 7x%[o+-.xϽ=Z6+_p~:p8։<׌g?+x `?-θ'5=yvG}hyv<|Cһ[|ur`( ڻzO__g??Ń k_>5KGFjlĮ"vTk&܆2t,2lRR] ;0HW_- T(}qPR()YI`PH9֜F4^BUtaTuD@c!/~zDn )ov%G49?aqy", Ox .8NFkZkyXͿ#BwG*z\0qHcI-^]42~ܷz 27'ݐ;Sl}/eCi\_{_k|KWͻnlG%`I{p Wh6?ԹsWjUu™ GPc@A S66 [ 1jTg3Xgna W;bAVf;?_Vp]ARD4`yzl3pF,yٛށ6(w5yq{16q1 4Ž_%ކ}-<7J:1X͓Sp]\ a@ϋ m؅xD|׮߁=[]1+=94pw2S.NמLs@쓦]!Mɴb(4Ly3!^$T͉-NClk kf)O,Z yyOgmT~`78p<|r ̈Pr:DZCzX+'QnYh4ѯKh|l6)*:1qQгgݪnqK5!Ѥ#%`ƪq, :q(2)"dXPNn`};xvuk_O|_:^Qޥ7lgAYOO8_V6Wx#d'~@f~ {_3OS_+ۺ藿yʵg<3?ȷ'~W9W=qO$ ^vi ~/d̞G4^UݟQ~???+>i[I}7I- y|v[z&%?,8`>|?KKF>u/W`給W4-4-c:ނuZ$KUӚIeMf- țV $*DPCP.HD5-4"R8)d%~vE?AZLBr}fK4-'!ډB7?Z"!0tA ֚%L _AeL^\ܘws^j2יiذA<`Yvco*'‚CaL^cB!/[ӮI` Ywi#rl3X/x9%>mOP `pf[UR#hYVMd"}w;x8޸4:#h'AV9ޖގhʻp߲ /CooUE >jqN8a_VwEE\ZDnLbڕlHN`K m<˛>RLIFDvn\Ͼk9c^P!PU*f+jg?%^uR顳H5/\ =הJFQ"yRնr>G^tc!s17"B݆)$u9*97gN8L6qb'PaݯȈbOkvM2Xcvd#t{L^Żz>/{uݕ~]K ؟r>{~=c>O}bWM}1ܲo{%_Gy5ùO7sčO/ݫޯ6~~_m<y#?MoگM'2D'=x+g.~/ ^?}-z2K_G?*w>\wZ:~̯я>K3~[_='Z,OAȊ?iW*~ t]Al!Bu֭1[R(yq%bȊrr 'x8N++,%(nJx5⇐ o$R0yh&ۼVy!+tHG{R%o7 ~[)Tp"&\A/{\zRXt< %\߀yYo->bm8怀̢ϊkhA UpY cmla7E:U=sԳν},,L4Ho|)\qRRtNm4}^gN㔲msj˜%H* 1"!?;3l 4 х)vC`v2)L;ߢѐ{;_ǰzxWd}W_ծPMKec=z$gq^+K6ubs[bύmTݍmp*wa>Ik {ڮ'o?4)gܩwxk`|(\&،@oH1j)Wl'丧2;O.k} T_󥥧NcKh8{i*Dυtx#e9WXOWtֈUx^ Gyc7s&' 'q"o̅1]LIXZ[;NSenRx/tpzG#^F8]9+Bפ@.) ʑI$ΐR8kNX1څ<t}#׮wo]o%Â3GJÛXJ]oN|ܬq){k.RngݮmM)t. NCeFўaĪUI6*HC ^hf:L1ǭmU3\$瘹UoT{'˓UVw{r;;,% )=cvuYR '뉔X&2HE A?ƬP|&(#vQVѹ+ x>e,W‚^c<)xƳbʙ(M†xor\7M5rh;`A ˆ( {1О^^*C8ʧkp)oz0sq~{P^ ӜAs2B4j-11 4&!vݐRs3׻e<ޛr2J1K&YD'g!D (d iԁzp';31oaxYAo](vNNZi(ڛ *%ecl9ݟc 2NroF{ ʮˡ#AR 92(~Ȣv1GbC4]ƄiQ[-X+zx쁉{hNܞoڹqk~p{هmy!H *W\pIcL([bmۦJz'˂ہks;mr  Ўӛ]U+H$$51 $$3aF^l^ 8 [+럄X#7&n^ݣ@rTL[Do3gs%,X&ea& h8>@cYs.dU*y*-A E\إ9D _5XM;8pL怬,"۝!\̈́ =hZS3, U%xf9T[683X9s.ʅh #:E^oNƣ]!4^Nh!~l&kH¾/Ƥmp%[g ^h} ?j 0nSұ:dA1'ܹx)"JȚ8 qG<$|;cU>X`XCVCTyҒF]g13,jN_)>d]k]b|?A׺͢5ojKAr!̶4e|Te B&QZzϧPbySr\rU>q`? eF1AkV!Xwqr{nzFH8瓍Qb),'e&Z(G/=ʝqY=٠1C"~ZW(?-kB$k,,漷s򚈐fIXRZm)<"".xHyuDI+Uhi--W4ee @il)vقz\04*@ r (NAYUR$l5Jzl:&a[FHX t8EZ9=%YMU։z¶mcPmlxԆ 8vi,hXH+v Bceq^9J9lw>[v*c7k&HEb*=9f9,ܯcwxw{7재-]bcC37v5%6 u#Q~ /A*`,-˖]-ed>GVfKHuO6Ű社\lG{oǘp#npklmˎSrfg ' E14=B@l~CQ]ft(Wݯ$ܤp4=fś maEXksq;L ;~}8'\gr:qph10SV7b=PumcJJv=țVJBzTՇm{aG`~:m%Ы:,&`5!ںḮ0 qIMuÖL3;*!eٳל"ٔNѵ]C֦CUg:JPW,@|%J)3+XXU /v[ SCОlR䧐1!-6+gbpwNzf5nӆ.]4cEuhC}Bt S=p)N*ݗZ8cͶwE&C}%-; ]h]Ba9Ҡ:J OG XAc\ ܘ3(Mv7h<+Zvݻ|劆mL)$P$<L(t",j1rܦ%3sdf` Zħ nXop'rswr ""pw`i&panZCB! (+xgf#M{Q@9Pկ nm5&ƞ=)95&A7ǙDuO9jg610)pfSLJ=oA-Uf-M5AKxZ}[LWhHQu]~s\ׄX ξX>=+EO EΛP}9q 4. o 9׹Ȝ7GR@U*e;ᶦja $( 07 H!6 A0~?q=L`k[nm^/]Z\cg7m db<Z1)(A<׽v 7ڜHk(+ŽJ wqm%;3SM[箬YhjK;-UICRtUsy>,B@[:GnZX$wK chq6`mZEfM@,v(ckLL`M0$@{5QBK:1(!^]:u̔Pf+7M̴ mW@ayA0$4 ͦ6 %WPI^Lc2ǹuNg1?r9#7wofW{yF#}$ e_FxY7 k STO&Eu?hM\mˌǸx>k)+Z1'34?|+\QJ.]+u.īS`)4rM@8| ӠzrsɍvSN.f`~ !Z`\Kƪ0&c[$2lBҩ"}Wժv0bk6+7%#= ߬g{ZUY`[KJޢvb )U)iD nL}o!b-Mq)oIQ,GD Ѳq?մPG7C K ÖnuBl9/6ik_kƴFNõs)^jwI爐,hcxs:qhL.>>SӪrw' #?xx;6U9jEFT>BBsxh_/|-hsDB=OtMl6I%N2dhgR" ucuA]fxC$-ZΛ;1@i cOc(^qP_ȲU-b̚mYo@ sq[]GC伽/&K|ԀzL+MT2<Ū"PA XRa\K(1S{ = gOx(dqi/ҤTEHf֔/PvihVr[0Q E Ɛaz[BʄG g&~?3B-+\#8*A$|װZ#ޓ"B<cԗ :EψsRA{CGnV3d0B jM0;"bkDQ# mJRij,bde}٦ :zofD`m3牪pD{GTAt=u5' 7QHiެR` NTj/)PmmC1ypؑDC ͟3ҽ1M PfIMh1¤D3}0 5X4ͽFX3ږZA^Y5oLZТYv&ƄOLm W{ǫ$Zyf!%  tP <_9js5КEabMu8߼SUͱr\7x+^Ƌ^''[v=>wrG*WXjAbZ$W  Zh5 |iЎQQJQd¸M[=w]޳<`˖xUl1 upq`~Xgע /E/wH `k٦`<+uWq] OU( ZMEZ` Bכ!M3rfCt!nNkϰjGmnD>oB<jv6 ܈/%t}55-ڙCa5aG,yC6NlB/QNs7U֌ k,2u-fiU0˂r&ߚx+1N:8z3|mǭZc߃.h ؉tobW(}uh9ue7)hu&PV#4{֐|+wu5/%.ׯq5>#^3<[ZUyifeXiD0px )yڃٮ[&Ɓ8O7C;yJp7$NsoG*UރR0d)'w+P.,OE)B`$;€PMV~? }]T6mC1^tE@~d[Pu3PN0GL6dz&@[ Q8+<-zvEIȽπ8ȘC!X\|t dt^#-5@@os!5hst_zw*BiY0,C@ڲߖڐF J*R!I1u\^!=<]sIjg| ׸c "bwV6|.UasMqi=9tljFgB!67B&p@Wa$1$ZC9&d%5l&\9Pۜi$by_ VX6X}Jc-32e~Rj ەFQS(4h] 4:^61؉8|e};Hgfg \rF\JIUh+O 0GB/cLhӊB:'Gd/żWm[q?G9%=q/IA;5*]ڦw{L|+143Я@-%td' K\M tUu=̘cX'ePLBy5L 4#.Y:67bWxørzU8՜$:]JWSdkGkWn=q\' <ɝh2ķjX\C^]B!sx샕lxAY!(\!szC*MfW3JUCzKCfcfeeA޶/ر+{A(\dVijSL\pAgjS;PC5ԗGf vtDv} Z8T"^sFPlSq(w\]c )"4g ^qo㘠':fq{D e΋KaV`F`RXGAТ)eM.O01fYM~ %SKNUYfM꫶]G"LL:w(54J/sX@cϓ#Ixߠ*mt5FW%],O1vQ}#뎓PSA4`{vTһb\) Ww-'n׽7[+^ܴǢ|"Lpw5Vvu&6؉J(D%Ka!`㰦cɻҽC6j Key$V[u@YkZ;%I0LO_0@V?3uޥL 6Bu0! mmCh!T(NgJRb&TCu1Bu>8zvKpw+NZ‘b78 (,@ qYɿ B5HYI[@HQ6EO `43@= B5:"F#)ǠTCE `L#ϟc5m{!0>xm MYOa#\>jL:8Yh{/w_X{j=.l e13kZ2|^9.WdH&P}4a>P}Mޘ jOW =+? w&-jޟܰUי`9PԀ̴–*cE9Z2eܔM Ylp&d:pUPC oe-} P{*+Y,C^[Sx,L/CYu뀥EcDѠ?.FUKz/ IDATǺ&-\}|:l=^u!CU>h E7"LP6iͮkՉ }^9 ֿ37uPHm6+"9\5軈pQ[U~C@j*|463mV} XsO1i)U2\P.؇Ω8hxhڶiR*VSnYUX]DRMEc5±Sc }3 Y[n+2i,IEefXz@9ӪpR).=ﮱy59;tŨYHǶfнD6OuMb⟾AAQ_OEF<)h{`Pg&Oe.e 05y*W <ċx%/sGruMykx׻W'K?|=7oG/(~{-'Ѹ3?ͳv|W~i|?yo/R'H!_VaYH)\i ;ܑL !☟.!a\ܳ=l4 ]As#AmV7t4|.펕(=QE"-ҢL߯![Aiخ[ffik=ce[( BUT[&ݐFZld6 иT)~Ojk#QZ^5.8Cp Ƒ^}l;0MHIpf7p]@FQaN~ Yd Sƾ'dh.n.h4^ZE*X}d}o 1R p=⛐BryM Aj}nB!WK,0o87{mRos8}x+>o!bX S;%А :oF^c-b8с,cyYJAМ'|~l{&<ZύEuj"#- u ekѤE؄ \ZsΠhML:{ Jf]k||ڄQSyhz>==w!hΞ6J=kޟmE4H]RLiK'CUCޤk/\) /|l/?rG?=[{o}j=3w$x7şsy6_){^/᫿Wq/w{o=]-\ (K.ʦ{,ݛt땛Vl{ܥ{5:hELnK(μwpf[̵?}jYo4]qX R.\+#&! p? !io)\s(\FazzE|ٍ>gÊ*Կ|*ȿR6l/:%3ŘfpQ/T"G2s_MF!։PdE5"/vzD0 >Yb(CU4`N.X7' [?Lp`\ aXSM7|"_|vwq?6(>I>tm?& '}9h^;d˿_^rݿv;~{?+o^㥟^71o}l}o6~}}_Oo6\>R~߹XC?X§oؾѿ<ǏOz5OC!o1~¿O|w? f :@s? "![&ޅ-ZT=\=wji55}H8`cǫ@ z3yI Vz&PBI&( ba$؆9fNp;|xeB32Y3ZH!1tMDV)1FS~Bb ;UKm ma:dԧ*qlg,u9ҖbxZIz,Ù}8t䯘? Pj1p2B0:=:p;n9>#>#AO4S'Yd\&6!hM௟% 88µӘbrgs05=ZS()AWh0:=C= cvTp׆ EMse]W:MJ=cU x}ϹBh)j TO!pg{LgrdÒ1K!^ BդtuT1SWf >G޷yUڶmrlci!:r]p#Z3[iM6.]YH`&/F<` Osk KfBU@׹8s9ԃr6k· BQxGEɴ}.uQh Ӭ} c+[ u>Hn\Bt׼t8!O!V6oc؋?/ ׾y͛:o_''q|/~#oz|= WwO| _3[^Yo _|c|_w?y_e o3}UʇYi{,Ra1n<ݡ+sϱ<7‹n,<{1^oxy2a5" 8Ђp ÚE.n*h(p04|]#hI_yaO $L$iBȽ9fn-N섛ۛϸh;L CJM܎ JWY} *(<u^g2ļ1~c ahCRyRdYL5ŅzTTŷHd=/6vt3[G =x5() 67I<:=\POl'+vLjgSڈR&-a7jK[,ݤ |p ;, ͻi7q'c76\ 7c(fUXHnwmp`RNPNbU{s(pfAٵ3fFII(Zd%۝$XtiA$) Jhm1u=pb1Xbqȇ^тMt'יĝ}B2GU1¯CIn3xveDwn~cԜkrn9}rmG6r`D! KPQH< )@Bo D A!e/vnw>{5gT}N/g'޵^kΚUF5w!Umf;;Á"ތ lպ70$, K_e~e0lAq=Yʈ?"v8'/7.Yvi-#jnaAz,dh5 &knlmEJO{z:[גi~κP^ro?CW>~'=8|7؇Ko{õj̏]>__qȻ/soբO#R>E^ԏw'.[cÿ_}}<?7?T(HekUnoѯWA2$п_ $> R^Z|rH)_& Q!喵KSg,bp%ҕBv*0!H bOu#?#F)$C*@;ZPK_8Dv)%Tf([H 2@IRmDE"0t,#0_M)/9_t2?1]RF1U?ZW&Zx5~v}7מlⓑ҆W?ky013YQ[3\1LgLН-Y6p7K EL .p.WŵND12=[gpqS$.c)r:0hΡ<тŖ%.׾VשѧoHYLiG4Mƕ/sOZoz^;=0]>{ΆϽx%b`"hVF>{O~?s?.gVd]^.C踦}?ÿ>_3|x\,_>@N獧/?e,B䝯<7΂8/W_7nu|~/OF[ 8)i?Ih">h)i.dmPR <ؽޮ?K1`w;4}^Wx˴gsNvVtJh&J51Eiݧ7MgrP]ݺdQM4FhsNj~N.h^Y%+6kOYse;(]JCHwvr5Usbx-ZwUx>"gdݥZr"!%9Vrk,|$:/% =hw %G3M|XF vcZu뒭]MjimZV$4%wGscnӐNL>]Y9䁌dm8qAˡ UH=7XPmL}|Y |}Ϸm-~;hʗ~􋜀ǾW?y'/~{^w| x훿w_6~CoW>05䓟$cW(o_S7678=n]oNK5?%הL8ڑG# _}ߕ>K8˜S&SaML ٯB7mr+K |R aRdy}f -ēc^a] (IhC&GeR8\] G99&bxAh{u4/I;pvn,w؛oq`:ںuDz|, MFizGQ%\ØNyE}"8hm_5󥔗bwG˗>/_7{9|l%3g5Կq>?I-}_> ~cP~|e>ǯ,ۿȏAC?.ƻ_AޟS?x"/|4!I`.ZӎNQP̱.z-e4k+XSZ¢ű[JA/ S q73eĉ%9"5K[]mvE{AߵX7)Pϧ) /Vz@9CS)h즾- i|Z !Q‘i $@8Puzt~mnuvlkL0ݮiQQl{;3f6}wI@KK/oR`8-'爟=>ߵVjMGocюRJk\|~l@ ӮצY􌦵vo߾/vK Ŗ^ic<k̥;^cL99<';M'IUu,<(~~<cX. ݢ`GS,oqѶ8441]o.zŔC(kěظ؅[ظ .%z6l9 AXB[LYׅܦ +ih=ٝACuv{ll\ :A[y#\!Ƞꋢ$FhRQ$a_Y]T/) ѺvBl!+Z鵧Vк6SZi|~r+>H,$9ϳΩ2L E1^FooU?o7_GX/|`|(1o_io;s+?̿~/_X5Oď_|lmk]mR|a [: fܜo}6-`]aLV[=?H{r 8e耭MH! BV.Q2ވ7xA˼cﰱ(W0}n.W~њsgH8KC ̙QM͡L !凉k> WoGfg;l/g/ml?qV<+se 7LC$ؿg7a+$>5_ؤv֊J3<|WoU]&x)z4E1ƹ8qS=jleom#Ý|'yvZۺ:.,>0O IDAT)^V=疰{]FRn[}#,)(3>35^\UEH8 sz?'<994osFdCpfQo#Û (ԳswS$dpEu_|«˫VQD??7#~#eDL>?+/v;Ld `LmJao=CaOyl$"zFC` V0#@#koKpǝr S1P`ox{`R`$R8J ݕ/ hA|PA[u,< O>4VƂ[9#k,vhJ hJݲҤhL_P*A=# Ek0m:+Em'akMCOFumzyry&(AS;m@QzѢ&N іחkZ7EgE]n孬5j[<17Il:mqhH-`Er7YVuxwӽj1t$';'ͧX 7 mnNL K Vr/ô$TzR};ʼnSV#/ջ]x_esp9p8ذ0pm~gHIݛ(zވ̔i{cwJ6_];LmA$ِWϚeVkޚ澙S{qt^͝DS clZς*JK[҇JN1 d;pw?k6Uf4Z"EN$BcJZ$3 ^E?DcJ"^3=L'ɖzwxEw:h[@jK/_;ؿw(PKD? >s#yCorsO3_>;CS^KX4ԩYʩN.n)a]BUk-(-kaT"=āՔR"*@d];47:G]em@rynAH mTS 5nc=z^/O8"#yVX+VkVcԫu42g=D_UaR[kVZ9Ol&|9NZc!RS^hh(P}c̤/pXth׵,(߽@yZސױ6\#ż)^"c~ouq.<'(]ۮvGJGK2TG`]@l mj0T 9=փhE`U,g[7G?9˶^5B|\`hތ^6:Rǒ!3Ҋwcɏ,o80 C1/UWqTa?ǜ.'. vcgKx-׺SNe{Z"m6`\>6e4[ʫLь,Z\S}"y`^ZTۊ{V)t}.cakѶF>ON0jWK ^p#?gx9j`gѿ-oStak&ѐm̅i)'HHYwdaƨ8ښ= zm)G@mp[v1$O1 =L^cg(qa\\^Ur 9?^?r9ħzOʥ#i;V/7.oRF-0FB(G7S) 2,!af0wssB@Ky&:pMϳ#pw([WR)EfY}RhWeauߒ_#*0&zfVM vbyU{h ^,OrgwjSPKD`gbҵ{2F*Q^q4̤Hj,o-oxyx7vi vH$[ Z'HӮ13ZTr-5))Ch7-P4+ 6f]-rMXJYWɞdOԢhwLXCӺEN/'Z;C@|sZRD<ݸ&h/Oω-u?)HR򰣨ʆ_59[F%<(_R}]k, DyZ,Z[KZTсt4V&je:yТ @KԾ&h]w8sg]Wt6tYtM-]t񎌫3 cȅ[͕Pה==-q3ApuW/5爾ss_60-Տ {katɼ6Ira # ;eL_m3;S}D|d¸fyt6L8T23k48&9\Ё6(49Gf.lMךmL_rPXQwӁcMZh.c$V^צ>-umU?0$_89RQZ22(L9z*u K(Cy(Qr w{RʃV **fGLgD e.< HՕH(ai/ iN;(R֥ztK) yYC)%9Az* gf ~Poz>ti\ޙ-ho6[7{S*]1h<8Z8v'vk_CVm}ZWb e* ]^GIhh=*Rb>u4>; J_F["lE16 J"VוR+ܘ+Afhx=5tnĹ>Yh+@ʞxZe@5&Uy] ]xZٷkQr"P3M{ .ݻ[dg+v+wARȪ;Eq~vjK/(:xdʹ&'PDsi͉V u0|v&_.uLl--o V/rFf0aZX ,oMO5@m^.&uҫoy?&(i{c] 9\ZcYF*O{[s >5K@.cڻƹ[Cd\{sъ a`g 7>>&s1н'ORѿi$oRBedKL#[:kS5>c^֫xliC |GS׵Th>Lzۭa'ڡ3ظ[\垡yWpM,VKÚErDƖxĒjp9AMoEsyѡvEӳCG֡qA4bH=b&N$_~Yy(l^cWw=*S!2D7$ UH @N {s|Y%2Cf,[aE/LʥI-AkBfރ!ao `` ]C)j V gi+Ŋ Lu  PR\Vӂsjj /\ 󙱵ߺq E:Ȧp)w[23Qu 6Vlsjh@+ TsAVB}2hkh f}_s+@ 3Edؔ_ţ#%h=PrTy{vJr2FbQjCn.pis,dR};C%v2ݥ~u+Ө4-cϮ^Pc]p9'N8ؘ@M4rPu-vmHILתr`y22 E;@m<5a2SQ\\y׼[S }wIr{Lz׎gfƀ>}Qdaw739lU(wqmk%E<0d\g%ePOYm&>xz^cn՛xl1N4M,Lሶr;_dx4I4°n]g* wIO-ZdnR 7?v*K_e/o5=w$p"_.$:[73%H6=uC3SQ/c|PJ+qe1|\ǽajdҮT3I3֪gJ(oU(ZzJ,s0l.uSzI;OTR{!eӘC^J( | $Z7M{`*1?$t:.3j[ z.7Ye+ 2fcT\P'`ʧJ#{ȯRA ֊VZ82ӨPU/=-g]hK }ҥW:K7j[ #;I} 2^5Þ)4:{(lD\F5׆2@Ry= IDATKyg.>}OUh\<$WazW44S|matlo\t"rL Cq64K9vLc鐒Dc<^Ke }I깙3rknK.9hu߰ 30C9CUOMc5;] GNFUr2vgs2ç\i~L6`| _GM_/R߽?/oX|#QsO"~J|yNd*C56Eo@tf]KΝz+CwLcl!xք˨ u E(mE~J+^skaF, &PV{2zt.hآq9HYXܜ]|#=qw\~ !ԇ ;7͉%nnwfJK Ԓ/_g_Cόv2&΢ &ߧCz.Y>ߢywYLƦ:@i(j*>e0`%+ q'|v::{w'el%)Yiwzެ9m9l /cL8;zK*Cy(Q;/G ] s,*?S^@/h `鋔"H7g)MJi7W:uVdXlI#}jC fAm 8f'|Ӭߚ ~O@P ź]Sv)Wt7y5Ch(zlmS]6:`A&c?*B`M@g;B_sش ~?оvi#k] >T߆wg ;k@Bֵ; j3िyAפUVLs8vъ *8RU{ fAzy(g-Ff |>[7p֚ͅ4>+M {@2љubakj222 `JBiH$:5zInt>C9֤jq6#7g24E uE˺׵YFɴoc\9h~ZcW3J9W؆겔̝јdʚ$'=w!^ꝨH-ɫls^̯RjGG2 DkH]L9t2<< 5 }0JMڟ:,\2%)ǢH+S>U}nwu-5N?1u,}'.3ӆ9@(;7Izy(| JF hAN w1R>C6JӮ^ฬRNW^GZ"'DŽh=MhrzjݪM֤R^y?;eeKx L\(;9o8aC^>03$g=걮4a+[I i%n90ƒϽ[@MF[EȼhUeT^+l1xmq>FlA[tй m_d&,gqŒ3\8L#ۘݕzhW' ;\]+?yə1yaxWG\YEFcEt+ˊ'Fyb74TSQrUFz^,SÈnrDߺ%\8eg/+M@mQ2X$ZcKWkoRi])\\2kM:\&;ڙi⋳r0bX&yPCb~0RQ3=̮QUFi 'n}O@q :4/e*rبy{S_(Cy(Qsgwmv]Q0~<ґ MK.M =+m.Hi\@HT oRyMwb KE>9^.MQ@Ӵ#И6ۦ5[m)9юUlaAvt{Ku싌x>o1 ŎRApu/nH'`z^+ƈ%ٿ3EN$ijmXU, Ep-4@YcC<↔}nsjKEf< L9V,K6{FphV;.^!!<Këz7>PȈȦ!U#P^UӗzخTht{bv][qg Ac.&(ZGrb]; PwfNE2x$s;>ZkޏH)͚ZJ ^$kb;Y/OH]O46:hR}qѨLHGq^fo+]/E40eg?_qm%.zN+=Li~#٭/{⥅e>cM vj}Ro$CM=+e#󙓟GeaLp9j{RiiPHJc®@5g@5Z:CX *Cy(ҚcՖ̠:3tkT~1#!o%> }.ŝv>mmF|7KA/\"]\4)u0LA%2=h ٧pE>.yaG;4 2 yGy ϙzKZG}WoglxwBk]Φg)MHE}uWސ2s|7>"n &<"3\$6%kih:jYs?s#ЦI~sgH 7|k wgO)p8#+egOP{/zaxژ"CE1R:, x#b)^Uǭ A4gu`c.9 {7ˀ\IiـF`4NNFjҖ aRcmLE68Y;qhb:|ҬO`uB}I܄fڒX_/ >Zw91l+@ǔUeE8]p?] y0¹F8v}?)蔜l/Y (g޳{=їDI4 wYJźPֱf 'PseQ'[`jmv5ÍxyDmwyVU;Vd|zQaeS:5*^ /vtʝ9҂-m+W~NkҽV% ?~=oNTJZol/HqHf)٨DFEjS0&l!CaD3b!Z4[8RV_ h2N][Z)wbHi(1`75+yǹk+ y 7V?$#B..KE}:ln1Y9⁑t59h;i)Ì>YmIwT-&\TGD.ᢳ  $R0ҭ6T" xЦWD<ⓊbH^9l)?7~7+lpXՆ7zӕiep.jC|L0]Rao9NwU?f42Z+5ؽ}2jp5n 55C1o=k<7fCMc4=z4?TM )Lv}E4d2&զϼg{1٬}*TQ$If$5A?iIs-,s,ToT?}%wXx<ޡ/glN;A5#;WECOEHW>3-ohC9tHU>e Cy(/^,M~ D7MHH/p5 )esSr# `V`T릒Jk(|_jΒ :,3ROӡj;HGxD=ߥ@hp\mHB^~͋7JR,p8*e̓nҺϳszE{HbS I "H ƩR8Tb qT2ID54Iih{os9~Qwg7Y}:SÉөE^ncyll:L@k*\َ[n^CAGiDf\+9 Q޶71 'pfjsWҮi:Bnn.ZH tH!8N'Z^+_ lEO}ߓ!a#S q ׼q; g;xeeqQC}B`|>!tcHBWC5t!q:r 8y7]eMuHt78-Eqյjvm|A?{G&QQsmaz?0mr v:YFT010vn򾡵ԧ}#\vǹp\R]q4F_yj@.m D+e~wn#,@ D߰Ym@ܛczdA $,&+FVJ{9i^y9OS6MP_nKI:lXI$GkL1i[t3=Y\}M|+z X9]@}<:&&z7C`5XiG$T^ _es_>|w/'?޴kwFg|/wr^ɗ~~_7S.o)׽\{߿s~kx+oʟp'N/=O?lN|/kKoo~c{7??>?o?x! ~~ i՟%wyǞ;!z_е_x'}!>S?üo|l^Y|ů|~>|w O6Oy7wq懲FzpWz } 1l@ q$dN V1X@#SP!3 9G F#G. 1bGZׂ- )Kg=ү=9QσAQXVJSM`ɇ/88bp?ˣl uSϤIXCea)ʁe~G5 SP!V$\ p}/iioz]Ak 'XԎ02쾍:G 2Ym;κOF s-[֐y+pӒS8>^ƚI`j||q&`Ǽѩ{ QXl\U_w8?A.hY4Nj򣢠2H1\N>P0p0,2}l,'(7t/:C_?8_vO+w|[|?Uʳk͟=?zԷq3xޭί㫿|j_+o_'>wq.${_0=/C ؼx׾ڟO|7|=M|.~z~/o|˳ _%wCx>/}S-Y?nū~? 7o>-^}_L]KK0|uCJ!q0 CsDH㿘v@ iB $,ݠX,bRqӪ-^oO/3p3 JC Tmtm^^*⠝0\2@"SD*z>*GW# b zv*i{ s{}(Ё;õг9f` #abŤQzqC XP#ԖWAkL)\×ݚcczE钣~.j X֭ %z<80*꜅Sy,{(nEUZ>J+O1[A&*f(&&+}鹈8%͟IƮgr!%cE҂kE0L8P"$|8iIpݮs)pbk_{X6r:xuSP$NdA O}ǂO)?x-[g>;}}}/䓟xwjLlsz ^'e-wxY ]|rJy E?O}'']xǻ L`6&1`,%MFqt[@%aJ}31uPkw{ L&\HgGHH RrUH+Jm.JG:`IكiN iM1hosٳo;P}g 旘|ÜGIv β s<~.BWa9di&!&cA/(soCt8jQ0@bDhw~H_C Hl}՞!6[a:cœΰ ܔf%^y ]7OSY]Nbpaؤ Mh;(6MLvx_dY6.wٝ꜏8* IDAT8Gm`qa3&+  XZWvCh֠& hG "L wcGjx-ϊ3ڬ!N(:!ݔp´d͚v BhLvGc 0#R Oy:_g[c== MyǞ-/ N DS}^u!׿ YCh U&(%cr^4GL4TqIP?F4رc{!`FI063̴S/25Vn-}U[G%,XJX̋\,פ6QBu7Ҳ/M/'7ז{`A jUʗRƮkKM^xȍTj?Hr<U "tIx"5Ğ"hC03 ,_9>e/Mo./:8+ \\zn~6e7~;o!4JJ7oůn5w |viy1X\BD.d tҟ‚q|E놀b_ 髛k!Qz= uiLP#P]L: 2\#p\wqrq-;{۳˼]օ5[ρcpyA[[.Z댋j589ԗ[6x!ƹ˶U J8.B ! !{$H;3 ^ Ve + _XANg/ֿ;τ^CqIZR9 Y^` ڒ|;0%lIy>/=MͯT]1{Gp0S4=]rhjNcl5ߣ%& 7땣$ )oiyQuaEX'I`}XB ;׸7y/3fg-nq' 1o8@r|Tr|0oѢAדvF^]B_+/f:qs(zLyRu⨰*Jk$CHB\KJ/s^=;? aQvBb/{=&t^HQszU(//c~ZO!Vh/%VnqCۺs>Ez$}˟g|-3vȝ㍟r>忌_qC|n|g=,oNn\g}<]{-|_,Oil^76;|x9[x!{ix_ϧ\Q~k?W<3W(5nl/xλ'xǛ_d+w?UץxO"~?RfbsԸ&1 BG~l\&| +Gi^ 8aG\Q8z&3̵Z"-)|Y1O~ ߨYȝ~sXXƨ.01NE XG儱 '*Gq?`hk w p BTN6iKN3;.($p5˱e"jAC1'O'(Ж-n4q<@rN55&?f|04@H4k\73MY 'wd{# tmf(%FjzΎV֘A2&fYuL'srÒXC1t 4n͕ky}4g䎶oIRSD+9Q^{#PYZ#TlG ;kYdR%>sg\7ꍡ ٰac3eBgbr(vc_$nmuL +o3c˖@eVfc=XC틦zFQTvQP֪wõV *MEsYvܲ[ru!V/by-U&K]f6;Qh5Rh ŘX3T0hAXHx,qASAP̕g:y w |WIwί;U(;qj,GF䟙8Go|N*w|WC~=χo~1_'^ oo߱} _o#<$UצxR~}qԇRz3MO~卥q[oY_|[χ΋ov{vtŌ~IQ/1𩾬C.U*D.~\q\F#Oq |kkеL9!ATbb&Y:MnӧD,zݛqO]8h'ʵuЩİj@{H,vh#tlo կN4s@P-tOk]sffp 6]p.L錹1t! D 2 {33w]v1zEcWvOCtB:} ;qEŠH@R pu^V|f[Dź~7͎EMo?{5W¾ scHD{ֽٱY] P]ՃLI9ц9aPrƦbȸ$`Zk- x0*~@ NZLʪ@֝vK i+5a( i2Dh,W;Ρ4"ŎRSa \?+cC_dk-,lCoGw]r6d+֑>(q_M|JQћ绾]3~ٵe/RNz=|'M忚+/~7$J;g>!}yҿqzǏ×i1>*IuVM m¦nض-sSg),p(.0@P }dTVS,Aמ@\p=36Z)ǥPת=/ "~ #tUuZYt]]=b6s&< fQ{@> n_6(:Ӧ.-@BsdŸvy,SFm/{SRs/bF^bUvʵƖ-+cϞ:xuŇtXI\ Љׁ(|hn }pWu[nMff8~/;1skA"H(,Vm' BFѸf'v`#pVF Ce.36IPU]M2,a;S?y/2cFSwnޯI.k̬gMX態zy9gݯ޴tY8/&Z1 -G߄菾LdciEm?+B[l3Ӥ^8~سiq%&PM<^U*^Q^6p7r۱ĸOUY= )84NZxĄ\/vS}7w@aK]b:X'˶#hHNCy=џ|jfXmpF;FBȷ N,`2 FAuj Y}unAxh5-G(uZGk>l?4oK3}N<M` % ȯVCtPn]21qs9܋{)i֭T?a5\r\# {_gp_z$H$ǯ؟@!01rݝf+ r|iִ-zIc7G%P;Kdn(>k_(q;{zo]Pk$u:uuބgy,TVG~N W&}W+ Vfwo1ңNhE QKZ8(**6)&FTt LiWJ *E8пZ2UPaYMsc`N n|1b dҲMM'W/GOew2(Y#%fA 1 PLI>(D+ȝNM֎rjXIc~H ƒ5Úі5A!ةw\sYrt릢m:JdLJw "6LR ao{oh;82H껃Эm9㌧֧ˋT$|ǹ^8rzE 1%TCU8j6$ZFSGOf:~@S}z1TvEq '!KuwƩmlQRٹBXubhc`5!m- 6+YDq x=eg[Kr<'3M,i@>sƎ1sCZqsT_>:bCs!T@n/ [܎۪%"KEQ?b.n:eSӔ#!!| <:p_&G랅^>"g~ P$Ԙ|scZQ\Q1 u8kDeS6T6ƺY)~(8滠OE瘏5dc?46= Ū3S4ر%L>a/|Dۿ<|*t+_<|*]G: 5-4W-dGMS_D%~,.Czx/[aV-}=ca:*:0L'|N`Ԭ?9miCxTF?V5z9I3իBlj061UtӕQ> ŠҊwqԙviVxIM)&1EO!p5X>8Pl؛Vab҂E>fҜ9,? -$ vl04V}*@ 1h묔;n.qN8&V`. 7vajϊd+ѷͽFKPi*lnK:}bCXI? .NB>n,AVvTzt?)w&bR@kgdZX~0>9Z@&z." Tn;pnyC@mV:cR9ıLo<-`$wLc$ZTTV V:F}b'=$* GW6z v#FaM`(ܑ(ИM&eqy e/:\tEcӧԉ l! 9y1ю{`&̟@c1G;ZrSYѸsx~,[lcw9\y٭sT4\{# 5gmτtXi=cGp0((5Sp!TB*9,$7긪y{yT^Vn:1WQA u[;mU(!CzJJ?i(yUٝ8Y:`acQ1b\5VVVmg6820{&&\.HDC;a Yo@@2mgfg5/*U>~g+vb򳿙 Vŧ3}ߐ0CAZMX334VwMoԠ6YG@`ZZ$ײۛgڈ `ڲ7AMAdur\ pRQvfFh\2I[V1bE8*ثJAZՍ5idh23t9BV&nVEuƚ@2-u=ofC = wELP7Gĥ:omެ>22T%Zߣ`i?L]5 Gj8^{o3`4ͤHX MM!7S?\4B܆sLPfY"T/;3stmk~tZvߕ S{8`} ^1~ʪ٢r i.M IDAT719%ǂ~&P_u ikpϋ1cXX\h3Ѧ%\ʀfѷO9C6Q1 }X3$Z w8JaCܛk+@d~. t㍢vG*]bq#HIֿrձGT=>Dֈ C"+uxHADJJ?is6?~JY5ܹ只]H +k`e0C 8] @MZմ"&"B Ȥ-w$d,e1 LC$6T+[4'LfXt:bnydyX-.T|Lu92 v3z?,`51> V'J(.|y~,/v .tAc#B]"-c?1hĐAGOZI/ҵJ%Hx~iCy5h  ?D$02>\!{RO}y W_%TtCBHQ"<@듄:{?M&Ŷȵ-hˊ-{I(.AqOL=>\:㳺[:ݜ.x[8SղYEB`v_AAdAbZgݒ YTsǽ"Z1~yAR xI fC.BPzYiӇ3T퐂!+9 DcqySKi$@D.y[Ct9FX0C\sm&%|X AMckuJuϚZ\`X|JmU-w`h4zuP񐾾Fkj+*?ɻB}Ɂa.f"׭q;|-m;r9xJJ?ivvzUJ?ɪ 0lI|iִ`Ғ" [$plB+EHܷr4\vm_k^Uĸ%ZVs}8jnF.%ĸTec0QBp.B9-L'4$ťUCu STцt1רmҭ"PHf&KF &8`B ",#7 ԓo܂b0ʼG2cf11yVhggM~=&)(>FP?g_OMILiw$ ͷ7C<U96KMM ;[L;-yӐeٮl+ٜާA>P[t!`\>!@mh\[; e- vnp= ?ґS!H)JQcӑM0:JܠdɾgGr5գ%v`"K hL14½?p"iV:1XQB 6;ʻ p&׶8R \ߵV8-C}78|kH}ڒ4N3}.o bwPi5I+Ea,h:R?S@ Sf6, }b)Y.\ۿҠmR!ψqFT!4Gl.HCnֶ:@5j9`O.ˁ@=$]`JU&gR LW E Cv ^t_IUYxPuذaNeB ϊ90D210V/,ȂM*j_8i ɝ LdPI؏8D>mNL>qZCbi(ǧYʁ1GMk#JWUJWc""Ą"q Hѵk15i331!e"{ gw`hm*°¨ ʯׇ\ &]]!SK5-_ByIȆƁp\3X<"Y”%&!IYޥ5H{%` qd/LgĔЙ-@ -$4}5la#P `"-Ko=nBK> yȫK]WbC nީ/C[MA,Tv=gAcu׆ޗ˺H( ! mwGjsHIvwBȣ{dREh Mmc:- \`W8L+񎪫]>怡^gFvA=bZAyFAkvE2.tZ.E-0).T3mOڍj]W/4"2:آ M!L* bAm.۹ >߱G۲ 64*/fz k6\{ `K(%M;oToJ׺($Nیgo73qֶ][w^=>>& o a=ӟY4&u$ It,RS1l+І/|~)3l|ӢQ`ke@g=>&jN1Ⱦ۠ݮrw1 [LP>_U㦏  +r{CsCδ6IpLޯMȒ!bvr?\p'P[en߹芄ڪvͻLpϼsN8i1Q'P0b虈hjO *Tr<5z!z u -0"M qk{]ǁ\Q)>z+RQ28d8Ek m*RX~!>qu#JWGi>t*7?j7Y`xO8VK#4iaǀ6't3>\̨Զ-/;sy2e Fsu@X RP >u6D!VI-p*fFHsۂrwpիR+r<;5s RhE>՘[$ @9A\cp7v;;.o/( ~^ >V?!šd}B΍vk\cw >8D6,Qͦ:uPJ4&b YA4P9aE YZ !XᤞhhFIKchGmR@&"MjX W;٢REn\rkRІcƉդ>eVYnWl8ZI X|@I)zu8{+\88o0%|U_xMZgVV %. @9 YrI7s_O !8fX"!,r6KKX h*]z~鼞/+밎1XXhmuL.4 V1$UB]4w"(!R$\+A0zfʳߝe;LVrZ/sJbGt%|\?GmL-_y4Sv#ΘBN[AV\Jĥm@2>v!ˠPA!\`i=P[u*hg\+㠯)!u?>g!scN}6ig` /P>P>" Δ2Y G=mvWqM5bzR ܖHa'lNd# ՋcEuC@>NZ4j!g~ugT*K,crXT5ic0PL>Y?=?1]96<&t3oزLD86WK+j/jGj7RпhD;{qϐOM]Йg0i]?<9~jhye  19 @Z"'a4;+ ;lcldՒ57߯]ukᨽnu=Wwpd8H=iNa siyK,l:֢ڮvAFpQ$Si }j75*=/Bmw= 7L*ꯑїA+yg2fmg'25v.ƚ(!#. \(+_ ɰyzC{lv;@R.}%d4EwT!FBJXh lZY&@ށR|O%KQ!%:W>?VkAE)V3v bR("mP9~iWLS&lERx.UQZ(^K i9G\)W8vic[3(KW(_bl5$bQXDa0 !+O%rÈ]cA랬+J׶88SSrTWj陮5чR@КMхY e 5~݄&Ӱ w}IN@4N7գް?wH$~~))6OTjۚ]J{]J)Pij{z@5U[N^23eN6gz~͕79>ٞ9cE> ][KUsb)e 1k ;H=#tTn]I,#U![0p=˪dΛ92]{>TD*~j=Lk EM*؆=VL΍?CR0ecMbrRY(brݫ\rQ=ۚ&Җʘ2~`5qfH%r2NA5D;w(GRAs?]A'sYSM3zkF=o8. ;J*"&5΀[XZyjt]QB\𫎻SO* ft 2B E WzQlBMxbfJYn1z\;:撅nEjEEz=v:V0@.E(ω'.6tA^%p(r@wzA@݋^C)@g0OXiAr4[Jq")]` Lef c\5&z%PJ>rHх :UE.(jF+mBItRU\̕ 6ac-L'22xx3rPoT*h?64"R{:&43Y6rnhX1Mld8clh zk?kA0,ANAe}97:\fԥPTOA| с| D'_[k#^6 k1a{3u 0# 3mAk[ f2xԾ 4 i`M@Z1fX !{%9Ar|&W-Y +Z_n>xޣAuoU!^F+ҫƬ99Q~;5ʑ)ӝۋn_j+5;vNshEDCP2L^ hThErеXiE3@_ Иo1e;'lW,Θ]c'Dh}qַJV` n  fcͦlh=Ŋ.vנTQDmD|z4fc%|_˰9uݳ Qpʘ$B0@.E(ωr:JrTyM;Ҥln}W*2a& /Ns8BqgKiZ,ya=)|^?R@HEb !/m6nakmVI(1-J] nR?Znj|viѺۅrVة1͓BI? IDAT;tձaexLUY6MS!v@:::^HDMptX+G{-L$z\ge+ܝf:N$qixGF M&$[ [(؍jWBѓưӍ֢Mu~J]k.;,E[tO2zʅrQ.sZ\XD8L)'qyl:Z@$K>q`XV\K 0T(My6n.C-*\Ŋmf˖6*w1L`RŔOLQuɞkӛw EaReT5)V1I/F(dZOZ{1\GOFOvIQ:(;r[2TToKȠYiOWW}(=A[(fB) =ŕY%HIѷJ`) \ N9ZV[5[q񢅀6m&߻%0& `z vI(gJhU`}~uՊi-|65O$]fq$בm͝,rƘ(-+%=i'`E/JFCZtm˾P;愺Zdid:& OwQPQ/ڴXBH$2l&m>r xWZstƈ.B2һ3 0AXat:e7  }%.qT9fnMui<5CzIO}ӠHCkH!E-5$kmXw"dFmlj:Ep>/X2kʨbXh,xHE;4f#QLq(*D8 9(LLl|Gv}s9fH-C%okz>}B)gqywz3DH,,Z&6X4&[ @[ rHQ>YcGx+_>ay<@ {p ؂g:,LqrqkblJIԎ(ʫ/hay16tEh2.r5R8۲ rI=d vʻ޷?$_ş<<|߷|+?kϹ3#?8~Wkx˼火g"?EUpɍyǻqCw=+$g6x7=3fͫ-|(7_ز%o2~;=fgo7rxT^|__\}O?U嗳»~[6~Ǹ׿xG_ʗ|e o_sE9O>#^<;G>1xg(%fH}nlʕPQ.$(j{sw',]XC68*<` 2$*lPgsw5A}\ )`׌ʺ\x|J9#Oiv:\OnA3;d0 NCcڎ?B}%y]@ u$((V\mvH;%@7TW!ãN۷15i8+ʪЪ;ђ'+$7iIv`;hVl6SCD+3ɚ=U.ĺYOkDn2P.ȋ{7JИ`8C?Fᯜ?~}WL7v> _jȟwe~{>oۯ^ᇿa>_g>-7(75M|ח1|^O}Η/|G|ğ}s!~?2V[8v%?|-7 ?u/]Oy0|o/o<9_񎼸੷䗽~AWx~͟ ѸgwHc5s@_onƲM;f&~32 E}BH1 hU2b5 ⊘#-HvK>w 5&f mꟁOr]Qnb2C:` &@hg6RHKYkwٺ[ <[:%G~«_Hy!}&.-vUY6zc|KX}?0;['޳{{w ;|%r/? 4?ͯyO9TU>M_2>;מtA^[4C|'◿y_;77yxZxo|yy#W7'wDy|_d-ӻ|(GT朹veYJiW6AIް U4@ Ckx{C  JJ)A$H19';FScĬ{p䱚Mʹ xB+@\‹1Z`= 7K_vybovlТSulR>[Kd?;@mM@4RNÀ4‚71!ooьqW+x9/BH? =791HQ$iG4wCߵYaM|VڞSJ6@5( K: PIkSX:3؝.Cuk\+QHhav6,]mXLVѠEFZ@fEHySXX2 }e*ڍhiIiv0ou+[1MZou)Yfk&P$،mCRcl_NY{q2J;g,e89S mǰ!/~6B9o vxƞ'qrvy&a;Lo [)C,ߗ]G|D(0\z>>s.]3hFk0=?%O;K |G?O(w,»>>_Kza㟛G @"qz(j řQ`&PҍC;5l(RdT必E'(^4utoߊ@O)TfgWA28:w}!/[1EAN!jʳr` {EA $_P ^xlNx,wLxCiA7۔`3#eˈh ZC2c9.,.=SPUd&׹FJ&sa$-D:QrTJ{TuO@"d:ee*jƴƣ%^+Deg&D: XX4gPQλk `Hf]N ԥ0'WLUi8SժAEiZX5E1KՓ.kUn=gb-3J0 ؅a! УNw&V0f2{D7xi;I{\gg;][&D3Ӈ jg. yWuewrzvyn0344*47a!C={.橮rܛ4/.Qh)6X1T)mnLti\Cf ֈV}C-y](}ռ%o3W'wӸV{!Ͻĥ=C\bVz9J>ʋ_gMW^yS6X8O/`/5WG>_:>^J^X/po}틒J,.w<3}36O~}}d9'ӳSz{~NO],}ٵ홝1RmRzر-;v,؃^h űk_.k 'PgtW1ф'4GzAC*dDz_dRAZ10KHs ^t ~7!>Aa7mxb p" T~+?'1ǟp70ri sqnIguk=N]a(ҴkZhJ. m.Pae*X'Y|affg;f!y^Ŋ>y9/Axy ֮qbtkZ !0`.~tx:tI?yca*"VB 4 F:˼ȀϛLvɿ~KeubZ&m-lmQa['vFbuO*ia@1D쨢H9E mc7G)q^H^%3ma E [L;infڕjX*Ax;52:LmN7urxbް:%jӚd׫+hWA}v#%~H#ϜxѸ(]e*LEZ+P>ވ{!ycg;_M{a1{6z!qOU='~󏿌W_w|8ͷ)_G~%>b(x/7|埾׸.~/-W~?1Q_}뾎;۾./lyzPjEm(D3)b YF rr)ben*a+@;XbGdR f=rQLv#)/ܫ!c& 'X'(GE;W {-uӽ+t‘;x9Hb~H5NP*Hˑ 4Iw3 y#-4cWW;ҁx3lYz,2 XfJ~&#{?r/vmns+pn z8eخmw{CFM B/=e2ST:-Bu&ٔ~`~'Y.jX F-FioGvVE'ԍb9S?]h@r Z4VR.b7NFYDKF]\0,%(ɌgV =aj?!9˚e=lǎ5H$=+&igh"2@dVqf.;' IH(J-l3+[i} C%P zH'%5U7"ʉE̴RSkE?5{isVȻhU웊Ǟ0@V^7{/yųz~O^ 252j"oEްj:)f94f7 RP;Ls x2`Gy}3xQ') _Ѯ'MPeF1PSэ(+~7  ЕvDHuPff]7`C9ygg8ݻn&@ /oR{ A׀63s y:+RP潄Rz.<ZˆcpP'뾜 J#ɣ?%n޻hf` /3R'#:UD *dzC EˎQ tdLb[LL~uWʩ2ql\J\TjJHH 7s)xCi1!דioyWx #s>+@Q  ;<kBߕH#0 U 쭌Pj[,] m|> *n1-[NeP&&yT]"EXL2/RP3&|R*#MFy  x1ċBSAEqH^heGz 6. ,*( ZhWB}mT <Xiqכ b^]B`3:?Gy[):gq܎܊[iaPA>d-Pt-$L ÏO㤡H+ʛGoIϋf&hޅR_xmS3\AYծX ܅'~oy\Xi .eR/_b`|ӴJ^3F&Ihh=F\D֢vd0ຯ+T&R\{90'S)+""J nټ2$Pˤ4mgJ\Bǧ@Ioac$C Euz99PGHgE %+F3>h$Nx'9@:TW|Cv)IHFTݥ켶ԬJ)$4G0|IC\6#`mt kjQq=BI ցҝ۹K ü"Ct/66&DqPR\z4ӫqxQ%ps"##2ھ>3ۯBm!#$<[Mc6SY Nt]+MhrK鈔ss[7ByO7 KdtTsU2RS" ,'Ƹ;[}3]< g9+}z[ {f(PZTkP/Mk9pLst)X#"Coy* ~B2"X=!-(Ec[i"ѐGL+-Ǚ#zE-m0*LE}Z鬨e]#1'[wv#?H.:'AܑRc46+tISJ-ԃHh!-jQdw +RCȯį@rlV\{L}-E/nֱm͛dfB*EF;2ͻzOY⊪ys*2ingwRRPݍ54ߥra\rQbP 'bӄkrCfXPiNVާ@`¥0f]ʩ+=^%r\s;7L oؐ:P`WvRYpUϋaa }-Wod}/xAHlhʂZ b7޳nz[EFLg:9Adݞ{CJbOѕ{bB~ΡT>3E'";,>Gt@wZZ n()ۅɸUFڈ~t]6 ʙfa)m յ%=Ǻ%%;={dJ˨6zG}O>P|nyNrtPt.Fjc76;PI P_2JA0ƠE ݊ ȓn]8cZ}fڶTel dL5efg;pB!>J؁!>sC73Ѡ-\z8>gtӋJv_$F*ٟ`NW#9ȒlCF^ i =X#վy}~%NPѵkY9^h !Pq$3E/Bj>|6 h=5"9omLw5ވ(!9XûP. OQ rQ~YZ0ұRPP0b~!0Kpv]AT\Pچ3J׏f8r5d*OqnRr=hCidR(We) n0q3nST "#W;_dx Ƃm)oe, '6u{{6XTmw 'JNV,EaqC|2_ZaYtYbX^`c/mFC[Tvك Y)$K,>4a9 n5_u<0R%/MԚa&Gv@" xub؁bG)3'mY$º)E@@N;6-X_T2$ZtzvnTH^WStN49s:l29Ǐc2- ljX@T%o l?n㜋 `XP==l*`𱒧 |ӵ}GL}FCʗas~QobdjK,of$ez[#G|B ]qo,h̭}5Ak'BΗ#xuagc0@>Iˍp=. V5nx_K9R d&ǮB1lwDTvPH傩>U+aϱS HJO76<O+ƍ?Q;{\< LQg0!#S=ã).5bakA‚<3]+HcyfJY'LםX%8D;q݃{m|%FHDۄN@Aƃ~Ox!ڌt䃅Lק;o+Ɛ1;vV2 mzv*V(H;Fh>7"te^x4Lҋ4^h 4kd92nːe⢭]JL#㦶h&^:BѱjGmOIZKǶZ2umavt2@Vgl8n; M`DL75֪u1=Rf=o,z8)S%g&vO/vrI;g{B>w<.EQonjM!H-czM66{z1>av>9M㜐4 b[7eXќMДNq騰7>>kt7`2I+H 1+QeDHZ!!d"4aeIB38,$h$@I FD!.(4mT]=PU][U{};|9V˗*Ss9ϙ2;6cu%_ _ Cx E6 /|.ү+9}АpM }a&d.2uH aՅziV* n&^|ôqF^JK.^Rɲ@sIpu[ N}`Ȫv ־5}/~cQjvN{ Y]gKd)ZmMyO`7L gNBMβ?t}] De$l+ɲYe̸U笜 { UԬ LUnzMe+m)ԆaQ'ۋ6K~ 0tI$ 5P|QC $ {`X5ڬuNy̌Tdy߻BjJ{PltXQ'&Ss5p V>ŊB +-mՈ}SA )NP9{2&GDz#z2Bc1aHQcR=|vh [W|Yjqd]b3|қY1v&JD8 y<}tKY'{q5V(ژnNkm1$M7W=]`j{wŶ{O쏭7]JoQT]&P쇚D i;0e ! }yk~ƧcIY4I NTmd{CT4^5l+r'GBY쒉W@x.EةNP\6g B52x2]( "]FJ Y "amx&0T@2>MV>#s P\PX|*2kO)0>}R'""(ut[ w\bCq c䲿F'U"IIN<l0A~:V#kRbH`7$c?BK+zU}il&@l5L6n¥A-aIhBaR,C}x{@t~} zvE$< 1۬|*ReٝormH`:_, V3 n%XZ 1@'fD Y%Bfy VC6l+Y`hvatt6I;+vG0TbE@q S( y9\Ļ8֪L*Pż{߃}} Um+[C.Mdaʷ<a!މlV (w^;TvE $,UֵcoC0<[ RaE&抦Y})5/L>Lt+%HiWB pI^Ykc't60 e!Zҫ"߽e^쐤Y\ʱUt-,ty-_Sµafv$y/ k&/ʗ|?\ äଚv=}KaQ@CE5LJ@sY ;]1qIt*. Vո=;[1xd 9+']q#m6}~< > )IeSc/o&N%O"/)Bm :cАKt9rGk*&@hڳo].¯oSvn6#b 3]Uoz>.L|;.t.K"uAѪ˜ȝ]MaY }X/@6!Ky5*W*03s(7)(~MrmE'눥`oHLjy֥E9kOL&Րс`~ WF'_6)f./IHz 8q!%F׃mK0Pڣ[͑Ul1!%'CBӧt3d,LT{3יo؆[Hksv*~&I1Ϟ={k9 xZvΕ 7t`<4tvl x ONOCn 83r~̼n}Z\Z.vtK5^쐵v3Y1ƶbW{^ʾ@e{~VۚuqPx5hQ#L }z>^hl7lZ8<jN+?pݒBXˣmkF8M(kk8k[6ɶ;X2MPOhyh!u*&mLmM:Kk}0e׭CSXkʧYG/=)j# cxl ]ye]QyFb$yg;{tO\cm6TcxJE2T=Lϕ(mxˍȶnU>:Jo:+TJ ȋ4](W"h/Q4p֨cǖ:W [p)G qW4QO_!pͯa30Rصf*nt6J&, ybVLʘC৆6?)c%g2J-0t817,r¢4J*K;d՝P;6T7m[NlC@T*\@y zgbKkDe8k n}(!i4"hN{4cMC~bF 4/~fsR~9}13<+YK8Ã};R(Zrc2T-Կq9rWh`'8V7Nj.XyVY.2U4pƲ>"4ƬAƑy?#ax =}='!ns tMz[w c\-!$[N0}jc+ <P[wlsaW1gy`hpuVƖqC]G<ǖ HP@.EH/&@e wNbխ]x\Ӥk!b e7XŊ={U∗qsǖr׷YzZ2d)V9wzz jW A #OOtىv?CY֎ȃOeS(ʢPp)k @VӏBޏSMω 2˯t/NS K- QVjk?l- W$j,%7AA;P͒>h1գeԽfo]oEqmJ(Mv/3AT)} :,u[sW/RW<ϰ+;Ӛ8iy'i6vw++[Muvy^j*mƀlk lP'PmSА܁vxo L+6SИj'U}td+v=wZ²rqaiWR:S1?q${͹Bu/e/*"H2R}8b 3}ys잟-RK^!s;Ɯx-QY@WtE˕GgcWzη=L25˺׊'X*hxh QC㩏ȾK/-Ϗ,ya|҅r.EzI$kɝME]82A1n+ e n*N^mٳU9HAB=bYH܅Z.?nLBB.S`[ jK031𛎽ʆ`$EV 9 u X?Ŝ3Dbm1CF̹m}RZ/o:oCgѠˁ}67#~19SoϯNxJF{ؑʛBDmNycΠ}6էE\eK1eO8DsL EH%ĮiY}_*!!&e6bI܊&O@+xi(eTf-i`MCe .<-7*n 7žgR@z,Z[#N߱Xi;}]\( B:EЗ OP`E9G #B =G#EӸ+7Eyg<JF0Tnw2N-v_I`uU ɿ&Zu^TioE,M|U}ʹͩ8;|?qU]qWʪ8ynrS;{,#&nmPLR_ޛP6sB" h0gMP^S(찙dY>T"U'8kǒ԰C<7~7MZewӺ 'McPvȫxƢY7F-cA0P|g^')?sN~Xnu#,|ZYh}t{wO߽ %\i_ A!aYϮ|ihPkBN;EHW"Z0 _3E8\I]ptԭK-tmeY`! T nmI |K1Z㐉?U$h -qHDL5s +=z L6Ivd^'p=:љsr!@ e;'A[HpꝮ +sHފdY{INψMAMlٲCXd; Vb`pUOcJE|u3<Ç|}3T{Lt^csT.kUJ2"ߒɚ i{SQBaKIXpsw`N>|V}HR_(q::g`6Hg~H%{!P1Cpw꙼ Z7[dW\Oo?,)0B(q+J:e]{ SĻimc|< EK%L|bv{~0μgG&Cu}0#bС^Yx(:? Z >|[MnXs5kL$6@vC( vo{ j/$kW<;z;ׄO*o+vzw{*?~;O΋!#ի?#5"(.үttxg}HC:9}wI 7&~ pK+0 }Kޞ lPttJB,z}"B&CJ3r>hI*lK R0@q ,{F8 OaVO\ 888 PQ %).dLIyX8ǹjs)͖t}hہz/oj!d:y"K~A asIčE<uh!j6`YͭHIuodo~(,)>Mln=7*ދ"ZuFCV/ʄx@ic'PE@g"~M⟾pDhmK(hn}-D;nX HA`Mk6L,{:NQ[~aQ}Pt<.6,XQ[eY,kdsX9CJA^fܙb{ۻrC( K2cg}X\acNeuȳa./zϿc$:*wԐ|+ƙrј]x=0Oy5P3z醠ޏclRXa잘u'Q@Dڛ~_z_gznW6NO}?4:9}x}k_xL ФZ6LZ"zQ(eѧ>" pDRWX%iFO =.\/@B˲.8_LGrf+=1rsOP|1-{:g.Klaa*JyH ]AIv7Co@}+j* LmD3u?JHZ1`n#b!П8:#0n7'uO+ŋٷ4(h{.ЦFq[l};s;`K{IcnT3S95woϞ}Y6MWZJOLLeb?9Svh)e[ 0c!.tlQ IDAT~q`W"̄nL j}l K@8(-0(pLkM,wRuwrCQq+!!跹i:6A'lR2#cg C42r >d}sᢳe*~"Z"E~CCQgO瀹3OwLr{9]z4~Bƅ>_w$ɲyeRKZ[@5e8Sg e†CچRSC,딒Ӯ7י9+nW+_9sQ@o~%۾yy}-na~>^[ȫozcԟ^~O?7WxGgkyO}]O~]1>?>nK<3/HvwE"&PRx?`Euv>9g/ ͵ u A@ 0 !mS,)PȄJEm)TJHt}շmHy]OpA UQ~ML-gLkLNz37P?Ϛ,p"Qٽm#X0Qp9J_L zgf9`HQҒi?>]I ;%F\@E/mzڊB8o?Ch~R~[qbKIp!~^VSa#k R*oaX( E =sxh:-O_#1k&aR{ˢ:ڊكͼ!<={=?l%x֭+FckJ?uߕ>7VSwEtIPg%T޸˚MxNϥ1YW;y*xcd#F/z~~V?1__xfO}9?.W_:?Q+-&o޻o|0G~+{[cw|v?g~u~< W{Gx+_Fpwu>WI_+=r||^x >LJ>/Ie=s5P3 EA@&dQv\9cØ @m)ܡ . cmk>@ b")(݅Ba( -d\r.z S]Xv!K){VKJY";Bg #X zO 绹=oS7B Ԯ.0&`JUHH0uo[Sy ~v /¬|ox"O>BcbeӇhe!iyVL.4FHLāLބISnR F=/mjm^UqzLUf_T*mڹA*$_1e1fo{K fMc3#r㹞_ƵPy'"fjg\qr֊<yj]a‘7 4O6Xo&L닦fG"^|Mo4s֟x=&j~ǧspF^;y3 7~gxWp<_&K>W3+9>|ԹT_|ׇl?Oy3_I}8}| l>a?4.'}>p_|_rռwq o(D<5~-}&f8:x ?'?x|RD7px{fڜmx?qVRsH-41K%E. V@#'S3wCVd=>pQ1+]OfMଜA0r]ve _,s>@S +Vm a\^)Ay_nQ -nwA +Y `=eשgvͽvGMr@Vό>  -L M̑ 9еP=0#1zi1y@/KezTGb!E7da_[AGXH^9fff۳ŤKxزӲ˒I[f \ELCF2ϧ]Uw/~?@=-]/&O߷jW36Hc;B~\p=e _rn8!;zg7n9_i?/Ty/?w Jy3_}-;o9̻>3ߠC~KD^ D Vm%4K@HpΨAH5HA-C~ M,V6Za%QpV@<@< %lI`$t!D kM;M% mCYZ[qݴPV $0Y9ko=(wŲ}Nv, 2 ag+v07N¹(A4yƼ^`6=lл!dq.9V:xy%PWKք ɪ3&oA? NL\\k\ws.Ӭq[ iÞBu?jdHwXmM~(۠G:֬}hQи*{@k[f];[4FkQN5h&ej@*PXb|FN1x̖ЗgBk֬(nmzhXV5t”/"Bi4Mǭ."90XJRT jm݈X"^mBuN:~͹.|Qd( S?%Rz(QA 0gοs뷕V*AEٟ^R]ӿdcjycϞ}Hl )mg\Vq~>8v{%=|<ozóS6{y/Ůxc_ckvY>S}(qu:_XwtA/< dZW(}7 ouڊW1;M}|CP^˽GRJg3yÜm=Lj'<.!́=&f閵.ryZ4U]I`XЁ^@<ƧZ^~9#Zِի?Y' O;ós~K ~oXI[vq-.T\7ny;Z% ;c6<ٞ##|x09,Z:Po}~$m>%"d <mj*[3ÔWq(ܝZ/#pbk8_^ ^˹7^{q8ONkNv Tpƙ41v >KEt'/t^+Pf*SL|Ś5Mޛ#? 8C`(?=173PxR<46#D' u;&5yP%BIׄtX6\`~i?{͜8< ]i ֚A*);GƼLCx~%dPwVԞk xQ5!ޝC4ЕmMg5He> y,'֜|۽-aCfis5>f S>-[Y#B{߳y3=˷1!s`YB'C^mە#r5{byN<_2OO/o}Gwc+vqw$OU$O\bqJ#/7?Dx=wy,Y`~/G?Ǿ~v˳ (p{~{{ O}sK&Ӿ'mV;?|ٗ~E¹οG?|֞?,,h1sqnL/1<~c,|ZzC{ > HzEAX}b5ڔ<$B(uI/Cc+SqTd֒|Ǟ=vl36'P|z{Ugn*cD-s10Ch-_Av'.kKW/O)ʇ^L/|᥋t>)G C|^x_^uмRYJ#%V׏& v.h(H JI %8,c`6N+]mD$ C;xkh4Q'xe;"_2EeSAB,^f/c+ZQ '`,f~!ǰfS2), k-h!l:жi|v\ Zڳ7!AMyAcOh߮ iJ٧1b #,8 TFE!E6v*Q88ȶ ЕnT13{?>{8F3.OQ><˳ jʫ{ z Z春\ @m]!l#di+qHBAr>z"OGCVy5洐+ZhS 6tp m/@j^iHKsa!œL+&= s\%B33=ĢP}a]O&ئ#)lTW7me3rL : y]~)Nnvϙ)фmT1[2'B_P1tLu@1]`u˼ho4oGHe7G R:Η+^hMN[7scEPoô3as2ʽEBF m ThaR@"9n&t\Eޏ#](gYvdp#Az / H}]t~%%O6)`zHS@+Vye+1q iJ`EBt2}'q£(qN8ᤝpXIY&٭}]wKb xҞdS7:.g` S=Ț.u{ V2AJxn%-{{Bjacv+ꟾ>. X, lRyjIP)w+C"iwZ kp.Yj^HR>JY<.4 hN?3N+7v0"P^پn-&TwLN:ر޳Td@hRnCfS2(ZEƾSԴ-pvPϤi;@$Zz0,M8&%h4*G+bŤ`|YwM@׶h!K3yPB2LHaHy7ISҸS۫Fd ֡Dw7 ҀދO|ʵh mL&iJyijAYlwi(VX怃+!h/Ų`A˯ZkZMc+W+ƉG۔ I7W;n ѡ_(TjR{:%L}J He({(=hFE|BH>/BR^"vvzѮRBr`FvdZWҐ.ς%\t~hʰڲJiBC,_s-`ayo0 &׎)t:F4 M6ph ma:-V88c6^i!!֭m#~( S޻TX /ZHHyۃ]p᧎o\ -du?=-1k}ιns;N* Iy%G0-JP,^  JDJJJ1@H0IwΣӝ{9Z7M(8{͞箻=c1ǜS4Myi+Ě>` \"zgG2so| ݁G 6PUyd} PLJM% )6;^C‚E0+ʫA,y!nΞ=,ŧrˀ0թEcnKP@@ o& =:q;\;l½ IDAT~'d=މPY4.? 0X<>z^dz֢r:k!l BF )!)(Wh4 /hk&@ 7#gVF(gp;~j8U|V 1͍Bx6u]~hykgS?y,B_EGM&>-CBU5K?D7`03ZߪqM*=1r'2 xTHzRwߡ'/X1C^m3ьR8~aѧ\ygozBթvʕu >~H><m8~ ͡?|yջtOh4nT.9qA,RhG-0m$|J6o-J M%&0m7s iyt=Jݜ)$djB0cA2'N*'[%89{ȦUس&š1-h]RSYv!Ō}0(l ׿P=lmX7EHb{XL+xu)5\ 0G u!a 6 ᡳlvm7tt<@S4L}d!w*DцsWmn b6TY"_U w9Iqq ^?1QkCl$HT_ڪ:R@P&T xyQ*2D3Byo6~ ݡ'#TF~|q)} V$SLaM VV Xʇwh8֜\y2,ǟFA,S O柳RA<F 859 H?hk4[tѣYck*|6z¬ ^2snZ>> 5ݣpTNGIu# Z;^x=P@]K?S2`䓡OCBՇ[Jn*ifs;a>O鈴>dXd` ZΙӡz7d <虖}򱭭ѥdThV1ᨱ=a5c2󊩬AJY!z٭.zz $T̓4ͥߘ䑇xÙa4&O 7yJNKw.)tn[nm#S '3&`4itz 4ϐ E3S+u,,LX׬myg캹| gx8N%mo%km)xf<=7=LޅTlҼ,s<ۣ#4 Kz^aR fMB7й&+9N{'S*ǘbUrMcZIc`SPV=)CȱYD7.Et-z-Lt<K)1qOXU|vO o .L"vnAM8NoI/2< eOQPip֣ #ۆKp,[8 N3͝Wj-"Pma v` CyʚW)2~pL&+8jh΢9h[c@@X+cO<\.ur賺RvߨxmBnF*RfR" Ά@`J<"KR1K1<6pd&$M|8 =IKcuO3^儇K!lRfc )IQ\nr9C"9{)K7h*'+o.~rgj E(LZU Zolʧ!K&Z#'vh6߱n B㰔d'q7aض>t4bc^`Ah s׼x> צcm'z'˨Ϊ$϶ׇPk13Ɂ>4 Dۚ:P[otxZ? lDHaީ/ϷLqKCXum Bmvj.޲]5raml=tA 77t~ʦjc;R`\ hB@f%L`?,4Y1L+{<@';IaH[ilmCy8qeW`Y^TB ,ma?\yqqK10gyRF;RFF]{魌vEGt6vw,OaR4q"![s[s)m9oMm#*`fTb 0a|(u).y̰l|mbeY&2)D tǹVVh#lAd:j罁`S.CxL d@cM\sa=~/N%G?2Hw ]KwMVtYEMH? ?k,RB,h?NBH&36VY7e%\lBB8-h6F<RB]-Myw-rN)/\BJlcHZ#vקU@`@ jk!9]DK,$T@`0bpjeǹ*L<|簱ClץlX>L:Bʒ<<,0vTcIGVԑaxYXk/C!-NϊZEۃ/h\DpSVu֧5N3RC!>z,R>/]$ q8mmqvCJeiny iU)gɑQ`ߎHņY% D7fMx9c*'bw4ƪnF>[d={@}S;ukߥE'<M_v5F&6v`kA❰`%7J:l}V@κmtA6Ts33܋{|?Axez08QgyZlv:Z2Bg#>t0Ìwס'ࢩ%Զ2!cM'?d!*jD`{OFk W>dɻ,N $HAjȣB \ XHIȔdj[*ׇ|T_lZQDJG%G[|5ɚb]8!uj3nu6+Ԭ)_dA6Dtdݳ"i}kwٻ4>w;' Ӹoj> ֚%s0K>Pg8*Ispϕ"هiE`=Sim'ڒ|*?HS*>,Hᢓ_kc!M!#е]frGZ.L2w7 B}A*lCֵR^lNn_9"0|Pc* :EiX_.aCTmָU ˾&TvoNMY 6 uѪwp%h}⼏=K[6<0=҂#M mMYq\@-A5swQVHA+5[MxrB !lvb꠾Rh:<ƽIpt'w)"*6#psڮGgo$#&uŽ6k |H!Z\| .&,0ʀ26;[}h L BTٕ&)&>R&@ʳXHQnw\ ]#\`PbЛB|hsxB IDAT#aӓV'>X/VD}~cgR6R+P04դ<1킔 Gk 3c*ʯBt1󽂸۰B_v*KOb/;q?'M[mm kL/G;5^۱.)̴>?b:{'^VeٯgAl؜,E>eÜg[v[9}2f6#4'v\E}H Cƙ:[ sIykSqW_B]ީ5W&{kÂ"~B|?ėCZy_ny1*OAl p 4ʳD5gHw ]KwM 1`յKZދ`F k7£"TB,;Y"B^RqICC R&JyXXVzDKA[16OHʷẏ?W(=~>.hquQI=B;Bt ]ESIg>cpz< 7N+^>ʯ9{0Vm+zDkbC4`m24&lh,Hc$خrYNeȈ`&qgGh37w p]녔 kZGѭӯ dhhmߑ cL/Z"i7еvZ7VBs'LHmgM!QGߞ7j,~MqD5gP6ʰ]|B) 1W_q)P^9絈l{CYn*G?>@2yϤ9>HP:d*Ϻ,ÙRNi>Yd+'cXWmN5G'J~ћTnCzi;.ݥH޴[B}M1(ATaQ0^Ip;x2AS幈+Wk=Yl0 Kp1-Se!!a]‰h{̙0$^ V;ii>~_@,2?{%Meh,IeN ]#/v .dDl[) vXs ąANSq| p◴;OXdH k?c(Eu#f9wգBgd>ohX]qdYl 6LWx Ŏͳޖ Hz&'jQNJΆ׶}|yog}y(,҉kҞVS@]KoԺ P&GT!-0s2^]#SZ< R8R Ҋ #oYe:K@9R*Yf]5zHX$b!0d<%{.#>~8XHGOApB! H=q;^$̂ DR*7,f/sc? 44d5}p ~rp m!+yd1l2Ia@Q4fYX!L凾=7Rʛ}Su;C8rg53r!O??*SYcl Tt-pO ܇sn2KɯS2noQnaSt 90!I%m˳IkR2tҡΤ,P$v_k~秔tқ"&>(1 H$|FH9Z@^XHX&xV$<8hO+3gf H J7\YiHx XF@ p4K sl@HzmKGNYN'^z h_;\@0==σ@uĞ狆C+|&{䏸W֝N'ApK[hv*:I֜]h1ALK{y#Z|i˛=YԛIuK1K{@DP[0ܦ8\- _--C(uE!0:gP{նN$K |>SƎ˸da(N]hlP&}8z겈_ !)kDE]0.&̱ӻ c'O c/Rǚ^>1爞t:Ez}\B9nm s}}잓68ia߾jagqU:I;4{!+rN=?=4/МJ}H`zjoYz!u"HO^E ԶzL'ce,l'{ bqp8,DUTe*|7^T2\qWĊ7TKnZ^iC|l =įUe"­[:ow4mѾG>EO%;5wC~_z~_ddo_/rkpgv7GFN.ON@|w?<~-ҭ 5D O9ɖЙ  ' bÆ&!T@2"èl!4DŽ .'h=4kse! JBB#,Jt^W(lv,e.}FH!>zdB )t$T@PGvH hPG,IÇMK#1Bnu1ZoVěhWWVv m6S> ;_&zT^G3^}y1srvB LwKvguM~e#P?T({z$A< \Իxj2|4+BʹKI-ned \R!d4.pלjg\9'I&hdz.h bإMŁ&@G6dGT'\r@J@p ) 7#ZgJ T9EҬ敳iFh1 ,0V)‹/xskvu`0Wƥ!MeRX;y#k-9~1\HC\#R(07嵞% eN[$3j-JX_zڧ|/>GEw3#w])?y#|{>~M|S8$&;=-) ^oh5LR\K@p Ҫ9 d7lOHp`r $2‘̻R!-H(yC ų p#n#V9譼yhGmLaG \rɈU\qmװ"A)z kY=U~E8Mal!Ж'N SY@^'N\q[x0N͵3QOzÁ½bMxO:\3f {L#ֳHh8z,̎ wm0t8V~;{E*"'0 S9@r :Y| 4KO!ѯ'zrv{CmzhT ?7'֘eZ@L 71F~pAm9 c;ϥ<A̰2;S MmSb߂СvC+A = /loy6\≈+3͡T=j~ՓM|kҵfAsaS G01ͽA^28P,l0UNu9.>`\6z&ߩ\thu* =:hv e4")~~J)) {>~~&v?#.n|*w9~7O?߹|.?g%=ҿW=~}7/xϻ;뾄o:|'_GӃw˾_~oϓ?|Y Ix_~~㍝~o<-?bWF~Gg/eO]V>?rfoMG ?0|[?CW_rwٟӸm3 oķ|Em%/LɟO>rK$ܿ;ߺ>#Oo4$<9yߋ{0nxi b )& h|=wYAeDXJ5˅yIL5 IL@{Cult5BOUoW縉dfI=.Gq,'֤Yաk-H|Qujzco=bo_5l ޝ~9B[.u )qV77 q9q F@rMnC?"t.K}G@@C;@1 uއN=U4}ৼOE MkU-<΀|C 2S}6ƮtͺځoУY3-f;d7(#630햅ֵݴ(RYlf)ux\!=oWrRLcZrpxSns DR jlb3)dY9,=Cei&exhkiKo>tlWlSu|) W1+iL )ϹB9qCZ#T2RY߬^ #G-1=ʼnvƓR)E Q1)"{Mr@}uBJڍcSQ@S_/7WR@ɯ׾7}/+~oWy kCGkǾ,=?Wz~'f?/Oh{}_o>En.>]o0w}OxWYͼ/}LFNG_?zQ=OgG9/_Os%-?9> ճM/ ?yx3_? |g_ïye>˻>C?-Ͽ&vq$̰ 4b93 m4^1 w -PJ\U8a>!v:fH{`H5wNvZ d2KŇ1->d$D p``S"daI1lu$5BFG@ oT2|z$tTIs,j.i>Ti96&0]mŚёx 䅉#D$1tq[yڮ7-> Tw=FoWxS΅]ppP(T-VOu߰i!W )%@Ϳ[rr޻[ J-΀aqQ7L}~"=onw;w]s7|t'Ns| 7o|#E0p|~=? '|ї?qxz{»y[jpR)"^.]޺ws}} ތj`K`/.[13^PMH;}B(ܥz(thl Z3Rd "3ߩ YAѢ)D3T\d2c˼#}n5nj&{mћ<66$`#@xrCm dW}p\}Kݵ"ԾmϬv0709,=Wɾ0PxMEPEXɃL'ghbaxhX<7\mqnaWxPZh_ur\y}:Rd*#EMmijгvac2S=)UQ鵰hPz"/=)tJ@ԛ2k÷?9|G[<j3`itX?~$sg_ݻO0|x翗4 *W6u $ d)ggxV[}v+ $;y&w @&`5pOS~̅˪eu=@6U8FmX7.%v`s dgrPy,*'-EB"2id]έiZ: }Ֆ}kt^G!G1tBFlVӝtyƘ&0rapD;dXְ1y¦7Z ] eEEB\'=`vN+O_уpѴ*dg ^R?v(koѶBq* Wƅ_p݇i͈q&h 72KOwALec4x>"p y.bxn.xa lpP4ջ`H,csߦ|9 19WLR@BٶaCW]8,#iv]& R<,sUx۱;hvmĿ? ?所5Rb:U~ ɍ6>-7Ex ITGLǏ|ysϻJy.{-y?__.ֶ%Qs>{nہ`,?bE)O B‡,GOA',ː"9%"  %!x8!e6Ivo{{5gT}n iٵ:k9cԨQUN>?=: ?g|º Y7my>|`=㭻wSH~ӿ0oGO|&IP>"C)HXr9 !壿1.C7ю`Bfcand\=H4xM |&Pin.YINAZ\`<%` 9-|DiNjcFBhG8$ S>RbUިcirP|o;ߘO\w5*G{(9()s)A_ s,t]z@ h^ 4i@EfR5LJ(*k\0AtzVF50բ4=o&0wC)EQ)Zx8k_Ych|-|H-ϨL Z\V떲g}"[ /9˅r4_/r#;p\;O{`{_Al+Ijq))^\Xom5iF[PZ댇R}DnׅV)i̱]v{o}JOepO.UifXU疄>Xba֜#L揆=3cy&~=1c}2= #YBRηVա(;EW*֮1QsP_iLk:o1i7JEgL&G2y${Jݐ~N4?-*lǛHo?ʿOm>$_?_gO_?_'9҅o7=OCo&c3?_9{'}?#OwgϾ4_A?/}?~?'x(%{#͟◿08+?>WG~ `s{ >7[.s@> ;/?xX>¿/[ɼ'?_}J zڏ )Z0)@~ }/x  k>SuRaUȗXʭrc7:qak>rdo:9'(>[Lo`EuUkEUtxXT%خO=!%ר-=%K6߰Ͷ!_g%L$tM\Ԏ3jA7"ڍG>^41 Sy(x__7o}dy4YkM1Lۢ* ߗO Rn, wzޯ^L)|S_S``:t=MI)$P}7fj Y+QGd#CꩺS^] WLVذlRl> L^梭a4ęx@`H #ZlZ|ƒ?=#`הgZ3xMTZThuhѵEGDQcN;qO ^ў7R#Sx8$PC=5N)0q G)0vchUN/hu)RfU]|ډ֔oRMjaw%VhatxMiunNB` eULi1ACu04_6޵{S(eqɅסq y]gM-^c`bɱ= c s9]ضMJF۷|wӇo?'OQzJO$/z-pMoKRBS >a&o K|/}L$BPvfG#rяlq3pK.Zڠ\mU@iM%V@]\ݥ$]0_,8#բ@p1 JHCJnZ?1zDY#1xYg[ z<οhBV$_ +|$3>㟹;-코, 'wv2]cᯛNB_Xf}\qjwܕ7xfgW8T0)HcR ТRËV,KS/5ήe\O-ku:RR|hyaвqrfs! ϰ66) Q172屉T_fOqR8-Ly=3_m>'f2 pU H̍5V,^S}fi򮖇hs@|n3-Lc.Tfsi5y֣fՏk1qTR4=fcV5Shx%]eWz޲@\ ;3wJy?&p0خZ>u2݂W^Nh7 !)=uNTʃ,k6hZd_k>8֍v&9:Bc昵D8&=ڗO\f/ڕރ^S@Ϥi)EKr*gҪ4{j6F_%y!JvGw Kxr{D@<hڀڐ=nERlôH4k^NҰPRcf=uGSA91Vg:;8]N{Rgi7ksGd)He~CmJ6,Y}lGޟᐖs^ǕW7$:]utg&e%䳤/]_7Wt˔Amr GDmS@AG?|)=~d^@5 )3MF.B>w r\ָ.!2M#=,=˲ֆ?sN^_WÂu{[V|c˚{= a0ywl16$/|?'+nsg%nݑ/u(0'3?׵IЦ {t@'"j/([of}<)*Сi4RL^;\x/9C|@..򔭹(\B#47֮ЫcaEXmuďl軅? $R'!6w(13FM@d%X^CɨGfQ7է1yqSHGUeyq>eߧUxFƓy 1oQR*I=`7\/(zd*H 9^4YЗNn@c6gOm*8j[G =5NA9=[YBT=55o?-khgR H)~fZcfs=-:6\sr~TO[3s[ZոE5Z{pYJ A3Б'*ߘi \7&Y pDЪ^`*d ~ܱ[+l $WK9Bb `OY_sg9~ݲwOnZX] I ؁D[ X~fɊ@@OYSzgKK>*/cVSh5AېϨ.C) 34u,L(mvQذ>yzfڡ|-v]{ӭZKqTT GyW\|sBVK6vXHaMM2c5d} DQWä8>I㴔$MtauU309F؊jz"ݴc7yMaFoBu1S &R4C >vW]zxdQGhxbbo.#ǞZSԽk^zvg9k5Dn)[hЮ!>gkͱ'2dE G:OR.o/NljrQ/L\DG?hd֙,\Eɠa8 EF7zdªzCNs>T]~9q;0:O3r2ޙsh IDATqh;`V|ғ{K;OJS-K;7{NΟwVVB|г&YAnL.|&حaό|(}Y%5L࣯a-&iA|S+$R!=: `|",h1BB d s齰 c u0D;xxܜ AR8A16%%eݚvU0p1Y=g3;`q0y0vz:T>^#"bUn/"p׸$$oa dHR0T} S/LXNM04˳VjZ)׈6v42o]}l^eGӧNuJi 4Ak d:S֥dݘ< jWvRV4:kpW4@R,ir@ȖzT""Iy:Os)avl9oI fd:NNM5&a!&&,kP0qKxD*&dGs;HH;=$yPp.RC=I%X-lZo>%W噧`O:ͦGf=Z a}_+PBR`А +x6\D\ .yѹ,MǠYGzpgv9j$%غ,{RzN0 }oKYEmx1 c3DNmYISߛ=KlRpdY jQL.466p@gf\ ]لe8HRC2ΉcvE@K1&DeŨO^)P6bI) UgL WE!P㾥@`h8p"j;= { '=&Py\GT;D)e{-!м3iRxϥs69 1|;Zg|]j~NP]&Z5j[707''Nꬑ ߨm|^ m'yW}ξν4)\-M[&bC &diO%/slSPp퓡P2JӉwK㼟gȚsߕ,rkc;Z rsa[)CYqJȥڷ]C~c)UԘBrY̮kRPzR@SzJo4؏ ЯZh4 ׺MB¢&V;,Z@nnYX^<䧱j g x-}] h,n?Mm v.|[cK4VD3!<[J ;D[c}iFq裢0DvBk #6́G+{QɩEs p%K2Cp_e%2d] ߳0l2I&{dғSz)"X2cA/ k'lu0-ڌ r + vg1ۮ0FE̔(RV#4~ڒak&9ALǂւ߰Nu15J@ ~Q A)(V!rW[kt$KbCh[^PCy۝9ò2.op(9 GThz?Z@93 vyg\{oq:x mHHK.qI`a(+!o(~~AYjkviUnP+E/Q;Ƨ Lz扆qғSHk[yB2j! + MA(d.-9ĝ,O:"kYw^) D纍Κ; Nc.k (,)o$(1妺eYE'1$^iN@_K8X,&C#+_"Vt5$QS#̪I^K 5C{P973ے]awl Yz" A.sbSMmWϨ~H&%0v?+'p8~]О۬A e3dUޤ3޵8I5B킙Z?@%/}t_\VfɇL}NBh(AH^`=0@1(~6O[\y3nV!tu#.Z%I6M;Mҟw)=ScLϙhն0ޝ}jNy#xu7)V|a֙ᰴva{k#NOffWEhyTGߍ>(O_6panz}دyZeG5gj}<oKp3A*.ZL` ST@p32e;lTX|P-uVl6-'L '~^qvzo,o 7:ޝlqDPXHIho5B!Y*Tpnx }shi9>d&Pr˒ V-tݸ7YFyPQ7v׏}:38[,vm]FVc2!+|5EGKjsbkdouxw~ӛ|v2 @OH BrՄ{YHg ˣ0\m;Sb\ܮYz!\~/?,v0-D,8 we.M(e@.Âeu1<.F jW3i(ϥe%s|jkP=U-\dS~6~ă?wཤM5FDy4\]ޏ4)݄,˥# PB9L0 X>Șh87Eׄxޛyx*@v$Iƒz 03x嵱cy`KDX쮬˻\\qZLdzO. 6ۨ)3GZKT{@~qo<_߀QuCu{ҹy;_q<о;$ӊZ+73"nS^HA{!b͠!Cw<Zc=T;*_z]  IPJ1!ʷ\1XaEmRVӳ[QT;0m\qL^=;۾Iek@4kP}l;[뜚amSqv-5"p^lY@ykEC!_ݮGʪ-*wuȅt b{S}TFғۑ.`.Ƭ&aDwm:-L%Hu%%0MYqE;8iCHYٖ֘ +bwv4#a%o$h;RfWwL SN og2)A4؂s $HThʫd&!<|ڻ@iiaʋeC4%Nt#]K SXoZXdg܎/Q 7.z3aVxh4Օ[,)28rV1x#C h17/+|jg3,_  ؾ2e{9 Rw\eUۨ;Iy[\fedzo ='@?*0n-w~Nj|ڪz󍓟{yq'٤ q >zr[@T=LOO7Cwvv7KڙP9,\黣˛h_ߵtGLd8-\1MZyS.@W-&9I]!_}e˰@C8HXS^5q չG}lΧ$b$Bs_B2ɎBWKyծʍmA^4Ӄ4=;c [뺛DŽͥ')=Fӗm(IUJ <Mʠ&&C!%7^$È& c 3:]5L.򦲇"3d;h7%^nN[3]By@EP@`[ P@dyc+SV4ݛ˜V XT<&PfN!7&%z& GG 8RA9TN> Yu0Yf04^%YMPGX):a'#4jY{7Dρڞj0Ok PĆbTW2:]|͓vVk-G`,̞qL'vfT}Rc0;R^4kү1єL^K`*x}/P:gHs < [;ǻ#}QGh%{#G޲pYCm(QjU[j`#Z~?9v >#'a̅梱/e,G.#hOTn ~4ְ{v&.9}]% LTJbaoVWCs^@t^*&":作"0vh$j_Ժ+:Ӆv^Z|^ NRj6_uGtOPHZ|z ]LXC's!~P;Tŗ'u*ْY}xPmjK0hHt$7)KygQwM')=Ցj8&c}A"lhB6G[3eY2sNS!9=t73|q ] -%D=J-uĮ#ƱY. $칳mi7PBcT5SS8ٽޡ:0ML.vB${%k%k EJtbfWzϪ:0N a6L(-4Տ!Kճv d+_($ɼ` -w`rGigTfxPYFF0v^E@4)1$smN#qf\k:Nx{\1G+NO{eHFѢ{`tY% [*#/%5bI=O!SzFaI&86UfzPtTX@U/)r>YxkE~}fDWlƆtΣ3M2t-Bj(k&UK%:,-];b,eC'KA :^lW_, IDATnIGthӣ&2cizƀW;LE=3_P{6$0m3la׭]con5ֻUkP76rZJ]HAd;ܓM[l?{ԖһcW[3s^5.q4/vVM`\j p07U`K埚/uǶ}k1 =\^Ѽ]?2[.NBXc{vp8 2v`i5kChwM1Ysz]L(ZsoO="GQ P0@o< .lּ>1O..bZ堗׾c+\?nz/6A|Fn RNà+AJ"kR!rKe`qm=΋.݌g o/:/PD9{kwag;.)&v8CR9ʼn!#.eZ$H/yPDlW93䶷i'GH0<^'0 ;5$6VLφn4էPX<^MtpmZ\uLyj"AQ-ZӔS6~w[@f^5#)cS!Ly.Ь6Ȼ߷v=Pl_S8SLYt2Рmi0_r9_ ٺl õ)m>v!C-L`+>|c' tUeşSӔeU/gЗSzL||䳻|/z:4Q8c1@'3JK|37=g

    /g~?໾+kO rA*O+_?|=vx?u wzyC>Pk0_gWrxW~o˟O}w?Q L}ɯďx9w ??w^ο'_|gw5|'a[O>sOu#}ȣkr V[f"`tr]S;Ko@p&ϰXsݘ|5_Y3)ChPfR`"P|]Ep2RGhD PB-V7%dR*Iɞ]$ RLcGTsȸ󂌱< 9Z3@C 1y6h\%oj R{|x8k)& c Օ@`Ȃf8sdD_,{T n\s8:SׇȀ紵 . ͨ: ~RAkw8|(8J+Ռڢe1ObiD ~Q_x̸w6aŨt9ÑG0Ʉ*2ߛ[UѲ JraM؍)+6ކ)Ga ^lGlUg%im\4h?)] Z^A;-zѱʊ{f8ԬvKUZez yXqg1{Bf#ⵚ f"p"K A 4>11K(ysY ז1퉦1kd-(VI-P>s9loQOU"|\@E};dpM#wTԴ[VMtM(2FDo[lTğZXiay\etd:)cJ|OoO> xfu~1?moxoR/??5_Gt/~1_o?-̛f~Ͽ/3#+L_}?7gO0Q[4_gog|_xϿ>{|4;Y?i|OGOa7БO|xkoӋ6ڻO=䣷x{}&׼fZʂ<ÃTRAt#Hw|Z)5좿ih:2 59|ohn5Iy }؀ A#a~;M/Zh{)- Vȋg1Z2LJE@aBJiRu`'vYVюV)|ے_GDogeGLf .cDbw. -R>0~Sp(C ] TF7j61 L5PM4 '(DQ^F3d!~[7v}1+ [vOzÓuXpծ@?眣y8'( b 8InqnY9˶hYP~6eeN> ̧֬YaY2@idcnJ?{fڽ- J)p\tpLg =| gxx?K^͡w;SaÉ48qNyU/~#9_TYg>ECθ(WYgot~,N3?<>.UWϼ]p?p\챛oWR ̛xoi/+On?)"xƽՁ}1] Y.D}" 5>ofqgvqwB~x7M 'QRLuŊDE]n\ۆS`Q4ݛfdӳ8OLo ؁YY$nMdC ÍOvL~-jiٔam8ÐP zNy\_<4X^rE{ pUL jS$PL'ӧ0/1;mg, -P M佊"1Qc[ߐQlT,A )"DOܚCLFA^g+=¶.Qhc24,~*c2Un , 0k>/h H7&M#hrAe#Uo {ph75cV'RV*Ñ +K`̧ Ia7@ӚM_S?,ķ t`޵d5^ ~}aO n^Q~ zT(jC'Ԗ8? 7z,|Ƿ3>4\;|#o뾅/~;7??mjƏ|Ouk}{x>A=9?ԛ~gXR]/?CMIs#c!NC.c!vVimIilJ=-J#BP<6XB ޴*sH3-6Ӕ N*>sWD~`'Z1^9A*RA|ʧG9DmAgx?JK^q! ڡ~C{a7<-!oqAk:z&b_ʾ&$<;_ ILA1/P~h$͔oXl_CS^j.,Lr%ixUR>_7 }hvl:i~R݈ax/Y'ɀ#.]EUK(a9C7eǪ\c )[g"$S\7Z33nD&_zDMS= (h mW>tS?oO݈mKl'w]me0 6MMϜ] s P}r̋zup84dimE 9A{Ǫ У]6d$Aҝeg xFvT #"duLSZΊ?9ffcݛzBdL%QB}d)Mcm{VI]ӍEP"P]3hE%_iZRƼSjsc7vj5k^,7Oi襏T_͟}KId_~x6ݦK7{G/%Uo,ml IDAT>#@L6q^9ӞjKCӹƒ4EjB? CUb*7R)] b \N[ÕK#egΤ4 `&Wz/]J̤P[a4?<`E f" #o!z.]N לɈnb(t= ) kA p}`: hơPY>-l38mUmj&b? Tv i̵7L&/M׻)Զ 5VMI@ h Jh'jKXi`쀊h=㇎sC'ݶ"Vi3C8E"cu6h,ɗ^ϣ狳C|դ+>89 Út+5L}qF wN~t<~g!!vb9m/A0 1MLe*\x3`Dnd꯽j%}GJXQT*lm-GIP{Tǒ~ O?~jۇ{ys6ݦԍ `1mPT\Ts*PMMRŊ@V,p@@+iOE SG {W|wR$*+\y0!"7@󤿃лE4DK`fI_-i#C4[. } F~eb)ᾈ$=޽5|>+cTox;  e=I)D<"B@]gn(-eQ4/<;2)h:ǝz;/S?i1.rHU`EgL膵^fb[Lx6CP(o蕩ha1#3d莤BjKzHp#+(#4DG?EcRw;n^۱M}+($?*HN%X*YTψP;5h?&Q'EeڥлEYӘg T.kL e] 6׶j1xnw;3KƶGO=bVJS] MF##0-5FEuL) WASAXCwdjE~gS͔WӬv>cF_zwr<Yy}+|nӋ"A4GxI@{\X [Al!L0kvwv&h]jPP̜2[]q7N0RaHy|TH3iR9T0[,n]&z;h:SvfcJ<:r0H>l̡XCkeLC,iQXW{clI  HM:VU['&E_BJ{% oAm^j#C:+.I&{२-"ob1q!h\Tw[V[ ?F4i<;7g"2@<a4BE<9Cg'A_4hBWQym@0S".8A 2G^oD646,|2G"FN E\̰џ)5-o:63+cC8WU%CE2ݒ*ImRQ t[Nj] aD }z|LftFOps,*_⬬# 0٤)%!_L}eAsS2P]NZ4^p#Z-L8 0v&Ԧ]w4;Q3S&i@>DzfCQ=~3CEעddyǐ~ 6ݦ[ꞞAs q \K+:H\ @rKK"IG*f]1u5ˊEHN a `iї^پḦ^]!QT ݻQVI3@@ l<Ìx"gS 0i ƣ@ ć2TÈ0lC37d1stSy=G5dTy/i_%ӽ2 y;lt@mAsGw Id"{PK d,Q Hy_'w^R^xtfy]2P~QbM#0=1d'l2-8aysE>۪u[T$U-6+cGɫÑasR:]Vm;}z jBYœQ ,lmQ :e*utG5(l|-ωmKI‚zVS;EQ&pBq8ڻi/SYe{͵9DNY.?vnpB]G >pƺC]{TLU0eڢbaA3HD0 :y5ҭrnmzQ _v C|/UEJZ@n13cXFZUsa7Xk*4XL^ )Y KA"ٽUQ6Ύ 5LA ̄:RIVUF9ԏ@4A_[U&Svw)YJ+gkYǢrMjVMB9E_6WYQBHu`z6Q:X0}GS3"TFPzW}3g7zٙi穗 z*w B4yߋɋ9ڮzEVG:?-‘횙VHY3bgiT>kD!tĊ6g_ZL=e|&d{wC%)x#H%̹s;h:k$&>6޲M)eG/ٶ/=C-^s6s$6 8v^GP5c:#i NV' %Tnifp8h bF5t%ݯYW2OڪGYcԊi8ٝ3q駄a; aڢե h#$ir3ӧav Qp݁aTFɼkH׷Wzt\BcCICkC7clU;L۬1}cHGi(ymMS?O`!] qEz |60ØיuaŴiG+"37sM+m2Ct`[z㬘54&JO*5T0x֧+0-mҸ|XixDK"yS󽮏@>lRuLs KRt$:8l"y ܐ|+Qcfe)wŊ~ !c!`@Tf>x #/At98߁kP}ĕ Ҋ<ʍ"E!X3C$w2L7&3PdJvN郬} S6 :x 8<`c6^Ջfcyh9x@\NRQn}V@ewh{L4,(;>ZO`'9U Ӛ5d82##~ac U B (I ^cǹQ[>yUu YޟqZ<^%NI[1wia[ȡ3e^}mGWgn9v-d})tkT[ak0O}&t^Rd0kkhܫ&sT4wgC ]S# ޽yɧn;5o;J1u.vc[ήb # LX.Bc`+tRaڦ7,Xv kYvە +EHU)ih43}/#JpY`s]i2fh)NT&Ӷn.<֔I/ݛ֣䘁l+*v:RlSz'݇,+|AwGa;1@B.wd#:/yJ xSV]LL;Ū:VO0 $!|HI1Ӧ gNOЖ{4A5 0!%@h(u vQ6 ؈(yf}};5VE֌2I"276Q@ib1{DMShf8"$;+H^VKde4PFNQ^1" :Hb h":.k?:дFc-:;֐!)|(fvk!@{HU;'up~GcÅiZ!4LyB2Н}׽Ma@OE,,+} Khf{Չ(h8!;S8h֚(BMmRֺ#WԠBL(gƢ*ݦD[7Bc/H6SGQ΋jIvm2I4"Yy">w29 h\EG>"2@tf!~x͚< =3@Վa|pvWz{6gf3&*<0fmlXh][Q^%!u?{vc||M6( p䈇v B[Ƶ`<]h Crg0EDrn% nESȋJT" )^ 2U0q\}g<"Z*+*w-M "!lf"R.(JPΊl 9LY!GO!wKajǒJlA dR}7%)@׊3{љ ,١H]m=Ɣ`?ѪN,Lz5[F'SQЙMz py'紪~F3"eu5P$o `* ~Zϑawsbι!/|t;d,7MuŽ#@ Ƣr)(O3(2@L<"@B0\2h20d&Lՙ Q?#ς| bPcyu5r̨QeV*uho4VtHS(;EKv84pjhDz IBunT1'Lu#ۨ^nL|C1% #uU}LX(EӰdk1\Cr.^mfg;rCMz&C)C/#X: R s53rPzUڴe!vzŊx*ͬ)UnیtCprpk>֒~ BzyLM68qmp`aaUm"$BW[!SӕP78A>TX gF@ =-@@+JyN7p5(ދBJ @4ht 5w-1X;rʘ5x=xpf&L^<[~z9 <`lBQ[4iyPm ;1 3)˖<4'b N,?"&7M['ۮOB0"l P\9۶O;֖ B3ĵVF:T8n p*@+,4N jM[sed)R<x" Ha>⟱id{aGMF_(yme19X<Wbm\)mV(ÐM*&cn3d-"h33VXT֐\cE``O1;XV_D[7ϑ1s4n3X1DJ.msˌ [L;U$-1Nڎ={j:.v&^1+!>O1 IDAT&: ᓌ8I*gǙy?i=aDloMBQ@|Ф/8O~ؗ,=^C%To _l[SB1<#1[6ݦHd&%5`9Aig5' 4 ҋWv>l FIL$'4ըn*0A[)Hў!ZMlMeI!<(VtILec~wkt ͇1M_!ИtEJ`S@*'ě0,1>Ǫ뜐bo} <&a ѥk= یN TPșdC yN ]mQfz:ӂ|m2ګvR;E w-$:h^-v6c#S6jڬ C@~M@;Ǘَ-@c \D(+mlԏ>ٝ:$Ƈ=@([ZUo^V0mLX5 G6jhj:i>@WlOK":bUdG)mXU {jGGV2 aO$~)ib1bݙS(Ϧ< C놚)sr,0 ^EW@pi 'ħqthj+;i gӐ/Brp7@E꬧u,c\ r Ηr\%9읓E〧lr>Ԃ[4s,elwB}-lO";͆ZH8Oq[6ݦHQC 4`"%\Hvdt\@[h/Sˬɇ4lN~^:w@q/5OsD%򷋖fn4 @קM]VV:0ޱDhN}4,wR?}b61Bj0*2 *uՈP]"떟\||\Y\_YջL3% 6˺ V65XPt UIS0qB(wQd$w?DD#Di$, ޿XݣuZd M3с#,z3e0Ɗ,92h~n`;9LSJ >&[Y9ʼnմ#aV_e&HnqdkEN? u1nz3W:l{e<ㅇ $Sۍ48g#RG0LPBm%==X) 6ݦEG~YF4)@ )+R}ASȃw%4%'\J){r ^-=IY ? pH)y c3XEDˤ)y+XáTW}aH릺fU~m3tM";N; RU42fDseK\uYbpT%sKz&+G^.76q 3@M+cK9 PY3=s(O(5=(k){;̍~+ MO,ǁm0={D`N>Kq˨ǚ3(t2*=W6-Rxh5Mph5V`k`V jŔhrw)>AhW?SN.5gO/=bMkYNZV Y1t8^wф꽜LuR}ai2:ݘUmreTeƵ=,sN[kZվdbኲk46Er%yڒ{Oaf 9,tv6[NO |YzuŘC7z0BtFtHN,ݧ뀵QkgM G}]ƚk hwΫœ;P"yn`LO)'i˰}2 De>3+<2"^w/=Գ ;4 c73ѯgh+Db b׍)(2HXv xnrC y=ww~X$])cf6u}it&^id7|Cjfߠv)0lIPCl/xHwAIP{u̯]jP'`զ&y3Fg$04dܥAl{_ò[oIrT1Ɖ`Ze&jZsP"N!902bV;Tt8/<>ͧϟsWqůϿ=%1 sj E)"r3slg2B5K9Qf}ڐ+^(N˸dMSd^ k nG,ٚX1[1b Q6eսcIHħ ZʊIr:SMFLs-S~ ;PEѣ Ϛ48ҭrnmzQj@!9AUJV |Af)"Af0 HSl@RIg" cMT8O2NRqEQ )a '̍|7o0[vԨ9Pxd89y?W짝|o]qTo،kj C#nra1he& ҆|( ib;deavM?G^WK~o-??(J穵a1$Xkv)UQ_"[Q}Ck[&t/6h+j3_*VZn1E1Lt$5eKfCMmXu$exR˪t`/KJ6sCmd#>uTȽ˽"bf-ʥH)=@ p9~0M2e@pϛ蝷j6CPT!`&ŒhOq)oK>䌀;='WJMyF ww~Kz:Yq)Ϣ'7G8B8D3EL"nR'fϮ>/{6=|pS)0 ? zy}\Eߓɨ+mt*Ya\[ɫjD<.KY][/2XԶ!З_8)_~Q=lljs , 8Nɯ zܳVsr{!Vu9.o6//ڕy}. j˛PxF'O׸jh?'OF[‚"V'U? 42$)fǘv>)#z\څ]vSs<:2N B閩:%Mπ.BrE֙ L'bZǐ 94,[6S$nY`2h\QrJ~ꕤC[렙xqx7ug^^_B]EL@b$%Q jln蔖E^mq]M RGyG{Z{^S)+ݦȳ! )}0%CFd6q&eH:A x;[5SNDTS[R20<E 'T4ٙ:83b]}_ȧԦRCqhS Yʗ2umyX :m\/;N<q/DXGKb"0y(0IAVL23l. }r[cXzASy|~6xfJ] }6<"ؖxdOS'5ƕ{iiC64, ]L^֊k03H]|#W|w|jxZ&'Շ;vQ`-=)(f3G:OG22fFDo_+֐]Zn51v(-S{?/y;sݮlu_&8@Mkw e_]q2ЯztAnI_WVg-:nue Ĥ lF;Ʀ^6dMh⹙U= V -5l)yyQS vR{2cA&81E0 Hi6HG,R/LemQch 7]٣-?#o`V,YHɐ?uT t1>Gf&7nw|hȖ<=ضM'ʐF^6Yo`=@s*ZOlAf `z޹Q`3Ŵ7J*E o@)KVOduw'ķ н 2:XSWeX T4mZ8$:F}5D u?HZ3̼54ƺ@-!$yᅯ|^:β.Nlq1 |#ff$WЈ "нfCQ1ۭ/;=(ڹi}]M~K+['$^O=8gvESM!i\M0,%+Dx-u}Ss:%. d3\2{j]F0@F!Yd147/"9 I~P}zBݮSYi.irC:;].:}'<E!cOL%C&knFC5r/r/ґ.rh0P`Wܦ +0T5IF]C˛HgHO@ ˪#ꕥk&뛅~gRxk\mJeS1^ Aa)Ř-̺3+mnpx {Cmy"3UdQ gg7Q_9QfMO17/1&Bg\ &}{-, wtV]w}Mur@RByqVƒ4g<A+X5ly#S<=q@:Gd  ԑ[i<#K*gG.zka@ꩈN2J"bJf2h\=3 gua?'^yݹ h4o8iUݴzq7zȓ~g_\[cꃥa^!T.ɠdg'%[H[idOMq d3^RC!MO CVex}qgNi>fR ot]. v#tsMEC~G﮾gQ CfUc_ꡔ8mtHdZ}~Ͽ_%w/߾pAG)XT5#Qtv1=w@"\LtiNɵ'DXm-)/ň-$k0iHJɊrWQ%ujw"yg]Vf^l6U' ŏNIJݴ?U?UwM%2heܸd~23j}L$^vk,E{0jkhP[dž"jk-LNsSc0 Ή}4̓) x̦Esy^U'u;Zt@ LFc2})9ZiLM_ :dKNy"SO4S- ZZh1/Hnk&jZl9ד3tX+عQT7mu#:hGkA࿷èN]f,>QȽ˽2O eċ4Tlt­:R{fmg[)L25`}jo^JIT k]C] <R v|nyO^{{~qRJEoYe];$C9i)Z)\ނY,fU@b?~qnQ'\Pm@S?l5=Fm41K_{*1Ѵd(Z6WeNHd>=iH^LLY h-B .93ijE|I!ڌzZk3nթN76" 4.;MSd.d|phUy}Jh+v 'KvMsYOZ2nڼQC;Etgnڰs 'Ԟ-F;7=dh49qP{BY$t^uj?I&PQMo[v6֖J2ItQ&-焌qY2ӄ-d.>vSlsr/r/R:KDy@J`^[e4d-ݙyǶ S F*/gE"d<"UF] e6)^rk}00ȧ <_ {⏎Yc%/N'}=kLi)n{CK˚ i9gc#t3/O'1mj;S ny0_-.TjCyi)07 u+Om8=7UMC7=z`Ec 7cQٮ$'kќmN{~uEchth鏨P[Mr$~* >yuy&6,/Zt!hlH2dUޚ^hZgNtAך0i?vz3-3$KL>A>oA~c|^ŧijLiPJ y[lB9{_F)EբKo):xۛҤ0*Ml.EAg)Be2jZ3ξJpHR^D3[F eMi&/I&YG2#mT6֞ 4g}"Bs/L pOӾ1~C{-No x{WO-"`\FR|ֺ߇jK &NjO$`vڸٍݸ|a{LcxYB)tIʫ@P;;Cݳ>AS67ă&8/nO?Qyzbn4Rdv$x2ǺD|i(=fSL㊀☛Fm]j<^m?`=LRa/,DQ#S=![߽{0lFh\rmMw" -outۭa&XOC랚S r!E<26] EJnä]NOw81?IЮ36[3h@զRXl1W ә{ߵS|̞3J[k> yJX̋hCeDT aPQ3ӳc)Y܌Т |\#q:y }.y \hmiEߑ44q8X3y(M1"}׉NPZev@}ߑxOrF1껡5=d{Ǜˈ)aoiNJh[fN?k90dU/'W ks_afIj=6d-%׎<(JF!HJv02k_TȽ˽"0*: =Z d*%%I 3пj* l ̕հ#=b3? [&*oH7z;v63!r+}{a(T@ߏHE[ d@>Ǫ^_T4T^4s;[eB_8MhV?l &R?)Y}4ukl5_ /ߒ^e?HE%ԖoeV4LʞsLW^2ӑ&oC&4h$W&@tfxnj7wk<_CjME#0?\n]t 5!S H~bXh} dgX >[ՑۈZ~y \\Ky}yMF y35VCS{0V 8LtAif `9jzm`St - hT40OȒ*7Kt4W>6!>e a]ȢC]!13Cծ'|Và`4wfߌQƕ-vhz; (udd&,,Xj0|z V3&O]֐y"o]a)Ti }F!YcxcpH$K(rT KDsZ琍k `c5뒡!j.uSΩ>ӀrBէ2rI`iȦu;"{ʹ$ֵJGS_kK i#j>{jl{rF/3'NCkRᚇ'dvgT/W?"-t&jkDOXkvTUM-cgkܳԚMs$ן?˽2AYL- %YiyxhS6Rv-ouu&G+ZgQ r޾gktzC zkRp?PI?wVOe}Y+{\ e#8FeB] K`)-:;xrQK"TL͗$RX/߲ngjhkZůEkakz\﹪ter5iU宺钱ϼUd?rnR}I*94+%EEBztA{i40쏚Ctԇm9 ߈ґR)IڷoW/k͆-3W4vR2Hn)ɦu r ~Ԙ'"|cuĺ6H\n˽/E7 5%~{zgtPGUIӂ| 6ؘO^o[_x{?Q yFL&[*%L @}Ӥp34=z_{O$TR@])Kq;hb6 `GEy[;91S&K1;K 7 "'`LOP?#N\/㪃7И--7eiLhxkqFG$?vECc t<]y2S4d/zTV(&sh~Thj"~ߥVͰ$^[L *L 枚3+y3E98qeƕK[XƼΆi&nݕC2c>9E<z9#PL"d[jMjwؗVn[ltE󼣓ﶺQ$hdúdoγ/x[YF3䴞| Jmjc9'?h8ҋ,L)T.91풗E0x7k\k 87sc~̵κ)ҙEuik]3"sV{*rM:~Vr j@m9#ݗ J\̛ I!Ǘ2@N!r7@^QNi"ΑS=xFvҁd\B9AՆ7xX>/_{mx]^azRY}{@߿⺕ZqC{&'yUllǘǡӄw@.ka3ĉ<+<۳q)f'1M* ę܉ƃnAc0i`y1/~[":Kį7D'N {=W 1 ٲОap>9N\Y`.>"A|jӵ*? <6BFCo]ǀvSO{ ܪ/-s98;֐GEƊЎxegwYQXBOrޫ]8Ps)hLkDArɃxMhg7J0ޖT3EsuHeX#; -DoR͵ٻ1Ȭ#îRX Hs`FCB>'hĈϨd ZMOYl"[?Zs`x랑@ْm˾6-mEpGek^KA`gԇZ{LY(W.~M^\M=-v,2QmE@|w񩫏ȠW|.>Z7=fZ;<""dwєBҒZr7@w77^_lIQ/Lm@49:u(Q  3==b<vf=R2]0lPjÑi#آ}u0, yo&t3\R7 _=s꯴EN]l]-XLbC97B,@82@ӚI[cN"40$I#{u5zs>\կ+kU㚻woN:?'b .q934Q}s?X=D|UsNF^$\:GԻL}Ֆ7{Ԝ`Eķd|H|2tBΔZ/E&:Z4]G8[RrkMJHޔDӊQFHim޾ZƧaO e/zg 0,t)>#=;~e;4v<ݫ?;NT-<5wI]gMc;Ե[Ѣ"T)]Mr֚X6cicNVYIXEkKlJ_޹u¢$Z:[h AJ2.]MU$NVbMr\i^,Qś-JTJNѿCvsY'mE;C|Y _8dgݴ 0'ŧ+%箵iБ/ӣn Ϳ?wK˽KV")L􌧴>| IDATB HL -Z-֍ ^yy^pQ%TJNAfJW8U{aǎN-A )AyMyR4)3)?tҋuy&i(GvdJ9=> ^QJ E 7gv7y_8N*GjT^?ڛn"Z8λx7 \< 7u&[-7,,_Sdr_㆞H\x6_mw G@DHLa'U':,JW&6V~iZ3Kѻm빦hm> PUxQwz$z)c0BV\O"с㾱ӵ'xi3\ജۮ!LF U}iDZNpH-.  M0Nj&bhoVCPS9i3״O1L4 y.ꬶ^g Wce:̾\j 4b!I=v W}mNV`]qקwg h`Zz)JQm(Lb{d S +oR{N$e&k&3#RΆ!9%Pꆯ?9,~ sr/r/(ef7)7 H-Cj 唻m&@u٥7ՕC+@ ?.?#?## ͥLe'xX'Èһvsn|]bOč{rxg&вqUWRNljp_ k_F\@Fy1t}4}7X&p΋>‰?Uy쭼~;ؗvέ߸7뤦&:I@~)]=oJgBv.JJ(JRr# UM@7@P7y~4η)Q'zgF8WC':}!y&,얇6<6C 66Ә缷z/t^_`;D%нTg;C;~*:Kh-ٍ׮=S׼*eEr6)Q^<@m&ZW3рGt"QjvoW]OYrhȠXl53L˞Ծqݯ|ͯ,ma9,^-XAVk8uJnk,t4rA@kiŋZcD_ݦ)~D]kRn[qlǺOorڤg#_R2RV5ckWeY4r7@M^_45ߎ֎&Hd=1* 4)>cuRcA<.87Ү^<F-( YuUCa$kуxnƾZ6cu"X63!Bv%=aGF N_#B:1Um}_Qz0@CzHM1׆NdP4е@4BҰ+.=@CsC]C*cWI bW佌#*xMTDM7!+/NkڧS[HŲ"+A@wxᥐ1`H19 $Z2''\ƥ;lwƭg$O~f F9j*4&8I^:U[̎|"Y[8-' EuR ¦ԬQ{\тS=&=v" vс Ctt-Ԓ?#u1x/ &@5[M3wIAÊh 37zSh5J&#dČYM4d$Ě齎aHVӨr=g=e`gJdԞPCxAd;sr6Е" H4CYbgpg+r[vuJ#(= !=OD 4PeNȩ-{w*jäX 4G; qIƕ_} v̦➊4='oe8t)~綊t3IɔR <ŷ܃-M[>빔  o]1߅"CrIo?)O+s>xf, a,g}ޡw`h_{Wo\r&L<u{҄mx(h]yZnAhal,Pm 0.xjn2HW!h-Az"&bD ]f+2dݰsmȃ,墀?qy"4)I)";gD.Zy.u]hهvtƙb3gtsvn@@1KZ%i̾7h~f$Ŝq&@AKp_ߛݯd]C߇l{6=ǂh}kvo!@[Cx-L^v ܠ@놆]uM7-JX[nLo C;4mMd}'3yך67vm ;*9#p! K:HζႌldD1ZMu9rTNi=-:SvedY=xƥ.k3wgv+ 25*= !=OD ?lãi0}@aJt1{(3y-y7M68s޷1 ׽d=e}MoXa)Y3l!pERHN_vJ`QX~O4Kc"gq6 _%oqxuyXhq#V*c6x`%jK) L;tjYx(F.^S9?% Bx6#!(3~s\ ›ICuи4O;h%L0FwwqFZZySh{\%S7|neԯk$3A:?@֑|>Hr6ECc AۜK>þyG8oG_C4_тtӘMTÔOU{$vqdC : ҃CD ؘTŋLpʅT%ޜK.y3S˧8qb\y2| AdY8R0!9@L,#{ŊED llKgi}$cbxKYw׌G7 ^qyq wP(pկfuv-m:T֠[yyre춇v:gnKj1"D8OݨA/B fE oIyI ցB'B8]αm_e彔':22zMg޸%Tn4…_.ZZm#kݣ1ÉƔ7Ԣ>p+^p5K4f^8y sm"Mނ{8&q{Iab"ˈxv'F| +ԶmUڦ1kDuAhCn(ԭBf?~4G<<?#Mok 0s>Ne}ީQ9ij0*ю]b <0* X6X.CۗZL Gl|BUqӰH:O%W=p Zh|*uOntD8*cĉJՂCHacȳ֒V@UBY鳒4 'wƠuW V[9V6tf4f[׉44:Cs=o-?D ц*>Ɛ-W58I >swdhc 4o!@L^ц;54ΕP8< ߱&z`z9AYVǘxf̼2Z1 v*^2t)\,?ޱطN;)GQ ҟq7>SJ[~~{w?R_;o?/ozH' -gc9>-+w7>?i*.4s@Ey 09acnS9zӀF]#b?n,3Р:Ff*T5kc&ϵYJԾl+dK@&d lbM `ya|G4 ,{C]{\o\ 66~Y{wtW} !9!_mSY; s 4&G987z-V,c(ܖ|-q IDATY::[@TRxGEu>93ycYB{dH0Z.kSWcBvZV7J܆eM?gtlۦT43vi27t~8aJ>[^O ս4 \\39 fyɱ,4s7U|d]И8wv섑OC!2B0aPs'QV*o0|vS;SOEFB[k׌H Mge"YPXYYBNVr29MW_=簮o_##_ ?kw{ҟK_#x #D]pخƬ!bbtäW `,l~¥Xuy1(S)> 4ҢO=őJ{S/Nk CF8B"knn= '(<kIZR`8AIZvㇼ/X=l}pFA(RmrwݷKsjD#)G!@|tD1=g=X@ٶA-:,EzE䃙 iEFUƋ@}Gn=U0Hy-Ixa<4+a9ZdOmQ=w]:fHErnnp#pA3}Z?v@҉7Lϻ%s謅@EtAngAБz|NC&yʤ}B`I%LJ!$+LD^Cr{ᏺ|f}S6Lu07Ya4>(E!?:qm85$yQ}s>_CrmX&Xˍ %I!c-UzfKKЎ[kCpy76e"Xюon,erG!}f{ʊ9CMˉ6k"HȗE ?#m|_2coSD[|3y0@Of|̛a|>=⳿s߽{̧%H9?vf27PB9/-hh(~G\qu;~8S1MY63~aؔ)dkcs`|L?7m;.@;{z2J !c!y4d<~C^ǴY@ݙ`ZJ?2HzhxXt/#4h3_1iBg.ZFQd~#q$oMuT*,d̜ .(XN@Gcwl1zE)M'W=4F ^=oCf2X)!cjʯKilFIk$Cܠ2.]4e聮'4MuvQUNY4xu8cG9E;MNE 3-<WFkhRztpҏd\\cO+7~7+ڿg~8>/}CzH?͏_S\7z6_Kw~_|7.|Ê]S/|מX?W/#?EmJr|?߯`7>Gu>|uc+Kʹ/|.h2 R#MEbR)Gst !uXfM4Pfa ,p8kȨq (g&-aَ> Me8jP^yAGf$B?,fv2(#&: iX>秠(N p`LojDB  cC= 97g:@dock|k\캊./[db]ben۔)$\4y.LĻj2 H{yq% q!lw\PN]ݶFH k;m&Ng,ZS"nEeh|" kzp& Sg1v>ܛAyzn3첅f.bUhl!`ڠ4>X߶dLR2MgQ8E;\DWMFTD2 W 3ǻƱS5Ϻm[NM|4*ZXx>k[Ymudyzk?anWG;#{o_;*o~gs!}~泼~fX,^ཏOwYzxw㜟~'?Ͻ ,9_w$s{~/_~}t &8"1 Ne {H/-< "dhtP53Nv",lO@*=r#CSUC9ִ{WQLKq٣#c3oF vO1^k v.I ֔1%azU8+ E^[d,i\L}9 :f=v`a%N h?=g_=[3/MfԬM6e!<8gaGnuO`W3,SfݜnaCX*m֩TZiNllZ ]hҏdxqŋW\]M_:NG~{xH'%9:W ^ ̧Y;[^p7x_Wlr,zNjwŶ_~ {}aY*~bu#4ѢIds!%=Kz,"R58k}#LM@GД Rr#y9PX͈,vf)MyLo7\O%}x7ݩ@v|s)V444J(\p{dž+ y]Q^>ҰCh x#V (l.3fT }pjfzqBQCƲ(NUMpMG[W֍pKh߹$7ZUh8wA3Z+Ēn @hMB1֛D~t+9ј@z$B<kz$&~G r{\nz:}ﮱrmf-.:easTod#/\685Buѽ [d1=Bώ1SJ :j,sSY OBe,cU&~C3= Omu70xpd,GcXKk.ᄏ/u\cpocf:2t]m >=0*1 FeA<ֵC/Kyh1N,,zjڙxս]amc+qO4Dq-*ҏd?Cwx ϾяW\]}/ ~=m΋ow/o73 ɸ o-О.|~}?Xl&K 0bE !4Li)P6z Li@5,󱐗0=A4+!C:Lgez7,. l_Ozd>#%+7f=3!?4m Lz Gp)L5K%w:' B2A cܐ7c3#R)/*:k]«Zc5ŚRMuH =ڎg͙ٛ;_rǖ E{գ-ZZIe ux\?Kmt\u:2*o%ϠI1p靎-eb{`Sy@uw!kHbX{8{)# +O::cbAWhV#,ͭ3ލ@,vN⋙omGT,=iWݻ )hy~n$=VfѼy^4IjBqET?_(_z!6CBQt\W?hẇdG2H5x2 ˶oC2m9Q"f-u P?83=yCcGÐo1,p ˲ ZrMZ3-U. >yH'>_ss!|?So۞{Sw~E?3/|R;_kGinH&!,+ 9ܻ5o! ukL {I`it/al|Y[ΌF?ZzCu,kفwY~d/ G \9s-:i7(dcjuȲN}!x T`=oU(\˻glXhD4RCL iuv&MN@:yF&\нg0<PPE޵Ή1eaT $y_x3\/<[u _9;@D}42AcIcyL5֘!cYN1IN4:{ro8:kߪɺब1H]/qP$tpQPheW8rh(2TtK0LjGVD\fa7&%H s6)|?aK] 92 7CEfY9iN]Ύ]ϙRk"d,)gG-$#-'WJCzH?v8ix?>~'7AJ!ZH% B1`]¦"HE L~9 Y~O}oXXԾABKhݵc̀A#dfxOx혭17yT፧I!YM`d{S[Hgqƣ\ %`P@94Ս s$  -3>0E'M'2G_l1CFos1-4 7pWxV"K2>E-!.Q*]z/s[[ŒyvdNMyLcpӽ "h)n!\UncӿGW-%d `?pdEd}?aI9g~=|:ɴHYuS-q|F3YqV8s@_;Él@}s SMm=əQI9&0Yaxw쳦 4* Vۚ 䴇ծ\o1=H# CP(Qh>dgz20gjubLOT!Gt43FM={uyA26cÐh_aꃅB9)|Oj{w;Bη .>zu5; @._/`{wtCN /eUenYm~u:2Ts= UX = !=ODݜt)1`=f{P< d!]6`= @ҕPtZ+zz I$3J+hv  gǐ;%(-gA | vmG;W6߈.00P6WhU!=HqgZrP6 RțYz֌9"! 6[,xM8ժJ,osT*[`JeA az[z xe3]ի (O m/ۍ@g6;  |uT1#1mKgDQКc Ah- j2GG@Dao:8MrX(KGކ~5dpËRKǦtqℇ@=#J!򓿇'4HT^D_E݃ZiH3axm32vM^Jtb7G8_K2p^?ǫq>IdZk,񼇌^MDtXәG?NcE*faX v}6WvGI9PnvYvs|Qcf{!:291Ǩ*'Njnw`!5Sf1 Ni@KK:Ea0Ŧ!=1w?_o73 :$[O0+t=Kg):PiA< إ Z z-#~6!HrHeF!=d_54mڬИriaѲ="d`2UY 5Z{ ghuͭii1Ʈ%>B2BW(Z8qR|p3L]0$d|34TA tS\A3&>2h|da*C^gB#EaL^f,B:ca'ݣ1ZZ27բiVUC; 5ƭ?*l<[2!=H-R֌țVd"j״%Ơn"+m EGj3E-ȵY0 C1by  Ү?b2FȂgz*:ϜzFHzٞŴ|WQmǁ[!UKhp6zϤ>LJi6=DV5֤%&ugUyo<(_vjFGn79G )81RrNMg3EQ9:͛zQV4)"#= o8纁 >0U4%o&%؝i!XrxNo }4A*ݓ8QF@&A"-v=j4O =d0} ea3^ܚ 8]N=PTBF2;²-S״ӣ;f*gx F[!VHntSC.iyX0x@%~a!˘˪~]cBtb=#chc ӻYn*PW:g=2i,g{|>ޗJ^c4e?G>ZׇS"R>c~x++ZL{Dw~:@㳋-Z=?yլqgvM|O]s;f?b6񦋮֤{v-q Yиrq8V[crc7nFx=4NMC=COLOᅅŖ |qا- ⎎v/DρFĤ$BԼ!S wle[8d=Y egJ*nwR+[jwC~N(\ Q⤅6o^S4=7]MљFut>X5eye8ZD0>&} j[5k)=&(aiN JhvcU [mU}#Bt"w:;-DH`PINyi Tڒn[S@WOm {} M)ivh b~2^G jR~2-ɉ0M&W~{ ܺ6#}_e#Ѷ%G/297U $!f$$$-@ =(hCvnzf@,Xfl! jJR9{g?#s{U*ҫh|s9DFFf#32Ib_ ZVi$bj 8]>ߌ&uը{&ZEخ#F1bȑ+b-=m)YL9Y?˙t3~e^rZID;41e)~rq4:Z?M"OWiU*8,X`ZF_kZ%}jD04ѿͭ&#H[I5Sb8aGGqd4F5vU h< ̵/: 84O%)a1X,t0@BTX@dc^[hv:ت~2Z @w ? `cՔ*xջpշoH[6 q /P(4%?itk1cJGp8M&]4sRpfy&Ԍ!{<?FPf}| uȆtUfM@b0ŔosY(8ݱ2\&W.j \rӖFa&=k2 lIL^a UUTfs:} !x6d2Rb6y!qdຫ$= gsmok״JZr[f' vkIKwڈK6n[}rwb&yEnMuѥDpIۿpԓ$OO%x̳VOLC*܍233;iݴˎWtg; 0iH->lw{>j\o@+!Um Hy=-&ؒ?6 T^=3ަ) ,hq/h ig$Óc&C%euw IcVICۺ}L S$_JYD~*G4@`#HvxOdYonx",Ⴁ7{fXuӠC4fΨo롔Y[%fwPVBFڒ k_ak6͚vP`ݥBق~7"2'IpXd0J՝ UJ?%+~C6i7fp]uibhQL`E떢M]Jv׊0MTK*Pde>PU╡$O|V_h}an !?[DO4n$CJ9TţEe >| fzuQ;c~oՎգ_=F g;\rZ<#vLu⌝JE1Vz&o'B-Y$#vH>8gaYڌiqh2lK#ϑq?vItz7lT_L.S+:=J>}t0탫OIi8j&o*{Dw+dڼ'' YUC+V{} pNix; 0ibr12hY<ę,zl[dyUll[(-d]Tp,iճ9lI9G87iA`~BuwMQ5IR %3dLhOEs_"@Ati@Vβ t^+H2-999,u\.sP 37򁡙+ Ljw w Q}F_3knБSZ,pzh͍4KLPeSf.֖Ut[dbL! bCn[N/[MkIewৢ@9[`WR]cJE% A{aOꢪ=& yM&fB.lq&|q3@n"/^AƩh4pۭ̙ aL<vLaX5q'N=c`ze}~57-|y%XM!af24mBNQ\n.s⦁Ƕ $~C U+!LlGJ蛏tgEMP#ΠSzXfcɂ67+17)C\eآ߮Ni |}F4~YoCS#`MGS6ל nA(ښ+[Ιe]rbȵv-)6+ՠ=^ϤYwdJŐ^ 4CXl:\  dEGa{Fݭ#F/\mQ3&In΅kshI'RȞeYM&Uڌ^;s2훹Bug]5=T=L?K& "]$5B'eӆ匎mxa,#c/ G&[* CҧC[=hq*2sy>U=mF/"{q$m\X5`.\+ّlinQ_à/lm[} 9{3?¿BxxW4 c.]cϞaK RY-gίcɒB͹5؛ɸ ta侊Z1Yp@8qđB Zp1&+Kd12D 36)h+M`xe{w @0`d P2;?t1lIz!;DKPlM63G^UFP~2ltetx4k##㼡GDR{ GǢ _t1>G&fm8x5Ļ'xF6??MG#?r$~o]hԨ;fEZt?tY^-naUmmIIݨ0Ph83wWz$G+FIH{"Tr:b:Vhl޵*9<ć΋y_yrӹxSu _|^c~ Zb %wF·(*iJO{*Ϻ'sX.|ŗc>;<7`ޔCd\:^ڣ ,V} bՒ[ \`hcdͺvT4ۙg"$BbLGw #ic^WzSNE*FY|}>4#C'>P\_,_QRٱn{>#Y*XTj.Dwth':6u, ۊVkvŒa:&7ndTR6:`iu`W{]"צ89q*'@J4ԕ)/q)Z\P[.}'Dg I"v|yIo -@LOب 噢n'd̲."9<^FV-Ҷv^~3cɒt6>r6^}RuGw1ųhIc/V{Lrx=O` yu Wy"ڏ-FQfQ xqCF}>-_f4mߏjLgT9T'3S:SE3Zk=bG+O9 T璊&6.!hj;LED_1bR A&8/rAaOOz;%/~!>tzDRJ\)>~կλ>%p"o.|u,}OkO>> 1~x˿ {|+^~13@|b(箌2Ĺfi)RDԡώg6He:G=U7@c|lA k_}Պ7hlN-k\h X qI5d{,~%sM Mzи=k8 t&cBV Rf%ҵ6&EyϝٵdZP9uRV(CY8#ֶfr%S,NrֆfZmyD6ۚ~T-{>Gt/l}@ľ.#ho HQKlY Dk3V %_BKdZ\ވm,@/+CV=t13}:͍UcKSKXE0R _ȸC'gm+&4Y؂E]IIe|4c.4D2ti8ķgmog682-]\q00D{ IG>v`)x*X 4D}=%D\ZqqfkR1Lw"n6a FܕWyZ->n6JϼP()ؘ\# @Rj$jedƆF;nƘ 2~OZrgΞ9~?}v">3|緱<>?Ν;Ǎ7\334\pwUxaZNaZaV #Yb׌3h4 Nqτf@U߶tC<8>dE bdoJDϛ6X ٵ6OA.RDWMUMMʇrZ]<͆TMO3?C߹|Cܹ8}rWjxLY_r3oo'o;yǞ?ᆋW?/ʯc`f|oy13_|=͖pW瞏|uNcwu7wu7{XWG yӛz"gK iI$}e-6q3 fi6 )f|4+%=RX(""hhpf7)3`gLǶ.46^Eц)KiaH /О4L3w;i7M 0d}gUuJQ^ 2Z)h ifjV֧vpӉ7dĞ⅑X!~9 g,g,6\G~*V =af BsfЁe5O cn28O"̫3 UGFΑ&!Z |/t #-exvQ*1 V>C8vh4,#7ia+PQ ꇙ8 C88k| *1)FVh#8 ]2ÂAVgmD ocNe#M\7s37Mg8!ib ˁp`|%Z&Xo~c;yo.cs^ R=իO.י OIy:7,:he)=\<QxmъIHmt:&תVg[0(^C0g;vxg5<&䋿y}γp>~<n03|]{_73p6l=iOX4/ON>~]']!@'*9HJu\57ܼ -Md`)ALJ%5hWNJٔ[OXj8V@2jvTT^]aVH0Iɶd*KU>陻i]= H ~F>Kw*xr:r+u+O,{].skkh#>*MPL 4FX`Mg <_NS2r$`PUiL  5WS+@cQVM({W4•LjAg :V<6qwq;) e''R_l ?7U5cykfAJ% w{jT曤U*}9NAjۃ ` A;E>^fkVj!>C*,szFYgddrw\cP}x{nP^w?hC}pݣ)h Cu\pN |$Վ3w\]}NcۓC:j1.e:qmbD{iww 1qUࡹuhz^UEDQ CQ\$ӳakuݛիrc={O˿qWe k^U\{~e>q gϞm<ٷ1N}/wڗ~%_/ G>rj/w; 7\7}N>~R:iSl,z!-+rM19^s ҔC1H]Az#bI<%m6w(vBOЎsc2`4Aq0"hy7!=^P]G>j'eeIc:guoLBc'G~(U{}Zfq7꺭܂{P?2v'p3L}/ʨhE74Flץe_g2Uy[ |ӟ;^>ۏ9qӍ7Zxn?oO(o|ӟl'{Lᆋ…sk^j%Zvy+r>tWk u`5WbndpON\?»[qGOPWhclX~v]0Ϫژ]J2Z 7sm6l0퐑+#vHuڠ,@._#qw erUpeX+5 L7 uLuBȚ>'X B pð=|7X" 囦$ &$}.feM+PJpeP6STn#5VǓ@2> ;T*v. WV(Uwhhh|3ݽL|}d?Pz3~5kHªX؂u 92ɵԿʴjHگdr@y4P!KI%M6=+O&@Yn@ ~cʧak'񾦐M>Wv &ѯ{y굏{45æL9GT`$ɔaZ}3f.,UW+<&w6?噷!?#?'ry,k }γ'}ثK|i];~u\2o{/7݈q>>49!=dzk`:_Ԕ/0 }P;ŠJow(8oCəvJiǃW]FIS=Ǘ[[TT>Lk>9rʨ|ucs1ē5`_@)M3ngg7fΣqgMRn4^uT Êc1EʷhFgVZSXRSuoXȏ8(nN=4 i@&#Tq:K'ߓ\ڎiCyQl:)/1i贴,z/pdĊyV~</ ` oeL.360 pAiWq[=2Wn>#6Tfk3 00.MxPjB6˰ tɈ)7B@c0Ŀ.W)Su4goLy+3oYRdԾzGذAd~+v+\Ǒq8Lo*~ǐVNw=Qoz͗U}f>t1n쮔Uϛqa|mƚL@]6j:d78>G^QWAtym1j#Vhu5l3L+y$OI5%W_K; Eytپ 1 -4>ny-;{/~w;?ͼ_w[N|a1 \w5'?lxSoɟc>W~׷af񟼍__ne]w[ =W 7x#>Fg>3f]l۠-I 6Vi̭) <ȣ6: ʫyNǥ8* IiGfr:n 2 G7rRRyȤe V[51/ލf\@3[Wia:d2L\>GvĔ'(L5Ѐl6d4UHS"_jV^- v_FƛQoJw}" 7D:վ m5Uu١:̐QbBKobEGe~fw} #(?6]eP=dsP>ǍMdBEGs{m7- i7e1;@[6w ڤ46fɣ2MRٞD'J3,-榕Z]L:bŊ6}uxEuQ7LuUutZ he/ykIk ClQ}W 3Hf={_y6 G?BKf$VfAʧ*%mU/i7>ioVEUSahm"XqФ'c2P>D' /Ty彡> r=|C%m41 jsKU~W~퓭2k.C?*~g_>x׏:p_Oi|l3^"7}=O8 4<\Q,z;>4a:aHzu 18kmMO')úl'YjR9O#tRS0M9Qf P)Eb--i)-1C6gU|B.47cU|:k(ȝ;1"tVNg~7zMn}ե)eDO*KmBIJv o1L|*+d`K9Bة;q]ʼW=+ ,"2jϤ=<5WJr TA2mq\@~m%]A/ aoG*lZ]`(iWR1..;>V6Mw؀{e-fD+3Z] 3-)[(P|R>mK.6q_[AG]ܘ5U~{˧q| ;KrάJ1#60ׅY IDATF_-0~g#SX FQ14>54TT~wm~hD[i}{-a|\8>G43x7z{8en34S2\· R#*7E.GaEX=i1Ʒ|@ecscK>.4I{Uێ&#E_X,8Z˿$#.#?Ӽ_ȝwͻsUjGڀ_|7'_?F>s˳nϏk- 9g/K_"ܝ_'sNix{;jl3}Mits̕ #ؔdF rW}6/&]raØGP lK^؂dZ}.*e(ev(PTKu,NU-`ࡄkI1griy_ N. r67*gU',铌#|VHfG3H-Nkњ"p||vZF) <$OPD6j&9䐍mv ]A~φ@ xDS&ϛzڱ֪K Ҿdw5 I:eYLk#V,TIum]ڿ4Λ/XڒqlZ[/ԥv,(+#s;4ԣ*É0_J\r߻azgjfL%ᑫ4r`ɑ\&m `,CѪcjޮBIEc [b No0lnL.yq~q΂KW+T_"3AI[#P!G̓d7#?덣S?fum2Fҟzt2\CKqnyI}$^`uƈfԤ,!$@?/HGB{v m ngmt~,ߎ9Ɗ6!Ypm1#O6q~輷HHh\Lh/o&_H[RxTg?, @7(?Y񖷿D,N> 9g\{7vPhC)zoY}6_? ;+neZ? ?p2i8 SK5`bPS܎6k+V6{"sh&ȍ(6C7)SyD5٤cbE~˂*PkRXR1 `H67EB<.Cwq'qb%&Z">E<@} ^R^(Ҧ"=G"/`."$Hz|8wopF_UjVxT_|vJ)l6| 02)wrNxx/:.;Ͽ<y~^W~K>=&{뮻Ow?ӯOw,i8 w88ZHH!ޣM`iE&#>nj.n[qă%(+n|)W\{.]3)e4_0첫#ڿz_ ~ h,$ C,`!c4tJ\mG32ZX||wmtypljȪkd'WrYclKʿVa6"M4i L%AA+w071׹jX%b qARܘ'RIxhutܤ_F벆ud~|*]䜻(_og=_||ď_C=WX;?%/~!//=S︋흼]]5яso&>q}|Cw9|%E怾Oɟy_lmtЍ;>U溑2&hfM3q)6D[ >8S#nKk@r #0,\5F?'է~CBi73EK]*I+jJ,]'tK0`m-dPDMPu̶lGF~ۤR;25ޙ޵k.)fOȖ{@3mfcԀfA(Uʈ= [y44MXQ.ţ=*}CX[:l0ĦlDs pZLžg\|uLdMFUDMqc`atD=sz(̠~Y6oyK1`s&ŧ/>;m5>ٖ Q^?3n8qڲG/ǐ.̀i *A[tD.75UfkӾ"2qnr Kg9|7$ct4*"D)j[ ev[Z='hgy£2@@F#"s^=<[| ?xxCjq71o?ዞ|JnƓ񖷽WwOe_+/xM߿qi8 W5F7\K3[ϓ-t^c`$_[( emJkIJ{Wx(Xɛ Mfsh&781 ?)#[AKD:J`}WQt5e-Mޙ˥ͥx>CeX5|VSϤ*H3@ FtTPK E+d,eJa c@xG_d6_=˲OT ̍ڗɸ9kj FQvB}^&ddVi6(InrlpVQ=0 &g@?k9\%nhmmfLT&$&뮐=yVLf"HCA+${n5e7ꪧtd:,koĨ]x[ cG=7l[R|nLcXm t ϒSUm֟wExde<ZKjO3j#ˆOO0И0وIJof}xFA=d1nw.nx_n<[pv%x~o_w\.KOr: 2o9S'MH&MI C3ss)<hOHI&g4A`f}(yѠ3=hn)&n1G5ˍaKAHe[2 9ä)7'=+p7#QLmu8=8>(]榲8"+%Mki}a$0f~\J5M+f=DS{-gfym.wrdmx5:ÈҖ3GqLu/*x#K||*Xfe]@|ww9Tϣͦ_^p1Kt/ hvdK:F*J[Yk=d:dūh%p` oND+C$UX2zZuIM d}LƜ-MCk*o ̹ ԯ"cP:Dqm3.~ܒF2M_#]3m56dȂ`NP`&^lSo8}sDmRݽ/FN;~2;nnO!tW[5^w"xdU)"^X],>cK7@ے,\PG>5 -Q_@)]<!}IgL`[|ܖ9U W>1{gtE'l?_=C~^ݱNix"Ûvvv<4>Rp 'w|ᒽ)'oYƙ֓dМ4&82|@if>o}!wFQ"ΐ-QR|ߓ53TIoGX8hh;>oL|X189ILmRUN%DS Bo"Q6UimiznYf;شE! ]LenljyBcPn]u!cvhj^cgnLf ,޶n:ڪ϶n8??s8ٴBV4^sT't 6וO$$QvBv÷FV:"޲1,d!3c(N_%Y.bȃ>ďkx}!w᫲wcOlW^4U&~t0̓WTUUTk ɋ 0bs}@Ayo+QF KQB<W.Y+9) V;[% YMCJ'm@O~eK݇?ǔIɄaF`t?vҶ5P0߬5^F딯}s,P[ۼ䣾 /PvdXɭŃ禸]'NU*y#0Ūݠ6n].pFAidpw}+0zv#cmoM!#7;'cvѵ:IDp^\ zMhw'6 2E-4O$w 19qN &D[rafQV7(F]4zf_]m@2nLrGQȫ(. R6aT#g2ԠPkYcQs㗛۵:s9kV5TAHk\12v׾7?2͛~ O)|?!;N:;7͐xbBO~N~t@wN:2rxF*4Zh;4R u)M[.RkQ}km@Sɘ@!#kJg9&=F*}D#mxUnCi W ڦ|)3ܕAe>4곞U 9ECnȨF- @ĉBv4C!4K4gh5V68s۽*Cc]l wIO vb `y/W. HsןE D @HH#0v0 "=5n W)9LeS>Zw`HgT/n;-L,hP[QuLfr"u 5 $3:4bf0[;,cT0* Xcx@?\zB={b] |ƾ! 2m b]'?h&c!b 욲@gi0;Z%nhI U;&- ;I'[ {x<lgc.` '-.E.WhoB;f4M~`7'ӌ³P6frv'!VX2lΌvg:Mϙig#6ݳPTncSv6Vz9# L:3B;xqyko'H%&rb4Ӭ%(!_oe\DV>U2[B͚a-4ÕMEC; 9'i)9hL>A^eaXy '塞 =a1E:W  Ot<ÏLOIi u5::BFu!;Qa>a;;^CL]gs4t3O1$4P&"1 7G5kq*)˗/LFrTzɑ5RԵs" @ R%fp/L+y3FJ'6I*Boqp+WCE*WĮh#G11N~A||,rlB%Qk\yo>c Z6hCdw٫!*6 IDATb,kSmx:}W҆KxZ2T}c(Nibʑ王6c`fVqeX$G-LuZ=2l{GHjۍk28RF=vfa(^=TmaDpBYS43iPm@ޚyx7kph0,x=4s`L#o^8B!UA[|z;~wT uRz=3fc+m]S~Gc{ߋ/jCʔ!"TE{W _|;|'{{)<~drf6 AE@;<mI8Z"7mQyH"ݾ xE<01t)%ܖ&EմcucZut<(O.9r7b⼜y3XcՉӦѰͅܠ<C6)GC;$]Լ mG{O3:i GJCFcq[(߮ߓI}vAo*ϣ^h*C!37q+{G)ڬiohHzKv#Ό/Kܜ׼h;㝽cCr]iZK v\iڛ23٥q|M[=?uwZ%&& Dwm% br 0p1M+Y_K=c̔nkVLo32O8gee!?ƣ:ujJ˺xdžP=$XP͌~ϰ .yos7 bV\3n> ݻ&0$_6f=wY@\ڕ=3`ya|r]捻vg]9@k`/pe2Զ)t:64؄_3۲v` l}y?_}}7Z΀f@’ⷳ[dCm%uy  t UWC3N#̳,ot'* [{_CJo?x?DSx ;(dm;&HDD %O:vLJd"C#JC>ghS*T#n)1="pg]a(*1 %sЗծi9>%;evsڕ]M.ok*9!@c{XC?A DY:*giɤq+]Tk6Ao:eEî 3JwUa! =_:E̳M.Woy˅@vjGCģ6{@4TqJX2hFۑ~ٲ!|Sx ?(aj 떧"[ `PW4 Yh\S&Q ԳyS%'v[teoQ`.JA#fgWsݮɸr@Nx$Oh)Bj/u |F `U2Y,!m70L@&ˇOhXYap)_h*p z{ߛ5ܻ|o S4 z6Ѯ[sW[ˌ. ][2Pǡ1fL;ֺ`?B#l4X|d'qfztԕ7W6DeyE0gDMb?WZokFymw->d\s{$UoLtF;=}|q>f}% sZvQ=x2!ð0ˬ812xkА^WE#+Lc7n[we^X^.l 43ΰL gyrw;]}lZF7њ}.Ɛ #TV7W"L*^L'E3xu@džY2N*7@ς(O$'󪖝*Y^+L6_}1 Legt[ EЈit$~NFzSmkWڡS6vSξe `FO"C3ؕ@ٶmEv>uκm!w{涃MF ZKУIE!5)-awX*wI4*\k6p}1GĚ7qߣIqgċ⍅e,v2mh$ئhFs -g*DQ]cHL3_kMDpL덷m_Q;\`tEkn-26H0fTr0 (#ew$AV#k ]5Tstƻ(2*TYMr`x!гU3Q1111I ů?!Vm7s䍙%*mh༡6p@0h,YiezchhiDO.e;mi=tfMo묧4>\@j5Fc{% e NȖ[Wy4zVM[4zB;<0@U@y>V[h.i+^(IcYlQ:i,`$cBt8Z!E] Y3 ޒ%.|_p71=MM[Z6ܕk6 Z֓ĵ|IfX(͔Ú!b*3)Lu2C Q)@*O>xrˈ~\AA 3MԮ>)!d]퐤= +eMys)*sxZ |!/`=ККssimԚwE4nQ{G?T_ !lQBʛ)+GE;iBm4*Bݵ\8&͊b谾@|sk͛CNVYrSF\2R&6H7엫V{DS2UK^g]Lj-J{5~LtF JH6+ˠ)42]|1֡#d90dz+D=/[ _Wx2@Sx ?F]$l:^´ XԴlJͳth6 X|axĆPl[*;?v".9ZahB~ `-3'./c d1 jqwfkM尰1 CQ⌙ &gwr>(N);zƝ'tX)@xU0FdTptlOqΪTY~0fol) A 2LL<x^3g|&(0]>*e m)_aJ>TeV>ue,?4sCO_"HG#2FQ= ԭS m _+Ztƒ?УC@#]xշifq`dxL?Y@M6ieis@@ш3$26k55N5, 䵻fd׻ bե4\#ZC v׫|aC{ѻfYj7'[e0ֺXzai3ĿQ֔GLqdd?K^v;hΜ1Ol6샬vՕ_x|0}'Ohkuy}Pq Nf5>쭉h& h4)/٪~} 4$i0& X5+m'6$+ BҎ.^W1\n.Yz:w(Dیb3qSJf={gهVۨ(sԩܙ EXm|Xy #p}mGʧF,gFz'l%o թ f %ߝONGB3{vb'S*7Xb?pA%483/ܾZt3XVj6`(NO%#qߤNu5Ee )K^d03m뒕a<!vnrõM/^F;*saٿamlJnAldzc>_r7f%Py"b7Jts(/7xɋY\m0iW[?X5')wi@*Ɏ&B|Ág 0!)2@%oP;oC J![9kƆ)ًYoOY?{OS>c>C>{|nw=wpU<8p0Yu_EdžRNߥP\D#EQ~aL]\[*- |vήRW{@=1FlY,pVL3?l<ҟ!Ω,S5s+rEYC5R5S^#7B掑3D yF%g'-2r5K^Gķ% NC뻑Y"zф VADMy=+EM+Vہ^b3m{ھ;64L'3lO!<8k1o 6+4BGiˇ ##xe9 deti1Q'lG8[GKF,]  T/!Ǧ!C.3m]4;B3,9(@Gu be!Ƌ{7>|wax]Xww;׸ŦVv<`lX7! 8f3^/bmh0(ҡ)?d74_bh] .ZM?+j|ʠ$}s/,eU ayK4XzF0LSJ?"2U<#!< O)<П1:T#[%.}oMl&̍RsJvӂR`'t}=uLy*їWy/RC'G se\SL/y"y8c15'N;9{>6!egb`y7ښ#5F.=h4R-9v&*^Ŧ+e9Rld|q'Χ3vfE6'1{e= !>\.$lI x}ұT>O-G%ӃN,^}@] Lt\~%k%{l(;e菮m=S7AQ۽IMFx$mɓ P&YF:Vkd&VW@{d&ćenX@|'ZƿEܳTAHFy%RQ,Ypz`;o)j9yPpAu*WgD_CxmSyo=-._\n._ڗ|;x+|!şJt1xvgXKI;ZMhmܰ#֠?hR\R (`(mviE0.ODK*Ye87.)3)}FMipEVmn:v1k m=kQqSD+T@g<0>*>40+=i@I/_Faiu<5lQaz(pg*wU"dNL;=*VHY'TlH>Q Cz#!>@k>xe!x>q@pewG'F`| >ٖH>F⛇ 6g%Ve%q{w& Ttd2 9~ެdĴ 3ʯKcYڳFAY̷dP$cG[NяhɝIixlp'3xo]~-(zIZ+-؍4 X J:`Gzv2^r--ŝRO|{4W*Sv~M?gP#m5Wh'~%  N<~B?=Mx.wr2dIhR8-쯧+ۺir ix;N.A=vw:zFN.V 3!{X6mᛊ6V4D3l2>"x B; kf9 PE vi>i 4p#+e7Fi2@ |yw3`̔vw臐LZ&4ZA(;Ek^$1f^ɋ?={xF~*-$ y@c{VK R^LJP:F(P嚋|=g'a!7Fz=ҵE <#בA[cmnz|NWaIw)=XL ?fgVC4ffs2@7:ēvoy]v94P{ExwRl5}hK>ǀ߻&\y|.~=dkaMd~)K4XQh]K.fBG'mxp9僅9f.+b\6ݷ0}Afyjֿ=B΅u >r5 쒫!ESaajcfuCM:#dZ/l/K}Z:qoynϙ ϻS}+6~TQL}W~ o)Hi}] fez/v~5TX?U!eE-~PTJPjvICKѢ/Ak{IGp5L&! @(1'䓀M15'iH)./R:gn7-x͞~? yAM)?9RuSMLų:}ڲG kUxYq ;SO{#$tQ#zO}s5nd y], Mw;20o Q8aS:6⶷"1S V_gY5S6ԦƴLf33tXW ƐiPV;f̊`{}\ەw.=w1t\5bʐWwȗC4aCOedߕn8ǡ'i'=/I{=oF*sfG)#oo5 +!;Sh3w˼1eU~ O)y$*: ʿѤ3m2B r{UOҪLCG%@bєū<=#Tg8$ʠ f[.@"͐!B- >ch}U_h{3c+/>b ͅm&SoM<3vo%Ť'T w8t%bєP>涟qE}BTD[7yIh9}7vcvg3#?i#PWLY/somlo}Ҥcx5.LczA AFFK*j5lo L7~Cw6?gєiWY' 6|} # 2^i|xɃ=y|!=5f7t1Q<6&]ƁN6@~ 'cD>3@q@VDiq~ ֒= $-Ձ[qua!ДCFHc((?Diq#:uCʹb]AY@Ù 6hwMn_hy@ 8C °0FAzFuu3zG=,WviEvuׁv 3HpoNH=d"cw -|7o2zrt4b7oA zgr2~mGā 8S`)w%#tq+2q=|߫䵙fR므&z(y PRGR wŤddbJ@_Γ2f h!eVB:;C&_GZI'-?vO1J'%cf36j.Rig2No{aϯȘ NȘ' xP4G!||?,izȴOɋUiZ'[Whb55z]߱@NDCDW IXX莳ծJ.y!o5 4M61q8I{~Lp}Cr3/1xw!,/gGi޻}6@ʺNeC.'-*_[ pǍ()<}EkH!L%;Rіb5YZl"]C"g0h$MäQ>s Kg_`fA7Z3h] χ, pO 7iF[syˈ`kRkaOaa?𬙗9fNI dC@Wo]E>^R- c]ET $2F{Tfm&يC&ZQ2VN0-o⋅|:E&qY{}zr!/.:a?zy{71lȰlQ!,iȝ 07St1Em!~x(Zˆ72Lekr]a5D[*BV8$?*B6KKL<{vFiwZJ^!hscr͂T>}.qd0'*cYm>y[V֝'YoOJ/}7`̠i%.tזּʅ iQCgWz^]-r  m(R[M9Khh!Unw`F.3]Rާ|/99>-x Bszg]u hfhPǹ$vqɓmY3:ym{!P4:P]fų[03]ChI~F=u` W{G@ LA<smKf@>ci,7,tEYgZS{m# c2,{@]ZлF 4l.Cv3tO:7{ jKeElVPgĻ2ja,XS;bfNvٴ͆0̹QrNE8n[(v2BҚq 23yk"b/{>=0/ Q띇c{'c7++r1N7 sӲ^f$;y֡x8+:0i "0dN!,)e@ڔ6|1itomG &w͞_s կC'VU+Zҏ5i{?: 5hƿ à&Z3(~92ZikCo=w}7O7'Ws_˦)Ӈ_oԙ7[~?p8uKW8?7+?wOo&~_~ۿ󎻫O _<|o~yȎPغ@b)Ž:zvh֡t1s4R<|!cnRp#^f.A|۹Peѭd%A%ʨ7#538>k'5L%I*pZgffE/F]=yh7\GEXWwj4mEl}A;C&}1/,c`wĜkhF9*zܹx8w9tqd-6C ;] Z@Ȫ.Ztk&BE4}et5ӌY ƕۖ\}{syS?4H p۱R8N\@ C c"*L 8ġL` @ a ͌{8g^+|W> 8Lʒu9{^zuw?NR*kXZ@D6V-[_CL㪟|?2\,7 Z ty!2u;<J_vbT{(HIMlo;x8B2!ړ }KN~eeL2tOn#BiE^T񰐷Fd7ŋ Ntn%Lvk.C|U6,t23oJMT\fo*ݫD$8Ge#%e?aX3 uvZ IDAT}M 5y˿3 h1?|?_=9_ _ēoҗ^ŷ/=vs w'<P'k;Bt~Gs4:5/ӂP Z<-@ﯬj37]{̋†,rMə r-gqD`7Jч#8r[!:pæϭ3[8h+K4{qNQ6_(M u>v q-`B dUpiD(:7H9G!:3GPFY^@j21"`( DfĚR}#תS5o2m?n}5ėnfw@Jk eA_Z>#B,=J-ml}jTsZ Upsv#+*gWJ "v@=yF2PUm{6OEuZ{ 4Th>miJЄDHr S*&ݓm.E/,=5Af<8/{'XaGyd@|uM~(e1j,!2[xwDZ{ ӿ޳iDSRV%n>{+ Y s+``рVvӐ"ry8> 5eUyw_vSu/~ޝ׾o.da˾oyKO0=0O+2>'~'ī믾O?5{>2'?$n^V_"^s|>aЅ-"uxϽ7r\",!ē:/}[>pyk!!7{Zcnc7#˽o &dERUI*(m f0uԡjax*@h!`TF޿ʇ#e&BkdnյAH:qVLSe+]Rl}ڠl+*vzV& 2X&/nv^u ox; 'thf yw?f>2zү{-;(w_gOr_w||/I_j':M_|p=O_{y_WǯGgxus փ ?nx"<`x]rb 's P:5rNn |;hKBzgz TK@ YtY (L*>7רc4X,q n ;VyM 5W+.ph+(CE[o,&ؤE4rтj-#̡y Pt P v5 (E7N~IOM $"r@vƳr4%e1׼VSG͸m oʪuꈪJ>kݔha!>>+Q@nǖ B(Nɳj$!M9 el@A _ %LeݿpsOl0q`8TEigzbwڀ!~' f򯱤;m,~QKc"42ɭlYNZ7mmĕn3!}]s]y nYNU?e^|5@~15? :YX!~C'+r`<}hO9t"Ԃ ?`_{Q~{&d<3 t)*_[[O?GkJy3^>^3ޯOON΅)G'ŷ}??{y eX^ih+++w)s̜iW( 묈"LV,~.Р^ 薵L 8}M&Me5+g&>l?v-gfz4ɦ2mZ e(Ge+0`9wMsִ\@TTe y>R`R4Jݙ"ד_!͢#Tt6eoJLI0@(ky"]*z$ -fSN XYO. $C4ϓe]$ + ٦`, Is.4\ 1롵׽i@443.7Cҟ5dy}"@Ϭk:PZ?ٶF9. ZF`[QKY% iՌrZ1 ኳ Ym=`:VMro0Nc+S]vfE) !`H9ͺj}Et-7%)X5Bic5KƭZ6wV9;1a*+h ̿ Tt6@7im DV@o(^6eh2J)=nzl&{uK>f'mX_ButŸ=t;a V}Rެ2G@mֆl3YjJ3z,2ҧ:w\׻&t|I)%ܔн1 C{hȢ(*byMF{QFـh(<y l:fH{f[\gW@VhmPHx~^_D3Hk! GdG ?#\; ?+?y_XvSui?W0s̋SG޿9|[~?9_%0ɳϻ\0f)ݧnlysчy7n'rXxg^s/}LJyc|痿\oܻ%CޤA>(y`hbc5"dSiaemG b87@/Ct%nwT|cށhFt qdF d}J:;guO;3l+-P8`` _(F諊3yvQ :le*xXŧ)mdEcew.Kt+⇉O͢?bmSNE<36Л7 FNvf*UE}Ds@!Ŭf-g@ocQR2n;ã)|mTV> n.Ċ<)sx8sՁM.-m8 ,m`.WvBqjTXE+,ӳ\۷aݛQ-B#zʉϾ1fc17JHQno\r7&90HRQnS:0)2oQQCmu*D VнK>ܓ,&{c pHݣrՍwZ_ Yܓ֘2"yjr7S|3#ДM y#PQMg2b`j?,[)rmĆ8崜-}T]h"h )ks}Gh A>ЧF1.OT=ó+ g#)<)'O#fw#hX $hMm(5 W%DQ_"׽w?vv׽G⃼pB#G{~G[/<3< mO;[[} |>r=C~@yk~w}X_%$O7L 7}7E ;85[+YpMp4a 'vuTG i [, @V3twOz[FӞ;.\4GmYh`3Zdc.u$-@@`d S(NU [iw .VfDA6AS@)pM+m j0Q=D[[&`I/U\G?wŠ)mO,J_M^1$Ys[o ]) Pem\C]-xC#?]b6yBQ`b]Dy 12ڨE>_,+C6MK2|@+yad623^2[7' >2e&j#ج^o+%ߦIIOOZt`5|O͇vݤN6Rjru58#//%yFЪ?quL{͢H)BꦮlMNX\0e XfM=Dw.8 ~;/(/ ذ]{gnp٤t9ߣcŰB eɂyo!v=VAó+ )a!$/GӦ|4yhJ֧_C)7/m ȿzO֟z//e+<`5xS?;'\nO7|_.~o *o{sOvI~7>* 6w:?'%J>r?S#1 oy;GZQs}>~7 N'^xX[_vl0gG9u iN[NS.Kpϵr)dim@+\68 R/V,9L4 G&` zV5{NULL #P;:w\ejmP[܌=0Ct@ӫ*wx2ʷ jwyC =`@$V61k:Eج5ѴrP0xf4k0H*NT>"a-KA&Ǩ4vtfs*,5gFq_Ꞃƽi$р+(Hks(J*UtmYk OgGnZ1$Ya##TF%%@|ys MJ*y}O:zH6&H^W=Haio+NOV vhξUyLv9t\6yRP) k*M6AdN ߺ(v3)B2Yw!L?)̗M&(w 3Do YhkHC 3 mVE;6Rvb)5\;(,yW(1J.>YʛU(pSHLm-Bm EN P\w˲\J.ẄP21wxV6@JgMoJ23ri*iC>O^3dy=+Q+\] *KvBr &M[2˶mE{tggp4 J#37[޶m\_r^* LVBtZuƧмk2R:Uu'9^1X C`t@`@m5T 5}@!J`ESj0e,.YITrJ*;%mE1LӉЧAq:~ uKa]ϰtcFcF'V)灝!@| '=H&:-}cb=-QTjHR`i!pb޼ @rPSٌ;g9$JʾBRQ%6 GHv|郛K~F UXSh9q]<.>9 Ϯ8Y Eaν밼shPJP¢j"||Ͻ:2D=_??%5^vͽ7߾(aD>(ѭv?)mr' vv4 ?u-69P; \\ A,_m Ԩ}a,sC}`dR= }hlMmus1ROo|"\4İtԯ,'V6o[\1Y9cǮ+ @ .-Xmz04(xf. 0yp |5?|x^ UÂg>,7P,h:&Zޟ"¬wq`7LF K_T0y7\iDi8q c J=S 5ьWLCit它1/P9LQt$l m?3)+XTrbʼn9hJK lcR&7i \T^P٠4IޒFVli5IEΟ@xKNI=ꔭ{[0gj猪G3Sdgv$1ԹÞQx@ѳfq6e'#GW=6\)[*6L0CLڠwcNYi ]&el|- Z! ht)!MoSexkXaO4OʎY4?lev{T<}QB9D@eҽ;|Qβn<|³+ wSP% y:Vyk;9!)nj@w$"\WA(? 2mWu"S?um+ԣP\Lňy9o}cv[ PZ*DZ b(g@g˹AW J)ⱬ`Ѐv fDm`@dK3 JfmnMk[kp UPsJ!4v9FZpXokAb;MG>eˊ-'٪ p: L԰XeA$*׀@IEc$_\4T? :0N, =DdE dmr+)1j]@-:mwФg-2"3)6aQw) ~k )kWζxW>bE@@.OP:9v,aPuM)hVR4=MA130 L:<3E41zbN~Y2Ⱥ; (Ag7XGqgP| ֖D ; صCbZqlh %8?u\u8,#dF4H{· Z=y9S|O04[e](r!@y9a LYf=ʪh}2g.z.XG/Pl%!4ADu(nۄ$P8=tQ<)&YPi~ꪖ-f]G! 60.a̺0B!lϊ3V ä잚S~+QBulQ=-`ѿҺ(竺^gYc1A^C^1o[]D<}k>$$KgɃq)$!B[KT03CTYH/mTQU6-߁SX0م0gޘ5QߖD&!PdYZ6lݤLˮ܌`+K1C=d?Q~2Zo<̚5qw]:W^pk}ic[74򷡵#-:p.E8&}Xne5> mHHn7˫f )! X_p  le:%9@"Ʊ=:[zW@=@^8<8xe.%3‚qTuNH47T4K2hȵ"1h̲QѼP\!4d29D[ tjLgAi!kE"L|6"ӦճBϞRP hzAVo \,Tv)*p!媄긠siF1R"v!y5K1@ns7<Ϣ_o+ ޏFs+;j2P"'ZSPTC\',`ra9hXdq0mxh,'t/Wvfj9a: M.|);d#I7⼒;Vh-DiD ;dz.;e5NqA=\;;eʸ2.Yz}n?$nk^(YvrGg L]<;6h1 H`X>[Zes=cft>AZ,c&PnE2 BfQ8, v-y2p0ԦYndi* %:hvMhҔf/cA䓃|D_dn^֔(#-YAJvm64mMZwc>5J_M[Q:a׬a,=G?/ZŠi&o0X:⁙hY ɢ!>d_đ?v&/`<6A5rF&{(h ^ăB{jҿ )6&Rt>g[k!YkӶXkm@SgfÏYȍ *y\S-M{qreZ4:,hYnW5J{2B<3޶S!)n3~}R{A)0%y+k("{PBHSqsn0332r.qpsGWq;p75OY<*gnĥ1w!wY@@9Sؘ=d5Q<4ŴKNJ8c'-UۮH 497ڏ5- v95įXC4PC KAhn;`kytcR*MS4uYxPdJGL\ScEњиBrqȶ3_SD8:e%KZ$ $+ gU}Zڟ(!oJh#֓'0QC Y*D^{ٙBJF5sjjz_Ӕ~x>:]9ޞQeƄSD45>yeUAş*XNh~Eo!{†W5>,]}d?hBmO佂<ةϪV#F3;-Jtwzi5(BFÎaEҔB*٣nnr, 8ng lɷ-^H`L79 Kqu]sͮ$Orb' b嚊B޾ ($'!֔פq_QB]Q1yQ%n=%뙞[@sD c|' $Cc \vO2~+g4;5;6 exu ,Ilzk2"0yaWtvд,.%l2;&n0M/.ՐbR˦Hi/,!ݙϡ:ͺouѦnfBLJ&Ӵl ,{:uU7vX=ۢLg ),A&;#ey" ֦Qrq P 4M7?IrdiaU V"ZUlّyHvf>Ra)X*P\>VXYeiFfN*M`@f!2̿;2<Ԑ!kBG7+nZpE)S"X,5)(qF$+X'Bጁ,<r.EKѽ!B`Nb꘣0LӋ"o(ڎt@s:bÆm8C? 03fӼ}`lb% =735/d DRlfuve0 U:5Ǘb@ ǙU8 5]hރ:R0( | ѠkQBC`扟 IoRGVX%p)4ZKLeN}Pfq0l:ҭ#{{62$0;&7hU)(XEr,$pRx/(vde$/B9Aw`2XʗX yA4I@YZL|ICmAL<4|Dʮ1f秷@O\|1TˊjmyB  RB-1Jbɗ׬Ֆ5^QBJ{eSwsbh&(3;B^:|2H1$M*ܳh\б.ZP]-JUkر(DCPGgW]4CWxƘe(VlVO~ Ȳ4xEC5E7޾K YJ#H|7oᒙRua!(,2S<L IDATd2Þg;41>/7M]%Oh+KRz3(Ȩt^7P7~=GB"|\7 Ӏ !wr asM%w峴(T*tp:>3SжGlS<;Ze;874@f>-4[_S3ƶȶ(v6i[ݘr&oC9)acY5Ipf=+lf6*T0q;vlYsJ7GӘ1rV90ʘ^,nԐlY+uĵl 6JvKa(͝XoX]hGhxZ"PūhZDgu2Z@&X]1h X WsĻVBK-[m>;!M2cQETkxeOUWVr!OY-5GX29/ڻjpywJ-<補n>vaWM8y !:2%ExZ@O$d [-'`>՘Ĵ،=[)]X+kU(؎/cGxi[f 7K_ۙZlـi >BX7I`|+yuK( $-j9[޷[\jo6nF:NݯpkPlR@my;]z?uA72F+228QF.ZjU}> Wv\"I+o$+P) tZqQ5S]+jtPf^kQ6m3.IQ  VyE"QVquLluHiY޳ w} jUmmN]rD2}9̡4>{7實ڕ?_B|ZʋqhC_@콋T951dnOCq,uT'o*UXO7,X%ՄBenx`O3y=.~Y f_&G1y9M :Pܽ`(Rk>o=޳È~OHK)>OUzR7X> r5k~Dޑy+)Mnr!7y?c<Ƃ qyI9e.]ʇ>UN_hiI$6chvj@t.5®rw :bY&:2ڨ8h,t7Im'RY=>[/f%!)M׎56`)n8j Uh <#]vp5m)5A,y3пpnf0%Y6)/O.VŋwUVLg;D\dIS^n\;)oZS>-E]WQ vW[7|5cVldHK)QJfkg 8mqթ$zx͛ªd&~fAuV{UΨ18pFaaFx?롟D6h}/l?V[Ե!@vHPh?2:uQujz Amz+֨[=0 :dȞuL]0'*|۔X_\( >HjMAfJ%f@r40^6,*.=#E+j;֘9?h^oS.4j\}mĽ3;2Y]`7.WV9ھo2I/fa\cG?;q][ ОBx%;]k RNKC`2P$Z*[OJsF(3M]xIWp ne&pp$h<&$@.k&@۬]Au(+,m:c]'׵L&&z((ѷxnR(v>tvȩ܂Ԧhs7t-(,I=9dqZXb'ݸV\ٵ簫% [X"a>(H4{ s%3[/2ݴ-I*x~A;g->SxW$ՙɦL݃O>VHk<\Txrטzߢ'(fLo- h ŋI%GUB;e#귺 %]c0Z=Q}rX i~zW|Z^c-EU0 礠clX*x`b['hʳ~E,zDppԆUms=%2\sR(j{ޤr, 7}utS{i"vsl;ơd$Όy}U<䪫Mq /]R0i4Ƥ~(,ٱ)6 Pv}7:8|y3up?!Jjܭ'̢S;xuS\Z]1oLzmվX5YL:Nf,Kju|5-|ϴ{#t_%,ύm1J(QtC>xd:Ms;Me9D G|6Ubޡyl=d+V/\cf ;#խR)V( &&J)T/c3fD02{[έ=ޞEs#c;<8&1Dr4.04& z>7 Np539_|ͺWɍvOF^;C0zu!DFɛLvy2}m1VZӴpRJ+uQ2%yl/5ϵ[ X%IsSh|RRo8|B<xE .r6q*#9A.r{?O<ɱ+>yzF鹰563 }\!N_ޔ&0F V X1vR%p&*B̂3L hwa "` #r~\3}ӕ6X|2Â@uWWVqrv"vB1F;*{Z$JڗЮy(Z5IEV2ZGRM'@,Bh* éVrXMxmD'j9 #|RxNU?.'˚xKkۯکw+W݆렁ntQ<< L6"8RkU)}4YxԤŽ6Q9"b)~Q_Bͪ.ꗐPſ>wաps-^Ds;V]hGz z^4hSI@ݻ`iSosGk W9*Gm=P;N"0IsO+#:r3GF4P_gӸ83"yq)j{|Mmc/^;67l4y">otѦ" fX2V]cXxfR2/?P]e%ai|v#y+l0C"~c=[_p=0M#:(3W2L֔82.3ǘMsG/.OQzR"۸~cÆBQP=\<<8eˇ0f5asw$P0Al\JD#jr|4UH+5+, Z&Mֿp1Ye] d'!@(s[3 X@{ifWŤPgҳ2m mJJAOFYb沵T'U@[{y2Tųu.rD Wxǹ ,9x@u,زS<3A)I1iqCy!Q'Us=zCpIn 9\ "ِ=ֺ$K]a1Dw U糸9;߉ǒa6xVHmKvcMȖyI_\ޯ̊h1pˑ_wh7x7Wo+U"؞Q#FF*=cǧpҷ=SNμŲssxțy3̊C<=.\:Oi|]y:O<ɐrb}7agn!> DbҞz(E&w8JxΐV`:)Vҗ,B5 `l W:6fwS 1vL]ә69Zw_PX kX7L5YBʒj;iQ̳3If氓>k6e0yk5A=QvQ+ H#VzP`7.:zͪ6kV@akG(#3D31{eu/\¾<}#%P|- -D=`*`1 Pk]}L4I. zpn0xL-<*e'Ig >Il}ĄcJuByML:c2* AʤZj_SC1&w@ecXRة 'J5 IDATưeR4Nmlueu(;}>sѽ&L~A6mk:,Mi5ŪեGNe̓;SM1Ȉfgȴuy«MO\U.*xR;*aԧcrwmp3d3n0[Nҽf1qǒ漑]֎<ݩs =t8Uq(C P@@`E3N/όf()ǻG> Op&yZ&!pѦ:fV?Aǧ =22r1rHdECqHzxA!b``æqy~ 3_úR&^ɛw􉙎Q\kn g# ! guu\mM\k'#:y;N[v && :;-n˕iT->+˞*w(=gy ޕ`tJA ԙGEгIGY{$U8>[g:l$ jebkTu(~&ţa% *R^P >(.z]GvaI'C\xI{#33R`ΐdB|}7͢B4n7y|riy Vׂt6{%ߩw==ctLIyIj<71(hYQ7 % SL[ zeWw8Ύ8)uB)2 wPٸ5OTz)ǃzLtNSf{Z:y G OB<[L4n1cIIh< cTNת 2Q г|jmn׹ɖa{}Tb7?iȀ4ֱF&(^єt]|:<THI=VP )ѦE㊭JUDZ%:&UiZu7 mS_?' W8唊<{=tꅼ_1=d&&qr 9QC| Z:>޴g}g{INy7۸qݷ/r^?ϻ޻=}K?y'23@0f$ޯBǻY,-poIϴU;fݕ'=%k+LYK`j}ΖԖ2AUm.H> J~w ߳ɢ-gSVc%iӁ蹶 d dr.9g3ŏ\[g:׀B #gX ,6 ҨSM>JZWNݯLR i]Ž[P*':n*{ G=y=ɛ,BaO7 .kORZ0U!K2nN=̂f`V!BJiH3g:8bvtΨ/wx1{5ͼԖdI/`~Zⵏ:Cz&Pl=z [?{7ۼhh,A{wQ(ٝ; ViH)3xd7 eB7[( *ZT^]^Rb!ʰ)MH񸊼 aİ5D3v}HF ۩nw(f 7+[1\׫0V9mnAQkpabf|}.__`*^_~7:Ʊ+G/ yǜynw\|xӿz+'ry J#1wO ғ TWC*t[XQ9G3t_*Ic?Bcq"K9Ίh|+ˏ$e~ js) n'v%cbN.[!erkWv_un+=MfRQWw-lG)%AF$=>̂m_\eŻر6UN9eÆD∣~{T*TtG%;O{}|7Sk_z~C?󐬏7Kї\3>;Gy~Wg{~xƱ 盾e|ɧ]hq{o[`_7q[O5GH/z&ykW2ޜ|ǔw_M 3VFLqG}ku仐0$멄I" `=ia2g}P$Ny4g-vVmVjw^<CO~# 'P Q([`ޅ+A 9f \ x{r( TI[4MB_ |LT13YGىTr ]"yq4`L ߺ<&?gDN%kSЂהA-^TeKѺ& ʻqɒuSOSRD} rژIU5 y!&] I"Ǥhw,bfAGSځmb~MS|tx.39cn|<>M}p(_Sf֍(D} И}OLJ28 5 #R8ikFDM #\Evգ)b KɛX|n6Ϣ{µVLRKզizK{?‘)͛ڔş৿{s+_/7\*/h8~ҫy+_^Ko}ο+K?Vijg}|w|I>>ү 0<65ӄQ64^]-\f& !֒){:sa k͊, zYR-5gwMVڔ?cy~6D ATsvY{;ZݽOQyxYf 6+lZAc,"|e^.e,d3"`k=?*(SU&(ucGH$Yv鉸uRI1KI uEŶ[9,[xd^/ )O )r[|yTPT-a?> i&fԞf%NDZ$R"p/6 Xs{zOZJ)l`bPxТdDPP߫hx?7m4f} 5W&#px®_Xɪѷ] ?<PLTͮ!鬛3lmn'tyB1 ^]h;Om*]ǎ{=;_n߻>"0oK<;xۃs.\ye$Cҟ=LX? >|szg0}WmJU_B)q"}5l0^r|v:ʈ6&e$* Q?GyȪpC**YUsn}e#1s!\8xMI .pGyļnc͚Lhn00Ǭ0Aq9 p ]%K:V|?ǯo~=ί7\.yq'kYL훷L'Ǜ3+Oy//\5qYol}ą?Qŋ8|+6l_e/>OtO}__7~KyڕgoOP2d#^~4 s=|u&\eUZюmr;ȉ(էfм#'hpxs*?sʧiK!X[-i ȭ@.V|1ǶpiT겒 ɒ11Y|sʮheӚy:}Nhls,>!wo-"*$:<&xI3HQ]_ N3dxҴ 4?$fP]: hMV<3LƳNWl;&&m>Ar}ắz e|/<l([c.2h'D?KR\ExbLW j`hX_NU=< XIڋ &3>_ӳIa!" 1+)uu% }ce ^I9vϙi8\Wrٞ: H&OpvN"q W[6̎Ɍ]Qi'@">6}ߏ6;?_b/E/lł7wxw%uOg}JW[x7?ܞt`pnݺ78|۸:OOA{/uo5xA_z~1OBɉ Yp\s,6@< Rl'#U^l]XhhRTYpӄUyn|h#V'm؀e{ Ph5A& tP751I:k<_6%%ڦL`\VXz\gg{F:Lw@63EʇYxu $:&kILEHk .\KĔJɒ2 ږSWt >+H'Kjw"+ҫP=wS++PEG-BLn, {{/k_ᅢ/>7y*O}U{{A}cLٟ7|W%_wz6ɔbq ݐ :!H<gm<@CDk@ٔ"__rN*$O!ȝ!`qbKMع`tz d@Wn]Mt=#X[=ZugTg*dq3%A;Ɔ K:ѓ_vA@9ĘF/ ꟤w˦vQG,4 dJ*|FVy+ځ裄Σ/h!겂݊ e ?/+uǠ}C64K+hfGuPiӿN}KCu%Yt%([B p+ +:z δŨx =l&)%Y vhG40on:3kssHN1SMpDmHAJ,6jxecw[]ɋWBeĘ">>+fjGЙ)U)HE]٢Vc Uqm4~C iF Pmlƥ[Rhשv!nl<@'̬c|L委EWлy`u۩I;2ͥ[.&&3VE,S䷇#\t1QUҘ4'naz?R,F[Ea\.Ԁ =`@"bJQ8bɒk\Qؑ W£K_N?j7>oF{ν, o?IK,Hi J ؖG{Uf R ttu&q67%ly`!VݕaZf`.]c3jCWS7+#c,mml+afwҤ(1:c`ݍTLLj{5购ʏ3vAТSsoҙ$/h:XTd`ބ/LfAC+ցH<+%Ґ%sϤ&TmdQ葙 %gz 8dRѧmMyT3 B^p(iyQRwU'/c_-8?yuɢ> 뤶RަW%3yU]VcnFF,k0 SjalM]U圓{GW>ϭrSܷuY -sPNeDpIy%{lD>?ߟ]7Lf@<naz9}}ީ| lԧF1'ɛi`!c_ԡv\rA)-_d -ykOHsﱣ<k!ְjφy+p¡u14 ܙG.?樨#o:?0Gi4 3g~+xŎO};kj5.C +'rc /d*o?;ֿ_޼fLR'wG{J߾{韙; bNMf!(HCIQ.Gxh& TD1NA0MI!  [Co^1H ,TTYu(Gi|x^v3 t DS^Az*Ὰ3' VcUz) 뾲z=/;vjڍoߎA{o.=|^-7oᓗ7ؚx5 XI&v /Pkv ZSR }_\< '1ƆlUc 5yW(8`Ѱ`5}m/ç/sR6K'ԎKwLloܝx; GUfe- |+釕Z}{Tŭ𘢹hYrcʜ2 IIsLPܵ.;.Vh@ QQ!"|.E>n%>$Qˇ54=&&-9MAgnm76+t_0^Yb` _X?Ӹ=v!AC~ WaO㽼!Fk~ZY *o c/Kz=VNa}|+aWrзr .6+?r?YGX/+s;~o._%^x}ÛLc.\d5_3!,x8sfg ǀxIVS[oԯ ?}֗:{B.N `H*X}#mKZ E#?>ko-!R +15@d`Wت>ʼ_oaL/=rFm*ϴ=kBxXY)o3!K`Oj:cPOa+A(ozYy[ղ>C-`M0R8`nж[]p:K["i=ě00N:ǒufhU<Y_%퐲mB2-czJ)V^uyUVD~[QqP= WA\bz-TZ]1-iP6=P9=% EwgP*lк KcuKYPR&-%\]JU:B^I#rXV c|+BmxL))XT~V8ԨzMD=xQ=c` M쎓Oc[kٛq/})l=  -]:qV*>d~y5Za-30cx& 7\~bb'VGhvG`4Ȋ/%ҭk^#99?Kqix-YYe;+_= zWq,cce2PsޏշwZCP2G?r{n= _ BռIEHW^etǡX!^ {WTP3ߚ/ad+p;4gw8D'+ y1{45C# OeY3ڦ4‚gsZd]aF_3Jdx>zwc?揹=76~O)?O|D>ϙ3lSҡ\vI `]L&KYD?y._l5xxnt2( :| ץRsV:tڏ>d[+s -!.>4W*fZ@ ´@_cmGEB;7S~S b*v š9תla fY(oy_Qko|ƕl#nxwr8%WrM>wK:֔5_Kx 9sw>q o{A+Sɑtxᾧ+_wy ?O8sua-o'y 'NE.,{ 7|8%iw {AWd=SHҀwY,šxJc AB {ϸ9 4xnky#luk-AJa)Y@B4nCqP$Ho8!SM:lO`e-I3 I n0z %8bڎ߄r򓈫w5"p]J/O3$.G0X,L/0C겂 W?A48[I= {9VQY@}(mw%gK0l$o)m*kwa EP~q-a~gsٞUzl( 0W(s)[*ུ\ǜj=C'Q.e!n1}֭b:yJVmT{'S'̓gJ/]~׼fl;u ܳc}|˯KzI/%1C:5t0naτ3`&GS>Fĉ[܈R-}lɼ 'Crr3c[k8yCݑ(pd\ PG{8YaBT/u .Ce5}zChklWl]O+gܶS;Xvi:~a8 B&+hq(*ovVBh!#aem4KIмe]VAEǂpӉhDGZ 8o/BqJ_c-ǎc[i.I8zW_-ëg-Bshjtz'\mQ[oo >zx?ry:Uu^.aKT^vB᝛.m_[;M 5CxJVҴSw=y6s91Wz̅zTҸ_(.g1u=CyҎ~PgY].c8'*Y|4\X81Mk}ȃ XW{#԰beVY![KG΃0͆BdcQW*'y\d# ogڊ7`F9_.zcSHo+x9|.ڏ%k+`ܰAcm;үD t:{T|H|A|ʕ3֔V8<|I/%_{Ka;mX(=<ByAPT|5ܸq-W HVҪR>mϾk>`W r; V|l˝@Y`:$2X0I|FC\am/K1>í_ZsuPse[E{QhE Kt[L$dpP܆!@j66ju"ӤCt,*+!.B}qL㴤|Q3`0 0Zޮ>þ>2WO1]v%9&=7R]"Tq Uаf,1b N/x<, *uo{2-K{r LDOYYigllصcY%J%`z֡`Ճw ~aZ(͠Euw^:^֑4XD \YwJd{ξNZhAL@3%> n5ɗn kZgsQgeu=͟;)"AsW o+kkآG\(x*Rı2Cv< )S@!˰Zgua نCHݖXŠ4Iwq 5z+݇;tmkk!E)gR+Z5a57[qJ\vg禛/q"ӈw'vs]XFM'qx\Yy XU.y+!*_q԰G?D3;TYL+Jƍ{'|ʕcg (oO| IDATfox_%y?ùVV^s[^KӕZO/8HP-ijA2,f$mf3`{({ۛ*Z 2,@)xHXp VΟ$dc[.7,nUPBu3q4% M +w,`Y7 `Z0@y2bWXh\FE`ZeO,ap0xn!+l5m[" pl'sgrd?[~<蕼T[lu$wy%}K*/[P.h }e8oR(iNj*ٮ/[c:m,)ùFy<$o<| ǓtY|9;f[]fz;X=~;Y(fFýhK95g$Ӧn@w)ґ(>CY1?3y Š3/!ʼWW շwX)GP#OKdis??kWܬ`n7o~J|a&?UIސ|B%4&__FT>O%L"IrՓG)& ZQ|uLsxa>Tdizr 2Yq?:]Pnš`[*`kǃ.n gK4}\wyI/%H7Ͼ{_HaA a2fցe{ޓ%Ű").Ջi8cqTjlYH&@tpSE afz50tXlkGؘ)36J0/8 p \w1x!|h؅vk>4B~S ;ܠG> 15 o7|I}i ?a&=vJ0 ^?/.5!b` J~V20Q8`_ rh~A%‡.&x+6BZЃhFkZsY&n@L yMLxb\T,11p|cxp8A{۽C1AY׺l8lq^nV P8 +Ft?;h({>Z'VbJ5MMmwJ礵\6i{˺6!X9jZ1oPs|dsugc;?-<[ݏżvц+z .~.0oE8E90rDq#1RH#7)(xny VnzB>$ZPϕέlQzKx\} oV֧ፁp ϷGГhm;u1Cke}ô"ڞI)J䗝\e?߽^KzIP"20VN?\``~6Xq,zO E P+3 µ;yo)"Ƴ߇2ջAm׹|mlд A:ګN퍆6%6Ȁem U9@L|2VS CnB;[ɮIL:vԉK^WYXoN:\KnKonkn˺x+)㌀~M.Xh0p2-J䑚1jIO7h6T/@>E2Kde}"]Ŧ#[[+嵂\4{W_lee91ƚ>xӁk78h ޮj}GGy9̗T3̹JWsM N - V2!qy)vY:VV8fj ϖtg5<;aZ ܟj=7y-|VE~ۘPz)yBҌNEPٖAr5 =,4|D E/Ѯ 8qyZU[H `o;!X.ݟ7'%z2_1+92MVl)xqX$|Joe&HݝV7Axȕ_7J%nX F2d!>,tCf֒ESR') kU'qHPB#dYOH3:L9Shwԏ0-]ְDO#Ltu9l|c:me?Nq.,|r\XJT\ejߐɛY~p=}qh cPhX(r-[/Y[[ ω3ҹB9+(5~M/ KzI/%-v !`-Rg fRH! ܴ3%s#$Hd֙א]Co#B[Bf`B3Ê9VYtzY.ݮ>CDE0-j_4'Ewz>lwaA 0&0x5 tl>c/;Ri#\;,ŹBE,nA?u..^/r ӹ]QdPиwc۵jK-*`,qo /pwFQeÀ&ړw$>B\j~痤,P/Ui J^eLD`wqW'ʥ|Z=zf[7Ls\{p/x㼹aǒ.9:n7 4J*_0ݞ)S%4yӎ +Ə}32Ɏ9~e؍p+QlL 8@nsx"F3pgVLtno:f1+r-+ÓKcj対K2 ˏ5eubI < #(i6xxMy{aԨV.v9EΡpBzΔ ڝৣ#wss*1!0mdž cC.ɧFapš F(sZoܛf?vM\t2Af`/`hAaَ)( /%DRXQU-8R)3tG yĞ7-U fQ"/ dy|ԑW8/ aLx][:QG%3' Vr[ɥ[ Eװ:ζ['Y+貗c  ܎w\s1Msז:NY K,h֨j0T]ve}(Y {GhՇi1jTZhfCgv\]*GAJ">!i{q^tYP$xwq aCg:P_ӲXBP+4Xn K,1:itQ80mٮ1Xw#LcP[ \^ֈ81}$[{ xVs%p Lu]}T\a%~{Y zg˛ I;IAׂǟD/ Xc (w׊{L>XC!e>᭧p_ c"o6y|P>6x~yP>g Mn>7(pC^g}}!!WbaA-z´C\mrߩP7lz,^+$yt]L kj:e&7yyFnH԰R WM%GҒ2Mou{SzQ@^KzI?H@!آp +sB !̫X6|`lga)eru/d!_|$xlvL'B-a@ 5 F)';C0,AKXr]![R""tvw/9Ò\]xʵLς|Dw)p`U1H~)ےS dK~W&#yǮO{mi$dOȂ%9CoHT]ʩW{?ʆNTu=r "#wk~…4",s׊O1|Jy-tC}p { v7wq "P=I??OLs`*@I ;/pnzMO:m1.r'݋)\sc9ر0.W3O;U7bgpMe"+l{zY6M=$wx ~NP|(EX-==`Z=K

    Vÿ=Y#eǞ<{qq䏘fI[{40h 1`p3XfH 2E6je (qc;~&0 L CIG&E%ooqd, B5gkXI~*_\;4ݱd/׆Af*V{jwŝ+6DԎ>@4zAЏ4"yc1ٵ|ig;?ҕz2 l 9qO:WK|:D|Z"yT楰7nJV@6x {ůg!c޲$ Z7s0Av:݊Ro#˹fHa#^^# a6L),T"܋lb MN!8 w{5VjTN83K_î[|J2.'" (HA6 K0QXQ:nAGw>E@X AA ]U4-gk[-blU/uւp{[l4[oqnn Cb~bϓ%mqUIzߓ/8*s6jj՟2]زY{d G+ *W:";#߅ۼC쒠t }2Oٻnq^ӡ*mk Ɨmo|Рu.Xp&:~@FW֣ 䂁s +9G_<y뵶8|m?A?u?o/>lF9IC}T^ԱUyΑO])(?-(gfF_ll(K1( @Rò.kk7 oovSv1-TtA6%}MQ_g:~6em" :h]Gyv+WỸI{"bMemࡀ;e+S!`3]] mP*%_VLRn`Vӫ-o=:ğ瀼^ҟR@==UZ 48U!˼) Vёxn e2LhXx06F i|ϟ >Ϡ CE˲BE3-3?Tdp3ta3´ea _;N_" JBu9ܻsMŭA/ɖq}/Xe pژ )8"wq | H.`00[ Z! >jaz22hcxB@@S F.{ƚ^ y uc(D`7+yOY^ RJ@BX 6j*V_Ǡ셥.T,-"ބm؞V'Bk>p]C_)܆4386v4s 8{^1}H az)llWY Թ_mى?ȋM[⹣"ڇf/+4\y!ӂq}옆%<B3ӲN3_a,}0V CwY3=73vC`(anweKL;%i5ެ{Lkᰨ5Zkb7,S]y[B f2Lp|e|=3b5?8|&yrLszλ-VĻ0k嶀j70s앤n78y)-hqmƆ=3[0 kL~)2 f J2)D2 <{-6É7ظΡ|/` l{ "+5! az}FY"ncI? =Ջ;8dr.NV\r74_1M[ d>cbZ,,6^<|K];챳i(/3YO:<+?\&-.Plx _B1v?h94:̱u-{(DDcI>\L6~ m/L4{x=,uYM=)@ |dERmJI5 Gdhdc3=r?^1ϲ3,ILNi5ww ng! wQ.d,LMwJ]G䜐 HW xWҀ,+L`^vTOG,Xyѳ]LYt IDAT8隟 ݘkP]9d1vG(]NJ>u9ό P2BżRiF?xA{9>Qұ\4^^" , CEc끭a˔,$23Ifaѣ([L$ma8[#w t,@~Ua r(!QY g en3h̿pCKaP|t'~\݂nrX5V[ݗX>5]oSu)|׉LP\xFOUi?js;֜ ;ػ,`-G)s-hC,(gw+}+VJ]ڭ8ԯ9򠳲x12BG&?k8@1wH:bs,1=UWEcd #grV73]y׹ӏV<&zYx׳fڎۊQ}"C_cȿ ?涊8 <Ƣ |0z\JLH+VʅKvוv}A !e: h Ie;}Q<,jqPJ[\yş߬OfE-fn"zS)u~}%:~1$G%|$ ~6wpt~yHSלAOOwz+A/{9oXVnu4Dz.ECٴ8K7Z 2< &eP/GeљwnL3^eszI"&/ C~M[oT(R݅6h2ȋփeE.G߯U 5inF -.a&mZ+>v>|hi-m=%HkY dOTZB nĂrZN{(2\(,o2& {1-a6gXI'zGB'Rx.e'9@nT$`BSI;F $ pw49c6W ʊ'r; 5g1 hzdTw[uwUih4 {2gxD띖xU6]%7j>nA;x31p)kLJZ&킰 V3[Xmg =- GZ|hjVvLONCZ ꌭ\c&о1IVN2Ëq[(<8Zq+ʼn {0s,0hi-ᙺ\Sk4i XʂzuP0qC6?z|p ˱3#"cNAu'Ms>LqgZw6.L sF1=Ci$_w+?(q6,1#)2 2Ӫv<3C~B{@\$sB_$ {n5SzQ@^KzI?4Y1zd1% ( {t[t0q})SaASkgXYwO@pQ ;|ƈBl,l` $ܑg0m&yzjԡ{GAU$3s,»\AX S?zcxMFP ~} -ӱw$-#d5yɺ`!8\Vٸt>P/zN YY)Y̹G`e0@\n /9GWt׭}-dPwjt묖px\\)< fCs0P>:`z*ę8{3f]ɑ4O3oYYU=22,LUW"Tgݳ܄…B P(.0a],RT&Yn9W닙͵dS;:Πc丫>%Fy1Py&ڋyDOkqQ'A"#P_㡵;9)\^Uwc3g įA߶ 1j}AcIh 00謗~lXXt:y =Og''CyGz"56o]8GEC) ܯ-& %Q!!cM 9MJ7T,KYMP-F0ơASUe! vYG0R''%@luf}N x;|zflߧO.K>"W ő@#"TV$}u,OXC/W۵ΦP848lgY#Rz:~ @y1d%<c3Ϟ 0k-5B6k}4m5{!;uy:h=4K[m4Y3˚/g)YbrC}&EMݗdԛd$"%XԱ\.$<]Co~GcSO@~2e?2Y'pv8sr=QowS)P 3\Lk[*!:@eGOQްa,cQ`@$o@ki*@c|;ZLo&R "m S D)3~4})㠱ݛݙLjb*[vhpj'^>]>||l\Uy{&D0*SHUV GD\ I;BmH8^Yb_L<>_L”Cbco JU&`5-05r~Jc,w8k Mҡ7ȶ]nv4+ZG.虞!Z n i=(zf*Ǧ~Pڂ+W"./]T_]e:>>O?<#yhh BAE)lp |\eS"*t;Ɣknc֤fii>+4kO$/R cw{13J"sgR,YFT;Bj15 ?d==nL=h@㴔T-L/y aGh}EUu^GSoh|2)¿f߭C/`v؀g!golˊ! |ywYmc ut,ϦHHE*KTL2S,ڼߺn-eneYW#e?;/disTVz,KO+[kG3 I7ƛ $I$ ȴ/# PB<%VSHae&zvY,~2X.ˤxO7pyK蹓+CcYHQؑa1C zh-58~"molLo0%aA,0 &90 1LP-F6 v]jw~_dL{W\ބU=?;Slkl^Mml:';; h7<#qEȊzc_M@ eng9}Ic"TPoC3y&ߑT횓|Y`'LM]43c ̚ :BsgW8 Rb`'T$b8`ss6è+JhMPYssxwJFy$B௫SF>ӐyөEx`'1-R۫[$ց5ע <ڦҠ`vOr=o1s Jϕhoц0Y!NJDZizcO~ajC[3U>UD8RBr EahXRp)TNbkqRBo;Z]aMF*_h RA Ti\:A5Z+>P9_0[ g`F|Ѹ0$[:Awy=~N@z( H]0(wVHckHxR :J8M߃b +x؝,g):!L7B-vkw1SCå96xZ'%D}{,./\ iap5f %TEa Ka]Y9,!o&dONs~5|6fhQIs :!}5"pʎTY^G򸡱E3 &|wT(0}qo'[ԓ>3t-h/ .ohx2h` -Wi`ه"5sΛ0_!^OɷlMtf&ܔ=hPrϗ.EcmO8qʛZ{zs]P?Vۋ% )͹Pc,l>WI1B^aIT3wJlH7&exGXY K l`%Ks-7<_lzThLo/CՈţ4V[Y?V\¾E}\)"`z( H]M\! ӞiavWi/!һBCC`mKi{OMoME!+æUaPP腁{OĘLQC 6d򊁄u)Ddza7~~Ûsy IZg'OZ*qǫA% GFb]" оܰ"V) l/;,Pm(aclur c 1A< 0ч>گ`.$_h8yG cܳ-5oz_H xb &@<~V_3d+_6YOYDO~xOWS'd v8^C\+R:joZ 0L@̑RИ 4ޡR)8RxBl-XgλsBʋæQNW*Q6Ѯ@LcYI8hJ+ЉjBx3춋4xʅ>/^f6u膞 ZqQI>M~W&d986_x+ )N榲҂kTNyPYKFyF}>տ>+g-RuoDwaK>1Zg8N: )*S_OF#uBW6&,g4i  g5<+n2q_~fw3GkD KіeG2Lc/Sy~5zh<˙+ihYL^d{N8+/XS0)N,̵`8og.CА⹰@{GQ.!q @yTsٖ#H0ԁzvu -Uکi6=GzGn` ~G$J@[Тڢѐu*"$L¿PKqWl]^ZfIV YگЗ6W ""JV}W PJ@$U$t ;v!Z>3.v ⎄Xz|^ۓhbEs?ґ8rGT2Y :dž `*g̞4w,#ɢoFp&^̎@EmOo Sy1cǯ Y_Q&Pٷjk۶]Lܨ~SdLO#|sthzfSc %}'И#=Xљ69C{1Әk(|*MⰯ;b|\T_* Ts (9i0hG'*rІ;wαZwZg"@/yI{!I):W@G4?SK~V7չo*F=ޔG7?syyYcn;#o n9'R0,w*1o5ey@4?<' CkŷRȮj7{y gh YfދЦ:GHHG2-4)C. AeZʹN@*3Y鷅IYL;3 IDAT㓦"e E50cgU_JLz4t ,z @`Bxtޢa*S; aNp!A[b.a-26W| YL:sSz8Bn  KIQ=(O*?{)糩~#P9h"޵'FQQY)I,ȗA0Mq|!u^8[K0C6YPIc$Eu`u60mzH)[~'!5c/lMzȼ߆/,=nHɊjS%6nnmz| 1S_jLZ9= 4Jy!iqZ1-*g,DCh 5r_S<ȀٿsNr*Z;R%r0ylS"BPHho]맻b*t#:S >ߎ1Ef;F֑)pzZ кΎ޿Sᤎ賻>6-Z6/ȓgj%*2ϳ2װh!)TΜe(Rm#CyGz'"XWi˅6?w(_ɸkϔ`fXSa&oE9Ngrz6\#zt^XF[2۝-o ӱ2H>%঵ 0Iog?$"nn#,&L2L @p4)ECGޒySb@780&gs4$7q)=#&0*y8C hkyciˌ'~k#kqѱq -yjSt=s΁H&j:97XBMAH)XuOVC圳&:rwC|qtRl5;u=_v2=kc4;x;|U}A|i9Y4__=:<~V²\ LrnBhiPQWCR^@c0¦f'SAOA~g9n꣜W3uWo3)M^Z 7}o%yS|1Ӯ) i=0l̹EzO+++v ץ,uyg3 /֓w֗֨Z-7O&׸*!%s'd顀<#= -sADke]kHS` dπ CbH0a('bcM0<pLweڱG qAnZO!{꙲c.FW_B6M D- XC藅#_};!L[ zxDK:-Ih9nv8|w<8JMkR\ћ*O CS$]d?(=GzGR&#-R^xX9yVe{a?Jd2I)R( Boz/`7g:H7߃6<م@|?˜ I1BmEe3 `㰐&K5S:<Y0ΡRO Oz¿p798N3KouӻMɹG=__cJo4-BʏJL%#}ft߆ƴsl԰iU/TO$ Q8c9۳ŏWrT7ܑyy(vvu?wEPvcSt=;+Zl5)#,M4fw0y.CNƸSJ֤\ Zӻynye$0f8Z)F ծSL48:zhpyA2`EB2s_ n}ZQ9 W{\Xqv<Pz( H}ȏiA% )5 |hYưYaQh6BTeWxXbB mzLDLT[zXB{N *!<䙚9V|݋H#Ve߂](pT@V-?G~'3z+φ~w/q!<8^#Fqäa1߫Acn.cPSIB_CG.,dͽ1B{jv6&k5-֧'Zc?㟲m e-g{>7]RfkϼXcg;c1sȺ8{aVHx]cl//5=g=u __}*/}*XFFp!4e`s捡5#1pϟlW)sG<{ġe\ݤPǣ Cw{>yCd@CkiCG߾y>[;}# Sp s3C07|n!4ڨ?jTnњVa!R>2t629hِ!!=%AhM?Pdͼg_ώOi]\z( H]ŖCx5$s/bPdPp({&Rע@.`r`ZͽFn++ÆG Ͱ8;;V%zwņ<:}ݵ1cwYHm(3)uЏſ Hπ~'O~ !lGAK[Î~lO}Qn6T%A/5{s/i1/.K'd=# 1c٫]* YW7:XXUG+_.z>pU]4{-M#4sSpOC0r^4GHY{r(%iV I]z󆔿~vk§OXME{3rB|:$Â6>ɤ}@gZ[LL2LގϹ&\ SWfngZ{hh-`c]ǻf=:r LNcTBCj\F=vW+܊7`A"Z{um~[p;B(=Yk ׾jpEkUk΂hZSgC/2 ݛCǪsWmo.Y/- PZȣ| )nR2>{`@|(Ɏ2pdR/hA顀<#=w Xa *Pa^/(`&q\c.n@on;{9[pW~% PlѕB) ~8R,^u**&5ǻz$Xbz8 64"R8fHP5}Ymt5x7v׏;seGH3RBDΊ ĴV*Pm\XX zi\q)ea H:Yk+= q:='y,cUUD^nugzƌ]{asb+|׈uwzD㧠CyGz&gZ hV @hs()*RȧT1Ce0=748ro1ƴG;CtTAmdZ!6zK 6T& 4 B[ ]uC@[뇖S딬 ՙI0A,{觝;mmYaYA|@#B׮sneH0K%:xKW+æW]y; e{VY { Z[ޛ6L^;R4gkhU]ӳuZծɹ>]qvShO*P{3=Z7 oG1jl=msx:vr':ʟ 짝"LzMv)5>(>t 6;~Ba@9μʏKТq+lV᨟s;'TYd!Rr:iȹUEMO!$f yͦw~-#xjmʹW 5 RPR,`M62> yanLϨ54&ozv* )SV\Lg!OkIЦs3Y:aB ^>LaC[ܴ:z .dix]FÒ ) 5?ugӚɪ EpcL{K0C}9~L~!QS=uR y5,;O+#XRkY?'Ʌc_@im "qn&z^셿-S?Ag~;F"T.RМ`axwD'x^yņqngNM`#S]JkHQRrFwK[1 g NJC`-^@cweޑ娏o; Ey8qxCޕ/+j5ۻ]3Peq<j[ nPy;va{f(]mn67֜eՇ j] wq_ /\BkwWn&a'y7b*>sѐkC% ڔ+P@BXB-{zD'HZT6{e8`,w?])ąl;_!>Mz 94VјܴPоެM mnϼ+ūi,}(pUݭȇevU]7Mqv՚\hVgfR ӫGHHEX1}%Q`t j)H -eIeH@PSh9tjnLo Ay \LK0h fYW`Zzp RBmlP=-7$sR?D_!r(g(s(% Y7#>g YǰƚenɛC5MoqJh#%>޻꾚e_UFIc$먜 =WD "eCXj)ۼgţTfyrSlWh 5K ,mеsx6IjW3ʰ[=;}0E@GZ. " /9gA!ʹS^E_hP:آ2^ko/|!z;҂5vos[H D3 ~<:/|\ 58%.&aj[0i<&WgzC@wXbOZ#cHi4ܴ.9;&ε+^YYkuhdGsl2D}} u]|aG3'޴֚s3ޙGE[~Y ɥČm*f'лi j@琵X3+[l 7/K,S*j(Ggmhc6[2KU/T g6~n(?c ' >ː00ILS`_\'#]׀@3ƺXy ]ܓ -S)[bƁ{ŮC,ZLךDC pC} + 'o7ͩ< <x3nCm@c~)cvИ'4>(/ֈ*!c|{~ c 4g0]! ZLzS?FWό 8~48lo}7~m]Z<-!%E}vuء,B=h&i ͯcxHHEX. 1A~ )t 1 J`.2ۣ0O &)t,D{F\bZ  )8%hwO]D+O͚5ď-|-mn` pR.!,صAyS~3g`] b:r'0P8TMm&ovwYh?\;C i[]2UV ކ¬ Z*k/;ל]Y5 578qSlhDZ=g9A3܍pfjw*h{n] yv|0ýJžQb {0Զ!Y8jl0m}Ӊ^\I@ 3ۏ#G䕹ڷ@IS.`gcח+~vF|:~7vutb% &ġ`ޭ/F qW̯񫼜͎wxXf "+};4h;&2gsGW휀񓔂hmhU=UWGv.h.w 7`3QJT`NhCSV}*xfZ nM^3Ly]FlgDh,h8ttf B"]"ʴnxȝ¯fʢJiT;wMwmw:Т̖tlRh  .8%ކoaBd-MPjfvdZKfSh!5v,нcj,`$-#yol5AHVP6>8,\é@ՈShOslKoz^\13SxU9j'}uN/iCX5ӵդ\^vY)#ʾ w)]9Nट@fz9[z>@jyB cU/ M}aa0ċ}sk DC3`0f?n5e@s(% )8(>1r38>ɖTA@O yELuVtDc\ćZi\6kW{ʒ#I ~*5G }07zȸpͺZҞ\aABtMsNjP7k / 7a\L{7QôYcoC$KԴ枙ٷV)eqcv?(=GzG.R7Y﭅Ӛj0$TP>CBn2xoB*zfZ罌n޽IYRq 쨧?BW{MޙvL~Փ/pSa~P&MC@#I GXhL=G DmMϸ^ 䕣}dk{-֐ pLZ XT~%w>Q™^f=J tNO'R!OF=4kz^Yo&pm򬏤)Kh^' l #VpW̎20, K)F^L<]EKYcyuoؘcmj-&o+u}]V<&CD^զ=T)IuJ wBg 9Xh D3 lڟ#T2chl4|h1> ],zfSmXч /-KhlԘD#-Ym#>r?<^UOG֓@JJm7ޕM^D[2a7nqc3[8q=7Sgw@Z}C5ߟ\4N;!F㴟x'Grw)4Tz( H]@ )E>@',۬~ wep YO o=C)pHh7h(?աr &~&/,LB4pd[nPoFadB=,,>ȶtf:K_x}]Q~njg cq/2aM*`g*ܱgŸRPi4W?S*;|n!E|熾Ӽ}S~ΨkC~LぇjS섡eLfeE 7fhϻ zk'DCkM^w#t ) S :똿-ېLX̾E5<=&>\aqſwwveM<:nYceecלܓY74.32t*X0VCyGz'"M2 JS6pl6Y=M%Z b*2Uv 1SR 2g-)zJ|dO]HJwmN@7- Z؊UKkf)αCp}e9-N.axv ޥH<_lY3}@lY=m:6BzY.p+  Bu0 u7g?pJ 8l)IpSl@erRڝ @O6?~A|6s>jWNm` Bi\_?ֳHo3jaM˪ 4cCbn1Ӏ 4q+"˕DQrSdI5XchnZbIf;W>>OCۨ_Yo~=g^{kbQ +zhBK4R>)aj=};ニCi1;FEY؂m[,-SPS0ZUicY$ИJE:a>3!p0BdfWL/$ gfO}U9 s6C:`c)P]]C09ѩGvz,BrF4Q~jt=ʶu3Z?U:^Z>fZV(lmAGg#V͠~j#,\T!_ֻvU2{ hv@fi˂0TFi#eHSt-g ʀe"Ŋ ]~q*a`,xӌw}`z^,AN#UC (T:@ dX (dir,BUeeo:,FG{_zӉ>,Ʒ,±s(2FQՅ5=#FTh+(}ђ\DCF^uw%&-W{D RT׶tdԩ\n.ZRfHu7h i. [*LLϛfj(ڼ2Y8D_Q||l2LQ{ڣ7nΤxZ\ S֦=>W瀎uQ^^8 rAe`j/}vh?Yį39j +O.`9ʸKsvU:f ʌ%S>cz(R35t76 {,Xyǖm4lg'?%<\eI^YGyz.edHH^cՇO}FԪEwL ޔ߁: [vk)XYWIg&oTپp{D*,GJ^M.R{)E<tis˓ H6*;{9=Qge'"9TV $R2_fEE իnwNl N|e}캨aƔ`Pl{׉P}Ŋe8z`Ro˸?FZBfT2ll0yu9%J!{G<!æ2mPY ]ۇz t8\;18FGd;fqC[#hWM4V?>PzE9DfBm:v>?/.%5sc?h,YUܦM:t% Qy).%9H;clӂxawuJNR.JtDeU^6N>&w(RD.~z$fX'gG2L3tِhs:&i_<; r 8 4x|Lke{x.~{g T1"*@C"ƽGHxۯ!XFy{$ S>*qK*j[{6/3ytg.>K[2vز=:~5sp9R>c@-w#0ڙfbV^\< H-3zI~U]RP;}:檓T/9s3ײnQ䷁BSm ?71 wz,e P壠6U`w3Y4&sD~|]zg2Җ Gy鲑Ujm1t@:攏z>Ի!plڣ2M[ezh`btltl]ԱټC@ũrJ;$:YKͿ4; 34G;ʳb CG_Q-'9q?E=KgՏ?ژ~Gz =~'AUU:iPU7Pc( 8kr]ZFّX2&4K 8C)ڵY=^鈇 *g Q^6=(qv@Z9ԨGAyE?GcK-\/Q4ôDi*jb@sؾw}w- VÊmZ= dꓺRՇWpIrZQ y3JnnPs*7N)񢶴h QE}FcrZ'ϕ'7TL}Pq4@S'2m ^rW9M^6 l=N)JL|n:Sԟ̭9Pi{Ĕg#WK\*z۔~o#d37]iv@fi˂ śNA1Ys؅ڞtow2L7qߊMQVD3){a""75 eT}-et!׆$Yb,|Z~ݧ+,=9c7 #9X{iM|hT-f\9Au<ϴa@A1ݫ@6 ֨wW~T d+TM͙:j$q; L\(i].Gd#Du{kJZyGKPgh`dn>̘yhpQ;Fy@#mj77ӪbE3K:,%C[doju}ۦ|އlb,鄲>"̅@;]ys4e̙ۢ'it =ػzz%s6:1>@Pt?۽gvtU߳3{WࠏG}ڴhe H֣=\&>_cm4ʞQtj  큺 ޲=TUߊI-}+1}MGErc̀d^:.j[>Ā5\1Y*C1zLgɣB_uqui8ze۬TTaUG  mC+TTDwf [uOVP\ˬ >^D*Q˫p&[Z=,~_Bf C 4ia7gOE[ MHy7g#@*E J=˺lNPŴ' x0j" xEzm P|ۯƱE Q=w V5R93tΕszK/\ ǖ_Y T5p>KЙf[\uk0. U#VD3OT@ֆ3mvZN)6aHlOiX?k? IDATm.BXj,Kv{D6 Y8؆mW<6wL^ٲ.yuv,>hmV&MDzT?yg0<*ڸ3},c:H&dimm7%{ ma,|ًg%scUVZ'!?{80~*g@6=q{ěo.T\2tuwX^Xrdu-{f*F8B[/ !10oZu=467~U\k6e?@V{Roy _AsF)tE<=n!0mfzLW/f(#۪ޒuLXh5}TU~ aǢ.XKBٲe⽴VmdIA㸓`/_4nLG̉tc `PYv_ ?MKD*`&z|dΌCi. J21 JR*T%7SEu^֌M26K[g{Rpt :GJ Ñe2R5xhETOڊi|n::֝5+_:ֱeK1?GyPeY=1nԉk&ūjE2rkmqLtڐ@~wo㮺9. 6>AsvI*m]\S襇 68R>zVL)U4I;jtmf/FQ2rG_66*uFXeL bfdm7B:1 #I=$ ܘoi@ZYqFHߩ6H^-6Ck!},˱e{qˊi˾I'u3[g~]ਯshv@fiˆ]!1q05p 1]In:tkj6]|==}Zߝz;;RNE2cC#hƥT[A׉kY'X1Q/EěE~aao5d& >g\JT<@n*2{.Y h}5ݫ;N(کJQ .#T REY-[άwU_C;.;nⱘSNLGoNܢ6>h*]urO<hݸ5W\lQ?t{Aԣ\s@s@v9R]2\rcn $q̅nkz6V鴙;AtƗ tqQZkWda:u]M씵C|ޗ>l779zXw!x;4-9KGCʣ-v$pPaE2q⃫]GԶzh13]+/v0`4c@7H'kʓWNcXGE;R&Ío7vDNJULGFUQXE?>>)ڝȈWToe3L|&H٫zIT=Ά)8c. A$vqi/+̈>0hŠpe(ؑ* &;Ƴ^\N1L>i9v9>=" sGzmpߏ|G!@O b;r5cd+e:6X?o)8NW=`9hnKķza%lirw*Ge43a/` [o0pa0~ٌWݵh]5^htbJ)EH4[Z!`=&FPf+[X1)SCm7ݕvWKrn,Ô'B`#e'Ni<|TtrifzQ? PwE(z4弫}m ` E=. 5 =kfP;;6l&ֹ+TSM˵P- .w0q\if0[4>WgEbXآ-9lhS֙EKU?wȒon8LG`aEM}O]z=.#,(!.d6R=Esf uG*^=w9"1+~D,>x/hC)s>|i#\m+m=Pnt-¡ŠV&0G}kȸ 4*F3=3&x(Qi2 lNED6+s.4WGuqبHs8Dg+(^T%?y>Dk{\U f#^_X T^i~$jG7o NV|anmgX 3r E.=sC|V\'ʇ^TVF$Rn[,##n`,4և@=m~ K/, ۥf;w9,GzJYs8ssHH.6-ًzꫨ=?EehmUi;%xu# Rw,C ~TF-3W'6] 'hu4L-%%>[Ԩaܥ(at숗 $ EsV jpPKe[Z)\Fm1.u( k(x2hcq懇1 *n4Jͭ]k!*֖aVZRU0zji"7(TQ7 LJ-V  DxEd?4@H'67Zkd]C=ug@|:94)wcGoŏڴjznck=eo ҉j}F_ 1YVQ)\KBϟG=FK|yS*@`j-jJ;ؐcouserwM`*mMbqlW}ߌSLEN]A=Szf&9LQnTf&'#:axS6͊/@T㓜z(c7Ao/1`ңn1uc-s}j:N+4kM}>ʇ:վhO\A{6q7qw֬2Vu%'rJɮtֳ%];#'Ba6cͺx|!7 24gG I#h "]uќ9cnG8{ F4^ 9c(٨풟d㠢C_Zs+!bf:@4q^d ^͂ocZuzwG2jo.4ӻv?,n-Uch0"~d/FoFu$k:`4 Hh]g:މsr@j$1vtկЧrӜf@[44L5NPًЇ !m(V'A~PiFoNyֈ,Nm#nZ"Ckôf,V/:}CdL ;_ɠQ=b(t4@ CqzF6\iuOJYGގRuL(+ȻRtFp}ol r-hώY<=cCU߽ʁ-ڥs`apܜnlm:5f.|IȾ\ @jNwѬ* CNQ-LcJKkmTotvQ[m36vNKp,`\B͕*l{iGuT=O}9暡YE=Dẏ&'(ٶ B^c > :ζ\/:%X~׊5_ƪa6SGw%x(l_9hoMr1ni<::Hb ھ+K]JOm֮;GV=[Ke\ 6z(Gqva:gdZ8EϤ 2h⮿rDךslh_#24QՖVp "P ݔmXFͤE#tCi. 7@QQHC`Hz(]E c[ FFJ 2`"9M6 |[((Ñ19ހ[Rbŕ(hg2MR^7&ǧ*[t4Tdڕ5fө,f!37e\yMJFQK{'IND주[[2y֍LYe&3W^=ų%ҩJȥ!Oq, =c4yft<"t8. (gME3\},#UPAArZB0Aڌ.!Y x#t:>Dz-._9ݶ[u-;؃ @]U{87ZK.4~]Y=wtv/ md趙ɩ)*Cݧ-JPt4Cc&'̪dſ//}8`5k-N}w}og*Kh9PSZ+Vc<(Z ) =VMU6}N# >:u НcfN|7ɋPUǧSŘ컀S?y}z]CE[ 9qw *6"I3M.gɣM}LoѴĹPؔ c?y R4L\5@ 46|rř !"F('hEHb7mRM<$2d^AosE2F (/?V3(2sCNi[=00`aәZBNfPvjKȕkw>KHW h?m)`4con3@[8Qw/h?vsԟ Z3 9qHoE'mӬ8ry˦JJkVݶk[9٪ sVZlk)y.? Ƶ켾UkUg)=-y,yQ^h2@stQR?|QXE{>EqSgk$'}LELw*?2 0-qR0踂+x=S).bS:cù(w2Rk;9x^ ]ҹӧaYI3e!SB 9fW_7LqW W r:(O1^FɮV ]:ͮ{+'fk=x5ս/WtPpfq%̤.7;c)U8 HjLF?xc'ji RYW.kmnx:ހ)PnIhQ4.a(1{0y?yƬWfx#HPdwEZ֖82`+D~ dxHe>/+.n98Sn|:ux$-4r.xc?R63X 󺏜NL>|G.t{=3y{,t-83QO댲\3:S_2=8cg8cgcO`RҶ>gLc䄘8f8"~@m_S޹zkA?P6dmyHlS_p4 >b9bm9Ojr0t3iHO ӛ;,1U9~]>oɊC[=]+d @%tF鐁 `M&amkֶnJ0=hv@fi˃33){&,a@SX 2࿢Qv B~wEGaIi'knx,±3;/ӵSQɩ.a{4dϵOLemHqL@p}P,PF[q\ij0 G3_e,xiGU6hE8M (5L,מgg&g:'р^11ZY!C!Bie'g5 :uU\D?h20LuljhZɾe9!l=t,oTꢲG{>gA5&mpj_(W@3^k|;[)7Ԑ˰ ow⤹$LK\ϥp^H(P3qʰ#S=Skŧv :e6”&eLaۼN2L3]JLENҸez6kIue801)40D-( %^HOX}".#aŚQ4$s'CƓ@m,Q-t!*qƪ!y.Y Vd-ڊ Л+FԈ6)4pdDA].Jk(e  >PŃ71 yk32F(\5)eEOGԊ !1m\'),ڲT̀dp ~EJFlʳu 2HIk͡!U_G7`8Sb{S s2 IDAT&g20jcj LAΟ8.အL[N /,mɞiOˠQ!K[59>ўٗSwQ Yq=NۘpԿ|Ty#$ouҗ}::z9Gr>RŐQ_:wcU)trLuK]N; > ñ>['3=j: dlj 2"ͭ-1,73:eO8bf;<ƆaHF)EkivϹOT/'ZJEwReLpdq=|Rr#n+!)teюNثS{,^ xd2ЌB-P[.L- ހ{Zݠ=@"k”eʦ;|{F/0bzs|R@WIvf69^3@67t~2LVMu;DrL19%q4vbg?_Z/+GvĆ c}k#~xPE&ԘQu&ʶuBNU2r,2v^okLPв;:z=֧w>m髫>͡ һ^]bxk .O!g-x:d^vl7?SVxWQRoL@ygHJ\'o^a⥝@ A*_iDwvEu}'#~aX˩"~-QKUo'7HBe|Q:jB[O;OPkY/S#c{ Y5[d4Ԇ>JFl?83-+ZﴫO^7lL:9I?tܝ#(V(0u+ 5RQkGW gOpm>鎐_s8f4 3ѸΥ#YcZguqS7`-Ŋqfc^@4'Bиk >u&z9T)|r웾#q^O_.QW#)ڮϱ݆9Aq<7&ZoV|[g?}\yt%Dcgut-`$=RbR '$azdӽ4 l M1G> 2H$+6w7}f .fh]:eX%>f9(M2^!LTwƸsڲЎ=_(`*GQ]%xi:w2tSLoR &~DV|U@<|UPyhKR$m\m\zLZP@}Q!}t\eLcG4>KS86] `T&C^\`\md5wo[kE/h sg+Ӆk}{~ 1*-KT1fL1ZqR>#?ߋdEՊc hX>u-w 2WBQ7=]yXY3;̓~:Qײ,$WFTHxsuʟ㤯։Bz8XLS*ttkoqG{nveqȞ9M< 8sws%3W\^ެ_<'? 7i#'O^f>N#?}4L34L3g>熓('/boaw>}eJ9pe}@.wYuy&rw8WYuR}u_.5}%˚ififHk/OgϞỾ >9X_˫_u#jfY =W%Ǯ?)'uw8^y;u3ǎ]TtcF}8ykfifiB9 *gqpg1Ν?ͷ~ϝ?yѹsGWwv<A7ż_SzѺȇ8=x ?v}`s@{Gok3guO|; fw|rK_ o;%wf{ asc34L34L3]"z@H\q _Γ鉏׽Utjk_z㘻?/yڿ*s쒙w'o+M{/{·̩C_No=|$x#r<] i3G+|Ss>gififR麎\n|Ky/9/U7}Mt+н׾+Ϟno*?: |͜:Kfw[e_ifif+G[>ӧO~FJ}Wg\0_r6`oWsgl6z 0VW?YptNS{čg~x ojɟ/\ M[c[?pINtnz|q˭̻oo|8<ei+GCZ/3-oz|4L34L3=9 ? f_ywsuO~Is&8怼5_L,zݻy?dzxS?^ٳy;~n|9s\57~[l6‡?򱯈wqwo.}H7:RוKr:L34L34L; /?zmUx9OI)>ϝxR^ts1;{{v`{ ~럧%Tz ̋_p՚oc[?q/{y~y5W¯@O??x3E_{x/ӭm_t{d]G]W=S>NL+yӟvmw{3<8vO~|Ǯ_z˛W9gϞo|=KzL34L34LZ2 O?'_yX=ʳmX+X) 8Uͷ~ 2Պ_)>?)x n__c~/g>yͿ 7}+y3?['~Q)W]'/'= ԩCO[M^ :y~Sd;t?k{?KyK7[ʗ+q34L34L}O~S<_@w\q,JzwOZ^{769ƗwО{XRG?>nz-=L9?zڌDS/G'g /=Y_Պ?擗/+Ux^O 'o4L34L3 !9 >g?׹1zq9c~4o| }vnz-rl7v;%>яwo>_Lkouv˿7cfn䭙fifiy藡5b)='~Ї|60y/|>%XI_u7޿`ނ@iO=:׳^o8u|?xλ4#PŶKҗ_v?a'Q;{jg~㮻?gififz3-o~#]mqǗXnprs}zr/;8J }˫^\2̴lR5+,r~j).Woi h 23D!}sfQ?g>'+'Pz=,b7cJU# |}jq#2׮G鏈=*ftK+*:X VDUU.\BrJnXs<o1[8VZ!BQze vL7V-v06lZw)AlTjiUAբpr_)(#3Ȩ6쒌9III)>55G==ݓ=jB!D0uD>+¦w o6C5{tbwo Wb kW/T; QѱMB!)<\.mW~B ]VhuZ::VVEաj?n{/‡`pa.+_ܕ[R1sz>s09”Wxu\βs *B!@R :j@cφ~jﳵa`gҩ|s7/? 8\8}{(sȩ3M,6.j3$| E nHJ,Wnkӂ*eDsHtl{l69vB!<2SU 8ڷm3O 7WWp0{[Ê5dE@UEnuϮ]|7"QAk7bqRRR8d\DtL,1(^!BPb1egBjѺrtvT¶,>BedY7(tbXUU9r,Гv888p8h-JB[ ^Wx3)u:B!ݕ@ WǽmDMbۄ:-[{" |Ƅ\<<<5EĢX0؞d^GYuvB!wiԤu*]8w/BFMZ[7Q>!B!(G@B!FB!”X.塸B! + B!B #D!BQa$!B!*!B!D"B!0@B!FB!HB!BT B!B #D!BQa$!B!*!B̶76d~{C3~惟Omxz^蛏eڑ<4cr!BvtcnY<ݓL-yq^hm"xF|~:E|q #U1é y!qSϰ؛^^^M5c8>_W@ߌ|l5Ng4k͖y||.#ڸaJH$=߽~̖Vw݋#:%3݉ЪduںziBW:PU^1ZNk&f3vӧơF8ؖ3eXW3d>ڃft#>z7,ޛ˄f5x IDAT/}wm}1gdԚ9El -5y/xyIh}lۖJ#Y~-W,!BTz62shǺqI@ņc>bV@əyiї,s_Y -΍z0dKg?۸!DxџS>Lf0[ >Gс:&nDmqL>з}'51}acy+}Q5w9C0)jAI2|O%{묹hAp>EC ۖ|d B!tjKm砐yTMǍM8f]]iUk~2[ֽIӳ_2alc5fbC,0%8_~ {p-,[S1,} sg;`_jby~*f3c$43кdJBob 64v˫P҈H*smo՞oOWֹ61!#D 4:4z |S+0 ' &&qIEQHKI>-ڻrd>nJ֕6/aSp)(pNيF㌫S6BQ1䛽춗]Rv[Dw zt2ڕ"D!h(899㤹5UI5sGkFEc"xeCR{>|%ed"myoHxoqRT-nƳtFϙ's0~ضQ![ODI$!مp)oJGíZYj^_yZ |>׃7ip;{|AEdqh3ڊx Y%C ZhkF g V@qmј˿n#op@M@PqVvh0w]QSCwr<ޝ=ڵ6![դ͢+y_)+!)J{VyK!FVaS:7KEt):2^t ]u[4h;֡iC'99qؽκɨhu2_ͬϾy㪂vUՂu]s/G,Mim5=?>rM`E`̮gjhР)nqYC$Gu{KTɴ6lʣ6hPHDD}qџƀhz0:'?*NNT1\btntj{ߪI=I TI i+`'fCN|,ú&?|&§/)qt"w,d4졎ȟ1n!BdzK6 c# }cO%4pJ$~kF&kLL_Pobʼ"cxB!xip91r/8:)B!xh2dHS $(&B!x`iz=[˘u.d B!,!B!D"B!0@B!~P)VIENDB`napari-0.5.6/napari/settings/000077500000000000000000000000001474413133200161105ustar00rootroot00000000000000napari-0.5.6/napari/settings/__init__.py000066400000000000000000000031031474413133200202160ustar00rootroot00000000000000from pathlib import Path from typing import Any, Optional from napari.settings._base import _NOT_SET from napari.settings._napari_settings import ( CURRENT_SCHEMA_VERSION, NapariSettings, ) from napari.utils.translations import trans __all__ = ['CURRENT_SCHEMA_VERSION', 'NapariSettings', 'get_settings'] class _SettingsProxy: """Backwards compatibility layer.""" def __getattribute__(self, name) -> 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.6/napari/settings/_appearance.py000066400000000000000000000104231474413133200207200ustar00rootroot00000000000000from typing import Union, cast from napari._pydantic_compat import Field from napari.settings._fields import Theme from napari.utils.events.evented_model import ComparisonDelayer, EventedModel from napari.utils.theme import available_themes, get_theme from napari.utils.translations import trans class HighlightSettings(EventedModel): highlight_thickness: int = Field( 1, title=trans._('Highlight thickness'), description=trans._( 'Select the highlight thickness when hovering over shapes/points.' ), ge=1, le=10, ) highlight_color: list[float] = Field( [0.0, 0.6, 1.0, 1.0], title=trans._('Highlight color'), description=trans._( 'Select the highlight color when hovering over shapes/points.' ), ) class AppearanceSettings(EventedModel): theme: Theme = Field( Theme('dark'), title=trans._('Theme'), description=trans._('Select the user interface theme.'), env='napari_theme', ) font_size: int = Field( int(get_theme('dark').font_size[:-2]), title=trans._('Font size'), description=trans._('Select the user interface font size.'), ge=5, le=20, ) highlight: HighlightSettings = Field( HighlightSettings(), title=trans._('Highlight'), description=trans._( 'Select the highlight color and thickness to use when hovering over shapes/points.' ), ) layer_tooltip_visibility: bool = Field( False, title=trans._('Show layer tooltips'), description=trans._('Toggle to display a tooltip on mouse hover.'), ) update_status_based_on_layer: bool = Field( True, title=trans._('Update status based on layer'), description=trans._( 'Calculate status bar based on current active layer and mouse position.' ), ) def update( self, values: Union['EventedModel', dict], recurse: bool = True ) -> None: if isinstance(values, self.__class__): values = values.dict() values = cast(dict, values) # Check if a font_size change is needed when changing theme: # If the font_size setting doesn't correspond to the default value # of the current theme no change is done, otherwise # the font_size value is set to the new selected theme font size value if 'theme' in values and values['theme'] != self.theme: current_theme = get_theme(self.theme) new_theme = get_theme(values['theme']) if values['font_size'] == int(current_theme.font_size[:-2]): values['font_size'] = int(new_theme.font_size[:-2]) super().update(values, recurse) def __setattr__(self, key: str, value: Theme) -> None: # Check if a font_size change is needed when changing theme: # If the font_size setting doesn't correspond to the default value # of the current theme no change is done, otherwise # the font_size value is set to the new selected theme font size value if key == 'theme' and value != self.theme: with ComparisonDelayer(self): new_theme = None current_theme = None if value in available_themes(): new_theme = get_theme(value) if self.theme in available_themes(): current_theme = get_theme(self.theme) if ( new_theme and current_theme and self.font_size == int(current_theme.font_size[:-2]) ): self.font_size = int(new_theme.font_size[:-2]) super().__setattr__(key, value) else: super().__setattr__(key, value) class NapariConfig: # Napari specific configuration preferences_exclude = ('schema_version',) def refresh_themes(self) -> None: """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.6/napari/settings/_application.py000066400000000000000000000205051474413133200211260ustar00rootroot00000000000000from __future__ import annotations from typing import Optional from psutil import virtual_memory from napari._pydantic_compat import Field, validator from napari.settings._constants import ( BrushSizeOnMouseModifiers, LabelDTypes, 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) _DEFAULT_MEM_FRACTION = 0.25 MAX_CACHE = virtual_memory().total * 0.5 / 1e9 class DaskSettings(EventedModel): enabled: bool = True cache: float = Field( virtual_memory().total * _DEFAULT_MEM_FRACTION / 1e9, ge=0, le=MAX_CACHE, title='Cache size (GB)', ) 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( Language(_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.' ), ) brush_size_on_mouse_move_modifiers: BrushSizeOnMouseModifiers = Field( BrushSizeOnMouseModifiers.ALT, title=trans._('Brush size on mouse move modifiers'), description=trans._( 'Modifiers to activate changing the brush size by moving the mouse.' ), ) # convert cache (and max cache) from bytes to mb for widget dask: DaskSettings = Field( default=DaskSettings(), title=trans._('Dask cache'), description=trans._( 'Settings for dask cache (does not work with distributed arrays)' ), ) new_labels_dtype: LabelDTypes = Field( default=LabelDTypes.uint8, title=trans._('New labels data type'), description=trans._( 'data type for labels layers created with the "new labels" button.' ), ) plugin_widget_positions: dict[str, str] = Field( default={}, title=trans._('Plugin widget positions'), description=trans._( 'Per-widget last saved position of plugin dock widgets. This setting is managed by the application.' ), ) @validator('window_state', allow_reuse=True) def _validate_qbtye(cls, v: str) -> str: 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', 'plugin_widget_positions', ) napari-0.5.6/napari/settings/_base.py000066400000000000000000000454311474413133200175420ustar00rootroot00000000000000from __future__ import annotations import contextlib import logging import os from collections.abc import Mapping, Sequence from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Optional, cast from warnings import warn from napari._pydantic_compat import ( BaseModel, BaseSettings, SettingsError, ValidationError, 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 collections.abc import Set as AbstractSet from typing import Any, Union from napari._pydantic_compat import ( EnvSettingsSource, SettingsSourceCallable, ) from napari.utils.events import Event IntStr = Union[int, str] AbstractSetIntStr = AbstractSet[IntStr] DictStrAny = dict[str, Any] MappingIntStrAny = Mapping[IntStr, Any] Dict = dict # rename, because EventedSettings has method dict class EventedSettings(BaseSettings, EventedModel): """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. class Config(EventedModel.Config): pass 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( 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, ) # Even when EventedConfigFileSettings is a subclass of BaseSettings, # mypy do not see this @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}') 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.6/napari/settings/_constants.py000066400000000000000000000021521474413133200206350ustar00rootroot00000000000000from enum import auto from napari.utils.compat import StrEnum from napari.utils.misc import StringEnum class LabelDTypes(StrEnum): uint8 = 'uint8' int8 = 'int8' uint16 = 'uint16' int16 = 'int16' uint32 = 'uint32' int32 = 'int32' uint64 = 'uint64' int64 = 'int64' uint = 'uint' int = 'int' 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() class BrushSizeOnMouseModifiers(StrEnum): ALT = 'Alt' CTRL = 'Control' CTRL_ALT = 'Control+Alt' CTRL_SHIFT = 'Control+Shift' DISABLED = 'Disabled' # a non-existent modifier that is never activated napari-0.5.6/napari/settings/_experimental.py000066400000000000000000000055651474413133200213310ustar00rootroot00000000000000from napari._pydantic_compat 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): async_: bool = Field( False, title=trans._('Render Images Asynchronously'), description=trans._( 'Asynchronous loading of image data. \nThis setting partially loads data while viewing.' ), env='napari_async', requires_restart=False, ) autoswap_buffers: bool = Field( False, title=trans._('Enable autoswapping rendering buffers.'), description=trans._( 'Autoswapping rendering buffers improves quality by reducing tearing artifacts, while sacrificing some performance.' ), env='napari_autoswap', requires_restart=True, ) rdp_epsilon: float = Field( 0.5, title=trans._('Shapes polygon lasso and path RDP epsilon'), description=trans._( 'Setting this higher removes more points from polygons or paths. \nSetting this to 0 keeps all vertices of ' 'a given polygon or path.' ), type=float, ge=0, ) lasso_vertex_distance: int = Field( 10, title=trans._( 'Minimum distance threshold of shapes lasso and path tool' ), description=trans._( 'Value determines how many screen pixels one has to move before another vertex can be added to the polygon' 'or path.' ), type=int, gt=0, lt=50, ) completion_radius: int = Field( default=-1, title=trans._( 'Double-click Labels polygon completion radius (-1 to always complete)' ), description=trans._( 'Max radius in pixels from first vertex for double-click to complete a polygon; set -1 to always complete.' ), ) compiled_triangulation: bool = Field( False, title=trans._( 'Use C++ code to speed up creation and updates of Shapes layers' '(requires optional dependencies)' ), description=trans._( 'When enabled, triangulation (breaking down polygons into ' "triangles that can be displayed by napari's graphics engine) is " 'sped up by using C++ code from the optional library ' 'PartSegCore-compiled-backend. C++ code can cause bad crashes ' 'called segmentation faults or access violations. If you ' 'encounter such a crash while using this option please report ' 'it at https://github.com/napari/napari/issues.' ), ) class NapariConfig: # Napari specific configuration preferences_exclude = ('schema_version',) napari-0.5.6/napari/settings/_fields.py000066400000000000000000000137721474413133200201010ustar00rootroot00000000000000import re from dataclasses import dataclass from functools import total_ordering from typing import Any, Optional, SupportsInt, 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 __slots__ = () @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 TypeError(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 __slots__ = () @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 TypeError(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.6/napari/settings/_migrations.py000066400000000000000000000135441474413133200210040ustar00rootroot00000000000000from __future__ import annotations import sys import warnings from contextlib import contextmanager from importlib.metadata import distributions from typing import TYPE_CHECKING, Callable, NamedTuple from napari.settings._fields import Version from napari.settings._shortcuts import ShortcutsSettings 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 def _swap_ctrl_cmd(keybinding): """Swap the Control and Command/Super/Meta modifiers in a keybinding. See `v050_060` for motivation. """ from napari.utils.key_bindings import KeyBinding kb = KeyBinding.from_str( str(keybinding) .replace('Ctrl', 'Temp') .replace('Meta', 'Ctrl') .replace('Temp', 'Meta') ) return kb @migrator('0.5.0', '0.6.0') def v050_060(model: NapariSettings): """Migrate from v0.5.0 to v0.6.0. In #5103 we went from using our own keybinding model to using app-model's. Several consequences of this are: - Control is written out as Ctrl (and that is the only valid input) - Option is written out as Alt (and that is the only valid input) - Super/Command/Cmd are written out as Meta (both Meta and Cmd are valid inputs) - modifiers with keys are written as Mod+Key rather than Mod-Key. However, both versions are valid inputs. - macOS shortcuts using command keys are written out as Meta, where they used to be written out as Control. The alias problem is solved in `napari.utils.keybindings.coerce_keybinding` by substituting all variants with the canonical versions in app-model. The separator problem (-/+) can be ignored. This migrator solves the final problem, by detecting whether the current OS is macOS, and swapping Control and Meta in all key bindings if so. """ if sys.platform == 'darwin': current_keybinds = model.shortcuts.shortcuts default_shortcuts = ShortcutsSettings().shortcuts new_keybinds = {} for action_str, keybind_list in current_keybinds.items(): new_keybind_list = [] for kb in keybind_list: if kb not in default_shortcuts[action_str]: new_keybind_list.append(_swap_ctrl_cmd(kb)) else: new_keybind_list.append(kb) new_keybinds[action_str] = new_keybind_list model.shortcuts.shortcuts = new_keybinds napari-0.5.6/napari/settings/_napari_settings.py000066400000000000000000000105711474413133200220170ustar00rootroot00000000000000import os from pathlib import Path from typing import Any, Optional from napari._pydantic_compat 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, 6, 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.'), allow_mutation=False, ) plugins: PluginsSettings = Field( default_factory=PluginsSettings, title=trans._('Plugins'), description=trans._('Plugins settings.'), allow_mutation=False, ) shortcuts: ShortcutsSettings = Field( default_factory=ShortcutsSettings, title=trans._('Shortcuts'), description=trans._('Shortcut settings.'), allow_mutation=False, ) experimental: ExperimentalSettings = Field( default_factory=ExperimentalSettings, title=trans._('Experimental'), description=trans._('Experimental settings.'), allow_mutation=False, ) # 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) @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.6/napari/settings/_plugins.py000066400000000000000000000037621474413133200203120ustar00rootroot00000000000000from typing_extensions import TypedDict from napari._pydantic_compat import Field from napari.settings._base import EventedSettings 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 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, ) 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', ) napari-0.5.6/napari/settings/_shortcuts.py000066400000000000000000000021101474413133200206510ustar00rootroot00000000000000from __future__ import annotations from napari._pydantic_compat 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): 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', allow_reuse=True, pre=True) def shortcut_validate( cls, v: dict[str, list[KeyBinding | str]] ) -> dict[str, list[KeyBinding]]: 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.6/napari/settings/_tests/000077500000000000000000000000001474413133200174115ustar00rootroot00000000000000napari-0.5.6/napari/settings/_tests/__init__.py000066400000000000000000000000001474413133200215100ustar00rootroot00000000000000napari-0.5.6/napari/settings/_tests/test_migrations.py000066400000000000000000000130411474413133200231750ustar00rootroot00000000000000import os import sys 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 @pytest.mark.skipif(sys.platform != 'darwin', reason='tests migration on macs') def test_050_to_060_migration_mac(): """Check that Ctrl and Meta are swapped on macOS when migrating.""" settings050 = NapariSettings( schema_version='0.5.0', shortcuts={ 'shortcuts': { 'napari:focus_axes_up': ['Alt-Up'], 'napari:roll_axes': ['Control-E'], 'napari:transpose_axes': ['Control-Meta-T'], 'napari:paste_shape': ['V', 'Meta-T'], } }, ) settings060 = NapariSettings( schema_version='0.6.0', shortcuts={ 'shortcuts': { 'napari:focus_axes_up': ['Alt-Up'], 'napari:roll_axes': ['Meta-E'], 'napari:transpose_axes': ['Ctrl-Meta-T'], 'napari:paste_shape': ['V', 'Ctrl-T'], } }, ) assert settings050 == settings060 @pytest.mark.skipif( sys.platform == 'darwin', reason='migration should not be no-op on macs' ) def test_050_to_060_migration_linux_win(): """Check that shortcuts are unchanged on non-macOS when migrating.""" shortcuts_dict = { 'napari:focus_axes_up': ['Alt-Up'], 'napari:roll_axes': ['Control-E'], 'napari:transpose_axes': ['Control-Meta-T'], 'napari:paste_shape': ['V', 'Meta-T'], } settings050 = NapariSettings( schema_version='0.5.0', shortcuts={'shortcuts': shortcuts_dict} ) settings060 = NapariSettings( schema_version='0.6.0', shortcuts={'shortcuts': shortcuts_dict} ) assert settings050 == settings060 napari-0.5.6/napari/settings/_tests/test_settings.py000066400000000000000000000344361474413133200226740ustar00rootroot00000000000000"""Tests for the settings manager.""" import os from pathlib import Path import pytest from yaml import safe_load from napari import settings from napari._pydantic_compat import Field, ValidationError 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(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 = f""" schema_version: {CURRENT_SCHEMA_VERSION} 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) assert 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(ValidationError): # Should be string test_settings.appearance.theme = 1 with pytest.raises(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(ValidationError): test_settings.appearance.theme = custom_theme_name blue_theme = get_theme('dark').to_rgb_dict() 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'} # can also use short `env` name for EventedSettings class assert NapariSettings(None).experimental.async_ is False monkeypatch.setenv('NAPARI_ASYNC', '1') assert NapariSettings(None).experimental.async_ is True def test_two_env_variable_settings(monkeypatch): assert NapariSettings(None).experimental.async_ is False assert NapariSettings(None).experimental.autoswap_buffers is False monkeypatch.setenv('NAPARI_EXPERIMENTAL_ASYNC_', '1') monkeypatch.setenv('NAPARI_EXPERIMENTAL_AUTOSWAP_BUFFERS', '1') assert NapariSettings(None).experimental.async_ is True assert NapariSettings(None).experimental.autoswap_buffers is True def test_settings_env_variables_fails(monkeypatch): monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'FOOBAR') with pytest.raises(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 = 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 overridden 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_env_variables_override_file(tmp_path, monkeypatch): # create a settings file with async_ = true data = 'experimental:\n async_: true\n autoswap_buffers: true' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) # make sure they wrote correctly disk_settings = fake_path.read_text() assert 'async_: true' in disk_settings assert 'autoswap_buffers: true' in disk_settings # make sure they load correctly assert NapariSettings(fake_path).experimental.async_ is True assert NapariSettings(fake_path).experimental.autoswap_buffers is True # now load settings again with an Env-var override monkeypatch.setenv('NAPARI_ASYNC', '0') monkeypatch.setenv('NAPARI_AUTOSWAP', '0') settings = NapariSettings(fake_path) # make sure the override worked, and save again assert settings.experimental.async_ is False assert settings.experimental.autoswap_buffers is False 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(schema_version=CURRENT_SCHEMA_VERSION).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( RuntimeError, match='The path can only be set once per session' ): settings.get_settings(p) 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, match='No path provided'): # 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) def test_shortcut_aliases(): """Check that Command, Option, Super, Cmd are all valid modifiers.""" settings_original = NapariSettings( schema_version='0.6.0', shortcuts={ 'shortcuts': { 'napari:focus_axes_up': ['Option-Up'], 'napari:roll_axes': ['Super-E'], 'napari:transpose_axes': ['Control-Alt-T'], 'napari:paste_shape': ['V', 'Command-T'], 'napari:reset_view': ['Cmd-R'], } }, ) settings_canonical = NapariSettings( schema_version='0.6.0', shortcuts={ 'shortcuts': { 'napari:focus_axes_up': ['Alt+Up'], 'napari:roll_axes': ['Meta+E'], 'napari:transpose_axes': ['Ctrl+Alt+T'], 'napari:paste_shape': ['V', 'Meta+T'], 'napari:reset_view': ['Meta+R'], } }, ) assert settings_original == settings_canonical napari-0.5.6/napari/settings/_tests/test_utils.py000066400000000000000000000022451474413133200221650ustar00rootroot00000000000000from 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.6/napari/settings/_utils.py000066400000000000000000000006661474413133200177710ustar00rootroot00000000000000from typing import TypeVar T = TypeVar('T') def _coerce_extensions_to_globs(reader_settings: dict[str, T]) -> dict[str, T]: """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.6/napari/settings/_yaml.py000066400000000000000000000057111474413133200175670ustar00rootroot00000000000000from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING from app_model.types import KeyBinding from yaml import SafeDumper, dump_all from napari._pydantic_compat import BaseModel from napari.settings._fields import Version if TYPE_CHECKING: from collections.abc import Mapping, Set as AbstractSet from typing import Any, 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 napari._pydantic_compat 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.6/napari/types.py000066400000000000000000000137111474413133200157710ustar00rootroot00000000000000from collections.abc import Iterable, Mapping, Sequence from functools import partial, wraps from pathlib import Path from types import TracebackType from typing import ( TYPE_CHECKING, Any, Callable, NewType, Optional, Union, ) import numpy as np # TODO decide where types should be defined to have single place for them from npe2.types import LayerName as LayerTypeName from typing_extensions import TypedDict, get_args if TYPE_CHECKING: # dask zarr should be imported as `import dask.array as da` But here it is used only in type annotation to # register it as a valid type fom magicgui so is passed as string and requires full qualified name to allow # magicgui properly register it. import dask.array # noqa: ICN001 import zarr from magicgui.widgets import FunctionGui from qtpy.QtWidgets import QWidget __all__ = [ 'ArrayBase', 'ArrayLike', 'AugmentedWidget', 'ExcInfo', 'FullLayerData', 'ImageData', 'LabelsData', 'LayerData', 'LayerDataTuple', 'LayerTypeName', 'PathLike', 'PathOrPaths', 'PointsData', 'ReaderFunction', 'SampleData', 'SampleDict', 'ShapesData', 'SurfaceData', 'TracksData', 'VectorsData', 'WidgetCallable', 'WriterFunction', 'image_reader_to_layerdata_reader', ] # 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, Mapping, LayerTypeName] LayerData = Union[tuple[Any], tuple[Any, Mapping], FullLayerData] PathLike = Union[str, Path] PathOrPaths = Union[PathLike, Sequence[PathLike]] 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 ArrayBase: type[np.ndarray] = np.ndarray ImageData = NewType('ImageData', np.ndarray) LabelsData = NewType('LabelsData', np.ndarray) PointsData = NewType('PointsData', np.ndarray) ShapesData = NewType('ShapesData', list[np.ndarray]) SurfaceData = NewType('SurfaceData', tuple[np.ndarray, np.ndarray, np.ndarray]) TracksData = NewType('TracksData', np.ndarray) VectorsData = NewType('VectorsData', np.ndarray) _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.""" 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, ) future_type = Future[type_] # type: ignore [valid-type] 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, ) register_type( Future[data_type], # type: ignore [valid-type] choices=_mgui.get_layers_data, return_callback=partial(_mgui.add_future_data, _from_tuple=False), ) register_type( Optional[data_type], # type: ignore [call-overload] choices=_mgui.get_layers_data, return_callback=_mgui.add_layer_data_to_viewer, ) register_type( Future[Optional[data_type]], # type: ignore [valid-type] choices=_mgui.get_layers_data, return_callback=partial(_mgui.add_future_data, _from_tuple=False), ) _register_types_with_magicgui() napari-0.5.6/napari/utils/000077500000000000000000000000001474413133200154105ustar00rootroot00000000000000napari-0.5.6/napari/utils/__init__.py000066400000000000000000000013441474413133200175230ustar00rootroot00000000000000from napari._check_numpy_version import NUMPY_VERSION_IS_THREADSAFE from napari.utils._dask_utils import resize_dask_cache from napari.utils.colormaps.colormap import ( Colormap, CyclicLabelColormap, DirectLabelColormap, ) from napari.utils.info import citation_text, sys_info from napari.utils.notebook_display import ( NotebookScreenshot, nbscreenshot, ) from napari.utils.progress import cancelable_progress, progrange, progress __all__ = ( 'NUMPY_VERSION_IS_THREADSAFE', 'Colormap', 'CyclicLabelColormap', 'DirectLabelColormap', 'NotebookScreenshot', 'cancelable_progress', 'citation_text', 'nbscreenshot', 'progrange', 'progress', 'resize_dask_cache', 'sys_info', ) napari-0.5.6/napari/utils/_appdirs.py000066400000000000000000000017471474413133200175740ustar00rootroot00000000000000import hashlib import os import sys from functools import partial from typing import Callable import appdirs PREFIX_PATH = os.path.realpath(sys.prefix) sha_short = f'{os.path.basename(PREFIX_PATH)}_{hashlib.sha1(PREFIX_PATH.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 ) napari-0.5.6/napari/utils/_base.py000066400000000000000000000005711474413133200170360ustar00rootroot00000000000000""" 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.6/napari/utils/_dask_utils.py000066400000000000000000000122401474413133200202620ustar00rootroot00000000000000"""Dask cache utilities.""" import collections.abc import contextlib from collections.abc import Iterator from typing import Any, Callable, Optional 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[ [], contextlib.AbstractContextManager[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 = int(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 = int(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: Any) -> 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: Any, cache: bool = 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: float = 0.5, ) -> Iterator[tuple[Any, Any]]: 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.6/napari/utils/_dtype.py000066400000000000000000000055751474413133200172620ustar00rootroot00000000000000from typing import 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] return None 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 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 vispy_texture_dtype = np.float32 napari-0.5.6/napari/utils/_indexing.py000066400000000000000000000046731474413133200177400ustar00rootroot00000000000000import numpy as np import numpy.typing as npt def elements_in_slice( index: tuple[npt.NDArray[np.int_], ...], position_in_axes: dict[int, int] ) -> npt.NDArray[np.bool_]: """Mask elements from a multi-dimensional index not in a given slice. Some n-D operations may edit data that is not visible in the current slice. Given slice position information (as a dictionary mapping axis to index on that axis), this function returns a boolean mask for the possibly higher-dimensional multi-index so that elements not currently visible are masked out. The resulting multi-index can then be subset and used to index into a texture or other lower-dimensional view. Parameters ---------- index : tuple of array of int A NumPy fancy indexing expression [1]_. position_in_axes : dict[int, int] A dictionary mapping sliced (non-displayed) axes to a slice position. Returns ------- visible : array of bool A boolean array indicating which items are visible in the current view. """ queries = [ index[ax] == position for ax, position in position_in_axes.items() ] return np.logical_and.reduce(queries, axis=0) def index_in_slice( index: tuple[npt.NDArray[np.int_], ...], position_in_axes: dict[int, int], indices_order: tuple[int, ...], ) -> tuple[npt.NDArray[np.int_], ...]: """Convert a NumPy fancy indexing expression from data to sliced space. Parameters ---------- index : tuple of array of int A NumPy fancy indexing expression [1]_. position_in_axes : dict[int, int] A dictionary mapping sliced (non-displayed) axes to a slice position. indices_order : tuple of int The order of the indices in data view. Returns ------- sliced_index : tuple of array of int The indexing expression (nD) restricted to the current slice (usually 2D or 3D). Examples -------- >>> index = (np.arange(5), np.full(5, 1), np.arange(4, 9)) >>> index_in_slice(index, {0: 3}, (0, 1, 2)) (array([1]), array([7])) >>> index_in_slice(index, {1: 1, 2: 8}, (0, 1, 2)) (array([4]),) References ---------- [1]: https://numpy.org/doc/stable/user/basics.indexing.html#integer-array-indexing """ index_in_slice = elements_in_slice(index, position_in_axes) return tuple( index[i][index_in_slice] for i in indices_order if i not in position_in_axes ) napari-0.5.6/napari/utils/_magicgui.py000066400000000000000000000312311474413133200177060ustar00rootroot00000000000000"""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. Because of headless tests the tests for this module are in napari/_tests/test_magicgui.py """ from __future__ import annotations import weakref from functools import cache, partial from typing import TYPE_CHECKING, Any, Optional 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._qt._qapp_model.injection._qprocessors 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._qt._qapp_model.injection._qprocessors 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._qt._qapp_model.injection._qprocessors 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 @cache 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: FunctionGui[Any], result: Any, return_type: 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._qt._qapp_model.injection._qprocessors 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.6/napari/utils/_proxies.py000066400000000000000000000203151474413133200176130ustar00rootroot00000000000000import 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 __init__(self, wrapped: Any, exceptions: tuple[str, ...] = ()): super().__init__(wrapped) self._self_exceptions = exceptions def __setattr__(self, name: str, val: Any) -> None: if ( name not in ('__wrapped__', '_self_exceptions') and name not in self._self_exceptions ): raise TypeError( trans._( 'cannot set attribute {name}', deferred=True, name=name, ) ) super().__setattr__(name, val) def __setitem__(self, name: str, val: Any) -> None: if name not in self._self_exceptions: raise TypeError( trans._('cannot set item {name}', deferred=True, name=name) ) super().__setitem__(name, val) _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) -> None: 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.6.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() -> bool: """ 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) -> Any: 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) with warnings.catch_warnings(record=True) as cx_manager: data = self.create(super().__getattr__(name)) for warning in cx_manager: warnings.warn(warning.message, warning.category, stacklevel=2) return data def __setattr__(self, name: str, value: Any) -> None: 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) if isinstance(value, PublicOnlyProxy): # if we want to set an attribute on a PublicOnlyProxy *and* the # value that we want to set is itself a PublicOnlyProxy, we unwrap # the value. This has two benefits: # # 1. Checking the attribute later will incur a significant # performance cost, because _is_called_from_napari() will be # checked on each attribute access and it involves inspecting the # calling frame, which is expensive. # 2. Certain equality checks fail when objects are # PublicOnlyProxies. Notably, equality checks fail when such # objects are included in a Qt data model. For example, plugins can # grab a layer from the viewer; this layer will be wrapped by the # PublicOnlyProxy, and then using this object to set the current # layer selection will not propagate the selection to the Viewer. # See https://github.com/napari/napari/issues/5767 value = value.__wrapped__ setattr(self.__wrapped__, name, value) return None def __getitem__(self, key: Any) -> Any: return self.create(super().__getitem__(key)) def __repr__(self) -> str: return repr(self.__wrapped__) def __dir__(self) -> list[str]: 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 if type(obj).__name__ == 'method': # If the given object is a method, we check the module *of the # object to which that method is bound*. Otherwise, the module of a # method is just builtins! mod = getattr(type(obj.__self__), '__module__', None) or '' else: # Otherwise, the module is of an object just given by the # __module__ attribute. 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): # type: ignore [no-untyped-def] # if a PublicOnlyProxy is callable, then when we call it we: # - unwrap the arguments, to avoid performance issues detailed in # PublicOnlyProxy.__setattr__, # - call the unwrapped callable on the unwrapped arguments # - wrap the result in a PublicOnlyProxy args = tuple( arg.__wrapped__ if isinstance(arg, PublicOnlyProxy) else arg for arg in args ) kwargs = { k: v.__wrapped__ if isinstance(v, PublicOnlyProxy) else v for k, v in kwargs.items() } return self.create(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 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() return res in_main_thread = _in_main_thread napari-0.5.6/napari/utils/_register.py000066400000000000000000000061571474413133200177560ustar00rootroot00000000000000import os import sys from inspect import Parameter, getdoc, signature from napari.utils.migrations import rename_argument from napari.utils.misc import camel_to_snake from napari.utils.translations import trans tmpl_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'add_layer.py_tmpl' ) with open(tmpl_path) as f: template = f.read() def create_func(cls, name=None, doc=None): """ Creates a function (such as `add_`) to add a layer to the viewer The functionality is inherited from the corresponding `` class. """ 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: # While the original class may have Attributes in its docstring, the # generated function should not have an Attributes section. # See https://numpydoc.readthedocs.io/en/latest/format.html#documenting-classes doc = getdoc(cls) start = doc.find('\n\nParameters\n----------\n') end = doc.find('\n\nAttributes\n----------\n') if end == -1: end = None if start > 0: doc = doc[start:end] 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) additional_parameters = [] if hasattr(cls.__init__, '_deprecated_constructor_args'): additional_parameters = [ Parameter( name=arg, kind=Parameter.KEYWORD_ONLY, default=None, ) for arg in cls.__init__._deprecated_constructor_args ] new_sig = sig.replace( parameters=[ Parameter('self', Parameter.POSITIONAL_OR_KEYWORD), *list(sig.parameters.values()), *additional_parameters, ], 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=tmpl_path, mode='exec') exec(code, execdict) func = execdict[name] func.__doc__ = doc func.__signature__ = sig.replace( parameters=[ Parameter('self', Parameter.POSITIONAL_OR_KEYWORD), *list(sig.parameters.values()), ], return_annotation=cls, ) if hasattr(cls.__init__, '_rename_argument'): for ( from_name, to_name, version, since_version, ) in cls.__init__._rename_argument: func = rename_argument(from_name, to_name, version, since_version)( func ) return func napari-0.5.6/napari/utils/_test_utils.py000066400000000000000000000073471474413133200203330ustar00rootroot00000000000000""" File with things that are useful for testing, but not to be fixtures """ import inspect from dataclasses import dataclass, field from typing import Optional, Union import numpy as np from docstring_parser import parse from napari.utils._proxies import ReadOnlyWrapper @dataclass class MouseEvent: """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 = field( default_factory=lambda: 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]) delta: Optional[tuple[float, float]] = None native: Optional[bool] = None def read_only_mouse_event(*args, **kwargs): return ReadOnlyWrapper( MouseEvent(*args, **kwargs), exceptions=('handled',) ) def validate_all_params_in_docstring(func): """ Validate if all the parameters in the function signature are present in the docstring. """ assert func.__doc__ is not None, f'Function {func} has no docstring' parsed = parse(func.__doc__) params = [x for x in parsed.params if x.args[0] == 'param'] # get only parameters from docstring signature = inspect.signature(func) assert set(signature.parameters.keys()) == {x.arg_name for x in params}, ( 'Parameters in signature and docstring do not match' ) for sig, doc in zip(signature.parameters.values(), params): assert sig.name == doc.arg_name, ( 'Parameters in signature and docstring are not in the same order.' ) # assert sig.annotation == doc.type_name, f"Type of parameter {sig.name} in signature and docstring do not match" def validate_kwargs_sorted(func): """ Validate if the keyword arguments in the function signature are sorted alphabetically. """ signature = inspect.signature(func) kwargs_list = [ x.name for x in signature.parameters.values() if x.kind == inspect.Parameter.KEYWORD_ONLY ] assert kwargs_list == sorted(kwargs_list), ( 'Keyword arguments are not sorted in function signature' ) def validate_docstring_parent_class_consistency(klass, skip=('data', 'ndim')): """ Validate if the docstrings of the class parameters and type information are consistent with the parent class. Parameters ---------- klass : type The class to validate. skip : tuple or set, optional Name of parameters that we know are different from the parent class. By default, ('data', 'ndim'). Raises ------ AssertionError If the docstrings of the parameters are not consistent with the parent class. """ parsed = parse(klass.__doc__) params = { x.arg_name: x for x in parsed.params if x.args[0] == 'param' and x.arg_name not in skip } for base_klass in klass.__bases__: base_parsed = { x.arg_name: x for x in parse(base_klass.__doc__).params if x.args[0] == 'param' } for name, doc in params.items(): if name not in base_parsed: continue assert doc.description == base_parsed[name].description, ( f'Description of parameter "{name}" in {klass} and {base_klass} do not match' ) assert doc.type_name == base_parsed[name].type_name, ( f'Type annotation of parameter "{name}" in {klass} ({doc.type_name}) and {base_klass} ({base_parsed[name].type_name}) do not match' ) napari-0.5.6/napari/utils/_tests/000077500000000000000000000000001474413133200167115ustar00rootroot00000000000000napari-0.5.6/napari/utils/_tests/__init__.py000066400000000000000000000000001474413133200210100ustar00rootroot00000000000000napari-0.5.6/napari/utils/_tests/test_action_manager.py000066400000000000000000000036511474413133200232760ustar00rootroot00000000000000""" This module test some of the behavior of action manager. """ from unittest.mock import Mock 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'] == [] def test_bind_key_generator(action_manager): def _sample_generator(): yield 'X' action_manager.register_action( 'napari:test_action_1', _sample_generator, 'this is a test action', None, ) with pytest.raises(ValueError, match='generator functions'): action_manager.bind_button('napari:test_action_1', Mock()) napari-0.5.6/napari/utils/_tests/test_compat.py000066400000000000000000000005501474413133200216050ustar00rootroot00000000000000from napari.utils.compat import StrEnum def test_str_enum(): class Cake(StrEnum): CHOC = 'chocolate' VANILLA = 'vanilla' STRAWBERRY = 'strawberry' assert Cake.CHOC == 'chocolate' assert Cake.CHOC == Cake.CHOC assert f'{Cake.CHOC}' == 'chocolate' assert Cake.CHOC != 'vanilla' assert Cake.CHOC != Cake.VANILLA napari-0.5.6/napari/utils/_tests/test_dtype.py000066400000000000000000000050221474413133200214460ustar00rootroot00000000000000import 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_types = ['complex64', 'complex128'] bools = ['bool'] pure_py = ['int', 'float'] @pytest.mark.parametrize( 'dtype_str', ['uint8', *ints, *floats, *complex_types, *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_types + 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_types + 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) assert len(lims) == 2 napari-0.5.6/napari/utils/_tests/test_geometry.py000066400000000000000000000416561474413133200221710ustar00rootroot00000000000000import 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([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.6/napari/utils/_tests/test_history.py000066400000000000000000000014431474413133200220250ustar00rootroot00000000000000from 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.6/napari/utils/_tests/test_info.py000066400000000000000000000035241474413133200212610ustar00rootroot00000000000000import 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.6/napari/utils/_tests/test_interactions.py000066400000000000000000000022211474413133200230210ustar00rootroot00000000000000import sys import 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' @pytest.mark.skipif( sys.platform != 'darwin', reason='Parsing macos specific keys' ) @pytest.mark.parametrize( ('expected', 'shortcut'), [ ('␣', 'Space'), ('⌥', 'Alt'), ('⌥-', 'Alt--'), ('⌘', 'Meta'), ('⌘-', 'Meta--'), ('⌘⌥', 'Meta-Alt'), ('⌥⌘P', 'Meta-Alt-P'), ], ) def test_partial_shortcuts(shortcut, expected): assert str(Shortcut(shortcut)) == expected napari-0.5.6/napari/utils/_tests/test_io.py000066400000000000000000000072761474413133200207450ustar00rootroot00000000000000import struct import numpy as np import pytest import tifffile from imageio.v3 import imread from napari.utils.io import imsave @pytest.mark.slow @pytest.mark.parametrize( 'image_file', ['image', 'image.png', 'image.tif', 'image.bmp'] ) def test_imsave(tmp_path, image_file): """Save data to image file.""" # create image data np.random.seed(0) data = np.random.randint(20, size=(10, 15), dtype=np.ubyte) image_file_path = tmp_path / image_file assert not image_file_path.is_file() # create image and assert image file creation imsave(str(image_file_path), data) assert image_file_path.is_file() # check image content img_to_array = imread(str(image_file_path)) assert np.equal(data, img_to_array).all() def test_imsave_bool_tiff(tmp_path): """Test saving a boolean array to a tiff file.""" np.random.seed(0) data = np.random.randint(low=0, high=2, size=(10, 15), dtype=bool) image_file_path = tmp_path / 'bool_image.tif' assert not image_file_path.is_file() # create image and assert image file creation imsave(str(image_file_path), data) assert image_file_path.is_file() # check image content img_to_array = imread(str(image_file_path)) assert np.equal(data, img_to_array).all() @pytest.mark.parametrize( 'image_file', ['image', 'image.png', 'image.tif', 'image.bmp'] ) def test_imsave_float(tmp_path, image_file): """Test saving float image data.""" # create image data np.random.seed(0) data = np.random.random((10, 15)) image_file_path = tmp_path / image_file assert not image_file_path.is_file() # create image imsave(str(image_file_path), data) # only TIF can store float if image_file.endswith('.tif'): assert image_file_path.is_file() img_to_array = imread(str(image_file_path)) assert np.equal(data, img_to_array).all() # if no EXT provided, for float data should save as .tif elif image_file == 'image': assert image_file_path.with_suffix('.tif').is_file() img_to_array = imread(str(image_file_path.with_suffix('.tif'))) assert np.equal(data, img_to_array).all() else: assert not image_file_path.is_file() def test_imsave_large_file(monkeypatch, tmp_path): """Test saving a bigtiff file. In napari IO, we use compression when saving to tiff. bigtiff mode is required when data size *after compression* is over 4GB. However, bigtiff is not as broadly implemented as normal tiff, so we don't want to always use that flag. Therefore, in our internal code, we catch the error raised when trying to write a too-large TIFF file, then rewrite using bigtiff. This test checks that the mechanism works correctly. Generating 4GB+ of uncompressible data is expensive, so here we: 1. Generate a smaller amount of "random" data using np.empty. 2. Monkeypatch tifffile's save routine to raise an error *as if* bigtiff was required for this small dataset. 3. This triggers saving as bigtiff. 4. We check that the file was correctly saved as bigtiff. """ old_write = tifffile.imwrite def raise_no_bigtiff(*args, **kwargs): if 'bigtiff' not in kwargs: raise struct.error old_write(*args, **kwargs) monkeypatch.setattr(tifffile, 'imwrite', raise_no_bigtiff) # create image data. It can be <4GB compressed because we # monkeypatched tifffile.imwrite to raise an error if bigtiff is not set data = np.empty((20, 200, 200), dtype='uint16') # create image and assert image file creation image_path = str(tmp_path / 'data.tif') imsave(image_path, data) with tifffile.TiffFile(image_path) as tiff: assert tiff.is_bigtiff napari-0.5.6/napari/utils/_tests/test_key_bindings.py000066400000000000000000000236001474413133200227700ustar00rootroot00000000000000import inspect import time import types from unittest.mock import patch import pytest from app_model.types import KeyBinding, 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, ) @pytest.mark.key_bindings def test_bind_key(): kb = {} # bind def forty_two(): return 42 bind_key(kb, 'A', forty_two) assert kb == {KeyBinding.from_str('A'): forty_two} # overwrite def spam(): return 'SPAM' with pytest.raises(ValueError, match='already used'): bind_key(kb, 'A', spam) bind_key(kb, 'A', spam, overwrite=True) assert kb == {KeyBinding.from_str('A'): spam} # unbind bind_key(kb, 'A', None) assert kb == {} # check signature # blocker bind_key(kb, 'A', ...) assert kb == {KeyBinding.from_str('A'): ...} # catch-all bind_key(kb, ..., ...) assert kb == {KeyBinding.from_str('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 == KeyBinding.from_str('Shift-A') @pytest.mark.key_bindings def test_bind_key_decorator(): kb = {} @bind_key(kb, 'A') def foo(): ... assert kb == {KeyBinding.from_str('A'): foo} @pytest.mark.key_bindings 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 == {KeyBinding.from_str('A'): ...} @pytest.mark.key_bindings 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)} @pytest.mark.key_bindings 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 == { KeyBinding.from_str('A'): types.MethodType( foo.class_keymap[KeyBinding.from_str('A')], foo ), KeyBinding.from_str('B'): types.MethodType( foo.keymap[KeyBinding.from_str('B')], foo ), KeyBinding.from_str('E'): types.MethodType( foo.keymap[KeyBinding.from_str('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') @pytest.mark.key_bindings @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 == { KeyBinding.from_str('A'): types.MethodType( foo.class_keymap[KeyBinding.from_str('A')], foo ), KeyBinding.from_str('B'): types.MethodType( foo.keymap[KeyBinding.from_str('B')], foo ), KeyBinding.from_str('D'): abc, KeyBinding.from_str('E'): types.MethodType( bar.class_keymap[KeyBinding.from_str('E')], bar ), } handler.press_key('D') assert x == 42 @pytest.mark.key_bindings 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 == { KeyBinding.from_str('A'): types.MethodType( foo.class_keymap[KeyBinding.from_str('A')], foo ), KeyBinding.from_str('B'): types.MethodType( foo.keymap[KeyBinding.from_str('B')], foo ), KeyBinding.from_str('E'): types.MethodType( bar.class_keymap[KeyBinding.from_str('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), KeyBinding.from_str('E'): types.MethodType( bar.class_keymap[KeyBinding.from_str('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 == { KeyBinding.from_str('E'): types.MethodType( bar.class_keymap[KeyBinding.from_str('E')], bar ), } del foo.B handler.press_key('B') assert not hasattr(foo, 'B') @pytest.mark.key_bindings 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 == { KeyBinding.from_str('F'): types.MethodType( baz.class_keymap[KeyBinding.from_str('F')], baz ), KeyBinding.from_str('E'): types.MethodType( Bar.class_keymap[KeyBinding.from_str('E')], baz ), } @pytest.mark.key_bindings 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 @pytest.mark.key_bindings def test_bind_key_method(): class Foo2(KeymapProvider): ... foo = Foo2() # instance binding foo.bind_key('A', lambda: 42) assert foo.keymap[KeyBinding.from_str('A')]() == 42 # class binding @Foo2.bind_key('B') def bar(): return 'SPAM' assert Foo2.class_keymap[KeyBinding.from_str('B')] is bar @pytest.mark.key_bindings 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.6/napari/utils/_tests/test_migrations.py000066400000000000000000000153721474413133200225060ustar00rootroot00000000000000import pytest from napari.utils.migrations import ( _DeprecatingDict, add_deprecated_property, deprecated_class_name, rename_argument, ) def test_simple(): @rename_argument('a', 'b', '1', '0.5') 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, match='already defined'): sample_fun(b=1, a=1) def test_constructor(): class Sample: @rename_argument('a', 'b', '1', '0.5') 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 def test_deprecated_property() -> None: class Dummy: def __init__(self) -> None: self._value = 0 @property def new_property(self) -> int: return self._value @new_property.setter def new_property(self, value: int) -> int: self._value = value instance = Dummy() add_deprecated_property( Dummy, 'old_property', 'new_property', '0.1.0', '0.0.0' ) assert instance.new_property == 0 instance.new_property = 1 msg = 'Dummy.old_property is deprecated since 0.0.0 and will be removed in 0.1.0. Please use new_property' with pytest.warns(FutureWarning, match=msg): assert instance.old_property == 1 with pytest.warns(FutureWarning, match=msg): instance.old_property = 2 assert instance.new_property == 2 def test_deprecated_class_name(): """Test the deprecated class name function.""" class macOS: pass MacOSX = deprecated_class_name( macOS, 'MacOSX', version='10.12', since_version='10.11' ) with pytest.warns(FutureWarning, match='deprecated.*macOS'): _os = MacOSX() with pytest.warns(FutureWarning, match='deprecated.*macOS'): class MacOSXServer(MacOSX): pass def test_deprecating_dict_with_renamed_in_deprecated_keys(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) assert 'c' in d.deprecated_keys def test_deprecating_dict_with_renamed_getitem_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): assert d['c'] == 1 def test_deprecating_dict_with_renamed_get_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): assert d.get('c') == 1 def test_deprecating_dict_with_renamed_set_nondeprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) d['a'] = 3 with pytest.warns(FutureWarning, match='is deprecated since'): assert d['c'] == 3 def test_deprecating_dict_with_renamed_set_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): d['c'] = 3 with pytest.warns(FutureWarning, match='is deprecated since'): assert d['c'] == 3 assert d['a'] == 3 def test_deprecating_dict_with_renamed_update_nondeprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) d.update({'a': 3}) with pytest.warns(FutureWarning, match='is deprecated since'): assert d['c'] == 3 def test_deprecating_dict_with_renamed_update_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): d.update({'c': 3}) with pytest.warns(FutureWarning, match='is deprecated since'): assert d['c'] == 3 assert d['a'] == 3 def test_deprecating_dict_with_renamed_del_nondeprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) assert 'a' in d with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' in d with pytest.warns(FutureWarning, match='is deprecated since'): del d['c'] assert 'a' not in d with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' not in d def test_deprecating_dict_with_renamed_del_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' in d assert 'a' in d with pytest.warns(FutureWarning, match='is deprecated since'): del d['c'] with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' not in d assert 'a' not in d def test_deprecating_dict_with_renamed_pop_nondeprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) assert 'a' in d with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' in d with pytest.warns(FutureWarning, match='is deprecated since'): d.pop('c') assert 'a' not in d with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' not in d def test_deprecating_dict_with_renamed_pop_deprecated(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' in d assert 'a' in d with pytest.warns(FutureWarning, match='is deprecated since'): d.pop('c') with pytest.warns(FutureWarning, match='is deprecated since'): assert 'c' not in d assert 'a' not in d def test_deprecating_dict_with_renamed_copy(): d = _DeprecatingDict({'a': 1, 'b': 2}) d.set_deprecated_from_rename( from_name='c', to_name='a', version='v2.0', since_version='v1.6' ) e = d.copy() assert d is not e assert e.data == d.data assert e.deprecated_keys == d.deprecated_keys napari-0.5.6/napari/utils/_tests/test_misc.py000066400000000000000000000203201474413133200212520ustar00rootroot00000000000000from enum import auto from importlib.metadata import version as package_version from os.path import abspath, expanduser, sep from pathlib import Path import numpy as np import pytest from packaging.version import parse as parse_version from napari.utils.misc import ( StringEnum, _is_array_type, _pandas_dataframe_equal, _quiet_array_equal, abspath_or_url, ensure_iterable, ensure_list_of_layer_data_tuple, ensure_sequence_of_iterables, is_iterable, 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_data', '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), (None, (None, None, None)), (PARTLY_NESTED_ITERABLE, REPEATED_PARTLY_NESTED_ITERABLE), ([], ([], [], [])), ], ) def test_sequence_of_iterables(input_data, expected): """Test ensure_sequence_of_iterables returns a sequence of iterables.""" zipped = zip( range(3), ensure_sequence_of_iterables(input_data, repeat_empty=True), expected, ) for _i, result, expectation in zipped: assert result == expectation def test_sequence_of_iterables_allow_none(): input_data = [(1, 2), None] assert ( ensure_sequence_of_iterables(input_data, allow_none=True) == input_data ) def test_sequence_of_iterables_no_repeat_empty(): assert ensure_sequence_of_iterables([], repeat_empty=False) == [] with pytest.raises(ValueError, match='must equal'): ensure_sequence_of_iterables([], repeat_empty=False, length=3) def test_sequence_of_iterables_raises(): with pytest.raises(ValueError, match='must equal'): # 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. iterable = (None, (0, 1), (0, 2)) result = iter(ensure_sequence_of_iterables(iterable)) with pytest.raises(AssertionError): assert next(result) is None @pytest.mark.parametrize( ('input_data', 'expected'), [ (ITERABLE, ITERABLE), (DICT, DICT), (1, [1, 1, 1]), ('foo', ['foo', 'foo', 'foo']), (None, [None, None, None]), ], ) def test_ensure_iterable(input_data, expected): """Test test_ensure_iterable returns an iterable.""" zipped = zip(range(3), ensure_iterable(input_data), 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, match='not a valid'): 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, match='may only be called with'): TestEnum(OtherEnum.SOMETHING) # test string conversion assert str(TestEnum.THING) == 'thing' # test direct comparison with a string assert TestEnum.THING == 'thing' assert TestEnum.THING == 'thing' assert TestEnum.THING != 'notathing' assert TestEnum.THING != 'notathing' # 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 pandas as pd 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 ) assert pick_equality_operator( pd.DataFrame({'A': [1]}) == _pandas_dataframe_equal ) @pytest.mark.skipif( parse_version(package_version('numpy')) >= parse_version('1.25.0'), reason='Numpy 1.25.0 return true for below comparison', ) def test_equality_operator_silence(): import numpy as np 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, QWidget from napari import Viewer from napari._qt._qapp_model.qactions import init_qactions from napari._qt.qt_viewer import QtViewer from napari.plugins import _initialize_plugins 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() _initialize_plugins.cache_clear() init_qactions.cache_clear() viewers: WeakSet[Viewer] = WeakSet() request.node._viewer_weak_set = viewers # may be overridden by using the parameter `strict_qt` _strict = False initial = QApplication.topLevelWidgets() prior_exception = getattr(sys, 'last_value', None) is_internal_test = request.module.__name__.startswith('napari.') # disable thread for status checker monkeypatch.setattr( 'napari._qt.threads.status_checker.StatusChecker.start', _empty, ) if 'enable_console' not in request.keywords: def _dummy_widget(*_): w = QWidget() w._update_theme = _empty return w monkeypatch.setattr( 'napari._qt.qt_viewer.QtViewer._get_console', _dummy_widget ) 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, ( f'{request.config.getoption(_SAVE_GRAPH_OPNAME)}, {_SAVE_GRAPH_OPNAME}' ) # only check for leaked widgets if an exception was raised during the test, # and "strict" mode was used. if _strict and getattr(sys, 'last_value', None) is prior_exception: QApplication.processEvents() leak = set(QApplication.topLevelWidgets()).difference(initial) leak = (x for x in leak if x.objectName() != 'handled_widget') # still not sure how to clean up some of the remaining vispy # vispy.app.backends._qt.CanvasBackendDesktop widgets... # observed in `test_sys_info.py` 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': 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() return actual_factory @pytest.fixture def MouseEvent(): """Create a subclass for simulating vispy mouse events. Returns ------- Event : Type A new dataclass named Event that can be used to create an object with fields "type" and "is_dragging". """ @dataclass class Event: type: str position: tuple[float] is_dragging: bool = False dims_displayed: tuple[int] = (0, 1) dims_point: list[float] = None view_direction: list[int] = None pos: list[int] = (0, 0) button: int = None handled: bool = False return Event napari-0.5.6/napari/utils/_tracebacks.py000066400000000000000000000174201474413133200202270ustar00rootroot00000000000000import re import sys from collections.abc import Generator from typing import Callable 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: # avoid verbose printing of the array data with np.printoptions(precision=5, threshold=10, edgeitems=2): 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) return tb_text except ModuleNotFoundError: import traceback if sys.version_info < (3, 11): import cgitb # 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: # avoid verbose printing of the array data with np.printoptions(precision=5, threshold=10, edgeitems=2): 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)) return tb_text else: def format_exc_info( info: ExcInfo, as_html: bool, color=None ) -> str: # avoid verbose printing of the array data with np.printoptions(precision=5, threshold=10, edgeitems=2): tb_text = ''.join(traceback.format_exception(*info)) if as_html: tb_text = '
    ' + tb_text + '
    ' 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 ''.format('; '.join(style)) in_span = True yield ansi_string[previous_end:] if in_span: yield '' in_span = False napari-0.5.6/napari/utils/_units.py000066400000000000000000000015521474413133200172660ustar00rootroot00000000000000"""Units utilities.""" from functools import lru_cache from typing import TYPE_CHECKING if TYPE_CHECKING: import pint # 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() -> 'pint.UnitRegistry': """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.6/napari/utils/action_manager.py000066400000000000000000000353451474413133200207430ustar00rootroot00000000000000from __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, 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_model return get_app_model().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: Optional[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 ) if keymapprovider: 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 = 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)) and isgeneratorfunction( getattr(action, 'command', None) ): raise ValueError( trans._( '`bind_button` cannot be used with generator functions', deferred=True, ) ) def _trigger(): self.trigger(name) button.clicked.connect(_trigger) 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.6/napari/utils/add_layer.py_tmpl000066400000000000000000000011111474413133200207340ustar00rootroot00000000000000def {name}{signature}: kwargs = dict(locals()) kwargs.pop('self', None) pos_kwargs = dict() for name in getattr({cls_name}.__init__, "_deprecated_constructor_args", []): pos_kwargs[name] = kwargs.pop(name, None) layer = {cls_name}(**kwargs) for name, value in pos_kwargs.items(): if value is not None: setattr(layer, name, value) self.layers.append(layer) return layer # This file is created to improve user bug reports when # user use `viewer.add_xxxx` and some error occurs. # this allow to have more readable stacktrace. napari-0.5.6/napari/utils/color.py000066400000000000000000000111131474413133200170750ustar00rootroot00000000000000"""Contains napari color constants and utilities.""" from collections.abc import Iterator from typing import Callable, Union import numpy as np from napari.utils.colormaps.standardize_color import transform_color ColorValueParam = Union[np.ndarray, list, tuple, str, None] ColorArrayParam = Union[np.ndarray, list, tuple, None] 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. """ def __new__(cls, value: ColorValueParam) -> 'ColorValue': return cls.validate(value) @classmethod def __get_validators__( cls, ) -> Iterator[Callable[[ColorValueParam], 'ColorValue']]: yield cls.validate @classmethod def validate(cls, value: ColorValueParam) -> 'ColorValue': """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].view(cls) 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. """ def __new__(cls, value: ColorArrayParam) -> 'ColorArray': return cls.validate(value) @classmethod def __get_validators__( cls, ) -> Iterator[Callable[[ColorArrayParam], 'ColorArray']]: yield cls.validate def __sizeof__(self) -> int: return super().__sizeof__() + self.nbytes @classmethod def validate(cls, value: ColorArrayParam) -> 'ColorArray': """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).view(cls) return transform_color(value).view(cls) napari-0.5.6/napari/utils/colormaps/000077500000000000000000000000001474413133200174075ustar00rootroot00000000000000napari-0.5.6/napari/utils/colormaps/__init__.py000066400000000000000000000020211474413133200215130ustar00rootroot00000000000000from napari.utils.colormaps.colorbars import make_colorbar from napari.utils.colormaps.colormap import ( Colormap, CyclicLabelColormap, DirectLabelColormap, LabelColormap, ) from napari.utils.colormaps.colormap_utils import ( ALL_COLORMAPS, AVAILABLE_COLORMAPS, CYMRGB, INVERSE_COLORMAPS, MAGENTA_GREEN, RGB, SIMPLE_COLORMAPS, ValidColormapArg, color_dict_to_colormap, direct_colormap, display_name_to_name, ensure_colormap, label_colormap, low_discrepancy_image, matplotlib_colormaps, ) __all__ = ( 'ALL_COLORMAPS', 'AVAILABLE_COLORMAPS', 'CYMRGB', 'INVERSE_COLORMAPS', 'MAGENTA_GREEN', 'RGB', 'SIMPLE_COLORMAPS', 'Colormap', 'CyclicLabelColormap', 'DirectLabelColormap', 'LabelColormap', 'ValidColormapArg', 'color_dict_to_colormap', 'direct_colormap', 'display_name_to_name', 'ensure_colormap', 'label_colormap', 'low_discrepancy_image', 'make_colorbar', 'matplotlib_colormaps', ) napari-0.5.6/napari/utils/colormaps/_accelerated_cmap.py000066400000000000000000000166001474413133200233570ustar00rootroot00000000000000""" Colormap utility functions to be sped-up by numba JIT. These should stay in a separate module because they need to be reloaded during testing, which can break instance/class relationships when done dynamically. See https://github.com/napari/napari/pull/7025#issuecomment-2186190719. """ from importlib.metadata import version from typing import TYPE_CHECKING import numpy as np from packaging.version import parse if TYPE_CHECKING: from numba import typed from napari.utils.colormaps import DirectLabelColormap __all__ = ( 'labels_raw_to_texture_direct', 'minimum_dtype_for_labels', 'zero_preserving_modulo', 'zero_preserving_modulo_numpy', ) MAPPING_OF_UNKNOWN_VALUE = 0 # For direct mode we map all unknown values to single value # for simplicity of implementation we select 0 def minimum_dtype_for_labels(num_colors: int) -> np.dtype: """Return the minimum texture dtype that can hold given number of colors. Parameters ---------- num_colors : int Number of unique colors in the data. Returns ------- np.dtype Minimum dtype that can hold the number of colors. """ if num_colors <= np.iinfo(np.uint8).max: return np.dtype(np.uint8) if num_colors <= np.iinfo(np.uint16).max: return np.dtype(np.uint16) return np.dtype(np.float32) def zero_preserving_modulo_numpy( values: np.ndarray, n: int, dtype: np.dtype, to_zero: int = 0 ) -> np.ndarray: """``(values - 1) % n + 1``, but with one specific value mapped to 0. This ensures (1) an output value in [0, n] (inclusive), and (2) that no nonzero values in the input are zero in the output, other than the ``to_zero`` value. Parameters ---------- values : np.ndarray The dividend of the modulo operator. n : int The divisor. dtype : np.dtype The desired dtype for the output array. to_zero : int, optional A specific value to map to 0. (By default, 0 itself.) Returns ------- np.ndarray The result: 0 for the ``to_zero`` value, ``values % n + 1`` everywhere else. """ if n > np.iinfo(dtype).max: # n is to big, modulo will be pointless res = values.astype(dtype) res[values == to_zero] = 0 return res res = ((values - 1) % n + 1).astype(dtype) res[values == to_zero] = 0 return res def _zero_preserving_modulo_loop( values: np.ndarray, n: int, dtype: np.dtype, to_zero: int = 0 ) -> np.ndarray: """``(values - 1) % n + 1``, but with one specific value mapped to 0. This ensures (1) an output value in [0, n] (inclusive), and (2) that no nonzero values in the input are zero in the output, other than the ``to_zero`` value. Parameters ---------- values : np.ndarray The dividend of the modulo operator. n : int The divisor. dtype : np.dtype The desired dtype for the output array. to_zero : int, optional A specific value to map to 0. (By default, 0 itself.) Returns ------- np.ndarray The result: 0 for the ``to_zero`` value, ``values % n + 1`` everywhere else. """ result = np.empty_like(values, dtype=dtype) # need to preallocate numpy array for asv memory benchmarks return _zero_preserving_modulo_inner_loop(values, n, to_zero, out=result) def _zero_preserving_modulo_inner_loop( values: np.ndarray, n: int, to_zero: int, out: np.ndarray ) -> np.ndarray: """``(values - 1) % n + 1``, but with one specific value mapped to 0. This ensures (1) an output value in [0, n] (inclusive), and (2) that no nonzero values in the input are zero in the output, other than the ``to_zero`` value. Parameters ---------- values : np.ndarray The dividend of the modulo operator. n : int The divisor. to_zero : int A specific value to map to 0. (Usually, 0 itself.) out : np.ndarray Preallocated output array Returns ------- np.ndarray The result: 0 for the ``to_zero`` value, ``values % n + 1`` everywhere else. """ for i in prange(values.size): if values.flat[i] == to_zero: out.flat[i] = 0 else: out.flat[i] = (values.flat[i] - 1) % n + 1 return out if parse('2.0') <= parse(version('numpy')) < parse('2.1'): def clip(data: np.ndarray, min_val: int, max_val: int) -> np.ndarray: """ Clip data to the given range. """ dtype_info = np.iinfo(data.dtype) min_val = max(min_val, dtype_info.min) max_val = min(max_val, dtype_info.max) return np.clip(data, min_val, max_val) else: def clip(data: np.ndarray, min_val: int, max_val: int) -> np.ndarray: return np.clip(data, min_val, max_val) def _labels_raw_to_texture_direct_numpy( data: np.ndarray, direct_colormap: 'DirectLabelColormap' ) -> np.ndarray: """Convert labels data to the data type used in the texture. This implementation uses numpy vectorized operations. See `_cast_labels_data_to_texture_dtype_direct` for more details. """ if direct_colormap.use_selection: return (data == direct_colormap.selection).astype(np.uint8) mapper = direct_colormap._array_map if any(x < 0 for x in direct_colormap.color_dict if x is not None): half_shape = mapper.shape[0] // 2 - 1 data = clip(data, -half_shape, half_shape) else: data = clip(data, 0, mapper.shape[0] - 1) return mapper[data] def _labels_raw_to_texture_direct_loop( data: np.ndarray, direct_colormap: 'DirectLabelColormap' ) -> np.ndarray: """ Cast direct labels to the minimum type. Parameters ---------- data : np.ndarray The input data array. direct_colormap : DirectLabelColormap The direct colormap. Returns ------- np.ndarray The cast data array. """ if direct_colormap.use_selection: return (data == direct_colormap.selection).astype(np.uint8) dkt = direct_colormap._get_typed_dict_mapping(data.dtype) target_dtype = minimum_dtype_for_labels( direct_colormap._num_unique_colors + 2 ) result_array = np.full_like( data, MAPPING_OF_UNKNOWN_VALUE, dtype=target_dtype ) return _labels_raw_to_texture_direct_inner_loop(data, dkt, result_array) def _labels_raw_to_texture_direct_inner_loop( data: np.ndarray, dkt: 'typed.Dict', out: np.ndarray ) -> np.ndarray: """ Relabel data using typed dict with mapping unknown labels to default value """ # The numba typed dict does not provide official Api for # determine key and value types for i in prange(data.size): val = data.flat[i] if val in dkt: out.flat[i] = dkt[data.flat[i]] return out try: import numba except ModuleNotFoundError: zero_preserving_modulo = zero_preserving_modulo_numpy labels_raw_to_texture_direct = _labels_raw_to_texture_direct_numpy prange = range else: _zero_preserving_modulo_inner_loop = numba.njit(parallel=True, cache=True)( _zero_preserving_modulo_inner_loop ) zero_preserving_modulo = _zero_preserving_modulo_loop labels_raw_to_texture_direct = _labels_raw_to_texture_direct_loop _labels_raw_to_texture_direct_inner_loop = numba.njit( parallel=True, cache=True )(_labels_raw_to_texture_direct_inner_loop) prange = numba.prange # type: ignore [misc] del numba napari-0.5.6/napari/utils/colormaps/_tests/000077500000000000000000000000001474413133200207105ustar00rootroot00000000000000napari-0.5.6/napari/utils/colormaps/_tests/__init__.py000066400000000000000000000000001474413133200230070ustar00rootroot00000000000000napari-0.5.6/napari/utils/colormaps/_tests/colors_data.py000066400000000000000000000063641474413133200235650ustar00rootroot00000000000000""" 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.6/napari/utils/colormaps/_tests/test_categorical_colormap.py000066400000000000000000000116021474413133200264720ustar00rootroot00000000000000import 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.6/napari/utils/colormaps/_tests/test_categorical_colormap_utils.py000066400000000000000000000037231474413133200277170ustar00rootroot00000000000000from 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.6/napari/utils/colormaps/_tests/test_color_to_array.py000066400000000000000000000034421474413133200253420ustar00rootroot00000000000000import 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.6/napari/utils/colormaps/_tests/test_colormap.py000066400000000000000000000435751474413133200241530ustar00rootroot00000000000000import importlib from collections import defaultdict from itertools import product from unittest.mock import patch import numpy as np import numpy.testing as npt import pytest from napari._pydantic_compat import ValidationError from napari.utils.color import ColorArray from napari.utils.colormaps import Colormap, _accelerated_cmap, colormap from napari.utils.colormaps._accelerated_cmap import ( MAPPING_OF_UNKNOWN_VALUE, _labels_raw_to_texture_direct_numpy, ) from napari.utils.colormaps.colormap import ( CyclicLabelColormap, DirectLabelColormap, LabelColormapBase, _normalize_label_colormap, ) from napari.utils.colormaps.colormap_utils import label_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( ValidationError, match='need to be sorted in ascending order' ): 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( ValidationError, match='Wrong number of control points' ): 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( ValidationError, match='must start with 0.0 and end with 1.0' ): 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( ValidationError, match='must start with 0.0 and end with 1.0' ): 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()) @pytest.mark.parametrize('ndim', range(1, 5)) def test_mapped_shape(ndim): np.random.seed(0) img = np.random.random((5,) * ndim) cmap = Colormap(colors=['red']) mapped = cmap.map(img) assert mapped.shape == img.shape + (4,) @pytest.mark.parametrize( ('num', 'dtype'), [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] ) def test_minimum_dtype_for_labels(num, dtype): assert _accelerated_cmap.minimum_dtype_for_labels(num) == dtype @pytest.fixture def _disable_jit(monkeypatch): """Fixture to temporarily disable numba JIT during testing. This helps to measure coverage and in debugging. *However*, reloading a module can cause issues with object instance / class relationships, so the `_accelerated_cmap` module should be as small as possible and contain no class definitions, only functions. """ pytest.importorskip('numba') with patch('numba.core.config.DISABLE_JIT', True): importlib.reload(_accelerated_cmap) yield importlib.reload(_accelerated_cmap) # revert to original state @pytest.mark.parametrize(('num', 'dtype'), [(40, np.uint8), (1000, np.uint16)]) @pytest.mark.usefixtures('_disable_jit') def test_cast_labels_to_minimum_type_auto(num: int, dtype, monkeypatch): cmap = label_colormap(num) data = np.zeros(3, dtype=np.uint32) data[1] = 10 data[2] = 10**6 + 5 cast_arr = colormap._cast_labels_data_to_texture_dtype_auto(data, cmap) assert cast_arr.dtype == dtype assert cast_arr[0] == 0 assert cast_arr[1] == 10 assert cast_arr[2] == 10**6 % num + 5 @pytest.fixture def direct_label_colormap(): return DirectLabelColormap( color_dict={ 0: np.array([0, 0, 0, 0]), 1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1]), 3: np.array([0, 0, 1, 1]), 12: np.array([0, 0, 1, 1]), None: np.array([1, 1, 1, 1]), }, ) def test_direct_label_colormap_simple(direct_label_colormap): np.testing.assert_array_equal( direct_label_colormap.map([0, 2, 7]), np.array([[0, 0, 0, 0], [0, 1, 0, 1], [1, 1, 1, 1]]), ) assert direct_label_colormap._num_unique_colors == 5 ( label_mapping, color_dict, ) = direct_label_colormap._values_mapping_to_minimum_values_set() assert len(label_mapping) == 6 assert len(color_dict) == 5 assert label_mapping[None] == MAPPING_OF_UNKNOWN_VALUE assert label_mapping[12] == label_mapping[3] np.testing.assert_array_equal( color_dict[label_mapping[0]], direct_label_colormap.color_dict[0] ) np.testing.assert_array_equal( color_dict[0], direct_label_colormap.color_dict[None] ) def test_direct_label_colormap_selection(direct_label_colormap): direct_label_colormap.selection = 2 direct_label_colormap.use_selection = True np.testing.assert_array_equal( direct_label_colormap.map([0, 2, 7]), np.array([[0, 0, 0, 0], [0, 1, 0, 1], [0, 0, 0, 0]]), ) ( label_mapping, color_dict, ) = direct_label_colormap._values_mapping_to_minimum_values_set() assert len(label_mapping) == 2 assert len(color_dict) == 2 @pytest.mark.usefixtures('_disable_jit') def test_cast_direct_labels_to_minimum_type(direct_label_colormap): data = np.arange(15, dtype=np.uint32) cast = _accelerated_cmap.labels_raw_to_texture_direct( data, direct_label_colormap ) label_mapping = ( direct_label_colormap._values_mapping_to_minimum_values_set()[0] ) assert cast.dtype == np.uint8 np.testing.assert_array_equal( cast, np.array( [ label_mapping[0], label_mapping[1], label_mapping[2], label_mapping[3], MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, label_mapping[3], MAPPING_OF_UNKNOWN_VALUE, MAPPING_OF_UNKNOWN_VALUE, ] ), ) @pytest.mark.parametrize( ('num', 'dtype'), [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] ) @pytest.mark.usefixtures('_disable_jit') def test_test_cast_direct_labels_to_minimum_type_no_jit(num, dtype): cmap = DirectLabelColormap( color_dict={ None: np.array([1, 1, 1, 1]), **{ k: np.array([*v, 1]) for k, v in zip( range(num), product(np.linspace(0, 1, num=256), repeat=3) ) }, }, ) cmap.color_dict[None] = np.array([1, 1, 1, 1]) data = np.arange(10, dtype=np.uint32) data[2] = 80005 cast = _accelerated_cmap.labels_raw_to_texture_direct(data, cmap) assert cast.dtype == dtype def test_zero_preserving_modulo_naive(): pytest.importorskip('numba') data = np.arange(1000, dtype=np.uint32) res1 = _accelerated_cmap.zero_preserving_modulo_numpy(data, 49, np.uint8) res2 = _accelerated_cmap.zero_preserving_modulo(data, 49, np.uint8) npt.assert_array_equal(res1, res2) @pytest.mark.parametrize( 'dtype', [np.uint8, np.uint16, np.int8, np.int16, np.float32, np.float64] ) def test_label_colormap_map_with_uint8_values(dtype): cmap = colormap.CyclicLabelColormap( colors=ColorArray(np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]])) ) values = np.array([0, 1, 2], dtype=dtype) expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) npt.assert_array_equal(cmap.map(values), expected) @pytest.mark.parametrize('selection', [1, -1]) @pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) def test_label_colormap_map_with_selection(selection, dtype): cmap = colormap.CyclicLabelColormap( colors=ColorArray( np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) ), use_selection=True, selection=selection, ) values = np.array([0, selection, 2], dtype=np.int8) expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 0, 0, 0]]) npt.assert_array_equal(cmap.map(values), expected) @pytest.mark.parametrize('background', [1, -1]) @pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) def test_label_colormap_map_with_background(background, dtype): cmap = colormap.CyclicLabelColormap( colors=ColorArray( np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) ), background_value=background, ) values = np.array([3, background, 2], dtype=dtype) expected = np.array([[1, 0, 0, 1], [0, 0, 0, 0], [0, 1, 0, 1]]) npt.assert_array_equal(cmap.map(values), expected) @pytest.mark.parametrize('dtype', [np.uint8, np.uint16]) def test_label_colormap_using_cache(dtype, monkeypatch): cmap = colormap.CyclicLabelColormap( colors=ColorArray(np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]])) ) values = np.array([0, 1, 2], dtype=dtype) expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) map1 = cmap.map(values) npt.assert_array_equal(map1, expected) monkeypatch.setattr( _accelerated_cmap, 'zero_preserving_modulo_numpy', None ) map2 = cmap.map(values) npt.assert_array_equal(map1, map2) @pytest.mark.parametrize('size', [100, 1000]) def test_cast_direct_labels_to_minimum_type_naive(size): pytest.importorskip('numba') data = np.arange(size, dtype=np.uint32) dtype = _accelerated_cmap.minimum_dtype_for_labels(size) cmap = DirectLabelColormap( color_dict={ None: np.array([1, 1, 1, 1]), **{ k: np.array([*v, 1]) for k, v in zip( range(size - 2), product(np.linspace(0, 1, num=256), repeat=3), ) }, }, ) cmap.color_dict[None] = np.array([255, 255, 255, 255]) res1 = _accelerated_cmap.labels_raw_to_texture_direct(data, cmap) res2 = _accelerated_cmap._labels_raw_to_texture_direct_numpy(data, cmap) npt.assert_array_equal(res1, res2) assert res1.dtype == dtype assert res2.dtype == dtype def test_direct_colormap_with_no_selection(): # Create a DirectLabelColormap with a simple color_dict color_dict = { 1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) # Map a single value mapped = cmap.map(1) npt.assert_array_equal(mapped, np.array([1, 0, 0, 1])) # Map multiple values mapped = cmap.map(np.array([1, 2])) npt.assert_array_equal(mapped, np.array([[1, 0, 0, 1], [0, 1, 0, 1]])) def test_direct_colormap_with_selection(): # Create a DirectLabelColormap with a simple color_dict and a selection color_dict = { 1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap( color_dict=color_dict, use_selection=True, selection=1 ) # Map a single value mapped = cmap.map(1) npt.assert_array_equal(mapped, np.array([1, 0, 0, 1])) # Map a value that is not the selection mapped = cmap.map(2) npt.assert_array_equal(mapped, np.array([0, 0, 0, 0])) def test_direct_colormap_with_invalid_values(): # Create a DirectLabelColormap with a simple color_dict color_dict = { 1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) # Map a value that is not in the color_dict mapped = cmap.map(3) npt.assert_array_equal(mapped, np.array([0, 0, 0, 0])) def test_direct_colormap_with_values_outside_data_dtype(): """https://github.com/napari/napari/pull/6998#issuecomment-2176070672""" color_dict = { 1: np.array([1, 0, 1, 1]), 2: np.array([0, 1, 0, 1]), 257: np.array([1, 1, 1, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) # Map an array with a dtype for which some dict values are out of range mapped = cmap.map(np.array([1], dtype=np.uint8)) npt.assert_array_equal(mapped[0], color_dict[1].astype(mapped.dtype)) def test_direct_colormap_with_empty_color_dict(): # Create a DirectLabelColormap with an empty color_dict with pytest.warns(Warning, match='color_dict did not provide'): DirectLabelColormap(color_dict={}) def test_direct_colormap_with_non_integer_values(): # Create a DirectLabelColormap with a simple color_dict color_dict = { 1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) # Map a float value with pytest.raises(TypeError, match='DirectLabelColormap can only'): cmap.map(1.5) # Map a string value with pytest.raises(TypeError, match='DirectLabelColormap can only'): cmap.map('1') def test_direct_colormap_with_collision(): # this test assumes that the the selected prime number for hash map size is 11 color_dict = { 1: np.array([1, 0, 0, 1]), 12: np.array([0, 1, 0, 1]), 23: np.array([0, 0, 1, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) npt.assert_array_equal(cmap.map(1), np.array([1, 0, 0, 1])) npt.assert_array_equal(cmap.map(12), np.array([0, 1, 0, 1])) npt.assert_array_equal(cmap.map(23), np.array([0, 0, 1, 1])) def test_direct_colormap_negative_values(): # Create a DirectLabelColormap with a simple color_dict color_dict = { -1: np.array([1, 0, 0, 1]), -2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 0]), } cmap = DirectLabelColormap(color_dict=color_dict) # Map a single value mapped = cmap.map(np.int8(-1)) npt.assert_array_equal(mapped, np.array([1, 0, 0, 1])) # Map multiple values mapped = cmap.map(np.array([-1, -2], dtype=np.int8)) npt.assert_array_equal(mapped, np.array([[1, 0, 0, 1], [0, 1, 0, 1]])) def test_direct_colormap_negative_values_numpy(): color_dict = { -1: np.array([1, 0, 0, 1]), -2: np.array([0, 1, 0, 1]), None: np.array([0, 0, 0, 1]), } cmap = DirectLabelColormap(color_dict=color_dict) res = _labels_raw_to_texture_direct_numpy( np.array([-1, -2, 5], dtype=np.int8), cmap ) npt.assert_array_equal(res, [1, 2, 0]) cmap.selection = -2 cmap.use_selection = True res = _labels_raw_to_texture_direct_numpy( np.array([-1, -2, 5], dtype=np.int8), cmap ) npt.assert_array_equal(res, [0, 1, 0]) @pytest.mark.parametrize( 'colormap_like', [ ['red', 'blue'], [[1, 0, 0, 1], [0, 0, 1, 1]], {None: 'transparent', 1: 'red', 2: 'blue'}, {None: [0, 0, 0, 0], 1: [1, 0, 0, 1], 2: [0, 0, 1, 1]}, defaultdict(lambda: 'transparent', {1: 'red', 2: 'blue'}), CyclicLabelColormap(['red', 'blue']), DirectLabelColormap( color_dict={None: 'transparent', 1: 'red', 2: 'blue'} ), 5, # test ValueError ], ) def test_normalize_label_colormap(colormap_like): if not isinstance(colormap_like, int): assert isinstance( _normalize_label_colormap(colormap_like), LabelColormapBase ) else: with pytest.raises(ValueError, match='Unable to interpret'): _normalize_label_colormap(colormap_like) napari-0.5.6/napari/utils/colormaps/_tests/test_colormap_utils.py000066400000000000000000000077611474413133200253700ustar00rootroot00000000000000import numpy as np import numpy.testing as npt import pytest from napari.utils.colormaps.colormap_utils import ( CoercedContrastLimits, _coerce_contrast_limits, label_colormap, ) FIRST_COLORS = [ [0.47058824, 0.14509805, 0.02352941, 1.0], [0.35686275, 0.8352941, 0.972549, 1.0], [0.57254905, 0.5372549, 0.9098039, 1.0], [0.42352942, 0.00784314, 0.75686276, 1.0], [0.2784314, 0.22745098, 0.62352943, 1.0], [0.67058825, 0.9254902, 0.5411765, 1.0], [0.56078434, 0.6784314, 0.69803923, 1.0], [0.5254902, 0.5647059, 0.6039216, 1.0], [0.99607843, 0.96862745, 0.10980392, 1.0], [0.96862745, 0.26666668, 0.23137255, 1.0], ] @pytest.mark.parametrize( ('index', 'expected'), enumerate(FIRST_COLORS, start=1) ) def test_label_colormap(index, expected): """Test the label colormap. Make sure that the default label colormap colors are identical to past versions, for UX consistency. """ np.testing.assert_almost_equal(label_colormap(49).map(index), expected) def test_label_colormap_exception(): with pytest.raises(ValueError, match='num_colors must be >= 1'): label_colormap(0) with pytest.raises(ValueError, match='num_colors must be >= 1'): label_colormap(-1) with pytest.raises( ValueError, match=r'.*Only up to 2\*\*16=65535 colors are supported' ): label_colormap(2**16 + 1) def test_coerce_contrast_limits_with_valid_input(): contrast_limits = (0.0, 1.0) result = _coerce_contrast_limits(contrast_limits) assert isinstance(result, CoercedContrastLimits) assert np.allclose(result.contrast_limits, contrast_limits) assert result.offset == 0 assert np.isclose(result.scale, 1.0) npt.assert_allclose( result.contrast_limits, result.coerce_data(np.array(contrast_limits)) ) def test_coerce_contrast_limits_with_large_values(): contrast_limits = (0, float(np.finfo(np.float32).max) * 100) result = _coerce_contrast_limits(contrast_limits) assert isinstance(result, CoercedContrastLimits) assert np.isclose(result.contrast_limits[0], np.finfo(np.float32).min / 8) assert np.isclose(result.contrast_limits[1], np.finfo(np.float32).max / 8) assert result.offset < 0 assert result.scale < 1.0 npt.assert_allclose( result.contrast_limits, result.coerce_data(np.array(contrast_limits)) ) def test_coerce_contrast_limits_with_large_values_symmetric(): above_float32_max = float(np.finfo(np.float32).max) * 100 contrast_limits = (-above_float32_max, above_float32_max) result = _coerce_contrast_limits(contrast_limits) assert isinstance(result, CoercedContrastLimits) assert np.isclose(result.contrast_limits[0], np.finfo(np.float32).min / 8) assert np.isclose(result.contrast_limits[1], np.finfo(np.float32).max / 8) assert result.offset == 0 assert result.scale < 1.0 npt.assert_allclose( result.contrast_limits, result.coerce_data(np.array(contrast_limits)) ) def test_coerce_contrast_limits_with_large_values_above_limit(): f32_max = float(np.finfo(np.float32).max) contrast_limits = (f32_max * 10, f32_max * 100) result = _coerce_contrast_limits(contrast_limits) assert isinstance(result, CoercedContrastLimits) assert np.isclose(result.contrast_limits[0], np.finfo(np.float32).min / 8) assert np.isclose(result.contrast_limits[1], np.finfo(np.float32).max / 8) assert result.offset < 0 assert result.scale < 1.0 npt.assert_allclose( result.contrast_limits, result.coerce_data(np.array(contrast_limits)) ) def test_coerce_contrast_limits_small_values(): contrast_limits = (1e-45, 9e-45) result = _coerce_contrast_limits(contrast_limits) assert isinstance(result, CoercedContrastLimits) assert np.isclose(result.contrast_limits[0], 0) assert np.isclose(result.contrast_limits[1], 1000) assert result.offset < 0 assert result.scale > 1 npt.assert_allclose( result.contrast_limits, result.coerce_data(np.array(contrast_limits)) ) napari-0.5.6/napari/utils/colormaps/_tests/test_colormaps.py000066400000000000000000000232701474413133200243240ustar00rootroot00000000000000import re import 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): if name in {'label_colormap', 'custom'}: pytest.skip( 'label_colormap and custom are inadvertantly added to AVAILABLE_COLORMAPS but is not a normal colormap' ) 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 @pytest.mark.parametrize( ('name', 'display_name'), [ ('twilight_shifted', 'twilight shifted'), # MPL ('light_blues', 'light blues'), # Vispy ], ) def test_colormap_error_suggestion(name, display_name): """ Test that vispy/mpl errors, when using `display_name`, suggest `name`. """ with pytest.raises( KeyError, match=rf'{display_name}.*you might want to use.*{name}' ): vispy_or_mpl_colormap(display_name) def test_colormap_error_from_inexistent_name(): """ Test that vispy/mpl errors when using a wrong name. """ name = 'foobar' with pytest.raises(KeyError, match=rf'{name}.*Recognized colormaps are'): vispy_or_mpl_colormap(name) 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(color) for color in _MULTI_RGB_COLORS], [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) assert re.match(r'\[unnamed colormap \d+\]', colormap.name) is not None @pytest.mark.parametrize('color', ['#abc', '#abcd', '#abcdef', '#00ABCDEF']) def test_ensure_colormap_with_hex_color_string(color): """ Test all the accepted hex color representations (single/double digit rgb with/without alpha) """ cmap = ensure_colormap(color) assert isinstance(cmap, Colormap) assert cmap.name == color.lower() @pytest.mark.parametrize('color', ['#f0f', '#f0fF', '#ff00ff', '#ff00ffFF']) def test_ensure_colormap_with_recognized_hex_color_string(color): """ Test that a hex color string for magenta is associated with the existing magenta colormap """ cmap = ensure_colormap(color) assert isinstance(cmap, Colormap) assert cmap.name == 'magenta' @pytest.mark.parametrize('color', ['white', '#FFFFFF', '#ffffff', '#ffFFffFF']) def test_ensure_colormap_handles_grayscale(color): cmap = ensure_colormap(color) assert isinstance(cmap, Colormap) assert cmap.name == 'gray' @pytest.mark.parametrize('color', ['black', '#000000', '#000000FF']) def test_ensure_colormap_handles_black(color): cmap = ensure_colormap(color) assert isinstance(cmap, Colormap) assert cmap.name == 'gray_r' def test_ensure_colormap_error_with_invalid_hex_color_string(): """ Test that ensure_colormap errors when using an invalid hex color string """ color = '#ff' with pytest.raises(KeyError, match=rf'{color}.*Recognized colormaps are'): ensure_colormap(color) @pytest.mark.parametrize('mpl_name', ['chartreuse', 'chocolate', 'lavender']) def test_ensure_colormap_with_recognized_mpl_color_name(mpl_name): """ Test that the colormap name is identical to the the mpl color name passed to ensure_colormap """ cmap = ensure_colormap(mpl_name) assert isinstance(cmap, Colormap) assert cmap.name == mpl_name napari-0.5.6/napari/utils/colormaps/bop_colors.py000066400000000000000000001453021474413133200221270ustar00rootroot00000000000000"""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.6/napari/utils/colormaps/categorical_colormap.py000066400000000000000000000100151474413133200241270ustar00rootroot00000000000000from typing import Any, Union import numpy as np from napari._pydantic_compat import Field 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] = Field(default_factory=dict) fallback_color: ColorCycle = Field( default_factory=lambda: 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.isin(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] = ColorValue(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 = {} fallback_color = params.get('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, np.ndarray)): return cls.from_array(val) if isinstance(val, dict): return cls.from_dict(val) raise TypeError( trans._( 'colormap should be an array or dict', deferred=True, ) ) def __eq__(self, other): return ( isinstance(other, CategoricalColormap) and compare_colormap_dicts(self.colormap, other.colormap) and np.allclose( self.fallback_color.values, other.fallback_color.values ) ) napari-0.5.6/napari/utils/colormaps/categorical_colormap_utils.py000066400000000000000000000055231474413133200253570ustar00rootroot00000000000000from dataclasses import dataclass from itertools import cycle from typing import 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) if isinstance(val, ColorCycle): return val 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] elif isinstance(color_cycle, cycle): transformed_color_cycle = color_cycle else: raise TypeError(f'cycle entry must be type(cycle), got {type(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.6/napari/utils/colormaps/colorbars.py000066400000000000000000000021241474413133200217460ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt if TYPE_CHECKING: from vispy.color import Colormap def make_colorbar( cmap: 'Colormap', size: tuple[int, int] = (18, 28), horizontal: bool = True ) -> npt.NDArray[np.uint8]: """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: basic_values = np.linspace(0, 1, size[1]) bar = np.tile(np.expand_dims(basic_values, 1), size[0]).transpose( (1, 0) ) else: basic_values = np.linspace(0, 1, size[0]) bar = np.tile(np.expand_dims(basic_values, 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.6/napari/utils/colormaps/colormap.py000066400000000000000000000767371474413133200216210ustar00rootroot00000000000000from collections import defaultdict from collections.abc import MutableMapping, Sequence from functools import cached_property from typing import ( TYPE_CHECKING, Any, Literal, Optional, Union, cast, overload, ) from warnings import warn import numpy as np from typing_extensions import Self from napari._pydantic_compat import Field, PrivateAttr, validator from napari.utils.color import ColorArray from napari.utils.colormaps import _accelerated_cmap as _accel_cmap from napari.utils.colormaps.colorbars import make_colorbar from napari.utils.colormaps.standardize_color import transform_color from napari.utils.compat import StrEnum from napari.utils.events import EventedModel from napari.utils.events.custom_types import Array from napari.utils.migrations import deprecated_class_name from napari.utils.translations import trans if TYPE_CHECKING: from numba import typed class ColormapInterpolationMode(StrEnum): """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 = Field(default_factory=lambda: cast(Array, [])) 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, allow_reuse=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.get('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 __len__(self): return len(self.colors) 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) class LabelColormapBase(Colormap): use_selection: bool = False selection: int = 0 background_value: int = 0 interpolation: Literal[ColormapInterpolationMode.ZERO] = Field( ColormapInterpolationMode.ZERO, frozen=True ) _cache_mapping: dict[tuple[np.dtype, np.dtype], np.ndarray] = PrivateAttr( default={} ) _cache_other: dict[str, Any] = PrivateAttr(default={}) class Config(Colormap.Config): # this config is to avoid deepcopy of cached_property # see https://github.com/pydantic/pydantic/issues/2763 # it is required until we drop Pydantic 1 or Python 3.11 and older # need to validate after drop pydantic 1 keep_untouched = (cached_property,) @overload def _data_to_texture(self, values: np.ndarray) -> np.ndarray: ... @overload def _data_to_texture(self, values: np.integer) -> np.integer: ... def _data_to_texture( self, values: Union[np.ndarray, np.integer] ) -> Union[np.ndarray, np.integer]: """Map input values to values for send to GPU.""" raise NotImplementedError def _cmap_without_selection(self) -> Self: if self.use_selection: cmap = self.__class__(**self.dict()) cmap.use_selection = False return cmap return self def _get_mapping_from_cache( self, data_dtype: np.dtype ) -> Optional[np.ndarray]: """For given dtype, return precomputed array mapping values to colors. Returns None if the dtype itemsize is greater than 2. """ target_dtype = _texture_dtype(self._num_unique_colors, data_dtype) key = (data_dtype, target_dtype) if key not in self._cache_mapping and data_dtype.itemsize <= 2: data = np.arange( np.iinfo(target_dtype).max + 1, dtype=target_dtype ).astype(data_dtype) self._cache_mapping[key] = self._map_without_cache(data) return self._cache_mapping.get(key) def _clear_cache(self): """Mechanism to clean cached properties""" self._cache_mapping = {} self._cache_other = {} @property def _num_unique_colors(self) -> int: """Number of unique colors, not counting transparent black.""" return len(self.colors) - 1 def _map_without_cache(self, values: np.ndarray) -> np.ndarray: """Function that maps values to colors without selection or cache""" raise NotImplementedError def _selection_as_minimum_dtype(self, dtype: np.dtype) -> int: """Treat selection as given dtype and calculate value with min dtype. Parameters ---------- dtype : np.dtype The dtype to convert the selection to. Returns ------- int The selection converted. """ return int(self._data_to_texture(dtype.type(self.selection))) class CyclicLabelColormap(LabelColormapBase): """Color cycle with a background value. Attributes ---------- colors : ColorArray Colors to be used for mapping. For values above the number of colors, the colors will be cycled. use_selection : bool Whether map only selected label. If `True` only selected label will be mapped to not transparent color. selection : int The selected label. background_value : int Which value should be treated as a background and mapped to transparent color. interpolation : Literal['zero'] required by implementation, please do not set value seed : float seed used for random color generation. Used for reproducibility. It will be removed in the future release. """ seed: float = 0.5 @validator('colors', allow_reuse=True) def _validate_color(cls, v): if len(v) > 2**16: raise ValueError( 'Only up to 2**16=65535 colors are supported for LabelColormap' ) return v def _background_as_minimum_dtype(self, dtype: np.dtype) -> int: """Treat background as given dtype and calculate value with min dtype. Parameters ---------- dtype : np.dtype The dtype to convert the background to. Returns ------- int The background converted. """ return int(self._data_to_texture(dtype.type(self.background_value))) @overload def _data_to_texture(self, values: np.ndarray) -> np.ndarray: ... @overload def _data_to_texture(self, values: np.integer) -> np.integer: ... def _data_to_texture( self, values: Union[np.ndarray, np.integer] ) -> Union[np.ndarray, np.integer]: """Map input values to values for send to GPU.""" return _cast_labels_data_to_texture_dtype_auto(values, self) def _map_without_cache(self, values) -> np.ndarray: texture_dtype_values = _accel_cmap.zero_preserving_modulo_numpy( values, len(self.colors) - 1, values.dtype, self.background_value, ) mapped = self.colors[texture_dtype_values] mapped[texture_dtype_values == 0] = 0 return mapped def map(self, values: Union[np.ndarray, np.integer, int]) -> np.ndarray: """Map values to colors. Parameters ---------- values : np.ndarray or int Values to be mapped. Returns ------- np.ndarray of the same shape as values, but with the last dimension of size 4 Mapped colors. """ original_shape = np.shape(values) values = np.atleast_1d(values) if values.dtype.kind == 'f': values = values.astype(np.int64) mapper = self._get_mapping_from_cache(values.dtype) if mapper is not None: mapped = mapper[values] else: mapped = self._map_without_cache(values) if self.use_selection: mapped[(values != self.selection)] = 0 return np.reshape(mapped, original_shape + (4,)) def shuffle(self, seed: int): """Shuffle the colormap colors. Parameters ---------- seed : int Seed for the random number generator. """ np.random.default_rng(seed).shuffle(self.colors[1:]) self.events.colors(value=self.colors) LabelColormap = deprecated_class_name( CyclicLabelColormap, 'LabelColormap', version='0.5.0', since_version='0.4.19', ) class DirectLabelColormap(LabelColormapBase): """Colormap using a direct mapping from labels to color using a dict. Attributes ---------- color_dict: dict from int to (3,) or (4,) array The dictionary mapping labels to colors. use_selection : bool Whether to map only the selected label to a color. If `True` only selected label will be not transparent. selection : int The selected label. colors : ColorArray Exist because of implementation details. Please do not use it. """ color_dict: defaultdict[Optional[int], np.ndarray] = Field( default_factory=lambda: defaultdict(lambda: np.zeros(4)) ) use_selection: bool = False selection: int = 0 def __init__(self, *args, **kwargs) -> None: if 'colors' not in kwargs and not args: kwargs['colors'] = np.zeros(3) super().__init__(*args, **kwargs) def __len__(self): """Overwrite from base class because .color is a dummy array. This returns the number of colors in the colormap, including background and unmapped labels. """ return self._num_unique_colors + 2 @validator('color_dict', pre=True, always=True, allow_reuse=True) def _validate_color_dict(cls, v, values): """Ensure colors are RGBA arrays, not strings. Parameters ---------- cls : type The class of the object being instantiated. v : MutableMapping A mapping from integers to colors. It *may* have None as a key, which indicates the color to map items not in the dictionary. Alternatively, it could be a defaultdict. If neither is provided, missing colors are not rendered (rendered as fully transparent). values : dict[str, Any] A dictionary mapping previously-validated attributes to their validated values. Attributes are validated in the order in which they are defined. Returns ------- res : (default)dict[int, np.ndarray[float]] A properly-formatted dictionary mapping labels to RGBA arrays. """ if not isinstance(v, defaultdict) and None not in v: warn( 'color_dict did not provide a default color. ' 'Missing keys will be transparent. ' 'To provide a default color, use the key `None`, ' 'or provide a defaultdict instance.' ) v = {**v, None: 'transparent'} res = { label: transform_color(color_str)[0] for label, color_str in v.items() } if ( 'background_value' in values and (bg := values['background_value']) not in res ): res[bg] = transform_color('transparent')[0] if isinstance(v, defaultdict): res = defaultdict(v.default_factory, res) return res def _selection_as_minimum_dtype(self, dtype: np.dtype) -> int: return int( _cast_labels_data_to_texture_dtype_direct( dtype.type(self.selection), self ) ) @overload def _data_to_texture(self, values: np.ndarray) -> np.ndarray: ... @overload def _data_to_texture(self, values: np.integer) -> np.integer: ... def _data_to_texture( self, values: Union[np.ndarray, np.integer] ) -> Union[np.ndarray, np.integer]: """Map input values to values for send to GPU.""" return _cast_labels_data_to_texture_dtype_direct(values, self) def map(self, values: Union[np.ndarray, np.integer, int]) -> np.ndarray: """Map values to colors. Parameters ---------- values : np.ndarray or int Values to be mapped. Returns ------- np.ndarray of same shape as values, but with last dimension of size 4 Mapped colors. """ if isinstance(values, np.integer): values = int(values) if isinstance(values, int): if self.use_selection and values != self.selection: return np.array((0, 0, 0, 0)) return self.color_dict.get(values, self.default_color) if isinstance(values, (list, tuple)): values = np.array(values) if not isinstance(values, np.ndarray) or values.dtype.kind in 'fU': raise TypeError('DirectLabelColormap can only be used with int') mapper = self._get_mapping_from_cache(values.dtype) if mapper is not None: mapped = mapper[values] else: values_cast = _accel_cmap.labels_raw_to_texture_direct( values, self ) mapped = self._map_precast(values_cast, apply_selection=True) if self.use_selection: mapped[(values != self.selection)] = 0 return mapped def _map_without_cache(self, values: np.ndarray) -> np.ndarray: cmap = self._cmap_without_selection() cast = _accel_cmap.labels_raw_to_texture_direct(values, cmap) return self._map_precast(cast, apply_selection=False) def _map_precast(self, values, apply_selection) -> np.ndarray: """Map values to colors. Parameters ---------- values : np.ndarray Values to be mapped. It need to be already cast using cast_labels_to_minimum_type_auto Returns ------- np.ndarray of shape (N, M, 4) Mapped colors. Notes ----- it is implemented for thumbnail labels, where we already have cast values """ mapped = np.zeros(values.shape + (4,), dtype=np.float32) colors = self._values_mapping_to_minimum_values_set(apply_selection)[1] for idx in np.ndindex(values.shape): value = values[idx] mapped[idx] = colors[value] return mapped @cached_property def _num_unique_colors(self) -> int: """Count the number of unique colors in the colormap. This number does not include background or the default color for unmapped labels. """ return len({tuple(x) for x in self.color_dict.values()}) def _clear_cache(self): super()._clear_cache() if '_num_unique_colors' in self.__dict__: del self.__dict__['_num_unique_colors'] if '_label_mapping_and_color_dict' in self.__dict__: del self.__dict__['_label_mapping_and_color_dict'] if '_array_map' in self.__dict__: del self.__dict__['_array_map'] def _values_mapping_to_minimum_values_set( self, apply_selection=True ) -> tuple[dict[Optional[int], int], dict[int, np.ndarray]]: """Create mapping from original values to minimum values set. To use minimum possible dtype for labels. Returns ------- Dict[Optional[int], int] Mapping from original values to minimum values set. Dict[int, np.ndarray] Mapping from new values to colors. """ if self.use_selection and apply_selection: return {self.selection: 1, None: 0}, { 0: np.array((0, 0, 0, 0)), 1: self.color_dict.get( self.selection, self.default_color, ), } return self._label_mapping_and_color_dict @cached_property def _label_mapping_and_color_dict( self, ) -> tuple[dict[Optional[int], int], dict[int, np.ndarray]]: color_to_labels: dict[tuple[int, ...], list[Optional[int]]] = {} labels_to_new_labels: dict[Optional[int], int] = { None: _accel_cmap.MAPPING_OF_UNKNOWN_VALUE } new_color_dict: dict[int, np.ndarray] = { _accel_cmap.MAPPING_OF_UNKNOWN_VALUE: self.default_color, } for label, color in self.color_dict.items(): if label is None: continue color_tup = tuple(color) if color_tup not in color_to_labels: color_to_labels[color_tup] = [label] labels_to_new_labels[label] = len(new_color_dict) new_color_dict[labels_to_new_labels[label]] = color else: color_to_labels[color_tup].append(label) labels_to_new_labels[label] = labels_to_new_labels[ color_to_labels[color_tup][0] ] return labels_to_new_labels, new_color_dict def _get_typed_dict_mapping(self, data_dtype: np.dtype) -> 'typed.Dict': """Create mapping from label values to texture values of smaller dtype. In https://github.com/napari/napari/issues/6397, we noticed that using float32 textures was much slower than uint8 or uint16 textures. When labels data is (u)int(8,16), we simply use the labels data directly. But when it is higher-precision, we need to compress the labels into the smallest dtype that can still achieve the goal of the visualisation. This corresponds to the smallest dtype that can map to the number of unique colors in the colormap. Even if we have a million labels, if they map to one of two colors, we can map them to a uint8 array with values 1 and 2; then, the texture can map those two values to each of the two possible colors. Returns ------- Dict[Optional[int], int] Mapping from original values to minimal texture value set. """ # we cache the result to avoid recomputing it on each slice; # check first if it's already in the cache. key = f'_{data_dtype}_typed_dict' if key in self._cache_other: return self._cache_other[key] from numba import typed, types # num_unique_colors + 2 because we need to map None and background target_type = _accel_cmap.minimum_dtype_for_labels( self._num_unique_colors + 2 ) dkt = typed.Dict.empty( key_type=getattr(types, data_dtype.name), value_type=getattr(types, target_type.name), ) iinfo = np.iinfo(data_dtype) for k, v in self._label_mapping_and_color_dict[0].items(): # ignore values outside the data dtype, since they will never need # to be colormapped from that dtype. if k is not None and iinfo.min <= k <= iinfo.max: dkt[data_dtype.type(k)] = target_type.type(v) self._cache_other[key] = dkt return dkt @cached_property def _array_map(self): """Create an array to map labels to texture values of smaller dtype.""" max_value = max( (abs(x) for x in self.color_dict if x is not None), default=0 ) if any(x < 0 for x in self.color_dict if x is not None): max_value *= 2 if max_value > 2**16: raise RuntimeError( # pragma: no cover 'Cannot use numpy implementation for large values of labels ' 'direct colormap. Please install numba.' ) dtype = _accel_cmap.minimum_dtype_for_labels( self._num_unique_colors + 2 ) label_mapping = self._values_mapping_to_minimum_values_set()[0] # We need 2 + the max value: one because we will be indexing with the # max value, and an extra one so that higher values get clipped to # that index and map to the default value, rather than to the max # value in the map. mapper = np.full( (max_value + 2), _accel_cmap.MAPPING_OF_UNKNOWN_VALUE, dtype=dtype ) for key, val in label_mapping.items(): if key is None: continue mapper[key] = val return mapper @property def default_color(self) -> np.ndarray: return self.color_dict.get(None, np.array((0, 0, 0, 0))) # we provided here default color for backward compatibility # if someone is using DirectLabelColormap directly, not through Label layer @overload def _convert_small_ints_to_unsigned( data: np.ndarray, ) -> np.ndarray: ... @overload def _convert_small_ints_to_unsigned( data: np.integer, ) -> np.integer: ... def _convert_small_ints_to_unsigned( data: Union[np.ndarray, np.integer], ) -> Union[np.ndarray, np.integer]: """Convert (u)int8 to uint8 and (u)int16 to uint16. Otherwise, return the original array. Parameters ---------- data : np.ndarray | np.integer Data to be converted. Returns ------- np.ndarray | np.integer Converted data. """ if data.dtype.itemsize == 1: # for fast rendering of int8 return data.view(np.uint8) if data.dtype.itemsize == 2: # for fast rendering of int16 return data.view(np.uint16) return data @overload def _cast_labels_data_to_texture_dtype_auto( data: np.ndarray, colormap: CyclicLabelColormap, ) -> np.ndarray: ... @overload def _cast_labels_data_to_texture_dtype_auto( data: np.integer, colormap: CyclicLabelColormap, ) -> np.integer: ... def _cast_labels_data_to_texture_dtype_auto( data: Union[np.ndarray, np.integer], colormap: CyclicLabelColormap, ) -> Union[np.ndarray, np.integer]: """Convert labels data to the data type used in the texture. In https://github.com/napari/napari/issues/6397, we noticed that using float32 textures was much slower than uint8 or uint16 textures. Here we convert the labels data to uint8 or uint16, based on the following rules: - uint8 and uint16 labels data are unchanged. (No copy of the arrays.) - int8 and int16 data are converted with a *view* to uint8 and uint16. (This again does not involve a copy so is fast, and lossless.) - higher precision integer data (u)int{32,64} are hashed to uint8, uint16, or float32, depending on the number of colors in the input colormap. (See `minimum_dtype_for_labels`.) Since the hashing can result in collisions, this conversion *has* to happen in the CPU to correctly map the background and selection values. Parameters ---------- data : np.ndarray Labels data to be converted. colormap : CyclicLabelColormap Colormap used to display the labels data. Returns ------- np.ndarray | np.integer Converted labels data. """ original_shape = np.shape(data) if data.itemsize <= 2: return _convert_small_ints_to_unsigned(data) data_arr = np.atleast_1d(data) num_colors = len(colormap.colors) - 1 zero_preserving_modulo_func = _accel_cmap.zero_preserving_modulo if isinstance(data, np.integer): zero_preserving_modulo_func = _accel_cmap.zero_preserving_modulo_numpy dtype = _accel_cmap.minimum_dtype_for_labels(num_colors + 1) if colormap.use_selection: selection_in_texture = _accel_cmap.zero_preserving_modulo_numpy( np.array([colormap.selection]), num_colors, dtype ) converted = np.where( data_arr == colormap.selection, selection_in_texture, dtype.type(0) ) else: converted = zero_preserving_modulo_func( data_arr, num_colors, dtype, colormap.background_value ) if isinstance(data, np.integer): return dtype.type(converted[0]) return np.reshape(converted, original_shape) @overload def _cast_labels_data_to_texture_dtype_direct( data: np.ndarray, direct_colormap: DirectLabelColormap ) -> np.ndarray: ... @overload def _cast_labels_data_to_texture_dtype_direct( data: np.integer, direct_colormap: DirectLabelColormap ) -> np.integer: ... def _cast_labels_data_to_texture_dtype_direct( data: Union[np.ndarray, np.integer], direct_colormap: DirectLabelColormap ) -> Union[np.ndarray, np.integer]: """Convert labels data to the data type used in the texture. In https://github.com/napari/napari/issues/6397, we noticed that using float32 textures was much slower than uint8 or uint16 textures. Here we convert the labels data to uint8 or uint16, based on the following rules: - uint8 and uint16 labels data are unchanged. (No copy of the arrays.) - int8 and int16 data are converted with a *view* to uint8 and uint16. (This again does not involve a copy so is fast, and lossless.) - higher precision integer data (u)int{32,64} are mapped to an intermediate space of sequential values based on the colors they map to. As an example, if the values are [1, 2**25, and 2**50], and the direct colormap maps them to ['red', 'green', 'red'], then the intermediate map is {1: 1, 2**25: 2, 2**50: 1}. The labels can then be converted to a uint8 texture and a smaller direct colormap with only two values. This function calls `_labels_raw_to_texture_direct`, but makes sure that signed ints are first viewed as their unsigned counterparts. Parameters ---------- data : np.ndarray | np.integer Labels data to be converted. direct_colormap : CyclicLabelColormap Colormap used to display the labels data. Returns ------- np.ndarray | np.integer Converted labels data. """ data = _convert_small_ints_to_unsigned(data) if data.itemsize <= 2: return data if isinstance(data, np.integer): mapper = direct_colormap._label_mapping_and_color_dict[0] target_dtype = _accel_cmap.minimum_dtype_for_labels( direct_colormap._num_unique_colors + 2 ) return target_dtype.type( mapper.get(int(data), _accel_cmap.MAPPING_OF_UNKNOWN_VALUE) ) original_shape = np.shape(data) array_data = np.atleast_1d(data) return np.reshape( _accel_cmap.labels_raw_to_texture_direct(array_data, direct_colormap), original_shape, ) def _texture_dtype(num_colors: int, dtype: np.dtype) -> np.dtype: """Compute VisPy texture dtype given number of colors and raw data dtype. - for data of type int8 and uint8 we can use uint8 directly, with no copy. - for int16 and uint16 we can use uint16 with no copy. - for any other dtype, we fall back on `minimum_dtype_for_labels`, which will require on-CPU mapping between the raw data and the texture dtype. """ if dtype.itemsize == 1: return np.dtype(np.uint8) if dtype.itemsize == 2: return np.dtype(np.uint16) return _accel_cmap.minimum_dtype_for_labels(num_colors) def _normalize_label_colormap( any_colormap_like, ) -> Union[CyclicLabelColormap, DirectLabelColormap]: """Convenience function to convert color containers to LabelColormaps. A list of colors or 2D nparray of colors is interpreted as a color cycle (`CyclicLabelColormap`), and a mapping of colors is interpreted as a direct color map (`DirectLabelColormap`). Parameters ---------- any_colormap_like : Sequence[color], MutableMapping[int, color], ... An object that can be interpreted as a LabelColormap, including a LabelColormap directly. Returns ------- CyclicLabelColormap | DirectLabelColormap The computed LabelColormap object. """ if isinstance( any_colormap_like, (CyclicLabelColormap, DirectLabelColormap) ): return any_colormap_like if isinstance(any_colormap_like, Sequence): return CyclicLabelColormap(any_colormap_like) if isinstance(any_colormap_like, MutableMapping): return DirectLabelColormap(color_dict=any_colormap_like) if ( isinstance(any_colormap_like, np.ndarray) and any_colormap_like.ndim == 2 and any_colormap_like.shape[1] in (3, 4) ): return CyclicLabelColormap(any_colormap_like) raise ValueError( f'Unable to interpret as labels colormap: {any_colormap_like}' ) napari-0.5.6/napari/utils/colormaps/colormap_utils.py000066400000000000000000001020751474413133200230220ustar00rootroot00000000000000import warnings from collections import OrderedDict, defaultdict from collections.abc import Iterable from functools import lru_cache from threading import Lock from typing import NamedTuple, Optional, Union import numpy as np import skimage.color as colorconv from vispy.color import ( BaseColormap as VispyColormap, Color, ColorArray, get_colormap, get_colormaps, ) from vispy.color.colormap import LUT_len from napari.utils.colormaps._accelerated_cmap import minimum_dtype_for_labels from napari.utils.colormaps.bop_colors import bopd from napari.utils.colormaps.colormap import ( Colormap, ColormapInterpolationMode, CyclicLabelColormap, DirectLabelColormap, ) 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'), 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() } # add conventional grayscale colormap as a simple one SIMPLE_COLORMAPS.update( { 'gray': Colormap( name='gray', display_name='gray', colors=[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], ) } ) # dictionary 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() } # Add the reversed grayscale colormap (white to black) to inverse colormaps INVERSE_COLORMAPS.update( { 'gray_r': Colormap( name='gray_r', display_name='gray r', colors=[[1.0, 1.0, 1.0], [0.0, 0.0, 0.0]], ), } ) _FLOAT32_MAX = float(np.finfo(np.float32).max) _MAX_VISPY_SUPPORTED_VALUE = _FLOAT32_MAX / 8 # Using 8 as divisor comes from experiments. # For some reason if use smaller number, # the image is not displayed correctly. _MINIMUM_SHADES_COUNT = 256 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) -> np.ndarray: """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. Effective range is [0,1.0), as only the decimals are used. 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 = np.float32(image) image_float = seed + image_float * 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) ) # Clear zero (background) values, matching the shader behavior in _glsl_label_step image_out[image == 0] = 0.0 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. Effective range is [0,1.0), as only the decimals are used. 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. Effective range is [0,1.0), as only the decimals are used. 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(): # skimage 0.20.0rc warnings.filterwarnings( action='ignore', message='Conversion from CIE-LAB, via XYZ to sRGB color space resulted in', category=UserWarning, ) # skimage <0.20 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, background_value=0 ) -> CyclicLabelColormap: """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, optional The seed for the random color generator. Effective range is [0,1.0), as only the decimals are used. Returns ------- colormap : napari.utils.CyclicLabelColormap A colormap for use with labels remapped to [0, 1]. Notes ----- 0 always maps to fully transparent. """ if num_colors < 1: raise ValueError('num_colors must be >= 1') # 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 + 1) control_points = np.concatenate( (np.array([0]), midpoints, np.array([1.0])) ) # make sure to add an alpha channel to the colors colors = np.concatenate( ( _color_random(num_colors + 2, seed=seed), np.full((num_colors + 2, 1), 1), ), axis=1, ) # from here values_ = np.arange(num_colors + 2) randomized_values = low_discrepancy_image(values_, seed=seed) indices = np.clip( np.searchsorted(control_points, randomized_values, side='right') - 1, 0, len(control_points) - 1, ) # here is an ugly hack to restore classical napari color order. colors = colors[indices][:-1] # ensure that we not need to deal with differences in float rounding for # CPU and GPU. uint8_max = np.iinfo(np.uint8).max rgb8_colors = (colors * uint8_max).astype(np.uint8) colors = rgb8_colors.astype(np.float32) / uint8_max return CyclicLabelColormap( name='label_colormap', display_name=trans._p('colormap', 'low discrepancy colors'), colors=colors, controls=np.linspace(0, 1, len(colors) + 1), interpolation='zero', background_value=background_value, seed=seed, ) @lru_cache def _primes(upto=2**16): """Generate primes up to a given number. Parameters ---------- upto : int The upper limit of the primes to generate. Returns ------- primes : np.ndarray The primes up to the upper limit. """ primes = np.arange(3, upto + 1, 2) isprime = np.ones((upto - 1) // 2, dtype=bool) max_factor = int(np.sqrt(upto)) for factor in primes[: max_factor // 2]: if isprime[(factor - 2) // 2]: isprime[(factor * 3 - 2) // 2 : None : factor] = 0 return np.concatenate(([2], primes[isprime])) def shuffle_and_extend_colormap( colormap: CyclicLabelColormap, seed: int, min_random_choices: int = 5 ) -> CyclicLabelColormap: """Shuffle the colormap colors and extend it to more colors. The new number of colors will be a prime number that fits into the same dtype as the current number of colors in the colormap. Parameters ---------- colormap : napari.utils.CyclicLabelColormap Colormap to shuffle and extend. seed : int Seed for the random number generator. min_random_choices : int Minimum number of new table sizes to choose from. When choosing table sizes, every choice gives a 1/size chance of two labels being mapped to the same color. Since we try to stay within the same dtype as the original colormap, if the number of original colors is close to the maximum value for the dtype, there will not be enough prime numbers up to the dtype max to ensure that two labels can always be distinguished after one or few shuffles. In that case, we discard some colors and choose the `min_random_choices` largest primes to fit within the dtype. Returns ------- colormap : napari.utils.CyclicLabelColormap Shuffled and extended colormap. """ rng = np.random.default_rng(seed) n_colors_prev = len(colormap.colors) dtype = minimum_dtype_for_labels(n_colors_prev) indices = np.arange(n_colors_prev) rng.shuffle(indices) shuffled_colors = colormap.colors[indices] primes = _primes( np.iinfo(dtype).max if np.issubdtype(dtype, np.integer) else 2**24 ) valid_primes = primes[primes > n_colors_prev] if len(valid_primes) < min_random_choices: valid_primes = primes[-min_random_choices:] n_colors = rng.choice(valid_primes) n_color_diff = n_colors - n_colors_prev extended_colors = np.concatenate( ( shuffled_colors[: min(n_color_diff, 0) or None], shuffled_colors[rng.choice(indices, size=max(n_color_diff, 0))], ), axis=0, ) new_colormap = CyclicLabelColormap( name=colormap.name, colors=extended_colors, controls=np.linspace(0, 1, len(extended_colors) + 1), interpolation='zero', background_value=colormap.background_value, ) return new_colormap def direct_colormap(color_dict=None): """Make a direct colormap from a dictionary mapping labels to colors. Parameters ---------- color_dict : dict, optional A dictionary mapping labels to colors. Returns ------- d : DirectLabelColormap A napari colormap whose map() function applies the color dictionary to an array. """ # we don't actually use the color array, so pass dummy. return DirectLabelColormap( color_dict=color_dict or defaultdict(lambda: np.zeros(4)), ) def vispy_or_mpl_colormap(name) -> Colormap: """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) display_name = _MATPLOTLIB_COLORMAP_NAMES.get(name, name) except AttributeError as e: suggestion = _MATPLOTLIB_COLORMAP_NAMES_REVERSE.get( name ) or _VISPY_COLORMAPS_TRANSLATIONS_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 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)), ) ) from e 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 = dict( 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'] AVAILABLE_LABELS_COLORMAPS = { 'lodisc-50': label_colormap(50), } def _increment_unnamed_colormap( existing: Iterable[str], name: str = '[unnamed colormap]' ) -> tuple[str, str]: """Increment name for unnamed colormap. NOTE: this assumes colormaps are *never* deleted, and does not check for name collision. If colormaps can ever be removed, please update. 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): # when black given as end color, want reversed grayscale colormap # from white to black, named gray_r if colormap.startswith('#000000') or colormap.lower() == 'black': colormap = 'gray_r' # Is a colormap with this name already available? custom_cmap = AVAILABLE_COLORMAPS.get(colormap) if custom_cmap is None: name = ( colormap.lower() if colormap.startswith('#') else colormap ) custom_cmap = _colormap_from_colors(colormap, name) if custom_cmap is None: custom_cmap = vispy_or_mpl_colormap(colormap) for cmap_ in AVAILABLE_COLORMAPS.values(): if ( np.array_equal(cmap_.controls, custom_cmap.controls) and np.array_equal(cmap_.colors, custom_cmap.colors) and cmap_.interpolation == custom_cmap.interpolation ): custom_cmap = cmap_ break name = custom_cmap.name AVAILABLE_COLORMAPS[name] = custom_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 _name is None: name, _display_name = _increment_unnamed_colormap( AVAILABLE_COLORMAPS ) else: name = _name # 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 = colormap[0] cmap = colormap[1] # 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 None: 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, ) ) name, _display_name = _increment_unnamed_colormap( AVAILABLE_COLORMAPS ) colormap.update({'name': name, '_display_name': _display_name}) AVAILABLE_COLORMAPS[name] = colormap elif isinstance(colormap, dict): if 'colors' in colormap and not ( isinstance(colormap['colors'], (VispyColormap, Colormap)) ): cmap = Colormap(**colormap) name = cmap.name AVAILABLE_COLORMAPS[name] = cmap elif not all( (isinstance(i, (VispyColormap, 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 = next(iter(colormap)) # first key in dict elif len(colormap) > 1: name = next(iter(colormap.keys())) 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: name, _display_name = _increment_unnamed_colormap( AVAILABLE_COLORMAPS ) colormap.update({'name': name, '_display_name': _display_name}) AVAILABLE_COLORMAPS[name] = colormap else: 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, name: Optional[str] = 'custom', display_name: Optional[str] = None, ) -> 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, name=name, display_name=display_name) 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, next(iter(AVAILABLE_COLORMAPS.keys())) ) class CoercedContrastLimits(NamedTuple): contrast_limits: tuple[float, float] offset: float scale: float def coerce_data(self, data: np.ndarray) -> np.ndarray: if self.scale <= 1: return data * self.scale + self.offset return (data + self.offset / self.scale) * self.scale def _coerce_contrast_limits(contrast_limits: tuple[float, float]): """Coerce contrast limits to be in the float32 range.""" if np.abs(contrast_limits).max() > _MAX_VISPY_SUPPORTED_VALUE: return scale_down(contrast_limits) c_min = np.float32(contrast_limits[0]) c_max = np.float32(contrast_limits[1]) dist = c_max - c_min if ( dist < np.abs(np.spacing(c_min)) * _MINIMUM_SHADES_COUNT or dist < np.abs(np.spacing(c_max)) * _MINIMUM_SHADES_COUNT ): return scale_up(contrast_limits) return CoercedContrastLimits(contrast_limits, 0, 1) def scale_down(contrast_limits: tuple[float, float]): """Scale down contrast limits to be in the float32 range.""" scale: float = min( 1.0, (_MAX_VISPY_SUPPORTED_VALUE * 2) / (contrast_limits[1] - contrast_limits[0]), ) ctrl_lim = contrast_limits[0] * scale, contrast_limits[1] * scale left_shift = max(0.0, -_MAX_VISPY_SUPPORTED_VALUE - ctrl_lim[0]) right_shift = max(0.0, ctrl_lim[1] - _MAX_VISPY_SUPPORTED_VALUE) offset = left_shift - right_shift ctrl_lim = (ctrl_lim[0] + offset, ctrl_lim[1] + offset) return CoercedContrastLimits(ctrl_lim, offset, scale) def scale_up(contrast_limits: tuple[float, float]): """Scale up contrast limits to be in the float32 precision.""" scale = 1000 / (contrast_limits[1] - contrast_limits[0]) shift = -contrast_limits[0] * scale return CoercedContrastLimits((0, 1000), shift, scale) napari-0.5.6/napari/utils/colormaps/inverse_colormaps.py000066400000000000000000000015211474413133200235120ustar00rootroot00000000000000"""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.6/napari/utils/colormaps/standardize_color.py000066400000000000000000000372161474413133200235000ustar00rootroot00000000000000"""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 collections.abc import Sequence from typing import Any, Callable, Optional, Union 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) for typ, handler in _color_switch.items(): if issubclass(colortype, typ): return handler(colors) raise ValueError(f"cannot convert type '{colortype}' to a color array.") @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) -> Optional[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()) return None def _handle_generator(colors) -> Optional[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 if kind == 'U': if colors.ndim == 1: return _handle_str_list_like(colors) 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) 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: Union[Sequence, np.ndarray]) -> 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.6/napari/utils/colormaps/vendored/000077500000000000000000000000001474413133200212155ustar00rootroot00000000000000napari-0.5.6/napari/utils/colormaps/vendored/__init__.py000066400000000000000000000000001474413133200233140ustar00rootroot00000000000000napari-0.5.6/napari/utils/colormaps/vendored/_cm.py000066400000000000000000002020711474413133200223270ustar00rootroot00000000000000""" 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 visualisation 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.6/napari/utils/colormaps/vendored/_cm_listed.py000066400000000000000000004075341474413133200237060ustar00rootroot00000000000000from .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]] _twilight_shifted_data = (_twilight_data[len(_twilight_data)//2:] + _twilight_data[:len(_twilight_data)//2]) _twilight_shifted_data.reverse() _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]] _berlin_data = [ [0.62108, 0.69018, 0.99951], [0.61216, 0.68923, 0.99537], [0.6032, 0.68825, 0.99124], [0.5942, 0.68726, 0.98709], [0.58517, 0.68625, 0.98292], [0.57609, 0.68522, 0.97873], [0.56696, 0.68417, 0.97452], [0.55779, 0.6831, 0.97029], [0.54859, 0.68199, 0.96602], [0.53933, 0.68086, 0.9617], [0.53003, 0.67969, 0.95735], [0.52069, 0.67848, 0.95294], [0.51129, 0.67723, 0.94847], [0.50186, 0.67591, 0.94392], [0.49237, 0.67453, 0.9393], [0.48283, 0.67308, 0.93457], [0.47324, 0.67153, 0.92975], [0.46361, 0.6699, 0.92481], [0.45393, 0.66815, 0.91974], [0.44421, 0.66628, 0.91452], [0.43444, 0.66427, 0.90914], [0.42465, 0.66212, 0.90359], [0.41482, 0.65979, 0.89785], [0.40498, 0.65729, 0.89191], [0.39514, 0.65458, 0.88575], [0.3853, 0.65167, 0.87937], [0.37549, 0.64854, 0.87276], [0.36574, 0.64516, 0.8659], [0.35606, 0.64155, 0.8588], [0.34645, 0.63769, 0.85145], [0.33698, 0.63357, 0.84386], [0.32764, 0.62919, 0.83602], [0.31849, 0.62455, 0.82794], [0.30954, 0.61966, 0.81963], [0.30078, 0.6145, 0.81111], [0.29231, 0.60911, 0.80238], [0.2841, 0.60348, 0.79347], [0.27621, 0.59763, 0.78439], [0.26859, 0.59158, 0.77514], [0.26131, 0.58534, 0.76578], [0.25437, 0.57891, 0.7563], [0.24775, 0.57233, 0.74672], [0.24146, 0.5656, 0.73707], [0.23552, 0.55875, 0.72735], [0.22984, 0.5518, 0.7176], [0.2245, 0.54475, 0.7078], [0.21948, 0.53763, 0.698], [0.21469, 0.53043, 0.68819], [0.21017, 0.52319, 0.67838], [0.20589, 0.5159, 0.66858], [0.20177, 0.5086, 0.65879], [0.19788, 0.50126, 0.64903], [0.19417, 0.4939, 0.63929], [0.19056, 0.48654, 0.62957], [0.18711, 0.47918, 0.6199], [0.18375, 0.47183, 0.61024], [0.1805, 0.46447, 0.60062], [0.17737, 0.45712, 0.59104], [0.17426, 0.44979, 0.58148], [0.17122, 0.44247, 0.57197], [0.16824, 0.43517, 0.56249], [0.16529, 0.42788, 0.55302], [0.16244, 0.42061, 0.5436], [0.15954, 0.41337, 0.53421], [0.15674, 0.40615, 0.52486], [0.15391, 0.39893, 0.51552], [0.15112, 0.39176, 0.50623], [0.14835, 0.38459, 0.49697], [0.14564, 0.37746, 0.48775], [0.14288, 0.37034, 0.47854], [0.14014, 0.36326, 0.46939], [0.13747, 0.3562, 0.46024], [0.13478, 0.34916, 0.45115], [0.13208, 0.34215, 0.44209], [0.1294, 0.33517, 0.43304], [0.12674, 0.3282, 0.42404], [0.12409, 0.32126, 0.41507], [0.12146, 0.31435, 0.40614], [0.1189, 0.30746, 0.39723], [0.11632, 0.30061, 0.38838], [0.11373, 0.29378, 0.37955], [0.11119, 0.28698, 0.37075], [0.10861, 0.28022, 0.362], [0.10616, 0.2735, 0.35328], [0.10367, 0.26678, 0.34459], [0.10118, 0.26011, 0.33595], [0.098776, 0.25347, 0.32734], [0.096347, 0.24685, 0.31878], [0.094059, 0.24026, 0.31027], [0.091788, 0.23373, 0.30176], [0.089506, 0.22725, 0.29332], [0.087341, 0.2208, 0.28491], [0.085142, 0.21436, 0.27658], [0.083069, 0.20798, 0.26825], [0.081098, 0.20163, 0.25999], [0.07913, 0.19536, 0.25178], [0.077286, 0.18914, 0.24359], [0.075571, 0.18294, 0.2355], [0.073993, 0.17683, 0.22743], [0.07241, 0.17079, 0.21943], [0.071045, 0.1648, 0.2115], [0.069767, 0.1589, 0.20363], [0.068618, 0.15304, 0.19582], [0.06756, 0.14732, 0.18812], [0.066665, 0.14167, 0.18045], [0.065923, 0.13608, 0.17292], [0.065339, 0.1307, 0.16546], [0.064911, 0.12535, 0.15817], [0.064636, 0.12013, 0.15095], [0.064517, 0.11507, 0.14389], [0.064554, 0.11022, 0.13696], [0.064749, 0.10543, 0.13023], [0.0651, 0.10085, 0.12357], [0.065383, 0.096469, 0.11717], [0.065574, 0.092338, 0.11101], [0.065892, 0.088201, 0.10498], [0.066388, 0.084134, 0.099288], [0.067108, 0.080051, 0.093829], [0.068193, 0.076099, 0.08847], [0.06972, 0.072283, 0.083025], [0.071639, 0.068654, 0.077544], [0.073978, 0.065058, 0.07211], [0.076596, 0.061657, 0.066651], [0.079637, 0.05855, 0.061133], [0.082963, 0.055666, 0.055745], [0.086537, 0.052997, 0.050336], [0.090315, 0.050699, 0.04504], [0.09426, 0.048753, 0.039773], [0.098319, 0.047041, 0.034683], [0.10246, 0.045624, 0.030074], [0.10673, 0.044705, 0.026012], [0.11099, 0.043972, 0.022379], [0.11524, 0.043596, 0.01915], [0.11955, 0.043567, 0.016299], [0.12381, 0.043861, 0.013797], [0.1281, 0.044459, 0.011588], [0.13232, 0.045229, 0.0095315], [0.13645, 0.046164, 0.0078947], [0.14063, 0.047374, 0.006502], [0.14488, 0.048634, 0.0053266], [0.14923, 0.049836, 0.0043455], [0.15369, 0.050997, 0.0035374], [0.15831, 0.05213, 0.0028824], [0.16301, 0.053218, 0.0023628], [0.16781, 0.05424, 0.0019629], [0.17274, 0.055172, 0.001669], [0.1778, 0.056018, 0.0014692], [0.18286, 0.05682, 0.0013401], [0.18806, 0.057574, 0.0012617], [0.19323, 0.058514, 0.0012261], [0.19846, 0.05955, 0.0012271], [0.20378, 0.060501, 0.0012601], [0.20909, 0.061486, 0.0013221], [0.21447, 0.06271, 0.0014116], [0.2199, 0.063823, 0.0015287], [0.22535, 0.065027, 0.0016748], [0.23086, 0.066297, 0.0018529], [0.23642, 0.067645, 0.0020675], [0.24202, 0.069092, 0.0023247], [0.24768, 0.070458, 0.0026319], [0.25339, 0.071986, 0.0029984], [0.25918, 0.07364, 0.003435], [0.265, 0.075237, 0.0039545], [0.27093, 0.076965, 0.004571], [0.27693, 0.078822, 0.0053006], [0.28302, 0.080819, 0.0061608], [0.2892, 0.082879, 0.0071713], [0.29547, 0.085075, 0.0083494], [0.30186, 0.08746, 0.0097258], [0.30839, 0.089912, 0.011455], [0.31502, 0.09253, 0.013324], [0.32181, 0.095392, 0.015413], [0.32874, 0.098396, 0.01778], [0.3358, 0.10158, 0.020449], [0.34304, 0.10498, 0.02344], [0.35041, 0.10864, 0.026771], [0.35795, 0.11256, 0.030456], [0.36563, 0.11666, 0.034571], [0.37347, 0.12097, 0.039115], [0.38146, 0.12561, 0.043693], [0.38958, 0.13046, 0.048471], [0.39785, 0.13547, 0.053136], [0.40622, 0.1408, 0.057848], [0.41469, 0.14627, 0.062715], [0.42323, 0.15198, 0.067685], [0.43184, 0.15791, 0.073044], [0.44044, 0.16403, 0.07862], [0.44909, 0.17027, 0.084644], [0.4577, 0.17667, 0.090869], [0.46631, 0.18321, 0.097335], [0.4749, 0.18989, 0.10406], [0.48342, 0.19668, 0.11104], [0.49191, 0.20352, 0.11819], [0.50032, 0.21043, 0.1255], [0.50869, 0.21742, 0.13298], [0.51698, 0.22443, 0.14062], [0.5252, 0.23154, 0.14835], [0.53335, 0.23862, 0.15626], [0.54144, 0.24575, 0.16423], [0.54948, 0.25292, 0.17226], [0.55746, 0.26009, 0.1804], [0.56538, 0.26726, 0.18864], [0.57327, 0.27446, 0.19692], [0.58111, 0.28167, 0.20524], [0.58892, 0.28889, 0.21362], [0.59672, 0.29611, 0.22205], [0.60448, 0.30335, 0.23053], [0.61223, 0.31062, 0.23905], [0.61998, 0.31787, 0.24762], [0.62771, 0.32513, 0.25619], [0.63544, 0.33244, 0.26481], [0.64317, 0.33975, 0.27349], [0.65092, 0.34706, 0.28218], [0.65866, 0.3544, 0.29089], [0.66642, 0.36175, 0.29964], [0.67419, 0.36912, 0.30842], [0.68198, 0.37652, 0.31722], [0.68978, 0.38392, 0.32604], [0.6976, 0.39135, 0.33493], [0.70543, 0.39879, 0.3438], [0.71329, 0.40627, 0.35272], [0.72116, 0.41376, 0.36166], [0.72905, 0.42126, 0.37062], [0.73697, 0.4288, 0.37962], [0.7449, 0.43635, 0.38864], [0.75285, 0.44392, 0.39768], [0.76083, 0.45151, 0.40675], [0.76882, 0.45912, 0.41584], [0.77684, 0.46676, 0.42496], [0.78488, 0.47441, 0.43409], [0.79293, 0.48208, 0.44327], [0.80101, 0.48976, 0.45246], [0.80911, 0.49749, 0.46167], [0.81722, 0.50521, 0.47091], [0.82536, 0.51296, 0.48017], [0.83352, 0.52073, 0.48945], [0.84169, 0.52853, 0.49876], [0.84988, 0.53634, 0.5081], [0.85809, 0.54416, 0.51745], [0.86632, 0.55201, 0.52683], [0.87457, 0.55988, 0.53622], [0.88283, 0.56776, 0.54564], [0.89111, 0.57567, 0.55508], [0.89941, 0.58358, 0.56455], [0.90772, 0.59153, 0.57404], [0.91603, 0.59949, 0.58355], [0.92437, 0.60747, 0.59309], [0.93271, 0.61546, 0.60265], [0.94108, 0.62348, 0.61223], [0.94945, 0.63151, 0.62183], [0.95783, 0.63956, 0.63147], [0.96622, 0.64763, 0.64111], [0.97462, 0.65572, 0.65079], [0.98303, 0.66382, 0.66049], [0.99145, 0.67194, 0.67022], [0.99987, 0.68007, 0.67995]] _managua_data = [ [1, 0.81263, 0.40424], [0.99516, 0.80455, 0.40155], [0.99024, 0.79649, 0.39888], [0.98532, 0.78848, 0.39622], [0.98041, 0.7805, 0.39356], [0.97551, 0.77257, 0.39093], [0.97062, 0.76468, 0.3883], [0.96573, 0.75684, 0.38568], [0.96087, 0.74904, 0.3831], [0.95601, 0.74129, 0.38052], [0.95116, 0.7336, 0.37795], [0.94631, 0.72595, 0.37539], [0.94149, 0.71835, 0.37286], [0.93667, 0.7108, 0.37034], [0.93186, 0.7033, 0.36784], [0.92706, 0.69585, 0.36536], [0.92228, 0.68845, 0.36289], [0.9175, 0.68109, 0.36042], [0.91273, 0.67379, 0.358], [0.90797, 0.66653, 0.35558], [0.90321, 0.65932, 0.35316], [0.89846, 0.65216, 0.35078], [0.89372, 0.64503, 0.34839], [0.88899, 0.63796, 0.34601], [0.88426, 0.63093, 0.34367], [0.87953, 0.62395, 0.34134], [0.87481, 0.617, 0.33902], [0.87009, 0.61009, 0.3367], [0.86538, 0.60323, 0.33442], [0.86067, 0.59641, 0.33213], [0.85597, 0.58963, 0.32987], [0.85125, 0.5829, 0.3276], [0.84655, 0.57621, 0.32536], [0.84185, 0.56954, 0.32315], [0.83714, 0.56294, 0.32094], [0.83243, 0.55635, 0.31874], [0.82772, 0.54983, 0.31656], [0.82301, 0.54333, 0.31438], [0.81829, 0.53688, 0.31222], [0.81357, 0.53046, 0.3101], [0.80886, 0.52408, 0.30796], [0.80413, 0.51775, 0.30587], [0.7994, 0.51145, 0.30375], [0.79466, 0.50519, 0.30167], [0.78991, 0.49898, 0.29962], [0.78516, 0.4928, 0.29757], [0.7804, 0.48668, 0.29553], [0.77564, 0.48058, 0.29351], [0.77086, 0.47454, 0.29153], [0.76608, 0.46853, 0.28954], [0.76128, 0.46255, 0.28756], [0.75647, 0.45663, 0.28561], [0.75166, 0.45075, 0.28369], [0.74682, 0.44491, 0.28178], [0.74197, 0.4391, 0.27988], [0.73711, 0.43333, 0.27801], [0.73223, 0.42762, 0.27616], [0.72732, 0.42192, 0.2743], [0.72239, 0.41628, 0.27247], [0.71746, 0.41067, 0.27069], [0.71247, 0.40508, 0.26891], [0.70747, 0.39952, 0.26712], [0.70244, 0.39401, 0.26538], [0.69737, 0.38852, 0.26367], [0.69227, 0.38306, 0.26194], [0.68712, 0.37761, 0.26025], [0.68193, 0.37219, 0.25857], [0.67671, 0.3668, 0.25692], [0.67143, 0.36142, 0.25529], [0.6661, 0.35607, 0.25367], [0.66071, 0.35073, 0.25208], [0.65528, 0.34539, 0.25049], [0.6498, 0.34009, 0.24895], [0.64425, 0.3348, 0.24742], [0.63866, 0.32953, 0.2459], [0.633, 0.32425, 0.24442], [0.62729, 0.31901, 0.24298], [0.62152, 0.3138, 0.24157], [0.6157, 0.3086, 0.24017], [0.60983, 0.30341, 0.23881], [0.60391, 0.29826, 0.23752], [0.59793, 0.29314, 0.23623], [0.59191, 0.28805, 0.235], [0.58585, 0.28302, 0.23377], [0.57974, 0.27799, 0.23263], [0.57359, 0.27302, 0.23155], [0.56741, 0.26808, 0.23047], [0.5612, 0.26321, 0.22948], [0.55496, 0.25837, 0.22857], [0.54871, 0.25361, 0.22769], [0.54243, 0.24891, 0.22689], [0.53614, 0.24424, 0.22616], [0.52984, 0.23968, 0.22548], [0.52354, 0.2352, 0.22487], [0.51724, 0.23076, 0.22436], [0.51094, 0.22643, 0.22395], [0.50467, 0.22217, 0.22363], [0.49841, 0.21802, 0.22339], [0.49217, 0.21397, 0.22325], [0.48595, 0.21, 0.22321], [0.47979, 0.20618, 0.22328], [0.47364, 0.20242, 0.22345], [0.46756, 0.1988, 0.22373], [0.46152, 0.19532, 0.22413], [0.45554, 0.19195, 0.22465], [0.44962, 0.18873, 0.22534], [0.44377, 0.18566, 0.22616], [0.43799, 0.18266, 0.22708], [0.43229, 0.17987, 0.22817], [0.42665, 0.17723, 0.22938], [0.42111, 0.17474, 0.23077], [0.41567, 0.17238, 0.23232], [0.41033, 0.17023, 0.23401], [0.40507, 0.16822, 0.2359], [0.39992, 0.1664, 0.23794], [0.39489, 0.16475, 0.24014], [0.38996, 0.16331, 0.24254], [0.38515, 0.16203, 0.24512], [0.38046, 0.16093, 0.24792], [0.37589, 0.16, 0.25087], [0.37143, 0.15932, 0.25403], [0.36711, 0.15883, 0.25738], [0.36292, 0.15853, 0.26092], [0.35885, 0.15843, 0.26466], [0.35494, 0.15853, 0.26862], [0.35114, 0.15882, 0.27276], [0.34748, 0.15931, 0.27711], [0.34394, 0.15999, 0.28164], [0.34056, 0.16094, 0.28636], [0.33731, 0.16207, 0.29131], [0.3342, 0.16338, 0.29642], [0.33121, 0.16486, 0.3017], [0.32837, 0.16658, 0.30719], [0.32565, 0.16847, 0.31284], [0.3231, 0.17056, 0.31867], [0.32066, 0.17283, 0.32465], [0.31834, 0.1753, 0.33079], [0.31616, 0.17797, 0.3371], [0.3141, 0.18074, 0.34354], [0.31216, 0.18373, 0.35011], [0.31038, 0.1869, 0.35682], [0.3087, 0.19021, 0.36363], [0.30712, 0.1937, 0.37056], [0.3057, 0.19732, 0.3776], [0.30435, 0.20106, 0.38473], [0.30314, 0.205, 0.39195], [0.30204, 0.20905, 0.39924], [0.30106, 0.21323, 0.40661], [0.30019, 0.21756, 0.41404], [0.29944, 0.22198, 0.42151], [0.29878, 0.22656, 0.42904], [0.29822, 0.23122, 0.4366], [0.29778, 0.23599, 0.44419], [0.29745, 0.24085, 0.45179], [0.29721, 0.24582, 0.45941], [0.29708, 0.2509, 0.46703], [0.29704, 0.25603, 0.47465], [0.2971, 0.26127, 0.48225], [0.29726, 0.26658, 0.48983], [0.2975, 0.27194, 0.4974], [0.29784, 0.27741, 0.50493], [0.29828, 0.28292, 0.51242], [0.29881, 0.28847, 0.51987], [0.29943, 0.29408, 0.52728], [0.30012, 0.29976, 0.53463], [0.3009, 0.30548, 0.54191], [0.30176, 0.31122, 0.54915], [0.30271, 0.317, 0.5563], [0.30373, 0.32283, 0.56339], [0.30483, 0.32866, 0.5704], [0.30601, 0.33454, 0.57733], [0.30722, 0.34042, 0.58418], [0.30853, 0.34631, 0.59095], [0.30989, 0.35224, 0.59763], [0.3113, 0.35817, 0.60423], [0.31277, 0.3641, 0.61073], [0.31431, 0.37005, 0.61715], [0.3159, 0.376, 0.62347], [0.31752, 0.38195, 0.62969], [0.3192, 0.3879, 0.63583], [0.32092, 0.39385, 0.64188], [0.32268, 0.39979, 0.64783], [0.32446, 0.40575, 0.6537], [0.3263, 0.41168, 0.65948], [0.32817, 0.41763, 0.66517], [0.33008, 0.42355, 0.67079], [0.33201, 0.4295, 0.67632], [0.33398, 0.43544, 0.68176], [0.33596, 0.44137, 0.68715], [0.33798, 0.44731, 0.69246], [0.34003, 0.45327, 0.69769], [0.3421, 0.45923, 0.70288], [0.34419, 0.4652, 0.70799], [0.34631, 0.4712, 0.71306], [0.34847, 0.4772, 0.71808], [0.35064, 0.48323, 0.72305], [0.35283, 0.48928, 0.72798], [0.35506, 0.49537, 0.73288], [0.3573, 0.50149, 0.73773], [0.35955, 0.50763, 0.74256], [0.36185, 0.51381, 0.74736], [0.36414, 0.52001, 0.75213], [0.36649, 0.52627, 0.75689], [0.36884, 0.53256, 0.76162], [0.37119, 0.53889, 0.76633], [0.37359, 0.54525, 0.77103], [0.376, 0.55166, 0.77571], [0.37842, 0.55809, 0.78037], [0.38087, 0.56458, 0.78503], [0.38333, 0.5711, 0.78966], [0.38579, 0.57766, 0.79429], [0.38828, 0.58426, 0.7989], [0.39078, 0.59088, 0.8035], [0.39329, 0.59755, 0.8081], [0.39582, 0.60426, 0.81268], [0.39835, 0.61099, 0.81725], [0.4009, 0.61774, 0.82182], [0.40344, 0.62454, 0.82637], [0.406, 0.63137, 0.83092], [0.40856, 0.63822, 0.83546], [0.41114, 0.6451, 0.83999], [0.41372, 0.65202, 0.84451], [0.41631, 0.65896, 0.84903], [0.4189, 0.66593, 0.85354], [0.42149, 0.67294, 0.85805], [0.4241, 0.67996, 0.86256], [0.42671, 0.68702, 0.86705], [0.42932, 0.69411, 0.87156], [0.43195, 0.70123, 0.87606], [0.43457, 0.70839, 0.88056], [0.4372, 0.71557, 0.88506], [0.43983, 0.72278, 0.88956], [0.44248, 0.73004, 0.89407], [0.44512, 0.73732, 0.89858], [0.44776, 0.74464, 0.9031], [0.45042, 0.752, 0.90763], [0.45308, 0.75939, 0.91216], [0.45574, 0.76682, 0.9167], [0.45841, 0.77429, 0.92124], [0.46109, 0.78181, 0.9258], [0.46377, 0.78936, 0.93036], [0.46645, 0.79694, 0.93494], [0.46914, 0.80458, 0.93952], [0.47183, 0.81224, 0.94412], [0.47453, 0.81995, 0.94872], [0.47721, 0.8277, 0.95334], [0.47992, 0.83549, 0.95796], [0.48261, 0.84331, 0.96259], [0.4853, 0.85117, 0.96722], [0.48801, 0.85906, 0.97186], [0.49071, 0.86699, 0.97651], [0.49339, 0.87495, 0.98116], [0.49607, 0.88294, 0.98581], [0.49877, 0.89096, 0.99047], [0.50144, 0.89901, 0.99512], [0.50411, 0.90708, 0.99978]] _vanimo_data = [ [1, 0.80346, 0.99215], [0.99397, 0.79197, 0.98374], [0.98791, 0.78052, 0.97535], [0.98185, 0.7691, 0.96699], [0.97578, 0.75774, 0.95867], [0.96971, 0.74643, 0.95037], [0.96363, 0.73517, 0.94211], [0.95755, 0.72397, 0.93389], [0.95147, 0.71284, 0.9257], [0.94539, 0.70177, 0.91756], [0.93931, 0.69077, 0.90945], [0.93322, 0.67984, 0.90137], [0.92713, 0.66899, 0.89334], [0.92104, 0.65821, 0.88534], [0.91495, 0.64751, 0.87738], [0.90886, 0.63689, 0.86946], [0.90276, 0.62634, 0.86158], [0.89666, 0.61588, 0.85372], [0.89055, 0.60551, 0.84591], [0.88444, 0.59522, 0.83813], [0.87831, 0.58503, 0.83039], [0.87219, 0.57491, 0.82268], [0.86605, 0.5649, 0.815], [0.8599, 0.55499, 0.80736], [0.85373, 0.54517, 0.79974], [0.84756, 0.53544, 0.79216], [0.84138, 0.52583, 0.78461], [0.83517, 0.5163, 0.77709], [0.82896, 0.5069, 0.76959], [0.82272, 0.49761, 0.76212], [0.81647, 0.48841, 0.75469], [0.81018, 0.47934, 0.74728], [0.80389, 0.47038, 0.7399], [0.79757, 0.46154, 0.73255], [0.79123, 0.45283, 0.72522], [0.78487, 0.44424, 0.71792], [0.77847, 0.43578, 0.71064], [0.77206, 0.42745, 0.70339], [0.76562, 0.41925, 0.69617], [0.75914, 0.41118, 0.68897], [0.75264, 0.40327, 0.68179], [0.74612, 0.39549, 0.67465], [0.73957, 0.38783, 0.66752], [0.73297, 0.38034, 0.66041], [0.72634, 0.37297, 0.65331], [0.71967, 0.36575, 0.64623], [0.71293, 0.35864, 0.63915], [0.70615, 0.35166, 0.63206], [0.69929, 0.34481, 0.62496], [0.69236, 0.33804, 0.61782], [0.68532, 0.33137, 0.61064], [0.67817, 0.32479, 0.6034], [0.67091, 0.3183, 0.59609], [0.66351, 0.31184, 0.5887], [0.65598, 0.30549, 0.58123], [0.64828, 0.29917, 0.57366], [0.64045, 0.29289, 0.56599], [0.63245, 0.28667, 0.55822], [0.6243, 0.28051, 0.55035], [0.61598, 0.27442, 0.54237], [0.60752, 0.26838, 0.53428], [0.59889, 0.2624, 0.5261], [0.59012, 0.25648, 0.51782], [0.5812, 0.25063, 0.50944], [0.57214, 0.24483, 0.50097], [0.56294, 0.23914, 0.4924], [0.55359, 0.23348, 0.48376], [0.54413, 0.22795, 0.47505], [0.53454, 0.22245, 0.46623], [0.52483, 0.21706, 0.45736], [0.51501, 0.21174, 0.44843], [0.50508, 0.20651, 0.43942], [0.49507, 0.20131, 0.43036], [0.48495, 0.19628, 0.42125], [0.47476, 0.19128, 0.4121], [0.4645, 0.18639, 0.4029], [0.45415, 0.18157, 0.39367], [0.44376, 0.17688, 0.38441], [0.43331, 0.17225, 0.37513], [0.42282, 0.16773, 0.36585], [0.41232, 0.16332, 0.35655], [0.40178, 0.15897, 0.34726], [0.39125, 0.15471, 0.33796], [0.38071, 0.15058, 0.32869], [0.37017, 0.14651, 0.31945], [0.35969, 0.14258, 0.31025], [0.34923, 0.13872, 0.30106], [0.33883, 0.13499, 0.29196], [0.32849, 0.13133, 0.28293], [0.31824, 0.12778, 0.27396], [0.30808, 0.12431, 0.26508], [0.29805, 0.12097, 0.25631], [0.28815, 0.11778, 0.24768], [0.27841, 0.11462, 0.23916], [0.26885, 0.11169, 0.23079], [0.25946, 0.10877, 0.22259], [0.25025, 0.10605, 0.21455], [0.24131, 0.10341, 0.20673], [0.23258, 0.10086, 0.19905], [0.2241, 0.098494, 0.19163], [0.21593, 0.096182, 0.18443], [0.20799, 0.094098, 0.17748], [0.20032, 0.092102, 0.17072], [0.19299, 0.09021, 0.16425], [0.18596, 0.088461, 0.15799], [0.17918, 0.086861, 0.15197], [0.17272, 0.08531, 0.14623], [0.16658, 0.084017, 0.14075], [0.1607, 0.082745, 0.13546], [0.15515, 0.081683, 0.13049], [0.1499, 0.080653, 0.1257], [0.14493, 0.07978, 0.12112], [0.1402, 0.079037, 0.11685], [0.13578, 0.078426, 0.11282], [0.13168, 0.077944, 0.10894], [0.12782, 0.077586, 0.10529], [0.12422, 0.077332, 0.1019], [0.12091, 0.077161, 0.098724], [0.11793, 0.077088, 0.095739], [0.11512, 0.077124, 0.092921], [0.11267, 0.077278, 0.090344], [0.11042, 0.077557, 0.087858], [0.10835, 0.077968, 0.085431], [0.10665, 0.078516, 0.083233], [0.105, 0.079207, 0.081185], [0.10368, 0.080048, 0.079202], [0.10245, 0.081036, 0.077408], [0.10143, 0.082173, 0.075793], [0.1006, 0.083343, 0.074344], [0.099957, 0.084733, 0.073021], [0.099492, 0.086174, 0.071799], [0.099204, 0.087868, 0.070716], [0.099092, 0.089631, 0.069813], [0.099154, 0.091582, 0.069047], [0.099384, 0.093597, 0.068337], [0.099759, 0.095871, 0.067776], [0.10029, 0.098368, 0.067351], [0.10099, 0.101, 0.067056], [0.10185, 0.1039, 0.066891], [0.1029, 0.10702, 0.066853], [0.10407, 0.11031, 0.066942], [0.10543, 0.1138, 0.067155], [0.10701, 0.1175, 0.067485], [0.10866, 0.12142, 0.067929], [0.11059, 0.12561, 0.06849], [0.11265, 0.12998, 0.069162], [0.11483, 0.13453, 0.069842], [0.11725, 0.13923, 0.07061], [0.11985, 0.14422, 0.071528], [0.12259, 0.14937, 0.072403], [0.12558, 0.15467, 0.073463], [0.12867, 0.16015, 0.074429], [0.13196, 0.16584, 0.075451], [0.1354, 0.17169, 0.076499], [0.13898, 0.17771, 0.077615], [0.14273, 0.18382, 0.078814], [0.14658, 0.1901, 0.080098], [0.15058, 0.19654, 0.081473], [0.15468, 0.20304, 0.08282], [0.15891, 0.20968, 0.084315], [0.16324, 0.21644, 0.085726], [0.16764, 0.22326, 0.087378], [0.17214, 0.23015, 0.088955], [0.17673, 0.23717, 0.090617], [0.18139, 0.24418, 0.092314], [0.18615, 0.25132, 0.094071], [0.19092, 0.25846, 0.095839], [0.19578, 0.26567, 0.097702], [0.20067, 0.2729, 0.099539], [0.20564, 0.28016, 0.10144], [0.21062, 0.28744, 0.10342], [0.21565, 0.29475, 0.10534], [0.22072, 0.30207, 0.10737], [0.22579, 0.30942, 0.10942], [0.23087, 0.31675, 0.11146], [0.236, 0.32407, 0.11354], [0.24112, 0.3314, 0.11563], [0.24625, 0.33874, 0.11774], [0.25142, 0.34605, 0.11988], [0.25656, 0.35337, 0.12202], [0.26171, 0.36065, 0.12422], [0.26686, 0.36793, 0.12645], [0.272, 0.37519, 0.12865], [0.27717, 0.38242, 0.13092], [0.28231, 0.38964, 0.13316], [0.28741, 0.39682, 0.13541], [0.29253, 0.40398, 0.13773], [0.29763, 0.41111, 0.13998], [0.30271, 0.4182, 0.14232], [0.30778, 0.42527, 0.14466], [0.31283, 0.43231, 0.14699], [0.31787, 0.43929, 0.14937], [0.32289, 0.44625, 0.15173], [0.32787, 0.45318, 0.15414], [0.33286, 0.46006, 0.1566], [0.33781, 0.46693, 0.15904], [0.34276, 0.47374, 0.16155], [0.34769, 0.48054, 0.16407], [0.3526, 0.48733, 0.16661], [0.35753, 0.4941, 0.16923], [0.36245, 0.50086, 0.17185], [0.36738, 0.50764, 0.17458], [0.37234, 0.51443, 0.17738], [0.37735, 0.52125, 0.18022], [0.38238, 0.52812, 0.18318], [0.38746, 0.53505, 0.18626], [0.39261, 0.54204, 0.18942], [0.39783, 0.54911, 0.19272], [0.40311, 0.55624, 0.19616], [0.40846, 0.56348, 0.1997], [0.4139, 0.57078, 0.20345], [0.41942, 0.57819, 0.20734], [0.42503, 0.5857, 0.2114], [0.43071, 0.59329, 0.21565], [0.43649, 0.60098, 0.22009], [0.44237, 0.60878, 0.2247], [0.44833, 0.61667, 0.22956], [0.45439, 0.62465, 0.23468], [0.46053, 0.63274, 0.23997], [0.46679, 0.64092, 0.24553], [0.47313, 0.64921, 0.25138], [0.47959, 0.6576, 0.25745], [0.48612, 0.66608, 0.26382], [0.49277, 0.67466, 0.27047], [0.49951, 0.68335, 0.2774], [0.50636, 0.69213, 0.28464], [0.51331, 0.70101, 0.2922], [0.52035, 0.70998, 0.30008], [0.5275, 0.71905, 0.30828], [0.53474, 0.72821, 0.31682], [0.54207, 0.73747, 0.32567], [0.5495, 0.74682, 0.33491], [0.55702, 0.75625, 0.34443], [0.56461, 0.76577, 0.35434], [0.5723, 0.77537, 0.36457], [0.58006, 0.78506, 0.37515], [0.58789, 0.79482, 0.38607], [0.59581, 0.80465, 0.39734], [0.60379, 0.81455, 0.40894], [0.61182, 0.82453, 0.42086], [0.61991, 0.83457, 0.43311], [0.62805, 0.84467, 0.44566], [0.63623, 0.85482, 0.45852], [0.64445, 0.86503, 0.47168], [0.6527, 0.8753, 0.48511], [0.66099, 0.88562, 0.49882], [0.6693, 0.89599, 0.51278], [0.67763, 0.90641, 0.52699], [0.68597, 0.91687, 0.54141], [0.69432, 0.92738, 0.55605], [0.70269, 0.93794, 0.5709], [0.71107, 0.94855, 0.58593], [0.71945, 0.9592, 0.60112], [0.72782, 0.96989, 0.61646], [0.7362, 0.98063, 0.63191], [0.74458, 0.99141, 0.64748]] cmaps = { name: ListedColormap(data, name=name) 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), ('berlin', _berlin_data), ('managua', _managua_data), ('vanimo', _vanimo_data), ]} napari-0.5.6/napari/utils/colormaps/vendored/_color_data.py000066400000000000000000001042701474413133200240410ustar00rootroot00000000000000from 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.6/napari/utils/colormaps/vendored/cm.py000066400000000000000000000303211474413133200221650ustar00rootroot00000000000000# closes commit to this file is matplotlib's e9cda7fbfb """ 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 . import colors from ._cm import datad from ._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 pickleable. 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.6/napari/utils/colormaps/vendored/colors.py000066400000000000000000002036361474413133200231020ustar00rootroot00000000000000""" 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 ._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.array_equal( self._lut[:, 0], self._lut[:, 1] ) and np.array_equal(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.6/napari/utils/compat.py000066400000000000000000000004641474413133200172510ustar00rootroot00000000000000"""compatibility between newer and older python versions""" import sys if sys.version_info >= (3, 11): from enum import StrEnum else: # in 3.11+, using the below class in an f-string would put the enum name instead of its value from enum import Enum class StrEnum(str, Enum): pass napari-0.5.6/napari/utils/config.py000066400000000000000000000057501474413133200172360ustar00rootroot00000000000000"""Napari Configuration.""" import os import warnings from typing import Optional from napari.utils.translations import trans 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 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. """ # Handle old async/octree deprecated attributes by returning their # fixed values in the module level __getattr__ # https://peps.python.org/pep-0562/ # Other module attributes are defined as normal. def __getattr__(name: str) -> Optional[bool]: if name == 'octree_config': warnings.warn( trans._( 'octree_config is deprecated in version 0.5 and will be removed in a later version.' 'More generally, the experimental octree feature was removed in napari version 0.5 so this value is always None. ' 'If you need to use that experimental feature, continue to use the latest 0.4 release. ' 'Also look out for announcements regarding similar efforts.' ), DeprecationWarning, ) return None if name == 'async_octree': warnings.warn( trans._( 'async_octree is deprecated in version 0.5 and will be removed in a later version.' 'More generally, the experimental octree feature was removed in version 0.5 so this value is always False. ' 'If you need to use that experimental feature, continue to use the latest 0.4 release. ' 'Also look out for announcements regarding similar future efforts.' ), DeprecationWarning, ) return False if name == 'async_loading': # For async_loading, we could get the value of the remaining # async setting. We do not because that is dynamic, so will not # handle an import of the form # # `from napari.utils.config import async_loading` # # consistently. Instead, we let this attribute effectively # refer to the old async which is always off in napari now. warnings.warn( trans._( 'async_loading is deprecated in version 0.5 and will be removed in a later version. ' 'The old approach to async loading was removed in version 0.5 so this value is always False. ' 'Instead, please use napari.settings.get_settings().experimental.async_ to use a new approach. ' 'If you need to specifically use the old approach, continue to use the latest 0.4 release.' ), DeprecationWarning, ) return False return None # Shared Memory Server monitor = _set('NAPARI_MON') napari-0.5.6/napari/utils/events/000077500000000000000000000000001474413133200167145ustar00rootroot00000000000000napari-0.5.6/napari/utils/events/__init__.py000066400000000000000000000021541474413133200210270ustar00rootroot00000000000000from 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__ = [ 'EmitterGroup', 'Event', 'EventEmitter', 'EventedDict', 'EventedList', 'EventedModel', 'EventedSet', 'NestableEventedList', 'SelectableEventedList', 'Selection', 'SupportsEvents', 'TypedMutableSequence', 'disconnect_events', 'set_event_tracing_enabled', ] napari-0.5.6/napari/utils/events/_tests/000077500000000000000000000000001474413133200202155ustar00rootroot00000000000000napari-0.5.6/napari/utils/events/_tests/test_event_emitter.py000066400000000000000000000156461474413133200245140ustar00rootroot00000000000000import 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_name='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_name='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_name='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_name='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_name='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_name='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 == [1, 3, 2] res_li = [] e.connect(fun4) e() assert res_li == [1, 3, 2, 4] res_li = [] e.connect(partial(fun5, val=5), position='first') e() assert res_li == [5, 1, 3, 2, 4] res_li = [] e.connect(partial(fun6, val=6), position='first') e() assert res_li == [5, 1, 3, 6, 2, 4] 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_name='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 == [1, 2, 3, 4] 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_name='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_name='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_name='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_name='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_name='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.6/napari/utils/events/_tests/test_event_migrations.py000066400000000000000000000012271474413133200252050ustar00rootroot00000000000000import pytest from napari.utils.events.migrations import deprecation_warning_event def test_deprecation_warning_event() -> None: event = deprecation_warning_event( 'obj.events', 'old', 'new', '0.1.0', '0.0.0' ) class Counter: def __init__(self) -> None: self.count = 0 def add(self, event) -> None: self.count += event.value counter = Counter() msg = 'obj.events.old is deprecated since 0.0.0 and will be removed in 0.1.0. Please use obj.events.new' with pytest.warns(FutureWarning, match=msg): event.connect(counter.add) event(value=1) assert counter.count == 1 napari-0.5.6/napari/utils/events/_tests/test_event_utils.py000066400000000000000000000025301474413133200241670ustar00rootroot00000000000000import gc from unittest.mock import Mock from napari.utils.events.event import Event, EventEmitter from napari.utils.events.event_utils import ( connect_no_arg, connect_setattr, connect_setattr_value, ) def test_connect_no_arg(): mock = Mock(['meth']) emiter = EventEmitter() connect_no_arg(emiter, mock, 'meth') emiter(type_name='a', value=1) mock.meth.assert_called_once_with() assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 def test_connect_setattr_value(): mock = Mock() emiter = EventEmitter() connect_setattr_value(emiter, mock, 'meth') emiter(type_name='a', value=1) assert mock.meth == 1 assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 def test_connect_setattr(): mock = Mock() emiter = EventEmitter() connect_setattr(emiter, mock, 'meth') emiter(type_name='a', value=1) assert isinstance(mock.meth, Event) assert mock.meth.value == 1 assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 napari-0.5.6/napari/utils/events/_tests/test_evented_dict.py000066400000000000000000000061271474413133200242710ustar00rootroot00000000000000from 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, False: '3', 4: 5} @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() assert len({type(k) for k in new_test}) >= 2, ( 'We want at least non-string keys to test edge cases' ) 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.6/napari/utils/events/_tests/test_evented_list.py000066400000000000000000000376001474413133200243210ustar00rootroot00000000000000from 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, match='is not in list'): 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.6/napari/utils/events/_tests/test_evented_model.py000066400000000000000000000455471474413133200244570ustar00rootroot00000000000000import inspect import operator from collections.abc import Sequence from enum import auto from typing import ClassVar, Protocol, 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 napari._pydantic_compat import Field, ValidationError 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(ValidationError, match='cannot reshape'): 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 @property def d(self) -> int: return sum(self.c) @d.setter def d(self, val: int): # note that d only uses c, which in turns affects in a and b self.c = [val // 2, val // 2] @property def e(self) -> int: # should also work without setter return self.a * 10 def test_evented_model_with_property_setters(): t = T() assert list(T.__properties__) == ['c', 'd', 'e'] # the metaclass should have figured out that both a and b affect c assert T.__field_dependents__ == {'a': {'c', 'd', 'e'}, 'b': {'c', 'd'}} # 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 t.d = 4 assert t.a == 2 assert t.b == 2 with pytest.raises(AttributeError): t.e = 100 @pytest.fixture def mocked_object(): t = T() t.events.a = Mock(t.events.a) t.events.b = Mock(t.events.b) t.events.c = Mock(t.events.c) t.events.d = Mock(t.events.d) t.events.e = Mock(t.events.e) return t @pytest.mark.parametrize( ('attribute', 'value', 'expected_event_values'), [ ('a', 5, {'a': 5, 'b': None, 'c': [5, 1], 'd': 6, 'e': 50}), ('b', 5, {'a': None, 'b': 5, 'c': [1, 5], 'd': 6, 'e': None}), ('c', [10, 20], {'a': 10, 'b': 20, 'c': [10, 20], 'd': 30, 'e': 100}), ('d', 8, {'a': 4, 'b': 4, 'c': [4, 4], 'd': 8, 'e': 40}), ], ) def test_evented_model_with_property_setter_events( mocked_object, attribute, value, expected_event_values ): """Test that setting connected fields and properties fires the right events. For each field and property, set a new value and check that all the dependent fields/properties fire events with the correct value, and that non-connected properties fire no event. """ assert attribute in mocked_object.events setattr(mocked_object, attribute, value) for attr, val in expected_event_values.items(): emitter = getattr(mocked_object.events, attr) if val is None: emitter.assert_not_called() else: emitter.assert_called_with(value=val) def test_evented_model_with_property_without_setter(mocked_object): with pytest.raises(AttributeError): # no setter provided for T.e mocked_object.e = 2 def test_evented_model_with_provided_dependencies(): class T(EventedModel): a: int = 1 @property def b(self): return self.a * 2 class Config: dependencies = {'b': ['a']} t = T() t.events.a = Mock(t.events.a) t.events.b = Mock(t.events.b) t.a = 2 t.events.a.assert_called_with(value=2) t.events.b.assert_called_with(value=4) # should fail if property does not exist with pytest.raises( ValueError, match='Fields with dependencies must be properties' ): class T(EventedModel): a: int = 1 @property def b(self): # pragma: no cover return self.a * 2 class Config: dependencies = {'x': ['a']} # should warn if field does not exist with pytest.warns(match='Unrecognized field dependency'): class T(EventedModel): a: int = 1 @property def b(self): # pragma: no cover return self.a * 2 class Config: dependencies = {'b': ['x']} def test_property_get_eq_operator(): """Test if the __eq_operators__ for properties are properly recognized""" class Tt(EventedModel): a: int = 1 @property def b(self) -> float: # pragma: no cover return self.a * 2 @property def c(self): # pragma: no cover return self.a * 3 assert Tt.__eq_operators__ == {'a': operator.eq, 'b': operator.eq} def test_property_str_annotation(): """Test if the __str_annotations__ for properties are properly recognized""" class Tt(EventedModel): a: int = 1 @property def b(self) -> 'np.ndarray': # pragma: no cover return np.ndarray([self.a, self.a]) @property def c(self): # pragma: no cover return self.a * 3 assert Tt.__eq_operators__ == {'a': operator.eq} def test_events_are_fired_only_if_necessary(monkeypatch): class Tt(EventedModel): a: int = 1 @property def b(self) -> float: return self.a * 2 @property def c(self): return self.a * 3 eq_op_get = Mock(return_value=operator.eq) monkeypatch.setattr( 'napari.utils.events.evented_model.pick_equality_operator', eq_op_get ) t = Tt() a_eq = Mock(return_value=False) b_eq = Mock(return_value=False) t.__eq_operators__['a'] = a_eq t.__eq_operators__['b'] = b_eq t.a = 2 a_eq.assert_not_called() b_eq.assert_not_called() call1 = Mock() t.events.a.connect(call1) t.a = 3 call1.assert_called_once() assert call1.call_args.args[0].value == 3 a_eq.assert_called_once() b_eq.assert_not_called() eq_op_get.assert_not_called() call2 = Mock() t.events.b.connect(call2) call1.reset_mock() a_eq.reset_mock() t.a = 4 call1.assert_called_once() call2.assert_called_once() assert call1.call_args.args[0].value == 4 assert call2.call_args.args[0].value == 8 a_eq.assert_called_once() b_eq.assert_called_once() eq_op_get.assert_not_called() call3 = Mock() t.events.c.connect(call3) call1.reset_mock() call2.reset_mock() a_eq.reset_mock() b_eq.reset_mock() t.a = 3 call1.assert_called_once() call2.assert_called_once() call3.assert_called_once() assert call1.call_args.args[0].value == 3 assert call2.call_args.args[0].value == 6 assert call3.call_args.args[0].value == 9 a_eq.assert_called_once() b_eq.assert_called_once() eq_op_get.assert_called_once_with(9) def _reset_mocks(*args): for el in args: el.reset_mock() def test_single_emit(): class SampleClass(EventedModel): a: int = 1 b: int = 2 @property def c(self): return self.a @c.setter def c(self, value): self.a = value @property def d(self): return self.a + self.b @d.setter def d(self, value): self.a = value // 2 self.b = value - self.a @property def e(self): return self.a - self.b s = SampleClass() a_m = Mock() c_m = Mock() d_m = Mock() s.events.a.connect(a_m) s.events.c.connect(c_m) s.events.d.connect(d_m) s.a = 4 a_m.assert_called_once() c_m.assert_called_once() d_m.assert_called_once() _reset_mocks(a_m, c_m, d_m) s.c = 6 a_m.assert_called_once() c_m.assert_called_once() d_m.assert_called_once() _reset_mocks(a_m, c_m, d_m) e_m = Mock() s.events.e.connect(e_m) s.d = 21 a_m.assert_called_once() c_m.assert_called_once() d_m.assert_called_once() e_m.assert_called_once() napari-0.5.6/napari/utils/events/_tests/test_evented_set.py000066400000000000000000000062311474413133200241350ustar00rootroot00000000000000from 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=set())]), ('discard', 2, [call.changed(added=set(), removed={2})]), ('remove', 2, [call.changed(added=set(), removed={2})]), ('discard', 10, []), # parity with set ('update', {3, 4, 5, 6}, [call.changed(added={5, 6}, removed=set())]), ( 'difference_update', {3, 4, 5, 6}, [call.changed(added=set(), removed={3, 4})], ), ( 'intersection_update', {3, 4, 5, 6}, [call.changed(added=set(), 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=set(), 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.6/napari/utils/events/_tests/test_selectable_list.py000066400000000000000000000051251474413133200247670ustar00rootroot00000000000000from collections.abc import Iterable from typing import 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 def test_select_next(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) assert 'a' in selectable_list.selection selectable_list.select_next() assert 'a' not in selectable_list.selection assert 'b' in selectable_list.selection def test_select_previous(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) selectable_list.selection.active = 'c' assert 'a' not in selectable_list.selection assert 'c' in selectable_list.selection selectable_list.select_previous() assert 'c' not in selectable_list.selection assert 'b' in selectable_list.selection def test_shift_select_next_previous(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) assert 'a' in selectable_list.selection selectable_list.select_next(shift=True) assert 'a' in selectable_list.selection assert 'b' in selectable_list.selection selectable_list.select_previous(shift=True) assert 'a' in selectable_list.selection assert 'b' not in selectable_list.selection def test_shift_select_previous_next(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) selectable_list.selection.active = 'c' assert 'a' not in selectable_list.selection assert 'c' in selectable_list.selection selectable_list.select_previous(shift=True) assert 'b' in selectable_list.selection assert 'c' in selectable_list.selection selectable_list.select_next(shift=True) assert 'b' not in selectable_list.selection assert 'c' in selectable_list.selection napari-0.5.6/napari/utils/events/_tests/test_selection.py000066400000000000000000000014531474413133200236160ustar00rootroot00000000000000from unittest.mock import Mock import pytest from napari._pydantic_compat 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.6/napari/utils/events/_tests/test_typed_dict.py000066400000000000000000000022361474413133200237610ustar00rootroot00000000000000import 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.6/napari/utils/events/_tests/test_typed_list.py000066400000000000000000000111131474413133200240030ustar00rootroot00000000000000import 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, match='attempt to assign sequence of size'): 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, match='is not in list'): assert a.index((1, 2, 3), stop=2) with pytest.raises(ValueError, match='is not in list'): 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.6/napari/utils/events/containers/000077500000000000000000000000001474413133200210615ustar00rootroot00000000000000napari-0.5.6/napari/utils/events/containers/__init__.py000066400000000000000000000015451474413133200231770ustar00rootroot00000000000000from 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__ = [ 'EventedDict', 'EventedList', 'EventedSet', 'NestableEventedList', 'Selectable', 'SelectableEventedList', 'SelectableNestableEventedList', 'Selection', 'TypedMutableMapping', 'TypedMutableSequence', ] napari-0.5.6/napari/utils/events/containers/_dict.py000066400000000000000000000036371474413133200225260ustar00rootroot00000000000000"""Evented dictionary""" from collections.abc import Iterator, Mapping, MutableMapping, Sequence from typing import ( Any, Optional, TypeVar, Union, ) _K = TypeVar('_K') _T = TypeVar('_T') class TypedMutableMapping(MutableMapping[_K, _T]): """Dictionary mixin that enforces item type.""" def __init__( self, data: Optional[Mapping[_K, _T]] = None, basetype: Union[type[_T], Sequence[type[_T]]] = (), ) -> None: if data is None: data = {} self._dict: dict[_K, _T] = {} self._basetypes = ( basetype if isinstance(basetype, Sequence) else (basetype,) ) self.update(data) # #### START Required Abstract Methods def __setitem__(self, key: _K, value: _T) -> None: 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[_K]: return iter(self._dict) def __repr__(self) -> str: 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] ) -> 'TypedMutableMapping[_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[_K, _T]': """Return a shallow copy of the dictionary.""" return self.__newlike__(self) napari-0.5.6/napari/utils/events/containers/_evented_dict.py000066400000000000000000000105061474413133200242310ustar00rootroot00000000000000"""MutableMapping that emits events when altered.""" from collections.abc import Mapping, Sequence from typing import Optional, 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 builtin ``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 ------ changing (key: K) emitted before an item at ``key`` is changed changed (key: K, old_value: T, value: T) emitted when item at ``key`` is changed 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: Optional[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, auto_connect=False, **_events ) super().__init__(data, basetype) def __setitem__(self, key: _K, value: _T) -> None: old = self._dict.get(key) 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: self.events.changing(key=key) super().__setitem__(key, value) self.events.changed(key=key, old_value=old, value=value) def __delitem__(self, key: _K) -> None: 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) -> None: """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) -> None: """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) -> None: """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) -> Optional[_K]: """Return first instance of value.""" for k, v in self._dict.items(): if v is value or v == value: return k return None napari-0.5.6/napari/utils/events/containers/_evented_list.py000066400000000000000000000343321474413133200242640ustar00rootroot00000000000000"""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 collections.abc import Generator, Iterable, Sequence from typing import ( Callable, Optional, 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 item at ``index`` is changed from ``old_value`` to ``value`` changed (index: slice, old_value: List[_T], value: List[_T]) emitted when item at ``index`` is changed 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: Optional[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, auto_connect=False, **_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: _T) -> None: 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))] if isinstance(key, slice): return [(self, i) for i in range(*key.indices(len(self)))] if 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) -> None: # 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) -> None: """Allow process item in inherited class before event was emitted""" def insert(self, index: int, value: _T) -> None: """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) -> None: """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) -> None: """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) -> None: """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( 'move_multiple(sources={sources}, dest_index={dest_index})', extra={'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 ) -> Generator[tuple[int, int], None, None]: """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, increment 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.6/napari/utils/events/containers/_nested_list.py000066400000000000000000000422521474413133200241140ustar00rootroot00000000000000"""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 collections.abc import Generator, Iterable, MutableSequence from typing import ( NewType, Optional, 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 if 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 item at ``index`` is changed from ``old_value`` to ``value`` changed (index: slice, old_value: list[_T], value: list[_T]) emitted when slice at ``index`` is changed 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]: ... # 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 ) -> None: ... # pragma: no cover @overload def __setitem__( self, key: slice, value: Iterable[_T] ) -> None: ... # pragma: no cover def __setitem__(self, key, 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) -> None: """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) -> None: """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 evented 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 TypeError( 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 TypeError( 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 TypeError( trans._( 'Terminal source index may not be a slice', deferred=True, ) ) if isinstance(dest_i, slice): raise TypeError( 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 = 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: int = 0, stop: Optional[int] = None, root: tuple[int, ...] = (), ) -> Generator[Union[int, tuple[int]]]: """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] except IndexError: return False else: return True raise TypeError(f'Not supported index type {type(index)}') napari-0.5.6/napari/utils/events/containers/_selectable_list.py000066400000000000000000000141201474413133200247260ustar00rootroot00000000000000import warnings from typing import Any, 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 item at ``index`` is changed from ``old_value`` to ``value`` changed (index: slice, old_value: List[_T], value: List[_T]) emitted when item at ``index`` is changed 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: Any, **kwargs: Any) -> None: self._activate_on_insert = True super().__init__(*args, **kwargs) # bound/unbound methods are ambiguous for mypy so we need to ignore # https://mypy.readthedocs.io/en/stable/error_code_list.html?highlight=method-assign#check-that-assignment-target-is-not-a-method-method-assign self.selection._pre_add_hook = self._preselect_hook # type: ignore[method-assign] def _preselect_hook(self, value: _T) -> _T: """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) -> None: self.selection.discard(item) def insert(self, index: int, value: _T) -> None: super().insert(index, value) if self._activate_on_insert: # Make layer selected and unselect all others self.selection.active = value def select_all(self) -> None: """Select all items in the list.""" self.selection.update(self) def remove_selected(self) -> None: """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) -> None: """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: int = 1, shift: bool = False) -> None: """Selects next item from list.""" if self.selection and self.selection._current: idx = self.index(self.selection._current) + step if len(self) > idx >= 0: next_layer = self[idx] if shift: if next_layer in self.selection: self.selection.remove(self.selection._current) self.selection._current = next_layer else: 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: bool = False) -> None: """Selects previous item from list.""" self.select_next(-1, shift=shift) class SelectableNestableEventedList( SelectableEventedList[_T], NestableEventedList[_T] ): pass napari-0.5.6/napari/utils/events/containers/_selection.py000066400000000000000000000164161474413133200235670ustar00rootroot00000000000000from collections.abc import Generator, Iterable from typing import ( TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union, ) 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 napari._pydantic_compat 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_: Optional[_T] = None self.events = EmitterGroup(source=self, _current=None, active=None) super().__init__(data=data) self._update_active() def _emit_change( self, added: Optional[set[_T]] = None, removed: Optional[set[_T]] = None, ) -> 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__}({self._set!r})' 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]) -> None: """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]) -> None: """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) -> None: """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 = next(iter(self)) 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) -> None: """Toggle selection state of obj.""" self.symmetric_difference_update({obj}) def select_only(self, obj: _T) -> None: """Unselect everything but `obj`. Add to selection if not present.""" self.intersection_update({obj}) self.add(obj) @classmethod def __get_validators__(cls) -> Generator: yield cls.validate @classmethod def validate( cls, v: Union['Selection', dict], # type: ignore[override] field: 'ModelField', ) -> 'Selection': """Pydantic validator.""" from napari._pydantic_compat 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 napari._pydantic_compat import ValidationError raise ValidationError(errors, cls) # type: ignore [arg-type] # need to be fixed when migrate to pydantic 2 obj = cls(data=data) obj._current_ = current return obj def _json_encode(self) -> dict: # type: ignore[override] """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: Any, **kwargs: Any) -> None: self._selection: Selection[_S] = Selection() super().__init__(*args, **kwargs) @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.6/napari/utils/events/containers/_set.py000066400000000000000000000156261474413133200223770ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator, Iterable, Iterator, MutableSet, Sequence from typing import ( TYPE_CHECKING, Any, Optional, TypeVar, ) from napari.utils.events import EmitterGroup from napari.utils.translations import trans _T = TypeVar('_T') if TYPE_CHECKING: from napari._pydantic_compat 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: changed = None # For inheritance: If the mro already provides an EmitterGroup, add... if hasattr(self, 'events') and isinstance(self.events, EmitterGroup): self.events.add(changed=changed) else: # otherwise create a new one self.events = EmitterGroup(source=self, changed=changed) 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: _T) -> _T: # for subclasses to potentially check value before adding return value def _emit_change( self, added: Optional[set[_T]] = None, removed: Optional[set[_T]] = None, ) -> 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=set()) 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=set(), 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=set(), removed=values) def __repr__(self) -> str: return f'{type(self).__name__}({self._set!r})' 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=set()) 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=set(), 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) -> Generator: yield cls.validate @classmethod def validate(cls, v: Sequence, field: ModelField) -> EventedSet: """Pydantic validator.""" from napari._pydantic_compat 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 napari._pydantic_compat import ValidationError raise ValidationError(errors, cls) # type: ignore [arg-type] # need to be fixed when migrate to pydantic 2 return cls(v) def _json_encode(self) -> list: """Return an object that can be used by json.dumps.""" return list(self) napari-0.5.6/napari/utils/events/containers/_typed.py000066400000000000000000000176061474413133200227310ustar00rootroot00000000000000import logging from collections.abc import Iterable, MutableSequence, Sequence from typing import ( Any, Callable, Optional, TypeVar, Union, overload, ) # change on import from typing when drop python 3.10 support from typing_extensions import Self from napari.utils.translations import trans logger = logging.getLogger(__name__) Index = Union[int, slice] _T = TypeVar('_T') _L = TypeVar('_L', bound=Any) 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: Optional[dict[type[_L], Callable[[_T], Union[_T, _L]]]] = None, ) -> None: if lookup is None: lookup = {} self._list: list[_T] = [] self._basetypes: tuple[type[_T], ...] = ( tuple(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: object) -> bool: 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): ... # pragma: no cover @overload def __setitem__( self, key: slice, value: Iterable[_T] ): ... # pragma: no cover def __setitem__(self, key, value): 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) -> None: self._list.insert(index, self._type_check(value)) def __contains__(self, key: Any) -> bool: 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: str) -> _T: ... # pragma: no cover @overload def __getitem__(self, key: int) -> _T: ... # pragma: no cover @overload def __getitem__( self, key: slice ) -> 'TypedMutableSequence[_T]': ... # pragma: no cover def __getitem__(self, key): """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) -> None: _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] ) -> 'TypedMutableSequence[_T]': new = self.__class__() # separating 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]) -> Self: """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: Optional[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: int = 0, stop: Optional[int] = None ) -> Iterable[int]: """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) return None # type: ignore def _noop(x: _T) -> _T: return x napari-0.5.6/napari/utils/events/custom_types.py000066400000000000000000000070041474413133200220250ustar00rootroot00000000000000from collections.abc import Generator from typing import ( TYPE_CHECKING, Any, Callable, Optional, Union, ) import numpy as np from napari._pydantic_compat import errors, types if TYPE_CHECKING: from decimal import Decimal from napari._pydantic_compat import ModelField Number = Union[int, float, Decimal] # In numpy 2, the semantics of the copy argument in np.array changed # so that copy=False errors if a copy is needed: # https://numpy.org/devdocs/numpy_2_0_migration_guide.html#adapting-to-changes-in-the-copy-keyword # # In numpy 1, copy=False meant that a copy was avoided unless necessary, # but would not error. # # In most usage like this use np.asarray instead, but sometimes we need # to use some of the unique arguments of np.array (e.g. ndmin). # # This solution assumes numpy 1 by default, and switches to the numpy 2 # value for any release of numpy 2 on PyPI (including betas and RCs). copy_if_needed: Optional[bool] = False if np.lib.NumpyVersion(np.__version__) >= '2.0.0b1': copy_if_needed = None 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 = () result = np.array( val, dtype=dtype, copy=copy_if_needed, 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: Optional[int] = None, ge: Optional[int] = None, lt: Optional[int] = None, le: Optional[int] = None, multiple_of: Optional[int] = None, ne: Optional[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 = { 'strict': strict, 'gt': gt, 'ge': ge, 'lt': lt, 'le': le, 'multiple_of': multiple_of, 'ne': ne, } return type('ConstrainedIntValue', (ConstrainedInt,), namespace) napari-0.5.6/napari/utils/events/debugging.py000066400000000000000000000102631474413133200212230ustar00rootroot00000000000000import inspect import os import site from textwrap import indent from typing import TYPE_CHECKING, ClassVar from napari._pydantic_compat import BaseSettings, Field, PrivateAttr from napari.utils.misc import ROOT_DIR from napari.utils.translations import trans try: from rich import print # noqa: A004 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] = Field( default_factory=lambda: {'TransformChain', 'Context'} ) exclude_events: set[str] = Field( default_factory=lambda: {'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 # separate 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.6/napari/utils/events/event.py000066400000000000000000001306251474413133200204160ustar00rootroot00000000000000# 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 contextlib import inspect import os import warnings import weakref from collections.abc import Iterable, Iterator, Sequence from functools import partial from typing import ( Any, Callable, Generic, Literal, Optional, TypeVar, Union, cast, ) from vispy.util.logs import _handle_exception from napari.utils.migrations import rename_argument 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. """ @rename_argument( from_name='type', to_name='type_name', version='0.6.0', since_version='0.4.18', ) def __init__( self, type_name: 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_name 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; documented in class docstring return self._type @property def native(self) -> Any: # No docstring; documented 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) -> None: 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) -> None: 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}') finally: _event_repr_depth -= 1 return f'<{self.__class__.__name__} {" ".join(attrs)}>' 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 _T = TypeVar('_T') class _WeakCounter(Generic[_T]): """ 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[_T, int] = ( weakref.WeakKeyDictionary() ) self._nonecount = 0 def update(self, iterable: Iterable[_T]): for it in iterable: if it is None: self._nonecount += 1 else: self._counter[it] = self.get(it, 0) + 1 def get(self, key: _T, default: int) -> int: 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_name: 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. """ @rename_argument('type', 'type_name', '0.6.0', '0.4.18') def __init__( self, source: Any = None, type_name: 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_name is not None: self.default_args['type_name'] = type_name 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: Literal['first', 'reminders', 'always', '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'. """ if isinstance(callback, partial): callback = callback.func if not isinstance(callback, tuple): try: return callback.__module__.startswith(f'{core}.') except AttributeError: return False obj = callback[0]() # get object behind weakref if obj is None: # object is dead return False try: return obj.__module__.startswith(f'{core}.') except AttributeError: return False def connect( self, callback: Union[Callback, CallbackRef, CallbackStr, 'EventEmitter'], ref: Union[bool, str] = False, position: Literal['first', 'last'] = 'last', 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 None # 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( x.kind in [ inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.VAR_POSITIONAL, ] for x in 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: str, category: type[Warning] = FutureWarning, stacklevel: int = 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] = {} # 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, ) ) if 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=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) -> Iterator[str]: """ 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, EventEmitter, 'EmitterGroup'], ref: Union[bool, str] = False, position: Literal['first', '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: EventEmitter, 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: EmitterGroup) -> 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 ) with contextlib.suppress(ModuleNotFoundError): # this could move somewhere higher up in napari imports ... but where? __import__('dotenv').load_dotenv() 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.6/napari/utils/events/event_utils.py000066400000000000000000000040651474413133200216340ustar00rootroot00000000000000from __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): if (ob := ref()) is None: emitter.disconnect(_cb) return setattr(ob, 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" # In all of these 3 functions, this should be uncommented instead of using # the if clause in _cb but that causes a segmentation fault in tests # weakref.finalize(obj, emitter.disconnect, _cb) def connect_no_arg(emitter: Emitter, obj, attr: str): ref = weakref.ref(obj) def _cb(*_value): if (ob := ref()) is None: emitter.disconnect(_cb) return getattr(ob, 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): if (ob := ref()) is None: emitter.disconnect(_cb) return setattr(ob, attr, value.value) emitter.connect(_cb) # weakref.finalize(obj, emitter.disconnect, _cb) napari-0.5.6/napari/utils/events/evented_model.py000066400000000000000000000477301474413133200221130ustar00rootroot00000000000000import sys import warnings from contextlib import contextmanager from typing import Any, Callable, ClassVar, Union import numpy as np from app_model.types import KeyBinding from napari._pydantic_compat import ( BaseModel, ModelMetaclass, 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(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 properties defined on the class, so we can allow them # in EventedModel.__setattr__ and create events cls.__properties__ = {} for name, attr in namespace.items(): if isinstance(attr, property): cls.__properties__[name] = attr # determine compare operator if ( hasattr(attr.fget, '__annotations__') and 'return' in attr.fget.__annotations__ and not isinstance( attr.fget.__annotations__['return'], str ) ): cls.__eq_operators__[name] = pick_equality_operator( attr.fget.__annotations__['return'] ) cls.__field_dependents__ = _get_field_dependents(cls) return cls def _update_dependents_from_property_code( cls, prop_name, prop, deps, visited=() ): """Recursively find all the dependents of a property by inspecting the code object. Update the given deps dictionary with the new findings. """ for name in prop.fget.__code__.co_names: if name in cls.__fields__: deps.setdefault(name, set()).add(prop_name) elif name in cls.__properties__ and name not in visited: # to avoid infinite recursion, we shouldn't re-check getter we've already seen visited = visited + (name,) # sub_prop is the new property, but we leave prop_name the same sub_prop = cls.__properties__[name] _update_dependents_from_property_code( cls, prop_name, sub_prop, deps, visited ) def _get_field_dependents(cls: 'EventedModel') -> dict[str, set[str]]: """Return mapping of field name -> dependent set of property names. Dependencies will be guessed by inspecting the code of each property in order 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'). Alternatvely, dependencies may be declared excplicitly in the Model Config. Note: accessing a field with `getattr()` instead of dot notation won't be automatically detected. 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 @property def d(self) -> int: return sum(self.c) @d.setter def d(self, val: int): self.c = [val // 2, val // 2] class Config: dependencies={ 'c': ['a', 'b'], 'd': ['a', 'b'] } """ if not cls.__properties__: return {} deps: dict[str, set[str]] = {} _deps = getattr(cls.__config__, 'dependencies', None) if _deps: for prop_name, fields in _deps.items(): if prop_name not in cls.__properties__: raise ValueError( 'Fields with dependencies must be properties. ' f'{prop_name!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_name) else: # if dependencies haven't been explicitly defined, we can glean # them from the property.fget code object: for prop_name, prop in cls.__properties__.items(): _update_dependents_from_property_code(cls, prop_name, prop, deps) 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 properties __properties__: 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]]] _changes_queue: dict[str, Any] = PrivateAttr(default_factory=dict) _primary_changes: set[str] = PrivateAttr(default_factory=set) _delay_check_semaphore: int = PrivateAttr(0) __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 # extra = Extra.forbid 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.__properties__)) ) # 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 is a property # if so, we use it instead. if name in self.__properties__: setter = self.__properties__[name].fset if setter is None: # raise same error as normal properties raise AttributeError(f"can't set attribute '{name}'") setter(self, value) else: super().__setattr__(name, value) def _check_if_differ(self, name: str, old_value: Any) -> tuple[bool, Any]: """ Check new value of a field and emit event if it is different from the old one. Returns True if data changed, else False. Return current value. """ new_value = getattr(self, name, object()) if name in self.__eq_operators__: are_equal = self.__eq_operators__[name] else: are_equal = pick_equality_operator(new_value) return not are_equal(new_value, old_value), new_value def __setattr__(self, name: str, value: Any) -> None: if name not in getattr(self, 'events', {}): # This is a workaround needed because `EventedConfigFileSettings` uses # `_config_path` before calling the superclass constructor super().__setattr__(name, value) return with ComparisonDelayer(self): self._primary_changes.add(name) self._setattr_impl(name, value) def _check_if_values_changed_and_emit_if_needed(self): """ Check if field values changed and emit events if needed. The advantage of moving this to the end of all the modifications is that comparisons will be performed only once for every potential change. """ if self._delay_check_semaphore > 0 or len(self._changes_queue) == 0: # do not run whole machinery if there is no need return to_emit = [] for name in self._primary_changes: # primary changes should contains only fields that are changed directly by assignment if name not in self._changes_queue: continue old_value = self._changes_queue[name] if (res := self._check_if_differ(name, old_value))[0]: to_emit.append((name, res[1])) self._changes_queue.pop(name) if not to_emit: # If no direct changes was made then we can skip whole machinery self._changes_queue.clear() self._primary_changes.clear() return for name, old_value in self._changes_queue.items(): # check if any of dependent properties changed if (res := self._check_if_differ(name, old_value))[0]: to_emit.append((name, res[1])) self._changes_queue.clear() self._primary_changes.clear() with ComparisonDelayer(self): # Again delay comparison to avoid having events caused by callback functions for name, new_value in to_emit: getattr(self.events, name)(value=new_value) def _setattr_impl(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 field_dep = self.__field_dependents__.get(name, set()) has_callbacks = { name: bool(getattr(self.events, name).callbacks) for name in field_dep } emitter = getattr(self.events, name) # equality comparisons may be expensive, so just avoid them if # event has no callbacks connected if not ( emitter.callbacks or self._events.callbacks or any(has_callbacks.values()) ): self._super_setattr_(name, value) return dep_with_callbacks = [ dep for dep, has_cb in has_callbacks.items() if has_cb ] if name not in self._changes_queue: self._changes_queue[name] = getattr(self, name, object()) for dep in dep_with_callbacks: if dep not in self._changes_queue: self._changes_queue[dep] = getattr(self, dep, object()) # set value using original setter self._super_setattr_(name, value) # expose the private EmitterGroup publicly @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 TypeError( 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 if self.__class__ != other.__class__: return False for f_name in self.__fields__: eq = self.__eq_operators__[f_name] if 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 class ComparisonDelayer: def __init__(self, target: EventedModel): self._target = target def __enter__(self): self._target._delay_check_semaphore += 1 def __exit__(self, exc_type, exc_val, exc_tb): self._target._delay_check_semaphore -= 1 self._target._check_if_values_changed_and_emit_if_needed() napari-0.5.6/napari/utils/events/migrations.py000066400000000000000000000025071474413133200214460ustar00rootroot00000000000000from napari.utils.events.event import WarningEmitter from napari.utils.translations import trans def deprecation_warning_event( prefix: str, previous_name: str, new_name: str, version: str, since_version: str, ) -> WarningEmitter: """ Helper function for event emitter deprecation warning. This event still needs to be added to the events group. Parameters ---------- prefix: Prefix indicating class and event (e.g. layer.event) previous_name : str Name of deprecated event (e.g. edge_width) new_name : str Name of new event (e.g. border_width) version : str Version where deprecated event will be removed. since_version : str Version when new event name was added. Returns ------- WarningEmitter Event emitter that prints a deprecation warning. """ previous_path = f'{prefix}.{previous_name}' new_path = f'{prefix}.{new_name}' return WarningEmitter( trans._( '{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}', deferred=True, previous_path=previous_path, since_version=since_version, version=version, new_path=new_path, ), type_name=previous_name, ) napari-0.5.6/napari/utils/events/types.py000066400000000000000000000002611474413133200204310ustar00rootroot00000000000000from typing import Protocol, runtime_checkable from napari.utils.events.event import EmitterGroup @runtime_checkable class SupportsEvents(Protocol): events: EmitterGroup napari-0.5.6/napari/utils/geometry.py000066400000000000000000000706031474413133200176230ustar00rootroot00000000000000from typing import Optional import numpy as np import numpy.typing as npt # 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: np.ndarray, vec_2: np.ndarray ) -> np.ndarray: """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`. """ return bool( np.all(point >= bounding_box[0]) and np.all(point <= bounding_box[1]) ) 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: npt.ArrayLike, line_direction: npt.ArrayLike, plane_position: npt.ArrayLike, plane_normal: npt.ArrayLike, ) -> 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) return in_triangles.sum() >= 1 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=np.array([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, np.array([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: if line_in_quadrilateral_3d( click_pos, view_dir, bbox_face_coords[k] ): front_face_normal = v elif 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 def get_center_bbox(roi: np.ndarray) -> tuple[list[float], int, int]: """Get the center coordinate, height, width of the roi. Parameters ---------- roi : np.ndarray An array of shape (4, 2) representing a rectangular roi. Returns ------- center_coords: list[float, float] center y and x coordinates of the roi height: int height of the roi in data pixels width: int width of the roi in data pixels """ height, width = roi.max(axis=0) - roi.min(axis=0) min_y, min_x = roi.min(axis=0) center_coords = [min_y + height / 2, min_x + width / 2] return center_coords, height, width napari-0.5.6/napari/utils/history.py000066400000000000000000000031651474413133200174700ustar00rootroot00000000000000import os from pathlib import Path from napari.settings import get_settings def update_open_history(filename: str) -> None: """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: str) -> None: """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() -> list[str]: """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() -> list[str]: """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.6/napari/utils/indexing.py000066400000000000000000000003561474413133200175730ustar00rootroot00000000000000import warnings from napari.utils._indexing import index_in_slice __all__ = ['index_in_slice'] warnings.warn( 'napari.utils.indexing is deprecated since 0.4.19 and will be removed in 0.5.0.', FutureWarning, stacklevel=2, ) napari-0.5.6/napari/utils/info.py000066400000000000000000000137701474413133200167250ustar00rootroot00000000000000import contextlib import os import platform import subprocess import sys from importlib.metadata import PackageNotFoundError, version import napari OS_RELEASE_PATH = '/etc/os-release' def _linux_sys_name() -> str: """ 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)' return _linux_sys_name_lsb_release() def _linux_sys_name_lsb_release() -> str: """ Try to discover linux system name base on lsb_release command output """ with contextlib.suppress(subprocess.CalledProcessError): 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 return '' def _sys_name() -> str: """ 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: bool = False) -> str: """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'), ('psygnal', 'psygnal'), ('npe2', 'npe2'), ('pydantic', 'pydantic'), ) loaded = {} for module, name in modules: try: loaded[module] = __import__(module) text += f'{name}: {version(module)}
    ' except PackageNotFoundError: text += f'{name}: Import failed
    ' text += '
    OpenGL:
    ' if loaded.get('vispy', False): from napari._vispy.utils.gl import get_max_texture_sizes sys_info_text = ( '
    '.join( [ loaded['vispy'].sys_info().split('\n')[index] for index in [-4, -3] ] ) .replace("'", '') .replace('
    ', '
    - ') ) text += f' - {sys_info_text}
    ' _, max_3d_texture_size = get_max_texture_sizes() text += f' - GL_MAX_3D_TEXTURE_SIZE: {max_3d_texture_size}
    ' 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 += '
    Optional:
    ' optional_modules = ( ('numba', 'numba'), ('triangle', 'triangle'), ('napari_plugin_manager', 'napari-plugin-manager'), ) for module, name in optional_modules: try: text += f' - {name}: {version(module)}
    ' except PackageNotFoundError: text += f' - {name} not installed
    ' 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.6/napari/utils/interactions.py000066400000000000000000000257301474413133200204730ustar00rootroot00000000000000import contextlib import inspect import sys import warnings from numpydoc.docscrape import FunctionDoc from napari.utils.key_bindings import ( KeyBindingLike, KeyCode, 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 generator 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 generator 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 with contextlib.suppress(StopIteration): # Run last part of the function to trigger release event next(gen) # Finally delete the generator and stored event del obj._mouse_drag_gen[func] del obj._persisted_mouse_event[gen] KEY_SYMBOLS = { 'Ctrl': KeyCode.from_string('Ctrl').os_symbol(), 'Shift': KeyCode.from_string('Shift').os_symbol(), 'Alt': KeyCode.from_string('Alt').os_symbol(), 'Meta': KeyCode.from_string('Meta').os_symbol(), 'Left': KeyCode.from_string('Left').os_symbol(), 'Right': KeyCode.from_string('Right').os_symbol(), 'Up': KeyCode.from_string('Up').os_symbol(), 'Down': KeyCode.from_string('Down').os_symbol(), 'Backspace': KeyCode.from_string('Backspace').os_symbol(), 'Delete': KeyCode.from_string('Delete').os_symbol(), 'Tab': KeyCode.from_string('Tab').os_symbol(), 'Escape': KeyCode.from_string('Escape').os_symbol(), 'Return': KeyCode.from_string('Return').os_symbol(), 'Enter': KeyCode.from_string('Enter').os_symbol(), 'Space': KeyCode.from_string('Space').os_symbol(), } JOINCHAR = '+' if sys.platform.startswith('darwin'): JOINCHAR = '' 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) @staticmethod def parse_platform(text: str) -> str: """ Parse a current_platform_specific shortcut, and return a canonical version separated with dashes. This replace platform specific symbols, like ↵ by Enter, ⌘ by Command on MacOS.... """ # edge case, shortcut combination where `+` is a key. # this should be rare as on english keyboard + is Shift-Minus. # but not unheard of. In those case `+` is always at the end with `++` # as you can't get two non-modifier keys, or alone. if text == '+': return text if JOINCHAR == '+': text = text.replace('++', '+Plus') text = text.replace('+', '') text = text.replace('Plus', '+') for k, v in KEY_SYMBOLS.items(): if text.endswith(v): text = text.replace(v, k) else: text = text.replace(v, k + '-') return text @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 self._kb.to_text(use_symbols=True, joinchar=JOINCHAR) 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.6/napari/utils/io.py000066400000000000000000000077151474413133200164030ustar00rootroot00000000000000import os import struct import warnings import numpy as np from napari._version import __version__ from napari.utils.notifications import show_warning from napari.utils.translations import trans 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].lower() # If no file extension was specified, choose .png by default if ext == '': if ( data.ndim == 2 or (data.ndim == 3 and data.shape[-1] in {3, 4}) ) and not np.issubdtype(data.dtype, np.floating): ext = '.png' else: ext = '.tif' filename = filename + ext # not all file types can handle float data if ext not in [ '.tif', '.tiff', '.bsdf', '.im', '.lsm', '.npz', '.stk', ] and np.issubdtype(data.dtype, np.floating): show_warning( trans._( 'Image was not saved, because image data is of dtype float.\nEither convert dtype or save as different file type (e.g. TIFF).' ) ) return # Save screenshot image data to output file if ext in ['.png']: imsave_png(filename, data) elif ext in ['.tif', '.tiff']: imsave_tiff(filename, data) else: import imageio.v3 as iio iio.imwrite(filename, data) # for all other file extensions def imsave_png(filename, data): """Save .png image to file PNG images created in napari have a digital watermark. The napari version info is embedded into the bytes of the PNG. Parameters ---------- filename : string The path to write the file to. data : np.ndarray The image data. """ import imageio.v3 as iio import PIL.PngImagePlugin # Digital watermark, adds info about the napari version to the bytes of the PNG file pnginfo = PIL.PngImagePlugin.PngInfo() pnginfo.add_text( 'Software', f'napari version {__version__} https://napari.org/' ) iio.imwrite( filename, data, extension='.png', plugin='pillow', pnginfo=pnginfo, ) def imsave_tiff(filename, data): """Save .tiff image to file Parameters ---------- filename : string The path to write the file to. data : np.ndarray The image data. """ import tifffile if data.dtype == bool: tifffile.imwrite(filename, data) else: try: tifffile.imwrite( filename, data, # compression arg structure since tifffile 2022.7.28 compression='zlib', compressionargs={'level': 1}, ) except struct.error: # regular tiffs don't support compressed data >4GB # in that case a struct.error is raised, and we write with the # bigtiff flag. (The flag is not on by default because it is # not as widely supported as normal tiffs.) tifffile.imwrite( filename, data, compression='zlib', compressionargs={'level': 1}, bigtiff=True, ) 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.6/napari/utils/key_bindings.py000066400000000000000000000356721474413133200204440ustar00rootroot00000000000000"""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 collections.abc import Mapping from types import MethodType from typing import Callable, 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 = { 'Super': 'Meta', 'Command': 'Meta', 'Cmd': 'Meta', '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 = {} @property def keymap(self): return self._keymap @keymap.setter def keymap(self, value): self._keymap = {coerce_keybinding(k): v for k, v in value.items()} 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 if 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.6/napari/utils/migrations.py000066400000000000000000000227641474413133200201510ustar00rootroot00000000000000import inspect import warnings from collections import UserDict from functools import wraps from typing import Any, Callable, NamedTuple from napari.utils.translations import trans _UNSET = object() class _RenamedAttribute(NamedTuple): """Captures information about a renamed attribute, property, or argument. Useful for storing internal state related to these types of deprecations. """ from_name: str to_name: str version: str since_version: str def message(self) -> str: return trans._( '{from_name} is deprecated since {since_version} and will be removed in {version}. Please use {to_name}', deferred=True, from_name=self.from_name, since_version=self.since_version, version=self.version, to_name=self.to_name, ) def rename_argument( from_name: str, to_name: str, version: str, since_version: str = '' ) -> Callable: """ 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 version : str version when old argument will be removed since_version : str version when new argument was added """ if not since_version: since_version = 'unknown' warnings.warn( trans._( 'The since_version argument was added in napari 0.4.18 and will be mandatory since 0.6.0 release.', deferred=True, ), stacklevel=2, category=FutureWarning, ) def _wrapper(func): if not hasattr(func, '_rename_argument'): func._rename_argument = [] func._rename_argument.append( _RenamedAttribute( from_name=from_name, to_name=to_name, version=version, since_version=since_version, ) ) @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!r} is deprecated, please use {to_name!r} instead. The argument {from_name!r} was deprecated in {since_version} and it will be removed in {version}.', from_name=from_name, to_name=to_name, version=version, since_version=since_version, ), category=FutureWarning, stacklevel=2, ) kwargs = kwargs.copy() kwargs[to_name] = kwargs.pop(from_name) return func(*args, **kwargs) return _update_from_dict return _wrapper def add_deprecated_property( obj: Any, previous_name: str, new_name: str, version: str, since_version: str, ) -> None: """ Adds deprecated property and links to new property name setter and getter. Parameters ---------- obj: Class instances to add property previous_name : str Name of previous property, its methods must be removed. new_name : str Name of new property, must have its getter (and setter if applicable) implemented. version : str Version where deprecated property will be removed. since_version : str version when new property was added """ if hasattr(obj, previous_name): raise RuntimeError( trans._( '{previous_name} property already exists.', deferred=True, previous_name=previous_name, ) ) if not hasattr(obj, new_name): raise RuntimeError( trans._( '{new_name} property must exist.', deferred=True, new_name=new_name, ) ) name = f'{obj.__name__}.{previous_name}' msg = trans._( '{name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}', deferred=True, name=name, since_version=since_version, version=version, new_name=new_name, ) def _getter(instance) -> Any: warnings.warn(msg, category=FutureWarning, stacklevel=3) return getattr(instance, new_name) def _setter(instance, value: Any) -> None: warnings.warn(msg, category=FutureWarning, stacklevel=3) setattr(instance, new_name, value) setattr(obj, previous_name, property(_getter, _setter)) def deprecated_constructor_arg_by_attr(name: str) -> Callable: """ Decorator to deprecate a constructor argument and remove it from the signature. It works by popping the argument from kwargs, and setting it later via setattr. The property setter should take care of issuing the deprecation warning. Parameters ---------- name : str Name of the argument to deprecate. Returns ------- function decorated function """ def wrapper(func): if not hasattr(func, '_deprecated_constructor_args'): func._deprecated_constructor_args = [] func._deprecated_constructor_args.append(name) @wraps(func) def _wrapper(*args, **kwargs): value = _UNSET if name in kwargs: value = kwargs.pop(name) res = func(*args, **kwargs) if value is not _UNSET: setattr(args[0], name, value) return res return _wrapper return wrapper def deprecated_class_name( new_class: type, previous_name: str, version: str, since_version: str, ) -> type: """Function to deprecate a class. Usage: class NewName: pass OldName = deprecated_class_name( NewName, 'OldName', version='0.5.0', since_version='0.4.19' ) """ msg = ( f'{previous_name} is deprecated since {since_version} and will be ' f'removed in {version}. Please use {new_class.__name__}.' ) prealloc_signature = inspect.signature(new_class.__new__) class _OldClass(new_class): def __new__(cls, *args, **kwargs): warnings.warn(msg, FutureWarning, stacklevel=2) if super().__new__ is object.__new__: return super().__new__(cls) return super().__new__(cls, *args, **kwargs) def __init_subclass__(cls, **kwargs): warnings.warn(msg, FutureWarning, stacklevel=2) _OldClass.__module__ = new_class.__module__ _OldClass.__name__ = previous_name _OldClass.__qualname__ = previous_name _OldClass.__new__.__signature__ = prealloc_signature # type: ignore [attr-defined] return _OldClass class _DeprecatingDict(UserDict[str, Any]): """A dictionary that issues warning messages when deprecated keys are accessed. This class is intended to be an implementation detail of napari and may change in the future. As such, it should not be used outside of napari. Instead, treat this like a plain dictionary. Deprecated keys and values are not stored as part of the dictionary, so will not appear when iterating over this or its items. Instead deprecated items can only be accessed using `__getitem__`, `__setitem__`, and `__delitem__`. Deprecations from pure renames should keep the old and new corresponding items consistent when mutating either the old or new item. """ # Maps from a deprecated key to its renamed key and deprecation information. _renamed: dict[str, _RenamedAttribute] def __init__(self, *args, **kwargs) -> None: self._renamed = {} super().__init__(*args, **kwargs) def __getitem__(self, key: str) -> Any: key = self._maybe_rename_key(key) return self.data.__getitem__(key) def __setitem__(self, key: str, value: Any) -> None: key = self._maybe_rename_key(key) return self.data.__setitem__(key, value) def __delitem__(self, key: str) -> None: key = self._maybe_rename_key(key) return self.data.__delitem__(key) def __contains__(self, key: object) -> bool: if not isinstance(key, str): return False key = self._maybe_rename_key(key) return self.data.__contains__(key) def _maybe_rename_key(self, key: str) -> str: if key in self._renamed: renamed = self._renamed[key] warnings.warn(renamed.message(), FutureWarning) key = renamed.to_name return key @property def deprecated_keys(self) -> tuple[str, ...]: return tuple(self._renamed.keys()) def set_deprecated_from_rename( self, *, from_name: str, to_name: str, version: str, since_version: str ) -> None: """Sets a deprecated key with a value that comes from another key. A warning message is automatically generated using the given version information. """ self._renamed[from_name] = _RenamedAttribute( from_name=from_name, to_name=to_name, version=version, since_version=since_version, ) napari-0.5.6/napari/utils/misc.py000066400000000000000000000557261474413133200167340ustar00rootroot00000000000000"""Miscellaneous utility functions.""" from __future__ import annotations import builtins import collections.abc import contextlib import importlib.metadata import inspect import itertools import os import re import sys import warnings from collections.abc import Iterable, Iterator, Sequence from enum import Enum, EnumMeta from os import fspath, path as os_path from pathlib import Path from typing import ( TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, ) import numpy as np import numpy.typing as npt from napari.utils.translations import trans _sentinel = object() if TYPE_CHECKING: import packaging.version ROOT_DIR = os_path.dirname(os_path.dirname(__file__)) def parse_version(v: str) -> 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) # type: ignore[attr-defined] def running_as_bundled_app(*, check_conda: bool = True) -> bool: """Infer whether we are running as a 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 warnings.warn( trans._( 'Briefcase installations are no longer supported as of v0.4.18. ' 'running_as_bundled_app() will be removed in a 0.6.0 release.', ), DeprecationWarning, stacklevel=2, ) if ( check_conda and (Path(sys.executable).parent / '.napari_is_bundled').exists() ): return True # TODO: Remove from here on? try: app_module = sys.modules['__main__'].__package__ except AttributeError: return False if not app_module: 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 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: str) -> list[int]: """Convert an rgb string 'rgb(x,y,z)' to a list of ints [x,y,z].""" match = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', arg) if match is None: raise ValueError("arg not in format 'rgb(x,y,z)'") return list(map(int, match.groups())) def ensure_iterable( arg: Union[None, str, Enum, float, list, npt.NDArray], color: object | bool = _sentinel, ): """Ensure an argument is an iterable. Useful when an input argument can either be a single value or a list. Argument color is deprecated since version 0.5.0 and will be removed in 0.6.0. """ # deprecate color if color is not _sentinel: warnings.warn( trans._( 'Argument color is deprecated since version 0.5.0 and will be removed in 0.6.0.', ), category=DeprecationWarning, stacklevel=2, # not sure what level to use here ) if is_iterable( arg, color=color ): # argument color is to be removed in 0.6.0 return arg return itertools.repeat(arg) def is_iterable( arg: Union[None, str, Enum, float, list, npt.NDArray], color: object | bool = _sentinel, allow_none: bool = False, ) -> bool: """Determine if a single argument is an iterable. Argument color is deprecated since version 0.5.0 and will be removed in 0.6.0. """ # deprecate color if color is not _sentinel: warnings.warn( trans._( 'Argument color is deprecated since version 0.5.0 and will be removed in 0.6.0.', ), category=DeprecationWarning, stacklevel=2, # not sure what level to use here ) if arg is None: return allow_none # Here if arg is None it used to return allow_none if isinstance(arg, (str, Enum)) or np.isscalar(arg): return False # this is to be removed in 0.6.0, color is never set True if color is True and isinstance(arg, (list, np.ndarray)): return np.array(arg).ndim != 1 or len(arg) not in [3, 4] return isinstance(arg, collections.abc.Iterable) def is_sequence(arg: Any) -> bool: """Check if ``arg`` is a sequence like a list or tuple. return True: list tuple return False: string numbers dict set """ return bool( isinstance(arg, collections.abc.Sequence) and not isinstance(arg, str) ) def ensure_sequence_of_iterables( obj: Any, 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) and (not repeat_empty or len(obj) > 0) ): if length is not None and len(obj) != length: # 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} ) finally: del frame return obj 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, # noqa: A002 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()) if isinstance(value, cls): return value 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) -> list[str]: return list(map(str, self)) class StringEnum(Enum, metaclass=StringEnumMeta): @staticmethod def _generate_next_value_(name: str, start, count, last_values) -> str: """autonaming function assigns each value its own name as a value""" return name.lower() def __str__(self) -> str: """String representation: The string method returns the lowercase string of the Enum name """ return self.value def __eq__(self, other: object) -> bool: if type(self) is type(other): return self is other if isinstance(other, str): return str(self) == other return False def __hash__(self) -> int: 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]|(? str: # https://gist.github.com/jaytaylor/3660565 return camel_to_snake_pattern.sub(r'\1_\2', name).lower() def camel_to_spaces(val: str) -> str: return camel_to_spaces_pattern.sub(r' \1', val) T = TypeVar('T', str, Path) def abspath_or_url(relpath: T, *, must_exist: bool = False) -> 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_str = fspath(relpath) urlp = urlparse(relpath_str) if urlp.scheme and urlp.netloc: return OriginType(relpath_str) path = os_path.abspath(os_path.expanduser(relpath_str)) 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): warnings.warn( trans._( '`CallDefault` in napari v0.5.0 and will be removed in v0.6.0.', ), category=DeprecationWarning, ) def __str__(self) -> str: """wrap defaults""" kind = self.kind formatted = self.name # Fill in defaults if ( self.default is not inspect._empty or kind == inspect.Parameter.KEYWORD_ONLY ): formatted = f'{formatted}={formatted}' if kind == inspect.Parameter.VAR_POSITIONAL: formatted = '*' + formatted elif kind == inspect.Parameter.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: Iterable, n: int, fill: int = 0) -> tuple: """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: tuple) -> tuple: 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], collections.abc.Mapping): 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]) -> 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) -> bool: with warnings.catch_warnings(): warnings.filterwarnings('ignore', 'elementwise comparison') return np.array_equal(*a, **k) def _pandas_dataframe_equal(df1, df2): return df1.equals(df2) 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: Any) -> 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: dict[str, Callable[[Any, Any], bool]] = { '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 'pandas.DataFrame': _pandas_dataframe_equal, # pandas.DataFrame.equals } for name in _arraylike_short_names(obj): func = _known_arrays.get(name) if func: return func return operator.eq def _is_array_type(array: npt.ArrayLike, 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: bool = True, ignore_hidden: bool = 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(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 ) -> None: """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: Iterable[str] = (), ) -> 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: bool = 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() -> None: """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.6/napari/utils/mouse_bindings.py000066400000000000000000000024061474413133200207710ustar00rootroot00000000000000class 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.6/napari/utils/naming.py000066400000000000000000000125641474413133200172430ustar00rootroot00000000000000"""Automatically generate names.""" import inspect import re from collections import ChainMap, ChainMap as ChainMapType from types import FrameType, TracebackType 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: re.Match) -> str: 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. """ names: tuple[str, ...] namespace: ChainMapType[str, Any] predicate: Callable[[int, FrameType], bool] def __init__( self, skip_predicate: Callable[[int, FrameType], bool] ) -> None: self.predicate = skip_predicate self.namespace = ChainMap() self.names = () def __enter__(self) -> 'CallerFrame': frame = inspect.currentframe() try: # See issue #1635 regarding potential AttributeError # since frame could be None. # https://github.com/napari/napari/pull/1635 for _ in range(2): 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: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: 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.6/napari/utils/notebook_display.py000066400000000000000000000100441474413133200213260ustar00rootroot00000000000000import 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 ImportError: lxml_unavailable = True from napari.utils.io import imsave_png __all__ = ['NotebookScreenshot', '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_html_clean library is not installed, and is ' 'required to sanitize alt text for napari screenshots. ' 'Alt Text will be stripped altogether.' ) 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 napari._qt.qt_event_loop import get_qapp get_qapp().processEvents() self.image = self.viewer.screenshot( canvas_only=self.canvas_only, flash=False ) with BytesIO() as file_obj: imsave_png(file_obj, self.image) 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.6/napari/utils/notifications.py000066400000000000000000000300201474413133200206260ustar00rootroot00000000000000from __future__ import annotations import logging import os import sys import threading import warnings from collections.abc import Sequence from datetime import datetime from enum import auto from types import TracebackType from typing import Callable, Optional, Union from napari.utils.events import Event, EventEmitter from napari.utils.misc import StringEnum name2num = { 'error': 40, 'warning': 30, 'info': 20, 'debug': 10, 'none': 0, } __all__ = [ 'ErrorNotification', 'Notification', 'NotificationManager', 'NotificationSeverity', 'WarningNotification', 'show_console_notification', 'show_debug', 'show_error', 'show_info', 'show_warning', ] 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_name=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: list[Callable] = [] self._original_showwarnings_hooks: list[Callable] = [] self._originals_thread_except_hooks: list[Callable] = [] self._seen_warnings: set[tuple[str, type, str, int]] = set() 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() 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: tuple[ type[BaseException], BaseException, Optional[TracebackType], Optional[threading.Thread], ], ): self.receive_error(*args) def receive_error( self, exctype: type[BaseException], value: BaseException, traceback: Optional[TracebackType] = None, thread: Optional[threading.Thread] = None, ): if isinstance(value, KeyboardInterrupt): sys.exit('Closed by KeyboardInterrupt') 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, ): msg = message if isinstance(message, str) else message.args[0] if (msg, category, filename, lineno) in self._seen_warnings: return self._seen_warnings.add((msg, category, filename, lineno)) 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) # noqa: T201 except Exception: logging.exception( '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.6/napari/utils/perf/000077500000000000000000000000001474413133200163445ustar00rootroot00000000000000napari-0.5.6/napari/utils/perf/__init__.py000066400000000000000000000043171474413133200204620ustar00rootroot00000000000000"""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__ = [ 'USE_PERFMON', 'PerfEvent', 'add_counter_event', 'add_instant_event', 'block_timer', 'perf_config', 'perf_timer', 'timers', ] napari-0.5.6/napari/utils/perf/_config.py000066400000000000000000000117171474413133200203310ustar00rootroot00000000000000"""Perf configuration flags.""" import json import os from pathlib import Path from types import ModuleType from typing import Any, Callable, Optional, Union 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 _patch_perf_timer( parent: Union[ModuleType, type], callable_name: 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_name : str The name of the callable (function or method). label : str The or . we are patching. """ @wrapt.patch_function_wrapper(parent, callable_name) def perf_time_callable( wrapped: Callable, instance: Any, args: tuple[Any], kwargs: dict[str, Any], ) -> Callable: 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) -> None: """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) -> None: """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) -> Optional[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. except KeyError: return None else: return path or None def _create_perf_config() -> Optional[PerfmonConfig]: value = os.getenv('NAPARI_PERFMON') if value is None or value == '0': return None # Totally disabled if value == '1': return PerfmonConfig(None) # Legacy no config, Qt events only. return PerfmonConfig(value) # Normal parse the config file. # The global instance perf_config = _create_perf_config() napari-0.5.6/napari/utils/perf/_event.py000066400000000000000000000073571474413133200202120ustar00rootroot00000000000000"""PerfEvent class.""" import os import threading from typing import NamedTuple, Optional class Span(NamedTuple): """The span of time that the event ocurred. Parameters ---------- start_ns : int Start time in nanoseconds. end_ns : int End time in nanoseconds. """ start_ns: int end_ns: int class Origin(NamedTuple): """What process/thread produced the event. Parameters ---------- process_id : int The process id that produced the event. thread_id : int The thread id that produced the event. """ process_id: int thread_id: int 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 as "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: Optional[int] = None, thread_id: Optional[int] = None, phase: str = 'X', # "X" is a "complete event" in their spec. **kwargs: float, ) -> 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: Optional[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) -> float: """Start time in microseconds.""" return self.span.start_ns / 1e3 @property def start_ms(self) -> float: """Start time in milliseconds.""" return self.span.start_ns / 1e6 @property def duration_ns(self) -> int: """Duration in nanoseconds.""" return self.span.end_ns - self.span.start_ns @property def duration_us(self) -> float: """Duration in microseconds.""" return self.duration_ns / 1e3 @property def duration_ms(self) -> float: """Duration in milliseconds.""" return self.duration_ns / 1e6 napari-0.5.6/napari/utils/perf/_patcher.py000066400000000000000000000153761474413133200205170ustar00rootroot00000000000000"""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 logging import types from importlib import import_module from typing import Callable, 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 _patch_attribute( module: types.ModuleType, attribute_str: str, patch_func: PatchFunction ) -> None: """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. logging.info('patching %s.%s', module.__name__, label) patch_func(parent, callable_str, label) def _import_module( target_str: str, ) -> Union[tuple[types.ModuleType, str], tuple[None, None]]: """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 successfully import. And return the rest of # the module_path we didn't use. attribute_str = '.'.join(parts[i - 1 :]) return module, attribute_str return None, None 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. logging.warning('skipping duplicate %s', target_str) continue # Patch the target and note that we did. try: module, attribute_str = _import_module(target_str) if module is not None and attribute_str is not None: _patch_attribute(module, attribute_str, patch_func) except PatchError: # 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. # logging.exception magically logs the stack trace too! logging.exception('Something went wrong while patching') patched.add(target_str) napari-0.5.6/napari/utils/perf/_stat.py000066400000000000000000000023501474413133200200300ustar00rootroot00000000000000"""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.6/napari/utils/perf/_timers.py000066400000000000000000000224341474413133200203650ustar00rootroot00000000000000"""PerfTimers class and global instance.""" import contextlib import os from collections.abc import Generator from time import perf_counter_ns from typing import Optional, Union 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: """Save an event to performance trace file and update the timers if the event has phase 'X'. 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 = int(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, *, category: Optional[str] = None, process_id: Optional[int] = None, thread_id: Optional[int] = None, **kwargs: float, ) -> None: """Build and add one event of length 0. Parameters ---------- name : str Add this event. category : str | None Comma separated categories such as "render,update". process_id : int | None The process id that produced the event. thread_id : int | None The thread id that produced the 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', category=category, process_id=process_id, thread_id=thread_id, **kwargs, ) ) def add_counter_event(self, name: str, **kwargs: float) -> None: """Add one counter event. Parameters ---------- name : str The name of this event like "draw". **kwargs : 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', category=None, process_id=None, thread_id=None, **kwargs, ) ) def clear(self) -> None: """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, *, process_id: Optional[int] = None, thread_id: Optional[int] = None, phase: str = 'X', **kwargs: float, ) -> Generator[PerfEvent, None, None]: """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 as "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, process_id=process_id, thread_id=thread_id, phase=phase, **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') # noqa: T201 class DummyTimer: """ An empty timer to use when perfmon is disabled. It implements the same interface as PerfTimers. """ def __init__(self) -> None: self.trace_file = None def add_instant_event( self, name: str, **kwargs: Union[str, float, None] ) -> None: """empty timer to use when perfmon is disabled""" def add_counter_event(self, name: str, **kwargs: float) -> None: """empty timer to use when perfmon is disabled""" def add_event(self, event: PerfEvent) -> None: """empty timer to use when perfmon is disabled""" def start_trace_file(self, path: str) -> None: """empty timer to use when perfmon is disabled""" def stop_trace_file(self) -> None: """empty timer to use when perfmon is disabled""" def add_instant_event( name: str, *, category: Optional[str] = None, process_id: Optional[int] = None, thread_id: Optional[int] = None, **kwargs: float, ) -> None: """Add one instant event. Parameters ---------- name : str Add this event. category : str | None Comma separated categories such as "render,update". process_id : int | None The process id that produced the event. thread_id : int | None The thread id that produced the event. **kwargs Arguments to display in the Args section of the Chrome Tracing GUI. """ timers.add_instant_event( name, category=category, process_id=process_id, thread_id=thread_id, **kwargs, ) def add_counter_event(name: str, **kwargs: float) -> None: """Add one counter event. Parameters ---------- name : str The name of this event like "draw". **kwargs : float The individual counters for this event. Notes ----- For example add_counter_event("draw", triangles=5, squares=10). """ timers.add_counter_event(name, **kwargs) timers: Union[DummyTimer, PerfTimers] if USE_PERFMON: timers = PerfTimers() perf_timer = block_timer else: # Make sure no one accesses the timers when they are disabled. timers = DummyTimer() # perf_timer is disabled. Using contextlib.nullcontext did not work. @contextlib.contextmanager # type: ignore [misc] def perf_timer( name: str, category: Optional[str] = None, print_time: bool = False, *, process_id: Optional[int] = None, thread_id: Optional[int] = None, phase: str = 'X', **kwargs: float, ) -> Generator[None, None, None]: """Do nothing when perfmon is disabled.""" yield napari-0.5.6/napari/utils/perf/_trace_file.py000066400000000000000000000057241474413133200211620ustar00rootroot00000000000000"""PerfTraceFile class to write the chrome://tracing file format (JSON)""" import json from time import perf_counter_ns from typing import TYPE_CHECKING 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) -> None: """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.6/napari/utils/progress.py000066400000000000000000000162001474413133200176250ustar00rootroot00000000000000from collections.abc import Generator, Iterable, Iterator from itertools import takewhile from typing import Callable, 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 __all__ = ['cancelable_progress', 'progrange', 'progress'] 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 the progress bar is set to disable the 'desc' member is not set by the # tqdm super constructor, so we set it to a dummy value to avoid errors thrown below if self.disable: self.desc = '' 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: Optional[str] = None, pos: Optional[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) class cancelable_progress(progress): """This class inherits from progress, providing the additional ability to cancel expensive executions. When progress is canceled by the user in the napari UI, two things will happen: Firstly, the is_canceled attribute will become True, and the for loop will terminate after the current iteration, regardless of whether or not the iterator had more items. Secondly, cancel_callback will be called, allowing the computation to close resources, repair state, etc. See napari.utils.progress and tqdm.tqdm API for valid args and kwargs: https://tqdm.github.io/docs/tqdm/ Examples -------- >>> def long_running(steps=10, delay=0.1): ... def on_cancel(): ... print("Canceled operation") ... for i in cancelable_progress(range(steps), cancel_callback=on_cancel): ... sleep(delay) """ def __init__( self, iterable: Optional[Iterable] = None, desc: Optional[str] = None, total: Optional[int] = None, nest_under: Optional['progress'] = None, cancel_callback: Optional[Callable] = None, *args, **kwargs, ) -> None: self.cancel_callback = cancel_callback self.is_canceled = False super().__init__(iterable, desc, total, nest_under, *args, **kwargs) def __iter__(self) -> Iterator: itr = super().__iter__() def is_canceled(_): if self.is_canceled: # If we've canceled, run the callback and then notify takewhile if self.cancel_callback: self.cancel_callback() # Perform additional cleanup for generators if isinstance(self.iterable, Generator): self.iterable.close() return False # Otherwise, continue return True return takewhile(is_canceled, itr) def cancel(self): """Cancels the execution of the underlying computation. Note that the current iteration will be allowed to complete, however future iterations will not be run. """ self.is_canceled = True napari-0.5.6/napari/utils/settings/000077500000000000000000000000001474413133200172505ustar00rootroot00000000000000napari-0.5.6/napari/utils/settings/__init__.py000066400000000000000000000005131474413133200213600ustar00rootroot00000000000000import 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.6/napari/utils/shortcuts.py000066400000000000000000000123721474413133200200250ustar00rootroot00000000000000from 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:delete_selected_layers': [ KeyMod.CtrlCmd | KeyCode.Delete, KeyMod.CtrlCmd | KeyCode.Backspace, ], '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:rotate_layers': [KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyT], 'napari:toggle_grid': [KeyMod.CtrlCmd | KeyCode.KeyG], 'napari:toggle_selected_visibility': [KeyCode.KeyV], 'napari:toggle_unselected_visibility': [KeyMod.Shift | KeyCode.KeyV], 'napari:show_only_layer_above': [ KeyMod.Shift | KeyMod.Alt | KeyCode.UpArrow ], 'napari:show_only_layer_below': [ KeyMod.Shift | KeyMod.Alt | KeyCode.DownArrow ], 'napari:hold_for_pan_zoom': [KeyCode.Space], # labels 'napari:activate_labels_erase_mode': [KeyCode.Digit1, KeyCode.KeyE], 'napari:activate_labels_paint_mode': [KeyCode.Digit2, KeyCode.KeyP], 'napari:activate_labels_polygon_mode': [KeyCode.Digit3], 'napari:activate_labels_fill_mode': [KeyCode.Digit4, KeyCode.KeyF], 'napari:activate_labels_picker_mode': [KeyCode.Digit5, KeyCode.KeyL], 'napari:activate_labels_pan_zoom_mode': [KeyCode.Digit6, KeyCode.KeyZ], 'napari:activate_labels_transform_mode': [KeyCode.Digit7], 'napari:new_label': [KeyCode.KeyM], 'napari:swap_selected_and_background_labels': [KeyCode.KeyX], '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.KeyB], 'napari:reset_polygon': [KeyCode.Escape], 'napari:complete_polygon': [KeyCode.Enter], # points 'napari:activate_points_add_mode': [KeyCode.Digit2, KeyCode.KeyP], 'napari:activate_points_select_mode': [KeyCode.Digit3, KeyCode.KeyS], 'napari:activate_points_pan_zoom_mode': [KeyCode.Digit4, KeyCode.KeyZ], '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.Digit1, KeyCode.Delete, KeyCode.Backspace, ], # 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_polyline_mode': [KeyMod.Shift | KeyCode.KeyL], 'napari:activate_add_polygon_mode': [KeyCode.KeyP], 'napari:activate_add_polygon_lasso_mode': [KeyMod.Shift | KeyCode.KeyP], 'napari:activate_direct_mode': [KeyCode.Digit4, KeyCode.KeyD], 'napari:activate_select_mode': [KeyCode.Digit5, KeyCode.KeyS], 'napari:activate_shapes_pan_zoom_mode': [KeyCode.Digit6, KeyCode.KeyZ], 'napari:activate_shapes_transform_mode': [KeyCode.Digit7], 'napari:activate_vertex_insert_mode': [KeyCode.Digit2, KeyCode.KeyI], 'napari:activate_vertex_remove_mode': [KeyCode.Digit1, KeyCode.KeyX], '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.Digit3, KeyCode.Delete, KeyCode.Backspace, ], 'napari:finish_drawing_shape': [KeyCode.Enter, KeyCode.Escape], # image 'napari:orient_plane_normal_along_x': [KeyCode.KeyX], 'napari:orient_plane_normal_along_y': [KeyCode.KeyY], 'napari:orient_plane_normal_along_z': [KeyCode.KeyZ], 'napari:orient_plane_normal_along_view_direction': [KeyCode.KeyO], '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: dict[str, list[KeyBinding]] = { name: [KeyBinding.from_int(kb) for kb in value] for name, value in _default_shortcuts.items() } napari-0.5.6/napari/utils/status_messages.py000066400000000000000000000054741474413133200212060ustar00rootroot00000000000000from collections.abc import Iterable from typing import Optional import numpy as np import numpy.typing as npt 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) return str(value) def generate_layer_coords_status( position: Optional[npt.ArrayLike], value: Optional[tuple] ) -> str: if position is not None: full_coord = map(str, np.round(np.array(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.6/napari/utils/stubgen.py000066400000000000000000000142661474413133200174420ustar00rootroot00000000000000"""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 collections.abc import Iterator from types import ModuleType from typing import Any, 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 _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.') and (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 hasattr(hint, '__module__') and hint.__module__ != '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 ruff pyi = pyi.replace('NoneType', 'None') if save: print( # noqa: T201 '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', 'format', file_path]) subprocess.run(['ruff', 'check', 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.6/napari/utils/theme.py000066400000000000000000000322051474413133200170660ustar00rootroot00000000000000# 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 logging import re import sys import warnings from ast import literal_eval from contextlib import suppress from typing import Any, Literal, Optional, Union, overload import npe2 from napari._pydantic_compat import Color, validator 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('.') # type: ignore[attr-defined] use_gradients = (int(major) >= 5) and (int(minor) >= 12) del major, minor, QT_VERSION except (ImportError, RuntimeError): 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. font_size : str Font size (in points, pt) used in the application. """ 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 font_size: str = '12pt' if sys.platform == 'darwin' else '9pt' @validator('syntax_style', pre=True, allow_reuse=True) def _ensure_syntax_style(cls, value: str) -> str: from pygments.styles import STYLE_MAP assert value in STYLE_MAP, trans._( 'Incorrect `syntax_style` value: {value} provided. Please use one of the following: {syntax_style}', deferred=True, syntax_style=f' {", ".join(STYLE_MAP)}', value=value, ) return value @validator('font_size', pre=True) def _ensure_font_size(cls, value: str) -> str: assert value.endswith('pt'), trans._( 'Font size must be in points (pt).', deferred=True ) assert int(value[:-2]) > 0, trans._( 'Font size must be greater than 0.', deferred=True ) return value def to_rgb_dict(self) -> dict[str, Any]: """ This differs from baseclass `dict()` by converting colors to rgb. """ th = super().dict() return { k: v if not isinstance(v, Color) else v.as_rgb() for (k, v) in th.items() } increase_pattern = re.compile(r'{{\s?increase\((\w+),?\s?([-\d]+)?\)\s?}}') decrease_pattern = re.compile(r'{{\s?decrease\((\w+),?\s?([-\d]+)?\)\s?}}') 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 decrease(font_size: str, pt: int) -> str: """Decrease fontsize.""" return f'{int(font_size[:-2]) - int(pt)}pt' def increase(font_size: str, pt: int) -> str: """Increase fontsize.""" return f'{int(font_size[:-2]) + int(pt)}pt' def _parse_color_as_rgb(color: Union[str, Color]) -> tuple[int, int, int]: if isinstance(color, str): if color.startswith('rgb('): return literal_eval(color.lstrip('rgb(').rstrip(')')) return Color(color).as_rgb_tuple()[:3] return color.as_rgb_tuple()[:3] def darken(color: Union[str, Color], percentage: float = 10) -> str: ratio = 1 - float(percentage) / 100 red, green, blue = _parse_color_as_rgb(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: float = 10) -> str: ratio = float(percentage) / 100 red, green, blue = _parse_color_as_rgb(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: int = 255) -> str: red, green, blue = _parse_color_as_rgb(color) return f'rgba({red}, {green}, {blue}, {max(min(int(value), 255), 0)})' def gradient(stops, horizontal: bool = True) -> str: 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 _increase_match(matchobj): font_size, to_add = matchobj.groups() return increase(theme[font_size], to_add) def _decrease_match(matchobj): font_size, to_subtract = matchobj.groups() return decrease(theme[font_size], to_subtract) 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 = increase_pattern.sub(_increase_match, css) css = decrease_pattern.sub(_decrease_match, css) 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(f'{{{{ {k} }}}}', v) return css def get_system_theme() -> str: """Return the system default theme, either 'dark', or 'light'.""" try: from napari._vendor import darkdetect except ImportError: return 'dark' try: id_ = darkdetect.theme().lower() except AttributeError: id_ = 'dark' return id_ @overload def get_theme(theme_id: str) -> Theme: ... @overload def get_theme(theme_id: str, as_dict: Literal[False]) -> Theme: ... @overload def get_theme(theme_id: str, as_dict: Literal[True]) -> dict[str, Any]: ... def get_theme(theme_id: str, as_dict: Optional[bool] = 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 ---------- theme_id : str ID of requested theme. as_dict : bool .. deprecated:: 0.5.0 Use ``get_theme(...).to_rgb_dict()`` 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 theme_id == 'system': theme_id = get_system_theme() if theme_id not in _themes: raise ValueError( trans._( 'Unrecognized theme {id}. Available themes are {themes}', deferred=True, id=theme_id, themes=available_themes(), ) ) theme = _themes[theme_id].copy() if as_dict is not None: warnings.warn( trans._( 'The `as_dict` kwarg has been deprecated since Napari 0.5.0 and ' 'will be removed in future version. You can use `get_theme(...).to_rgb_dict()`', deferred=True, ), category=FutureWarning, stacklevel=2, ) if as_dict: return theme.to_rgb_dict() return theme _themes: EventedDict[str, Theme] = EventedDict(basetype=Theme) def register_theme(theme_id, theme, source): """Register a new or updated theme. Parameters ---------- theme_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[theme_id] = theme build_theme_svgs(theme_id, source) def unregister_theme(theme_id): """Remove existing theme. Parameters ---------- theme_id : str id of the theme to be removed. """ _themes.pop(theme_id, None) def available_themes() -> list[str]: """List available themes. Returns ------- list of str ids of available themes. """ return [*_themes, 'system'] def is_theme_available(theme_id): """Check if a theme is available. Parameters ---------- theme_id : str id of requested theme. Returns ------- bool True if the theme is available, False otherwise. """ if theme_id == 'system': return True if theme_id not in _themes and _theme_path(theme_id).exists(): plugin_name_file = _theme_path(theme_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 theme_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', font_size='12pt' if sys.platform == 'darwin' else '9pt', ) 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', font_size='12pt' if sys.platform == 'darwin' else '9pt', ) 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) try: register_theme(theme.id, theme_dict, manifest.name) except ValueError: logging.exception('Registration theme failed.') _install_npe2_themes(_themes) _themes.events.added.connect(rebuild_theme_settings) _themes.events.removed.connect(rebuild_theme_settings) napari-0.5.6/napari/utils/transforms/000077500000000000000000000000001474413133200176065ustar00rootroot00000000000000napari-0.5.6/napari/utils/transforms/__init__.py000066400000000000000000000005471474413133200217250ustar00rootroot00000000000000from napari.utils.transforms.transform_utils import shear_matrix_from_angle from napari.utils.transforms.transforms import ( Affine, CompositeAffine, ScaleTranslate, Transform, TransformChain, ) __all__ = [ 'Affine', 'CompositeAffine', 'ScaleTranslate', 'Transform', 'TransformChain', 'shear_matrix_from_angle', ] napari-0.5.6/napari/utils/transforms/_tests/000077500000000000000000000000001474413133200211075ustar00rootroot00000000000000napari-0.5.6/napari/utils/transforms/_tests/test_transform_chain.py000066400000000000000000000102211474413133200256710ustar00rootroot00000000000000import 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_expanded = transform_chain_b.expand_dims([1]) new_coord_2 = transform_chain_a(coord) new_coord_1 = transform_chain_expanded(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 def test_setitem_invalidates_cache(): chain = TransformChain((Affine(scale=(2, 3)), Affine(scale=(4, 5)))) chain[0] = Affine(scale=(1, -1)) npt.assert_array_equal(chain((1, 1)), (4, -5)) npt.assert_array_equal(chain.inverse((1, 1)), (1 / 4, -1 / 5)) def test_delitem_invalidates_cache(): chain = TransformChain((Affine(scale=(2, 3)), Affine(scale=(4, 5)))) del chain[1] npt.assert_array_equal(chain((1, 1)), (2, 3)) npt.assert_array_equal(chain.inverse((1, 1)), (1 / 2, 1 / 3)) def test_mutate_item_invalidates_cache(): chain = TransformChain((Affine(scale=(2, 3)), Affine(scale=(4, 5)))) chain[0].scale = (1, -1) npt.assert_array_equal(chain((1, 1)), (4, -5)) npt.assert_array_equal(chain.inverse((1, 1)), (1 / 4, -1 / 5)) napari-0.5.6/napari/utils/transforms/_tests/test_transform_utils.py000066400000000000000000000065001474413133200257540ustar00rootroot00000000000000import 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_decompose_linear_matrix_3d(): arr = np.array( [ [1, 0, 0], [0, 0, -1], [0, 1, 0], ] ) rotate, scale, shear = decompose_linear_matrix( arr * 5, upper_triangular=False ) np.testing.assert_almost_equal(rotate, arr) np.testing.assert_almost_equal(scale, [5, 5, 5]) 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.6/napari/utils/transforms/_tests/test_transforms.py000066400000000000000000000354131474413133200247240ustar00rootroot00000000000000import numpy as np import numpy.testing as npt import pint import pytest from scipy.stats import special_ortho_group from napari.utils.transforms import Affine, CompositeAffine, ScaleTranslate transform_types = [Affine, CompositeAffine, ScaleTranslate] affine_type = [Affine, CompositeAffine] REG = pint.get_application_registry() PIXEL = REG.pixel @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 transform 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, match='provided axes list and transform differ' ): Affine().replace_slice([0], Affine()) def test_affine_rotate_3d(): a = Affine(rotate=90, ndim=3) npt.assert_array_almost_equal( np.array( [ [1, 0, 0], [0, 0, -1], [0, 1, 0], ] ), a.rotate, ) @pytest.mark.parametrize('AffineType', affine_type) def test_empty_units(AffineType): assert AffineType(ndim=2).units == (PIXEL, PIXEL) assert AffineType(ndim=3).units == (PIXEL, PIXEL, PIXEL) assert AffineType(ndim=2).physical_scale == (1 * PIXEL, 1 * PIXEL) assert AffineType(ndim=3).physical_scale == ( 1 * PIXEL, 1 * PIXEL, 1 * PIXEL, ) @pytest.mark.parametrize('AffineType', affine_type) def test_set_units_constructor(AffineType): assert AffineType(ndim=2, units=('mm', 'mm')).units == (REG.mm, REG.mm) assert AffineType(ndim=2, units=(REG.m, REG.m)).units == (REG.m, REG.m) # TODO I think that we should normalize all units of same dimensionality # to the same registry, but this is not currently the case. assert AffineType(ndim=2, units=('cm', 'mm')).units == (REG.cm, REG.mm) @pytest.mark.parametrize('AffineType', affine_type) def test_set_units_constructor_error(AffineType): with pytest.raises(ValueError, match='must have length ndim'): AffineType(ndim=2, units=('mm', 'mm', 'mm')) with pytest.raises(ValueError, match='Could not find unit'): AffineType(ndim=2, units=('ugh', 'ugh')) @pytest.mark.parametrize('AffineType', affine_type) def test_set_units_error(AffineType): affine = AffineType(ndim=2) with pytest.raises(ValueError, match='must have length ndim'): affine.units = ('m', 'm', 'm') with pytest.raises(ValueError, match='Could not find unit'): affine.units = ('ugh', 'ugh') @pytest.mark.parametrize('AffineType', affine_type) def test_set_units(AffineType): affine = AffineType(ndim=2) affine.units = ('mm', 'mm') assert affine.units == (REG.mm, REG.mm) affine.units = (REG.m, REG.m) assert affine.units == (REG.m, REG.m) @pytest.mark.parametrize('AffineType', affine_type) def test_empty_axis_labels(AffineType): assert AffineType(ndim=2).axis_labels == ('axis -2', 'axis -1') assert AffineType(ndim=3).axis_labels == ('axis -3', 'axis -2', 'axis -1') @pytest.mark.parametrize('AffineType', affine_type) def test_set_axis_labels(AffineType): affine = AffineType(ndim=2) affine.axis_labels = ('x', 'y') assert affine.axis_labels == ('x', 'y') @pytest.mark.parametrize('AffineType', affine_type) def test_set_axis_labels_error(AffineType): affine = AffineType(ndim=2) with pytest.raises(ValueError, match='must have length ndim'): affine.axis_labels = ('x', 'y', 'z') @pytest.mark.parametrize('AffineType', affine_type) def test_set_axis_error(AffineType): affine = AffineType(ndim=2) with pytest.raises(ValueError, match='must have length ndim'): affine.axis_labels = ('x', 'y', 'z') napari-0.5.6/napari/utils/transforms/_units.py000066400000000000000000000022431474413133200214620ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Sequence from typing import ( Union, overload, ) import pint UnitsLike = Union[None, str, pint.Unit, Sequence[Union[str, pint.Unit]]] UnitsInfo = Union[None, pint.Unit, tuple[pint.Unit, ...]] __all__ = ( 'UnitsInfo', 'UnitsLike', 'get_units_from_name', ) @overload def get_units_from_name(units: None) -> None: ... @overload def get_units_from_name(units: Union[str, pint.Unit]) -> pint.Unit: ... @overload def get_units_from_name( units: Sequence[Union[str, pint.Unit]], ) -> tuple[pint.Unit, ...]: ... def get_units_from_name(units: UnitsLike) -> UnitsInfo: """Convert a string or sequence of strings to pint units.""" try: if isinstance(units, str): return pint.get_application_registry()[units].units if isinstance(units, Sequence): return tuple( pint.get_application_registry()[unit].units if isinstance(unit, str) else unit for unit in units ) except AttributeError as e: raise ValueError(f'Could not find unit {units}') from e return units napari-0.5.6/napari/utils/transforms/transform_utils.py000066400000000000000000000365371474413133200234310ustar00rootroot00000000000000import numpy as np import numpy.typing as npt import scipy.linalg from napari.utils.translations import trans def compose_linear_matrix(rotate, scale, shear) -> npt.NDArray: """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) if 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 np.floor(N) != 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 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 ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]: """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_with_sign = np.diag(tri).copy() scale = np.abs(scale_with_sign) normalize = scale / scale_with_sign tri *= normalize.reshape((-1, 1)) rotate *= normalize # Take any reflection into account 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 return np.max(np.abs(non_diag)) <= tol napari-0.5.6/napari/utils/transforms/transforms.py000066400000000000000000001001421474413133200223540ustar00rootroot00000000000000from collections.abc import Iterable, Sequence from typing import Generic, Optional, TypeVar, Union, overload import numpy as np import numpy.typing as npt import pint import toolz as tz from psygnal import Signal from napari.utils.events import EventedList from napari.utils.transforms._units import get_units_from_name 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. """ changed = Signal() def __init__(self, func=tz.identity, inverse=None, name=None) -> None: self.func = func self._inverse_func = inverse self.name = name self._cache_dict = {} 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 None: raise ValueError( trans._('Inverse function was not provided.', deferred=True) ) if 'inverse' not in self._cache_dict: self._cache_dict['inverse'] = Transform( self._inverse_func, self.func ) return self._cache_dict['inverse'] 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 overridden in subclasses to enable performance optimizations that are specific to this case. """ return False def _clean_cache(self): self._cache_dict.clear() self.changed.emit() _T = TypeVar('_T', bound=Transform) class TransformChain(EventedList[_T], Transform, Generic[_T]): def __init__( self, transforms: Optional[Iterable[Transform]] = 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) for tr in self: if hasattr(tr, 'changed'): tr.changed.connect(self._clean_cache) def __call__(self, coords): return tz.pipe(coords, *self) def __newlike__(self, iterable): return TransformChain(iterable) @overload def __getitem__(self, key: int) -> _T: ... @overload def __getitem__(self, key: str) -> _T: ... @overload def __getitem__(self, key: slice) -> 'TransformChain[_T]': ... def __getitem__(self, key): if f'getitem_{key}' not in self._cache_dict: self._cache_dict[f'getitem_{key}'] = super().__getitem__(key) return self._cache_dict[f'getitem_{key}'] def __setitem__(self, key, value): if key in self and hasattr(self[key], 'changed'): self[key].changed.disconnect(self._clean_cache) super().__setitem__(key, value) if hasattr(value, 'changed'): value.changed.connect(self._clean_cache) self._clean_cache() def __delitem__(self, key): val = self[key] if hasattr(val, 'changed'): val.changed.disconnect(self._clean_cache) super().__delitem__(key) self._clean_cache() @property def inverse(self) -> 'TransformChain': """Return the inverse transform chain.""" if 'inverse' not in self._cache_dict: self._cache_dict['inverse'] = TransformChain( [tf.inverse for tf in self[::-1]] ) return self._cache_dict['inverse'] @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) -> _T: """ Return the composite of the transforms inside the transform chain. Raises ------ ValueError If the transform chain is empty. """ if len(self) == 0: raise ValueError( trans._('Cannot simplify an empty transform chain.') ) if len(self) == 1: return self[0] if 'simplified' not in self._cache_dict: self._cache_dict['simplified'] = tz.pipe( self[0], *[tf.compose for tf in self[1:]] ) return self._cache_dict['simplified'] 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]) -> 'TransformChain': """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, ), *, affine_matrix=None, axis_labels: Optional[Sequence[str]] = None, linear_matrix=None, name=None, ndim=None, rotate=None, shear=None, units: Optional[Sequence[Union[str, pint.Unit]]] = 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) self._axis_labels = tuple(f'axis {i}' for i in range(-ndim, 0)) self._units = (pint.get_application_registry().pixel,) * ndim self.axis_labels = axis_labels self.units = units 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 axis_labels(self) -> tuple[str, ...]: """tuple of axis labels for the layer.""" return self._axis_labels @axis_labels.setter def axis_labels(self, axis_labels: Optional[Sequence[str]]) -> None: if axis_labels is None: axis_labels = tuple(f'axis {i}' for i in range(-self.ndim, 0)) if len(axis_labels) != self.ndim: raise ValueError( f'{axis_labels=} must have length ndim={self.ndim}.' ) axis_labels = tuple(axis_labels) self._axis_labels = axis_labels @property def units(self) -> tuple[pint.Unit, ...]: """List of units for the layer.""" return self._units @units.setter def units(self, units: Optional[Sequence[pint.Unit]]) -> None: units = get_units_from_name(units) if units is None: units = (pint.get_application_registry().pixel,) * self.ndim if isinstance(units, pint.Unit): units = (units,) * self.ndim if len(units) != self.ndim: raise ValueError(f'{units=} must have length ndim={self.ndim}.') self._units = units @property def scale(self) -> npt.NDArray: """Return the scale of the transform.""" if self._is_diagonal: return np.diag(self._linear_matrix) self._setup_decompose_linear_matrix_cache() return self._cache_dict['decompose_linear_matrix'][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: self._linear_matrix = compose_linear_matrix( self.rotate, scale, self._shear_cache ) self._clean_cache() @property def physical_scale(self) -> tuple[pint.Quantity, ...]: """Return the scale of the transform, with units.""" return tuple(np.multiply(self.scale, self.units)) @property def translate(self) -> npt.NDArray: """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) self._clean_cache() def _setup_decompose_linear_matrix_cache(self): if 'decompose_linear_matrix' in self._cache_dict: return self._cache_dict['decompose_linear_matrix'] = decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular ) @property def rotate(self) -> npt.NDArray: """Return the rotation of the transform.""" self._setup_decompose_linear_matrix_cache() return self._cache_dict['decompose_linear_matrix'][0] @rotate.setter def rotate(self, rotate): """Set the rotation of the transform.""" self._linear_matrix = compose_linear_matrix( rotate, self.scale, self._shear_cache ) self._clean_cache() @property def shear(self) -> npt.NDArray: """Return the shear of the transform.""" if self._is_diagonal: return np.zeros((self.ndim,)) self._setup_decompose_linear_matrix_cache() return self._cache_dict['decompose_linear_matrix'][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 self._linear_matrix = compose_linear_matrix( self.rotate, self.scale, shear ) self._clean_cache() @property def _shear_cache(self): self._setup_decompose_linear_matrix_cache() return self._cache_dict['decompose_linear_matrix'][2] @property def linear_matrix(self) -> npt.NDArray: """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) -> npt.NDArray: """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.""" if 'inverse' not in self._cache_dict: self._cache_dict['inverse'] = Affine( affine_matrix=np.linalg.inv(self.affine_matrix) ) return self._cache_dict['inverse'] @overload def compose(self, transform: 'Affine') -> 'Affine': ... @overload def compose(self, transform: 'Transform') -> 'Transform': ... def compose(self, 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)] units = [self.units[i] for i in axes] axes_labels = [self.axis_labels[i] for i in axes] return Affine( linear_matrix=linear_matrix, translate=self.translate[axes], ndim=len(axes), name=self.name, units=units, axis_labels=axes_labels, ) 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, ) @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. """ if '_is_diagonal' not in self._cache_dict: self._cache_dict['_is_diagonal'] = is_diagonal( self.linear_matrix, tol=1e-8 ) return self._cache_dict['_is_diagonal'] 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), *, axis_labels=None, rotate=None, shear=None, ndim=None, name=None, units=None, ) -> None: super().__init__( scale, translate, axis_labels=axis_labels, rotate=rotate, shear=shear, ndim=ndim, name=name, units=units, ) 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) -> npt.NDArray: """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() self._clean_cache() @property def rotate(self) -> npt.NDArray: """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) -> npt.NDArray: """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() @property def linear_matrix(self): return super().linear_matrix @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, ) ) @property def affine_matrix(self): return super().affine_matrix @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, units=[self.units[i] for i in axes], axis_labels=[self.axis_labels[i] for i in axes], ) 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.6/napari/utils/translations.py000066400000000000000000000475031474413133200205140ustar00rootroot00000000000000""" Localization utilities to find available language packs and packages with localization data. """ import gettext import os from pathlib import Path from typing import ClassVar, 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 except ModuleNotFoundError: display_name = display_locale.capitalize() else: 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) display_name_ = loc.get_display_name(display_locale) if display_name_ is None: raise RuntimeError(f'Could not find {display_locale}') display_name = display_name_.capitalize() return display_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. See https://docs.python.org/3/library/gettext.html for documentation of the arguments to __new__ and __init__ in this class. """ __slots__ = ( '_deferred', '_domain', '_kwargs', '_msgctxt', '_msgid', '_msgid_plural', '_n', ) 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: str, msgid: str, msgctxt: Optional[str] = None, msgid_plural: Optional[str] = None, n: Optional[int] = None, deferred: bool = False, **kwargs, ) -> None: 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 not None and self._msgid_plural is not None and self._msgctxt is not None ): translation = gettext.dnpgettext( self._domain, self._msgctxt, self._msgid, self._msgid_plural, self._n, ) elif self._n is not None and self._msgid_plural is not None: translation = gettext.dngettext( self._domain, self._msgid, self._msgid_plural, self._n, ) elif self._msgctxt is not None: translation = gettext.dpgettext( self._domain, self._msgctxt, self._msgid, ) else: translation = gettext.dgettext( self._domain, self._msgid, ) 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]) if mod.__file__ is not None: localedir = Path(mod.__file__).parent / LOCALE_DIR else: raise RuntimeError(f'Could not find __file__ for {mod}') gettext.bindtextdomain(self._domain, localedir=localedir) def _dnpgettext( self, *, msgid: str, msgctxt: 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. Must provide one of the following sets of arguments: - msgid - msgid, msgctxt - msgid, msgid_plural, n - msgid, msgid_plural, n, msgctxt 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 msgctxt is not None and n is not None and msgid_plural is not None: translation = gettext.dnpgettext( self._domain, msgctxt, msgid, msgid_plural, n, ) elif n is not None and msgid_plural is not None: translation = gettext.dngettext( self._domain, msgid, msgid_plural, n, ) elif msgctxt is not None: translation = gettext.dpgettext(self._domain, msgctxt, msgid) else: translation = gettext.dgettext(self._domain, msgid) 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: int, 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: ClassVar[dict[str, TranslationBundle]] = {} _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. """ if (config_path := Path(default_config_path)).exists(): with config_path.open() 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.6/napari/utils/tree/000077500000000000000000000000001474413133200163475ustar00rootroot00000000000000napari-0.5.6/napari/utils/tree/__init__.py000066400000000000000000000001571474413133200204630ustar00rootroot00000000000000from napari.utils.tree.group import Group from napari.utils.tree.node import Node __all__ = ['Group', 'Node'] napari-0.5.6/napari/utils/tree/_tests/000077500000000000000000000000001474413133200176505ustar00rootroot00000000000000napari-0.5.6/napari/utils/tree/_tests/test_tree_model.py000066400000000000000000000145041474413133200234040ustar00rootroot00000000000000from 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' assert g1.is_group() g2 = g1[1] assert g2.parent is g1 assert g2.name == 'g2' assert 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.6/napari/utils/tree/group.py000066400000000000000000000106261474413133200200620ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator, Iterable from typing import TYPE_CHECKING, 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.6/napari/utils/tree/node.py000066400000000000000000000067671474413133200176660ustar00rootroot00000000000000from collections.abc import Generator from typing import TYPE_CHECKING, Optional 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.6/napari/utils/validators.py000066400000000000000000000075371474413133200201460ustar00rootroot00000000000000from collections.abc import Collection, Generator, Iterable from itertools import tee 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.6/napari/view_layers.py000066400000000000000000000374241474413133200171650ustar00rootroot00000000000000"""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, Optional from numpydoc.docscrape import NumpyDocString as _NumpyDocString from napari.components.dims import Dims from napari.layers import Image from napari.viewer import Viewer __all__ = [ 'imshow', 'view_image', 'view_labels', 'view_path', 'view_points', 'view_shapes', 'view_surface', 'view_tracks', 'view_vectors', ] _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', 'axis_labels'), ) # merge the __annotations__ func.__annotations__ = { **add_method.__annotations__, **Viewer.__init__.__annotations__, 'return': Viewer, } # _forwardrefns_ is used by stubgen.py to populate the globals # 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 if k != 'axis_labels' } if 'axis_labels' in kwargs: vkwargs['axis_labels'] = ( kwargs['axis_labels'] if kwargs['axis_labels'] is not None else () ) # 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, affine=None, axis_labels=None, attenuation=0.05, blending=None, cache=True, colormap=None, contrast_limits=None, custom_interpolation_kernel_2d=None, depiction='volume', experimental_clipping_planes=None, gamma=1.0, interpolation2d='nearest', interpolation3d='linear', iso_threshold=None, metadata=None, multiscale=None, name=None, opacity=1.0, plane=None, projection_mode='none', rendering='mip', rgb=None, rotate=None, scale=None, shear=None, translate=None, units=None, visible=True, viewer=None, title='napari', ndisplay=2, order=(), 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, other parameters MAY be provided as lists. The Nth value of the list will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. All parameters except data, rgb, and multiscale can be provided as list of values. If a list is provided, it must be the 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. attenuation : float or list of float Attenuation rate for attenuated maximum intensity projection. blending : str or list of 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 {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. cache : bool or list of bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. colormap : str, napari.utils.Colormap, tuple, dict, list or list of these types Colormaps to use for luminance images. If a string, it can be the name of a supported colormap from vispy or matplotlib or the name of a vispy color or a hexadecimal RGB color representation. 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,) Intensity value limits to be used for determining the minimum and maximum colormap bounds for luminance images. If not passed, they will be calculated as the min and max intensity value of the image. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. depiction : str or list of str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. 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. gamma : float or list of float Gamma correction for determining colormap linearity; defaults to 1. interpolation2d : str or list of str Interpolation mode used by vispy for rendering 2d data. Must be one of our supported modes. (for list of supported modes see Interpolation enum) 'custom' is a special mode for 2D interpolation in which a regular grid of samples is taken from the texture around a position using 'linear' interpolation before being multiplied with a custom interpolation kernel (provided with 'custom_interpolation_kernel_2d'). interpolation3d : str or list of str Same as 'interpolation2d' but for 3D rendering. iso_threshold : float or list of float Threshold for isosurface. metadata : dict or list of dict Layer metadata. 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. name : str or list of str Name of the layer. opacity : float or list Opacity of the layer visual, between 0.0 and 1.0. 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'}. projection_mode : str How data outside the viewed dimensions, but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass rendering : str or list of str 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. rgb : bool, optional Whether the image is RGB or RGBA if rgb. If not specified by user, but 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. 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. scale : tuple of float or list of tuple of float Scale factors for the layer. shear : 1-D array or list. A vector of shear values for an upper triangular n-D shear matrix. translate : tuple of float or list of tuple of float Translation values for the layer. visible : bool or list of bool Whether the layer visual is currently being displayed. 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, axis_labels=axis_labels, 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, units=units, experimental_clipping_planes=experimental_clipping_planes, custom_interpolation_kernel_2d=custom_interpolation_kernel_2d, projection_mode=projection_mode, title=title, ndisplay=ndisplay, order=order, show=show, ) napari-0.5.6/napari/viewer.py000066400000000000000000000240701474413133200161260ustar00rootroot00000000000000import typing from pathlib import Path from typing import Optional, Union from weakref import WeakSet import magicgui as mgui import numpy as np from napari.components.viewer_model import ViewerModel from napari.utils import _magicgui from napari.utils.events.event_utils import disconnect_events if typing.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 _instances: typing.ClassVar[WeakSet['Viewer']] = WeakSet() def __init__( self, *, title='napari', ndisplay=2, order=(), axis_labels=(), show=True, **kwargs, ) -> None: super().__init__( title=title, ndisplay=ndisplay, order=order, axis_labels=axis_labels, **kwargs, ) # 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 publicly. 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: self.window._qt_viewer.add_to_console_backlog(variables) return self.window._qt_viewer.console.push(variables) def export_figure( self, path: Optional[str] = None, *, scale_factor: float = 1, flash: bool = False, ) -> np.ndarray: """Export an image of the full extent of the displayed layer data. This function finds a tight boundary around the data, resets the view around that boundary, takes a screenshot for which each pixel is equal to the pixel resolution of the data, then restores the previous zoom and canvas sizes. The pixel resolution can be upscaled or downscaled by the given `scale_factor`. For example, an image with 800 x 600 pixels with scale_factor 1 will be saved as 800 x 600, or 1200 x 900 with scale_factor 1.5. For anisotropic images, the resolution is set by the highest-resolution dimension. For an anisotropic 800 x 600 image with scale set to [0.25, 0.5], the screenshot will be 800 x 1200, or 1200 x 1800 with a scale_factor of 1.5. Upscaling will be done using the interpolation mode set on each layer. Parameters ---------- path : str, optional Filename for saving screenshot image. scale_factor : float By default, the zoom will export approximately 1 pixel per smallest-scale pixel on the viewer. For example, if a layer has scale 0.004nm/pixel and another has scale 1µm/pixel, the exported figure will have 0.004nm/pixel. Upscaling by 2 will produce a figure with 0.002nm/pixel through the interpolation mode set on each layer. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. By default, False. 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.export_figure( path=path, scale=scale_factor, flash=flash, ) def export_rois( self, rois: list[np.ndarray], paths: Optional[Union[str, Path, list[Union[str, Path]]]] = None, scale: Optional[float] = None, ): """Export the given rectangular rois to specified file paths. Iteratively take a screenshot of each given roi. Note that 3D rois or taking rois when number of dimensions displayed in the viewer canvas is 3, is currently not supported. Parameters ---------- rois: numpy array A list of arrays with each having shape (4, 2) representing a rectangular roi. paths: str, Path, list[str, Path], optional Where to save the rois. If a string or a Path, a directory will be created if it does not exist yet and screenshots will be saved with filename `roi_{n}.png` where n is the nth roi. If paths is a list of either string or paths, these need to be the full paths of where to store each individual roi. In this case the length of the list and the number of rois must match. If None, the screenshots will only be returned and not saved to disk. scale: float, optional Scale factor used to increase resolution of canvas for the screenshot. By default, uses the displayed scale. Returns ------- screenshot_list: list The list containing all the screenshots. """ # Check to see if roi has shape (n,2,2) if any(roi.shape[-2:] != (4, 2) for roi in rois): raise ValueError( 'ROI found with invalid shape, all rois must have shape (4, 2), i.e. have 4 corners defined in 2 ' 'dimensions. 3D is not supported.' ) screenshot_list = self.window.export_rois( rois, paths=paths, scale=scale ) return screenshot_list def screenshot( self, path: Optional[str] = None, *, size: Optional[tuple[str, str]] = None, scale: Optional[float] = None, canvas_only: bool = True, flash: bool = False, ): """Take currently displayed screen and convert to an image array. Parameters ---------- path : str, optional Filename for saving screenshot image. size : tuple of two ints, optional Size (resolution height x width) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float, optional 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, False. 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.""" # Shutdown the slicer first to avoid processing any more tasks. self._layer_slicer.shutdown() # Disconnect changes to dims before removing layers one-by-one # to avoid any unnecessary slicing. disconnect_events(self.dims.events, self) # Remove all the layers from the viewer self.layers.clear() # Close the main window self.window.close() self._instances.discard(self) @classmethod def close_all(cls) -> int: """ Class method, 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 = list(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 except ImportError: return None else: return _QtMainWindow.current_viewer() napari-0.5.6/napari/window.py000066400000000000000000000015751474413133200161410ustar00rootroot00000000000000"""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.6/napari_builtins/000077500000000000000000000000001474413133200161615ustar00rootroot00000000000000napari-0.5.6/napari_builtins/__init__.py000066400000000000000000000002401474413133200202660ustar00rootroot00000000000000from importlib.metadata import PackageNotFoundError, version try: __version__ = version('napari') except PackageNotFoundError: __version__ = 'unknown' napari-0.5.6/napari_builtins/_ndims_balls.py000066400000000000000000000017311474413133200211630ustar00rootroot00000000000000import numpy as np from napari.benchmarks.utils import labeled_particles def labeled_particles2d(): seed = np.random.default_rng().integers(np.iinfo(np.int64).max) labels, density, points = labeled_particles( (1024, 1024), seed=seed, return_density=True ) return [ (density, {'name': 'density', 'metadata': {'seed': seed}}, 'image'), (labels, {'name': 'labels', 'metadata': {'seed': seed}}, 'labels'), (points, {'name': 'points', 'metadata': {'seed': seed}}, 'points'), ] def labeled_particles3d(): seed = np.random.default_rng().integers(np.iinfo(np.int64).max) labels, density, points = labeled_particles( (256, 512, 512), seed=seed, return_density=True ) return [ (density, {'name': 'density', 'metadata': {'seed': seed}}, 'image'), (labels, {'name': 'labels', 'metadata': {'seed': seed}}, 'labels'), (points, {'name': 'points', 'metadata': {'seed': seed}}, 'points'), ] napari-0.5.6/napari_builtins/_skimage_data.py000066400000000000000000000034441474413133200213100ustar00rootroot00000000000000from 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)], }, ) ] if name == 'kidney': return [ ( skimage.data.kidney(), { 'channel_axis': -1, 'name': ['nuclei', 'WGA', 'actin'], 'colormap': ['blue', 'green', 'red'], }, ) ] if name == 'lily': return [ ( skimage.data.lily(), { 'channel_axis': -1, 'name': ['lily-R', 'lily-G', 'lily-W', 'lily-B'], 'colormap': ['red', 'green', 'gray', 'blue'], }, ) ] if 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.6/napari_builtins/_tests/000077500000000000000000000000001474413133200174625ustar00rootroot00000000000000napari-0.5.6/napari_builtins/_tests/conftest.py000066400000000000000000000030301474413133200216550ustar00rootroot00000000000000from pathlib import Path 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 @pytest.fixture def layers_list(): return LAYERS napari-0.5.6/napari_builtins/_tests/test_io.py000066400000000000000000000301521474413133200215030ustar00rootroot00000000000000import csv import os from pathlib import Path from typing import NamedTuple 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(store=str(fname), mode='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, match='No files found in'): magic_imread(tmp_path) 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(tmp_path): image = np.random.random((10, 20, 20)) data_path = str(tmp_path / 'data.zarr') z = zarr.open(store=data_path, mode='a', shape=image.shape) z[:] = image image_in = magic_imread([data_path]) 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(store=str(root_path), mode='a') grp.create_dataset(image_name, data=image, shape=image.shape) image_in = magic_imread([str(root_path / image_name)]) np.testing.assert_array_equal(image, image_in) def test_zarr_with_unrelated_file(tmp_path): image = np.random.random((10, 20, 20)) image_name = 'my_image' root_path = tmp_path / 'dataset.zarr' grp = zarr.open(store=str(root_path), mode='a') grp.create_dataset(image_name, data=image, shape=image.shape) txt_file_path = root_path / 'unrelated.txt' txt_file_path.touch() image_in = magic_imread([str(root_path)]) np.testing.assert_array_equal(image, image_in[0]) def test_zarr_multiscale(tmp_path): multiscale = [ np.random.random((20, 20)), np.random.random((10, 10)), np.random.random((5, 5)), ] fout = str(tmp_path / 'multiscale.zarr') root = zarr.open_group(fout, mode='a') for i in range(len(multiscale)): shape = 20 // 2**i z = root.create_dataset(str(i), shape=(shape,) * 2, dtype=np.float64) z[:] = multiscale[i] multiscale_in = magic_imread([fout]) assert len(multiscale) == len(multiscale_in) 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, match='not recognized as'): 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, match='not recognized as'): assert read_csv(temp, require_type='any') with pytest.raises(ValueError, match='not recognized as'): assert read_csv(temp, require_type='points') with pytest.raises(ValueError, match='not recognized as'): 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, match='not recognized as'): 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, match='not recognized as'): assert csv_to_layer_data(temp, require_type='any') with pytest.raises(ValueError, match='not recognized as'): assert csv_to_layer_data(temp, require_type='points') with pytest.raises(ValueError, match='not recognized as'): 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 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(tmp_path): zarr_dir = str(tmp_path / 'data.zarr') # For more details: https://github.com/napari/napari/issues/1471 z = zarr.open(store=zarr_dir, mode='w') z.zeros(name='1d', shape=(3,), chunks=(3,), dtype='float32') 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(tmp_path): # For more details: https://github.com/napari/napari/issues/1471 zarr_dir = str(tmp_path / 'data.zarr') z = zarr.open(store=zarr_dir, mode='w') z.zeros(name='1d', shape=(3,), chunks=(3,), dtype='float32') z.zeros(name='2d', shape=(3, 4), chunks=(3, 4), dtype='float32') z.zeros(name='3d', shape=(3, 4, 5), chunks=(3, 4, 5), dtype='float32') for name in z.array_keys(): [out] = npe2.read([os.path.join(zarr_dir, name)], stack=False) if name.endswith('1d'): assert out == (None,) else: assert isinstance(out[0], da.Array), name assert out[0].ndim == int(name[0]) napari-0.5.6/napari_builtins/_tests/test_ndims_balls.py000066400000000000000000000016431474413133200233660ustar00rootroot00000000000000import numpy as np from napari_builtins._ndims_balls import ( labeled_particles2d, labeled_particles3d, ) def test_labeled_particles2d(): img, labels, points = labeled_particles2d() assert img[0].ndim == 2 assert labels[0].ndim == 2 assert 'seed' in img[1]['metadata'] assert 'seed' in labels[1]['metadata'] assert 'seed' in points[1]['metadata'] assert img[2] == 'image' assert labels[2] == 'labels' assert points[2] == 'points' assert np.all(img[0][labels[0] > 0] > 0) def test_labeled_particles3d(): img, labels, points = labeled_particles3d() assert img[0].ndim == 3 assert labels[0].ndim == 3 assert 'seed' in img[1]['metadata'] assert 'seed' in labels[1]['metadata'] assert 'seed' in points[1]['metadata'] assert img[2] == 'image' assert labels[2] == 'labels' assert points[2] == 'points' assert np.all(img[0][labels[0] > 0] > 0) napari-0.5.6/napari_builtins/_tests/test_reader.py000066400000000000000000000046211474413133200223400ustar00rootroot00000000000000from pathlib import Path from typing import Callable, Optional import imageio.v3 as iio 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 filename.endswith(('png', 'jpg')): data_ = (data_ * 255).astype(np.uint8) if dest.suffix in {'.tif', '.tiff'}: tifffile.imwrite(str(dest), data_) elif dest.suffix in {'.npy'}: np.save(str(dest), data_) else: iio.imwrite(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_animated_gif_reader(save_image): threeD_data = (np.random.rand(5, 20, 20, 3) * 255).astype(np.uint8) dest = save_image('animated.gif', threeD_data) layer_data = npe2.read([str(dest)], stack=False) assert len(layer_data) == 1 assert layer_data[0][0].shape == (5, 20, 20, 3) @pytest.mark.slow 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.6/napari_builtins/_tests/test_writer.py000066400000000000000000000043751474413133200224200ustar00rootroot00000000000000from pathlib import Path from typing import TYPE_CHECKING import npe2 import numpy as np import pytest 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, layers_list: 'list[layers.Layer]' ): """Test writing layers data.""" path = tmp_path / 'layers_folder' written = npe2.write(path=str(path), layer_data=layers_list) # type: ignore # check expected files were written expected = { str(path / f'{layer.name}{_EXTENSION_MAP[layer._type_string]}') for layer in layers_list } assert path.is_dir() assert set(written) == expected for expect in expected: assert Path(expect).is_file() napari-0.5.6/napari_builtins/builtins.yaml000066400000000000000000000260461474413133200207060ustar00rootroot00000000000000display_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 - id: napari.data.balls_2d title: Generate 2d_balls sample python_name: napari_builtins._ndims_balls:labeled_particles2d - id: napari.data.balls_3d title: Generate 3d_balls sample python_name: napari_builtins._ndims_balls:labeled_particles3d 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: Balls key: balls_2d command: napari.data.balls_2d - display_name: Balls (3D) key: balls_3d command: napari.data.balls_3d - 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.6/napari_builtins/io/000077500000000000000000000000001474413133200165705ustar00rootroot00000000000000napari-0.5.6/napari_builtins/io/__init__.py000066400000000000000000000012661474413133200207060ustar00rootroot00000000000000from 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.6/napari_builtins/io/_read.py000066400000000000000000000406211474413133200202170ustar00rootroot00000000000000import csv import os import re import tempfile import urllib.parse from collections.abc import Sequence from contextlib import contextmanager, suppress from glob import glob from pathlib import Path from typing import TYPE_CHECKING, Optional, Union from urllib.error import HTTPError, URLError import dask.array as da import imageio.v3 as iio import numpy as np from dask import delayed from imageio import formats from napari.utils.misc import abspath_or_url from napari.utils.translations import trans if TYPE_CHECKING: from napari.types import FullLayerData, LayerData, ReaderFunction IMAGEIO_EXTENSIONS = {x for f in 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 iio.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: str): """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 """ path = Path(path) if (path / '.zarray').exists(): # load zarr array image = da.from_zarr(path) shape = image.shape elif (path / '.zgroup').exists(): # else load zarr all arrays inside file, useful for multiscale data image = [ read_zarr_dataset(subpath)[0] for subpath in sorted(path.iterdir()) if not subpath.name.startswith('.') and subpath.is_dir() ] assert image, 'No arrays found in zarr group' shape = image[0].shape elif (path / 'zarr.json').exists(): # zarr v3 import zarr data = zarr.open(store=path) if isinstance(data, zarr.Array): image = da.from_zarr(data) shape = image.shape else: image = [data[k] for k in sorted(data)] 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' if {'axis-0', 'axis-1'}.issubset(column_names): return 'points' 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, ) ) if 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.6/napari_builtins/io/_write.py000066400000000000000000000226641474413133200204450ustar00rootroot00000000000000import csv import os import shutil from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any, Optional, 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-{n!s}' 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-{n!s}' 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: if not already_existed: shutil.rmtree(path, ignore_errors=True) raise return written napari-0.5.6/pyproject.toml000066400000000000000000000611031474413133200157130ustar00rootroot00000000000000[build-system] requires = [ "setuptools >= 69", "setuptools_scm[toml]>=8" ] build-backend = "setuptools.build_meta" [project] name = "napari" description = "n-dimensional array viewer in Python" authors = [ { name = "napari team", email = "napari-steering-council@googlegroups.com" }, ] 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.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "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", ] requires-python = ">=3.9" dependencies = [ "appdirs>=1.4.4", "app-model>=0.3.0,<0.4.0", "cachey>=0.2.1", "certifi>=2018.1.18", "dask[array]>=2021.10.0", "imageio>=2.20,!=2.22.1", "jsonschema>=3.2.0", "lazy_loader>=0.2", "magicgui>=0.7.0", "napari-console>=0.1.1", "napari-plugin-engine>=0.1.9", "napari-svg>=0.1.8", "npe2>=0.7.6", "numpy>=1.22.2", "numpydoc>=0.9.2", "pandas>=1.3.0", "Pillow>=9.0", "pint>=0.17", "psutil>=5.0", "psygnal>=0.5.0", "pydantic>=1.9.0", "pygments>=2.6.0", "PyOpenGL>=3.1.0", "pywin32 ; platform_system == 'Windows'", "PyYAML>=5.1", "qtpy>=2.3.1", "scikit-image[data]>=0.19.1", "scipy>=1.5.4", "superqt>=0.6.7", "tifffile>=2022.7.28", "toolz>=0.10.0", "tqdm>=4.56.0", "typing_extensions>=4.2.0", "vispy>=0.14.1,<0.15", "wrapt>=1.11.1", ] dynamic = [ "version", ] [project.license] text = "BSD 3-Clause" [project.readme] file = "README.md" content-type = "text/markdown" [project.urls] Homepage = "https://napari.org" Download = "https://github.com/napari/napari" "Bug Tracker" = "https://github.com/napari/napari/issues" Documentation = "https://napari.org" "Source Code" = "https://github.com/napari/napari" [project.optional-dependencies] pyside2 = [ "PySide2>=5.13.2,!=5.15.0 ; python_version < '3.11' and platform_machine != 'arm64'", ] pyside6_experimental = [ "PySide6 < 6.5 ; python_version < '3.12'" ] pyqt6 = [ "PyQt6 > 6.5", "PyQt6 != 6.6.1 ; platform_system == 'Darwin'" ] pyside = [ "napari[pyside2]" ] pyqt5 = [ "PyQt5>=5.13.2,!=5.15.0", ] pyqt = [ "napari[pyqt5]" ] qt = [ "napari[pyqt]" ] all = [ "napari[pyqt,optional]" ] optional-base = [ "zarr>=2.12.0", # needed by `builtins` (dask.array.from_zarr) to open zarr "napari-plugin-manager >=0.1.3, <0.2.0", ] optional-numba = [ "numba>=0.57.1", ] optional = [ "napari[optional-base,optional-numba]", "triangle", "PartSegCore-compiled-backend>=0.15.8", ] testing = [ "babel>=2.9.0", "fsspec>=2023.10.0", "hypothesis>=6.8.0", "lxml[html_clean]>5", "matplotlib >= 3.6.1", "pooch>=1.6.0", "coverage>7", "docstring_parser>=0.15", "pretend>=1.0.9", "pyautogui>=0.9.54", "pytest-qt>=4.3.1", "pytest-pretty>=1.1.0", "pytest>=8.1.0", "tensorstore>=0.1.13", "virtualenv>=20.17", "xarray>=0.16.2", "IPython>=7.25.0", "qtconsole>=4.5.1", "rich>=12.0.0", "napari[optional-base]", ] testing_extra = [ "torch>=1.7", ] release = [ "PyGithub>=1.44.1", "twine>=3.1.1", "gitpython>=3.1.0", "requests-cache>=0.9.2", ] dev = [ "ruff", "check-manifest>=0.42", "pre-commit>=2.9.0", "pydantic", "python-dotenv", "napari[testing]", ] build = [ "ruff", "pyqt5", ] [project.entry-points.pytest11] napari = "napari.utils._testsupport" [project.entry-points."napari.manifest"] napari_builtins = "napari_builtins:builtins.yaml" [project.scripts] napari = "napari.__main__:main" [tool.setuptools] zip-safe = false include-package-data = true license-files = [ "LICENSE", ] [tool.setuptools.packages.find] namespaces = false [tool.setuptools.package-data] "*" = [ "*.pyi", ] napari_builtins = [ "builtins.yaml", ] [tool.setuptools_scm] write_to = "napari/_version.py" [tool.check-manifest] ignore = [ ".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 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*", ] fix = true [tool.ruff.format] quote-style = "single" [tool.ruff.lint] select = [ "E", "F", "W", #flake8 "UP", # pyupgrade "I", # isort "YTT", #flake8-2020 "TC", # flake8-type-checing "BLE", # flake8-blind-exception "B", # flake8-bugbear "A", # flake8-builtins "C4", # flake8-comprehensions "ISC", # flake8-implicit-str-concat "G", # flake8-logging-format "PIE", # flake8-pie "COM", # flake8-commas "SIM", # flake8-simplify "INP", # flake8-no-pep420 "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise "RET", # flake8-return "TID", # flake8-tidy-imports # replace absolutify import "TRY", # tryceratops "ICN", # flake8-import-conventions "RUF", # ruff specyfic rules "NPY201", # checks compatibility with numpy version 2.0 "ASYNC", # flake8-async "EXE", # flake8-executable "FA", # flake8-future-annotations "LOG", # flake8-logging "SLOT", # flake8-slots "PT", # flake8-pytest-style "T20", # flake8-print ] ignore = [ "E501", "TC001", "TC002", "TC003", "A003", # flake8-builtins - we have class attributes violating these rule "COM812", # flake8-commas - we don't like adding comma on single line of arguments "COM819", # conflicts with ruff-format "SIM117", # flake8-simplify - we some of merged with statements are not looking great with black, reanble after drop python 3.9 "RET504", # not fixed yet https://github.com/charliermarsh/ruff/issues/2950 "TRY003", # require implement multiple exception class "RUF005", # problem with numpy compatybility, see https://github.com/charliermarsh/ruff/issues/2142#issuecomment-1451038741 "B028", # need to be fixed "PYI015", # it produces bad looking files (@jni opinion) "W191", "Q000", "Q001", "Q002", "Q003", "ISC001", # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules ] [tool.ruff.lint.per-file-ignores] "napari/_vispy/__init__.py" = ["E402"] "**/_tests/*.py" = ["B011", "INP001", "TRY301", "B018", "RUF012"] "napari/utils/_testsupport.py" = ["B011"] "tools/validate_strings.py" = ["F401"] "tools/**" = ["INP001", "T20"] "examples/**" = ["ICN001", "INP001", "T20"] "**/vendored/**" = ["TID"] "napari/benchmarks/**" = ["RUF012", "TID252"] [tool.ruff.lint.flake8-builtins] builtins-allowed-modules = ["io", "types", "threading"] [tool.ruff.lint.pyupgrade] keep-runtime-typing = true [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" inline-quotes = "single" multiline-quotes = "double" [tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. ban-relative-imports = "all" [tool.ruff.lint.isort] known-first-party=['napari'] combine-as-imports = true [tool.ruff.lint.flake8-import-conventions] [tool.ruff.lint.flake8-import-conventions.extend-aliases] # Declare a custom alias for the `matplotlib` module. "dask.array" = "da" xarray = "xr" [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", "-ra", "--strict-markers", "--strict-config"] console_output_style = "count" minversion = "8" # log_cli_level = "INFO" xfail_strict = true testpaths = ["napari", "napari_builtins"] # 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::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:", "ignore:(?s).*Pyarrow will become a required dependency of pandas:DeprecationWarning", # pandas pyarrow (pandas<3.0), # TODO: remove once xarray is updated to avoid this warning # https://github.com/pydata/xarray/blame/b1f3fea467f9387ed35c221205a70524f4caa18b/pyproject.toml#L333-L334 # https://github.com/pydata/xarray/pull/8939 "ignore:__array__ implementation doesn't accept a copy keyword, so passing copy=False failed.", "ignore:pkg_resources is deprecated", "ignore:Deprecated call to `pkg_resources.declare_namespace", "ignore:Use Group.create_array instead." ] markers = [ "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", "enable_console: Don't mock the IPython console (in QtConsole) in this Test", # mark slow tests, so they can be skipped using: pytest -m "not slow" "slow: mark a test as slow", "key_bindings: Test of keybindings", ] [tool.mypy] files = "napari" # This file triggers an internal mypy error, so exclude collection # TODO: fix this exclude = "napari/utils/events/_tests/test_evented_model\\.py" plugins = "numpy.typing.mypy_plugin, pydantic.mypy" ignore_missing_imports = true hide_error_codes = false warn_redundant_casts = true disallow_incomplete_defs = true disallow_untyped_calls = true disallow_untyped_defs = true warn_unused_ignores = true check_untyped_defs = true no_implicit_optional = true disable_error_code = [ # See discussion at https://github.com/python/mypy/issues/2427; # mypy cannot run type checking on method assignment, but we use # that in several places, so ignore the error 'method-assign' ] # see `$ qtpy mypy-args` and qtpy readme. This will be used by `tox -e mypy` # to properly infer with PyQt6 installed always_false=['PYSIDE6', 'PYSIDE2', 'PYQT5'] always_true=['PYQT6'] # gloabl ignore error [[tool.mypy.overrides]] module = [ '*._tests.*', '*.experimental.*', '*._vendor.*', '*.benchmarks.*', 'napari_builtins.*' ] ignore_errors = true # individual ignore error # we should strive to remove those # See https://github.com/napari/napari/issues/2751 # you can regenerate this list with the following command \047 is single quote. # mypy napari | cut -f1 -d: | sort | uniq | tr '/' '.' | sed 's/\.py//' | awk '{ print " \047" $0 "\047," }' [[tool.mypy.overrides]] module = [ 'napari._qt.code_syntax_highlight', 'napari._qt.containers._base_item_model', 'napari._qt.containers._base_item_view', 'napari._qt.containers._layer_delegate', 'napari._qt.containers.qt_axis_model', 'napari._qt.containers.qt_layer_model', 'napari._qt.containers.qt_list_model', 'napari._qt.containers.qt_list_view', 'napari._qt.containers.qt_tree_model', 'napari._qt.containers.qt_tree_view', 'napari._qt.dialogs.confirm_close_dialog', 'napari._qt.dialogs.preferences_dialog', 'napari._qt.dialogs.qt_about', 'napari._qt.dialogs.qt_activity_dialog', 'napari._qt.dialogs.qt_modal', 'napari._qt.dialogs.qt_notification', 'napari._qt.dialogs.qt_package_installer', 'napari._qt.dialogs.qt_plugin_dialog', 'napari._qt.dialogs.qt_plugin_report', 'napari._qt.dialogs.qt_reader_dialog', 'napari._qt.dialogs.screenshot_dialog', 'napari._qt.experimental.qt_chunk_receiver', 'napari._qt.experimental.qt_poll', 'napari._qt.layer_controls.qt_colormap_combobox', 'napari._qt.layer_controls.qt_image_controls', 'napari._qt.layer_controls.qt_image_controls_base', 'napari._qt.layer_controls.qt_labels_controls', 'napari._qt.layer_controls.qt_layer_controls_base', 'napari._qt.layer_controls.qt_layer_controls_container', 'napari._qt.layer_controls.qt_points_controls', 'napari._qt.layer_controls.qt_shapes_controls', 'napari._qt.layer_controls.qt_surface_controls', 'napari._qt.layer_controls.qt_tracks_controls', 'napari._qt.layer_controls.qt_vectors_controls', 'napari._qt.menus._util', 'napari._qt.menus.file_menu', 'napari._qt.perf.qt_event_tracing', 'napari._qt.perf.qt_performance', 'napari._qt.qt_event_filters', 'napari._qt.qt_event_loop', 'napari._qt.qt_main_window', 'napari._qt.qt_resources._svg', 'napari._qt.qt_viewer', 'napari._qt.qthreading', 'napari._qt.utils', 'napari._qt.widgets._slider_compat', 'napari._qt.widgets.qt_color_swatch', 'napari._qt.widgets.qt_dict_table', 'napari._qt.widgets.qt_dims', 'napari._qt.widgets.qt_dims_slider', 'napari._qt.widgets.qt_dims_sorter', 'napari._qt.widgets.qt_extension2reader', 'napari._qt.widgets.qt_font_size', 'napari._qt.widgets.qt_highlight_preview', 'napari._qt.widgets.qt_keyboard_settings', 'napari._qt.widgets.qt_message_popup', 'napari._qt.widgets.qt_mode_buttons', 'napari._qt.widgets.qt_plugin_sorter', 'napari._qt.widgets.qt_progress_bar', 'napari._qt.widgets.qt_range_slider_popup', 'napari._qt.widgets.qt_scrollbar', 'napari._qt.widgets.qt_size_preview', 'napari._qt.widgets.qt_spinbox', 'napari._qt.widgets.qt_splash_screen', 'napari._qt.widgets.qt_theme_sample', 'napari._qt.widgets.qt_tooltip', 'napari._qt.widgets.qt_viewer_buttons', 'napari._qt.widgets.qt_viewer_dock_widget', 'napari._qt.widgets.qt_viewer_status_bar', 'napari._qt.widgets.qt_welcome', 'napari._vispy.canvas', 'napari._vispy.experimental.texture_atlas', 'napari._vispy.experimental.tile_set', 'napari._vispy.experimental.tiled_image_visual', 'napari._vispy.experimental.vispy_tiled_image_layer', 'napari._vispy.overlays.base', 'napari._vispy.utils.cursor', 'napari.components.layerlist', 'napari.layers._layer_actions', 'napari.layers._multiscale_data', 'napari.layers.intensity_mixin', 'napari.layers.points._points_key_bindings', 'napari.layers.points._points_utils', 'napari.layers.points.points', 'napari.layers.shapes._shapes_mouse_bindings', 'napari.layers.shapes.shapes', 'napari.layers.utils.color_encoding', 'napari.layers.utils.color_manager', 'napari.layers.utils.stack_utils', 'napari.layers.utils.string_encoding', 'napari.layers.utils.style_encoding', 'napari.layers.utils.text_manager', 'napari.utils._magicgui', 'napari.utils._testsupport', 'napari.utils._tracebacks', 'napari.utils.action_manager', 'napari.utils.events.containers._evented_dict', 'napari.utils.events.containers._evented_list', 'napari.utils.events.containers._nested_list', 'napari.utils.events.custom_types', 'napari.utils.events.debugging', 'napari.utils.events.event', 'napari.utils.events.evented_model', 'napari.utils.interactions', 'napari.utils.key_bindings', 'napari.utils.mouse_bindings', 'napari.utils.progress', 'napari.utils.shortcuts', 'napari.utils.stubgen', 'napari.utils.transforms.transforms', 'napari.utils.tree.group', 'napari.view_layers', 'napari._app_model.injection._processors', ] ignore_errors = true [[tool.mypy.overrides]] module = [ "napari.settings", "napari.settings._yaml", "napari.plugins.exceptions", "napari._app_model.actions._toggle_action", "napari._vispy.filters.tracks", "napari._vispy.utils.text", "napari._vispy.utils.visual", "napari._vispy.visuals.clipping_planes_mixin", "napari._vispy.visuals.markers", "napari._vispy.visuals.surface", "napari.layers.shapes._shapes_models.path", "napari.layers.shapes._shapes_models.polygon", "napari.layers.shapes._shapes_models._polygon_base", "napari.layers.shapes._shapes_models.ellipse", "napari.layers.shapes._shapes_models.line", "napari.layers.shapes._shapes_models.rectangle", "napari.layers.shapes._shapes_models.shape", "napari.resources._icons", "napari.utils.color", "napari.utils.events.containers._dict", "napari.utils.events.event_utils", "napari.utils.migrations", "napari.utils.validators", "napari.window" ] disallow_incomplete_defs = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari._event_loop", "napari._vispy.utils.quaternion", "napari._vispy.visuals.bounding_box", "napari._vispy.visuals.image", "napari._vispy.visuals.interaction_box", "napari._vispy.visuals.points", "napari._vispy.visuals.scale_bar", "napari.components._layer_slicer", "napari.components._viewer_mouse_bindings", "napari.components.overlays.base", "napari.components.overlays.interaction_box", "napari.utils.colormaps.categorical_colormap_utils", ] disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari.components.viewer_model", "napari.settings._fields", "napari.settings._migrations", "napari.settings._base", "napari.types", "napari.plugins._npe2", "napari.settings._napari_settings", "napari.plugins._plugin_manager", "napari.plugins.utils", "napari._qt._qapp_model.qactions._file", "napari._qt._qapp_model.qactions._help", "napari._qt._qapp_model.qactions._view", "napari._vispy.camera", "napari._vispy.layers.image", "napari._vispy.layers.scalar_field", "napari._vispy.layers.tracks", "napari._vispy.layers.vectors", "napari._vispy.overlays.axes", "napari._vispy.overlays.interaction_box", "napari._vispy.overlays.labels_polygon", "napari._vispy.overlays.scale_bar", "napari._vispy.overlays.text", "napari.layers.labels._labels_key_bindings", "napari.layers.utils._slice_input", "napari.utils._register", "napari.utils.colormaps.categorical_colormap", "napari.utils.colormaps.standardize_color", "napari.utils.geometry", "napari.utils.io", "napari.utils.notebook_display", "napari.utils.transforms.transform_utils", "napari.utils.translations", "napari.utils.tree.node", "napari.viewer", "napari.layers.shapes._shape_list", "napari.layers.vectors.vectors", ] disallow_incomplete_defs = false disallow_untyped_calls = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari.plugins", "napari._vispy.layers.base", "napari._vispy.visuals.axes", "napari.layers.labels._labels_mouse_bindings", "napari.layers.utils.color_manager_utils", "napari.utils.colormaps.vendored._cm", "napari.utils.colormaps.vendored.cm", "napari.utils.status_messages", "napari.layers.shapes._shapes_utils" ] disallow_untyped_calls = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari._app_model._app", "napari.utils.theme", ] disallow_incomplete_defs = false disallow_untyped_calls = false disallow_untyped_defs = false warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari._app_model.context._context", "napari._qt.containers._factory" ] disallow_incomplete_defs = false disallow_untyped_defs = false warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari._qt.menus.plugins_menu", "napari._vispy.layers.labels", "napari._vispy.layers.points", "napari._vispy.layers.shapes", "napari._vispy.layers.surface", "napari.components._viewer_key_bindings", "napari.layers.labels.labels", "napari.layers.surface.surface", "napari.layers.tracks.tracks", "napari.layers.utils.layer_utils", "napari.utils._dtype", "napari.utils.colormaps.colormap_utils", "napari.utils.misc", ] check_untyped_defs = false disallow_incomplete_defs = false disallow_untyped_calls = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari.components.camera", "napari.components.dims", "napari.conftest", "napari.layers.labels._labels_utils", "napari.layers.points._points_mouse_bindings", "napari.utils.colormaps.colormap", "napari.utils.notifications", ] check_untyped_defs = false disallow_incomplete_defs = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari.utils.events.containers._typed", ] check_untyped_defs = false disallow_incomplete_defs = false disallow_untyped_calls = false disallow_untyped_defs = false warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari.__main__", "napari.utils.colormaps.vendored.colors", "napari.layers.image.image", "napari.layers._scalar_field.scalar_field", ] check_untyped_defs = false disallow_untyped_calls = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari._app_model.context._layerlist_context", "napari.components.overlays.labels_polygon", "napari.plugins.io", "napari.utils.colormaps.vendored._cm_listed" ] disallow_untyped_calls = false [[tool.mypy.overrides]] module = [ "napari._qt.containers.qt_layer_list", "napari.layers.base.base" ] check_untyped_defs = false disallow_untyped_calls = false disallow_untyped_defs = false warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari._vispy.overlays.bounding_box", "napari._vispy.overlays.brush_circle", "napari.utils._test_utils", ] check_untyped_defs = false disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari._pydantic_compat", ] ignore_errors = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if typing.TYPE_CHECKING:", "raise NotImplementedError()", "except ImportError:", "^ +\\.\\.\\.$", ] [tool.coverage.run] omit = [ "*/_vendor/*", "*/_version.py", "*/benchmarks/*", "napari/utils/indexing.py", "**/add_layer.py_tmpl" ] source = [ "napari", "napari_builtins", ] [tool.coverage.paths] source = [ "napari/", "D:\\a\\napari\\napari\\napari", "/home/runner/work/napari/napari/napari", "/Users/runner/work/napari/napari/napari", ] builtins = [ "napari_builtins/", "D:\\a\\napari\\napari\\napari_builtins", "/home/runner/work/napari/napari/napari_builtins", "/Users/runner/work/napari/napari/napari_builtins", ] [tool.importlinter] root_package = "napari" include_external_packages = true [[tool.importlinter.contracts]] name = "Forbid import PyQt and PySide" type = "forbidden" source_modules = "napari" forbidden_modules = ["PyQt5", "PySide2", "PyQt6", "PySide6"] ignore_imports = [ "napari._qt -> PySide2", "napari.plugins._npe2 -> napari._qt._qplugins", ] [[tool.importlinter.contracts]] name = "Block import from qt module in napari.layers" type = "layers" layers = ["napari.qt","napari.layers"] ignore_imports = [ "napari.plugins._npe2 -> napari._qt._qplugins", # TODO: remove once npe1 deprecated "napari._qt.qt_main_window -> napari._qt._qplugins", ] [[tool.importlinter.contracts]] name = "Block import from qt module in napari.components" type = "layers" layers = ["napari.qt","napari.components"] ignore_imports = [ "napari.plugins._npe2 -> napari._qt._qplugins", # TODO: remove once npe1 deprecated "napari._qt.qt_main_window -> napari._qt._qplugins", ] napari-0.5.6/resources/000077500000000000000000000000001474413133200150105ustar00rootroot00000000000000napari-0.5.6/resources/bundle_license.rtf000066400000000000000000000054361474413133200205100ustar00rootroot00000000000000{\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.6/resources/bundle_license.txt000066400000000000000000000032161474413133200205260ustar00rootroot00000000000000BSD 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.6/resources/bundle_readme.md000066400000000000000000000044761474413133200201330ustar00rootroot00000000000000Welcome 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.6/resources/conda_menu_config.json000066400000000000000000000037671474413133200213550ustar00rootroot00000000000000{ "$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, "app_user_model_id": "napari.napari.viewer.__PKG_VERSION__" }, "linux": { "Categories": [ "Graphics", "Science" ], "StartupWMClass": "napari" }, "osx": { "link_in_bundle": { "{{ PREFIX }}/bin/python": "{{ MENU_ITEM_LOCATION }}/Contents/Resources/python" }, "command": ["{{ MENU_ITEM_LOCATION }}/Contents/Resources/python", "-m", "napari"], "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.6/resources/constraints/000077500000000000000000000000001474413133200173575ustar00rootroot00000000000000napari-0.5.6/resources/constraints/benchmark.in000066400000000000000000000000351474413133200216370ustar00rootroot00000000000000asv[virtualenv] pympler!=1.1 napari-0.5.6/resources/constraints/benchmark.txt000066400000000000000000000013311474413133200220500ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile benchmark.in -o benchmark.txt asv==0.6.3 # via -r benchmark.in asv-runner==0.2.1 # via asv build==1.2.1 # via asv distlib==0.3.8 # via virtualenv filelock==3.15.4 # via virtualenv importlib-metadata==8.0.0 # via asv-runner json5==0.9.25 # via asv packaging==24.1 # via # asv # build platformdirs==4.2.2 # via virtualenv pympler==1.0.1 # via # -r benchmark.in # asv pyproject-hooks==1.1.0 # via build pyyaml==6.0.1 # via asv tabulate==0.9.0 # via asv tomli==2.0.1 # via asv virtualenv==20.26.3 # via asv zipp==3.19.2 # via importlib-metadata flexparser!=0.4 napari-0.5.6/resources/constraints/constraints_py3.10.txt000066400000000000000000000310551474413133200235050ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.10 --output-file napari_repo/resources/constraints/constraints_py3.10.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.13.1 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==2.18.3 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.10_docs.txt000066400000000000000000000406471474413133200245240ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.10 --output-file napari_repo/resources/constraints/constraints_py3.10_docs.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/version_denylist_examples.txt docs/requirements.txt napari_repo/resources/constraints/pydantic_le_2.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional accessible-pygments==0.0.5 # via pydata-sphinx-theme alabaster==0.7.16 # via sphinx anyio==4.8.0 # via # starlette # watchfiles app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # jupyter-cache # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # pydata-sphinx-theme # sphinx beautifulsoup4==4.12.3 # via pydata-sphinx-theme build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # jupyter-cache # sphinx-external-toc # typer # uvicorn cloudpickle==3.1.1 # via dask colorama==0.4.6 # via sphinx-autobuild comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via # myst-parser # pydata-sphinx-theme # sphinx # sphinx-tabs exceptiongroup==1.2.2 # via # anyio # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr fastjsonschema==2.21.1 # via nbformat filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch greenlet==3.1.1 # via sqlalchemy h11==0.14.0 # via uvicorn heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via # anyio # requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imageio-ffmpeg==0.6.0 # via -r docs/requirements.txt imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask # jupyter-cache # myst-nb importlib-resources==6.5.2 # via nibabel in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # myst-nb # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # myst-nb # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # myst-parser # sphinx # torch joblib==1.4.2 # via # nilearn # scikit-learn jsonschema==4.23.0 # via # napari (napari_repo/pyproject.toml) # nbformat jsonschema-specifications==2024.10.1 # via jsonschema jupyter-cache==1.0.1 # via myst-nb jupyter-client==8.6.3 # via # ipykernel # nbclient # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # nbclient # nbformat # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image linkify-it-py==2.0.3 # via -r docs/requirements.txt llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean # nilearn lxml-html-clean==0.4.1 # via # -r docs/requirements.txt # lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via # mdit-py-plugins # myst-parser # rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via # -r docs/requirements.txt # napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdit-py-plugins==0.4.2 # via myst-parser mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy myst-nb==1.1.2 # via -r docs/requirements.txt myst-parser==4.0.0 # via myst-nb napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-sphinx-theme==0.6.0 # via -r docs/requirements.txt napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) natsort==8.4.0 # via seedir nbclient==0.10.2 # via # jupyter-cache # myst-nb nbformat==5.10.4 # via # jupyter-cache # myst-nb # nbclient nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch nibabel==5.3.2 # via nilearn nilearn==0.11.1 # via -r napari_repo/resources/constraints/version_denylist_examples.txt npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.13.1 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # nibabel # nilearn # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scikit-learn # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # -r napari_repo/resources/constraints/version_denylist_examples.txt # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # napari-sphinx-theme # nibabel # nilearn # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # nilearn # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image # sphinx-gallery pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==1.10.21 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydata-sphinx-theme==0.16.1 # via napari-sphinx-theme pydeps==3.0.0 # via -r docs/requirements.txt pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # accessible-pygments # ipython # pydata-sphinx-theme # qtconsole # rich # sphinx # sphinx-tabs # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # -r docs/requirements.txt # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # jupyter-cache # myst-nb # myst-parser # npe2 # sphinx-external-toc # sphinxcontrib-mermaid pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # nilearn # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scikit-learn==1.6.1 # via nilearn scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # nilearn # scikit-image # scikit-learn seedir==0.5.0 # via -r docs/requirements.txt shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil sniffio==1.3.1 # via anyio snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis soupsieve==2.6 # via beautifulsoup4 sphinx==7.4.7 # via # -r docs/requirements.txt # myst-nb # myst-parser # numpydoc # pydata-sphinx-theme # sphinx-autobuild # sphinx-autodoc-typehints # sphinx-copybutton # sphinx-design # sphinx-external-toc # sphinx-favicon # sphinx-gallery # sphinx-tabs # sphinx-tags # sphinxcontrib-mermaid sphinx-autobuild==2024.10.3 # via -r docs/requirements.txt sphinx-autodoc-typehints==1.12.0 # via -r docs/requirements.txt sphinx-copybutton==0.5.2 # via -r docs/requirements.txt sphinx-design==0.6.1 # via -r docs/requirements.txt sphinx-external-toc==1.0.1 # via -r docs/requirements.txt sphinx-favicon==1.0.1 # via -r docs/requirements.txt sphinx-gallery==0.18.0 # via -r docs/requirements.txt sphinx-tabs==3.4.7 # via -r docs/requirements.txt sphinx-tags==0.4 # via -r docs/requirements.txt sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-mermaid==1.0.0 # via -r docs/requirements.txt sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx sqlalchemy==2.0.37 # via jupyter-cache stack-data==0.6.3 # via ipython starlette==0.45.2 # via sphinx-autobuild stdlib-list==0.11.0 # via pydeps superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via # jupyter-cache # numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) threadpoolctl==3.5.0 # via scikit-learn tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # nbclient # nbformat # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # anyio # app-model # flexcache # flexparser # ipython # magicgui # myst-nb # nibabel # pint # pydantic # pydata-sphinx-theme # referencing # rich # sqlalchemy # superqt # torch # typer # uvicorn tzdata==2025.1 # via pandas uc-micro-py==1.0.3 # via linkify-it-py urllib3==2.3.0 # via requests uvicorn==0.34.0 # via sphinx-autobuild virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg watchfiles==1.0.4 # via sphinx-autobuild wcwidth==0.2.13 # via prompt-toolkit websockets==14.2 # via sphinx-autobuild wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==2.18.3 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.10_pydantic_1.txt000066400000000000000000000311031474413133200256120ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.10 --output-file napari_repo/resources/constraints/constraints_py3.10_pydantic_1.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/pydantic_le_2.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.13.1 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==1.10.21 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==2.18.3 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.10_windows.txt000066400000000000000000000276211474413133200252630ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-platform windows --python-version 3.10 --output-file napari_repo/resources/constraints/constraints_py3.10_windows.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask colorama==0.4.6 # via # build # click # ipython # pytest # sphinx # tqdm comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.13.1 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pywin32==308 # via # napari (napari_repo/pyproject.toml) # jupyter-core pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==2.18.3 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.11.txt000066400000000000000000000305501474413133200235050ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.11 --output-file napari_repo/resources/constraints/constraints_py3.11.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via coverage tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.11_pydantic_1.txt000066400000000000000000000305761474413133200256300ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.11 --output-file napari_repo/resources/constraints/constraints_py3.11_pydantic_1.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/pydantic_le_2.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==1.10.21 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via coverage tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.11_windows.txt000066400000000000000000000273141474413133200252630ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-platform windows --python-version 3.11 --output-file napari_repo/resources/constraints/constraints_py3.11_windows.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask colorama==0.4.6 # via # build # click # ipython # pytest # sphinx # tqdm comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via dask in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside6==6.4.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pywin32==308 # via # napari (napari_repo/pyproject.toml) # jupyter-core pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken6==6.4.2 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via coverage tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via importlib-metadata napari-0.5.6/resources/constraints/constraints_py3.12.txt000066400000000000000000000276371474413133200235220ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.12 --output-file napari_repo/resources/constraints/constraints_py3.12.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image setuptools==75.8.0 # via torch shellingham==1.5.4 # via typer six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # magicgui # pint # pydantic # pydantic-core # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) napari-0.5.6/resources/constraints/constraints_py3.12_pydantic_1.txt000066400000000000000000000276651474413133200256360ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.12 --output-file napari_repo/resources/constraints/constraints_py3.12_pydantic_1.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/pydantic_le_2.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==1.10.21 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image setuptools==75.8.0 # via torch shellingham==1.5.4 # via typer six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # magicgui # pint # pydantic # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) napari-0.5.6/resources/constraints/constraints_py3.12_windows.txt000066400000000000000000000264031474413133200252620ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-platform windows --python-version 3.12 --output-file napari_repo/resources/constraints/constraints_py3.12_windows.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==1.0.0 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask colorama==0.4.6 # via # build # click # ipython # pytest # sphinx # tqdm comm==0.2.2 # via ipykernel contourpy==1.3.1 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov crc32c==2.7.1 # via numcodecs cycler==0.12.1 # via matplotlib dask==2025.1.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython deprecated==1.2.15 # via numcodecs distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx donfig==0.8.1.post1 # via zarr executing==2.1.0 # via stack-data filelock==3.17.0 # via # torch # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.31.0 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.8 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.44.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.10.0 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.4.2 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.61.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.15.0 # via zarr numpy==2.1.3 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray # zarr pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pywin32==308 # via # napari (napari_repo/pyproject.toml) # jupyter-core pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # donfig # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.25.0 # via napari (napari_repo/pyproject.toml) scipy==1.15.1 # via # napari (napari_repo/pyproject.toml) # scikit-image setuptools==75.8.0 # via torch shellingham==1.5.4 # via typer six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==8.1.3 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.71 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2025.1.10 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # magicgui # pint # pydantic # pydantic-core # referencing # superqt # torch # typer # zarr tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) # deprecated xarray==2025.1.1 # via napari (napari_repo/pyproject.toml) zarr==3.0.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) napari-0.5.6/resources/constraints/constraints_py3.9.txt000066400000000000000000000312451474413133200234360ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.9 --output-file napari_repo/resources/constraints/constraints_py3.9.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==0.7.16 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.0 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2024.8.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask # jupyter-client # sphinx importlib-resources==6.5.2 # via matplotlib in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.18.1 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.7 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.43.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.9.4 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.60.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==2.0.2 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.24.0 # via napari (napari_repo/pyproject.toml) scipy==1.13.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.3.1 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==7.4.7 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.69 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2024.8.30 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2024.7.0 # via napari (napari_repo/pyproject.toml) zarr==2.18.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via # importlib-metadata # importlib-resources napari-0.5.6/resources/constraints/constraints_py3.9_examples.txt000066400000000000000000000324301474413133200253310ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.9 --output-file napari_repo/resources/constraints/constraints_py3.9_examples.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/version_denylist_examples.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==0.7.16 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.0 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2024.8.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask # jupyter-client # sphinx importlib-resources==6.5.2 # via # matplotlib # nibabel in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.18.1 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch joblib==1.4.2 # via # nilearn # scikit-learn jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.7 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.43.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean # nilearn lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.9.4 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 # via # scikit-image # torch nibabel==5.3.2 # via nilearn nilearn==0.11.1 # via -r napari_repo/resources/constraints/version_denylist_examples.txt npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.60.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==2.0.2 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # nibabel # nilearn # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scikit-learn # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # -r napari_repo/resources/constraints/version_denylist_examples.txt # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # nibabel # nilearn # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # nilearn # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # nilearn # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.24.0 # via napari (napari_repo/pyproject.toml) scikit-learn==1.6.1 # via nilearn scipy==1.13.1 # via # napari (napari_repo/pyproject.toml) # nilearn # scikit-image # scikit-learn shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.3.1 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==7.4.7 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.69 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) threadpoolctl==3.5.0 # via scikit-learn tifffile==2024.8.30 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # nibabel # pint # pydantic # pydantic-core # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2024.7.0 # via napari (napari_repo/pyproject.toml) zarr==2.18.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via # importlib-metadata # importlib-resources napari-0.5.6/resources/constraints/constraints_py3.9_min_req.txt000066400000000000000000000000161474413133200251400ustar00rootroot00000000000000setuptools<70 napari-0.5.6/resources/constraints/constraints_py3.9_pydantic_1.txt000066400000000000000000000312731474413133200255520ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.9 --output-file napari_repo/resources/constraints/constraints_py3.9_pydantic_1.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt napari_repo/resources/constraints/pydantic_le_2.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==0.7.16 # via sphinx app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask comm==0.2.2 # via ipykernel contourpy==1.3.0 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2024.8.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # triton # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask # jupyter-client # sphinx importlib-resources==6.5.2 # via matplotlib in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.18.1 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.7 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.43.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.9.4 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.60.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==2.0.2 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.4.5.8 # via # nvidia-cudnn-cu12 # nvidia-cusolver-cu12 # torch nvidia-cuda-cupti-cu12==12.4.127 # via torch nvidia-cuda-nvrtc-cu12==12.4.127 # via torch nvidia-cuda-runtime-cu12==12.4.127 # via torch nvidia-cudnn-cu12==9.1.0.70 # via torch nvidia-cufft-cu12==11.2.1.3 # via torch nvidia-curand-cu12==10.3.5.147 # via torch nvidia-cusolver-cu12==11.6.1.9 # via torch nvidia-cusparse-cu12==12.3.1.170 # via # nvidia-cusolver-cu12 # torch nvidia-nccl-cu12==2.21.5 # via torch nvidia-nvjitlink-cu12==12.4.127 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 # torch nvidia-nvtx-cu12==12.4.127 # via torch packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pexpect==4.9.0 # via ipython pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==1.10.21 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.16 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas python3-xlib==0.15 # via # mouseinfo # pyautogui pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.24.0 # via napari (napari_repo/pyproject.toml) scipy==1.13.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.3.1 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==7.4.7 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.69 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2024.8.30 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) triton==3.1.0 # via torch typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2024.7.0 # via napari (napari_repo/pyproject.toml) zarr==2.18.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via # importlib-metadata # importlib-resources napari-0.5.6/resources/constraints/constraints_py3.9_windows.txt000066400000000000000000000300111474413133200251760ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-platform windows --python-version 3.9 --output-file napari_repo/resources/constraints/constraints_py3.9_windows.txt napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt --extra pyqt5 --extra pyqt6 --extra pyside2 --extra pyside6_experimental --extra testing --extra testing_extra --extra optional alabaster==0.7.16 # via sphinx annotated-types==0.7.0 # via pydantic app-model==0.3.1 # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr asttokens==3.0.0 # via stack-data attrs==24.3.0 # via # hypothesis # jsonschema # referencing babel==2.16.0 # via # napari (napari_repo/pyproject.toml) # sphinx build==1.2.2.post1 # via npe2 cachey==0.2.1 # via napari (napari_repo/pyproject.toml) certifi==2024.12.14 # via # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via # dask # typer cloudpickle==3.1.1 # via dask colorama==0.4.6 # via # build # click # ipython # pytest # sphinx # tqdm comm==0.2.2 # via ipykernel contourpy==1.3.0 # via matplotlib coverage==7.6.10 # via # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib dask==2024.8.0 # via napari (napari_repo/pyproject.toml) debugpy==1.8.12 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.9 # via virtualenv docstring-parser==0.16 # via # napari (napari_repo/pyproject.toml) # magicgui docutils==0.21.2 # via sphinx exceptiongroup==1.2.2 # via # hypothesis # ipython # pytest executing==2.1.0 # via stack-data fasteners==0.19 # via zarr filelock==3.17.0 # via # torch # virtualenv flexcache==0.3 # via pint flexparser==0.4 # via pint fonttools==4.55.4 # via matplotlib freetype-py==2.5.1 # via vispy fsspec==2024.12.0 # via # napari (napari_repo/pyproject.toml) # dask # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy hypothesis==6.124.2 # via napari (napari_repo/pyproject.toml) idna==3.10 # via requests imageio==2.37.0 # via # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx importlib-metadata==8.6.1 # via # build # dask # jupyter-client # sphinx importlib-resources==6.5.2 # via matplotlib in-n-out==0.2.1 # via app-model iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari-console # qtconsole ipython==8.18.1 # via # napari (napari_repo/pyproject.toml) # ipykernel # napari-console jedi==0.19.2 # via ipython jinja2==3.1.5 # via # sphinx # torch jsonschema==4.23.0 # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema jupyter-client==8.6.3 # via # ipykernel # qtconsole jupyter-core==5.7.2 # via # ipykernel # jupyter-client # qtconsole kiwisolver==1.4.7 # via # matplotlib # vispy lazy-loader==0.4 # via # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.43.0 # via numba locket==1.0.0 # via partd lxml==5.3.0 # via # napari (napari_repo/pyproject.toml) # lxml-html-clean lxml-html-clean==0.4.1 # via lxml magicgui==0.10.0 # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 matplotlib==3.9.4 # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.5.1 # via tensorstore mouseinfo==0.1.3 # via pyautogui mpmath==1.3.0 # via sympy napari-console==0.1.3 # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via napari (napari_repo/pyproject.toml) napari-plugin-manager==0.1.4 # via napari (napari_repo/pyproject.toml) napari-svg==0.2.1 # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 # via # scikit-image # torch npe2==0.7.7 # via # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.60.0 # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==2.0.2 # via # napari (napari_repo/pyproject.toml) # contourpy # dask # imageio # matplotlib # ml-dtypes # napari-svg # numba # numcodecs # pandas # partsegcore-compiled-backend # scikit-image # scipy # tensorstore # tifffile # triangle # vispy # xarray # zarr numpydoc==1.8.0 # via napari (napari_repo/pyproject.toml) packaging==24.2 # via # build # dask # ipykernel # lazy-loader # matplotlib # napari-plugin-manager # pooch # pytest # qtconsole # qtpy # scikit-image # sphinx # vispy # xarray pandas==2.2.3 # via # napari (napari_repo/pyproject.toml) # xarray parso==0.8.4 # via jedi partd==1.4.2 # via dask partsegcore-compiled-backend==0.15.9 # via napari (napari_repo/pyproject.toml) pillow==11.1.0 # via # napari (napari_repo/pyproject.toml) # imageio # matplotlib # pyscreeze # scikit-image pint==0.24.4 # via napari (napari_repo/pyproject.toml) pip==24.3.1 # via napari-plugin-manager platformdirs==4.3.6 # via # jupyter-core # pint # pooch # virtualenv pluggy==1.5.0 # via # pytest # pytest-qt pooch==1.8.2 # via # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.50 # via ipython psutil==6.1.1 # via # napari (napari_repo/pyproject.toml) # ipykernel psygnal==0.11.1 # via # napari (napari_repo/pyproject.toml) # app-model # magicgui # npe2 pure-eval==0.2.3 # via stack-data pyautogui==0.9.54 # via napari (napari_repo/pyproject.toml) pyconify==0.2 # via superqt pydantic==2.10.5 # via # napari (napari_repo/pyproject.toml) # app-model # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydantic-core==2.27.2 # via pydantic pygetwindow==0.0.9 # via pyautogui pygments==2.19.1 # via # napari (napari_repo/pyproject.toml) # ipython # qtconsole # rich # sphinx # superqt pymsgbox==1.0.9 # via pyautogui pyopengl==3.1.9 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyparsing==3.2.1 # via matplotlib pyperclip==1.9.0 # via mouseinfo pyproject-hooks==1.2.0 # via build pyqt5==5.15.11 # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.16.1 # via pyqt5 pyqt6==6.8.0 # via napari (napari_repo/pyproject.toml) pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyrect==0.2.0 # via pygetwindow pyscreeze==1.0.1 # via pyautogui pyside2==5.15.2.1 # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons pytest==8.3.4 # via # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==6.0.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 # via napari (napari_repo/pyproject.toml) pytest-qt==4.4.0 # via napari (napari_repo/pyproject.toml) python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas pytweening==1.2.0 # via pyautogui pytz==2024.2 # via pandas pywin32==308 # via # napari (napari_repo/pyproject.toml) # jupyter-core pyyaml==6.0.2 # via # napari (napari_repo/pyproject.toml) # dask # npe2 pyzmq==26.2.0 # via # ipykernel # jupyter-client qtconsole==5.6.1 # via # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.2 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-console # napari-plugin-manager # qtconsole # superqt referencing==0.36.1 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via # pooch # pyconify # sphinx rich==13.9.4 # via # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty # typer rpds-py==0.22.3 # via # jsonschema # referencing scikit-image==0.24.0 # via napari (napari_repo/pyproject.toml) scipy==1.13.1 # via # napari (napari_repo/pyproject.toml) # scikit-image shellingham==1.5.4 # via typer shiboken2==5.15.2.1 # via pyside2 shiboken6==6.3.1 # via # pyside6 # pyside6-addons # pyside6-essentials six==1.17.0 # via python-dateutil snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis sphinx==7.4.7 # via numpydoc sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx stack-data==0.6.3 # via ipython superqt==0.7.1 # via # napari (napari_repo/pyproject.toml) # magicgui # napari-plugin-manager sympy==1.13.1 # via torch tabulate==0.9.0 # via numpydoc tensorstore==0.1.69 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) tifffile==2024.8.30 # via # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.2.1 # via # build # coverage # npe2 # numpydoc # pytest # sphinx tomli-w==1.2.0 # via npe2 toolz==1.0.0 # via # napari (napari_repo/pyproject.toml) # dask # partd torch==2.5.1 # via napari (napari_repo/pyproject.toml) tornado==6.4.2 # via # ipykernel # jupyter-client tqdm==4.67.1 # via napari (napari_repo/pyproject.toml) traitlets==5.14.3 # via # comm # ipykernel # ipython # jupyter-client # jupyter-core # matplotlib-inline # qtconsole triangle==20250106 # via napari (napari_repo/pyproject.toml) typer==0.15.1 # via npe2 typing-extensions==4.12.2 # via # napari (napari_repo/pyproject.toml) # app-model # flexcache # flexparser # ipython # magicgui # pint # pydantic # pydantic-core # referencing # rich # superqt # torch # typer tzdata==2025.1 # via pandas urllib3==2.3.0 # via requests virtualenv==20.29.1 # via napari (napari_repo/pyproject.toml) vispy==0.14.3 # via # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.17.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) xarray==2024.7.0 # via napari (napari_repo/pyproject.toml) zarr==2.18.2 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/pyproject.toml) zipp==3.21.0 # via # importlib-metadata # importlib-resources napari-0.5.6/resources/constraints/pydantic_le_2.txt000066400000000000000000000000131474413133200226260ustar00rootroot00000000000000pydantic<2 napari-0.5.6/resources/constraints/version_denylist.txt000066400000000000000000000005431474413133200235220ustar00rootroot00000000000000pyopengl!=3.1.9a1 pytest-cov PySide6 < 6.3.2 ; python_version < '3.10' PySide6 != 6.4.3, !=6.5.0, !=6.5.1, !=6.5.1.1, !=6.5.2, != 6.5.3, != 6.6.0, != 6.6.1, != 6.6.2 ; python_version >= '3.10' and python_version < '3.12' pytest-json-report tensorstore!=0.1.38 ipykernel!=7.0.0a0 wrapt!=1.17.0 # problem with macOS intel zarr!=3.0.0b3,!=3.0.0b2,!=3.0.0rc1 napari-0.5.6/resources/constraints/version_denylist_examples.txt000066400000000000000000000000221474413133200254100ustar00rootroot00000000000000nilearn packaging napari-0.5.6/resources/osx_pkg_welcome.rtf.tmpl000066400000000000000000000021201474413133200216600ustar00rootroot00000000000000{\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.6/resources/requirements_mypy.in000066400000000000000000000001611474413133200211370ustar00rootroot00000000000000magicgui mypy numpy!=2.2.0,!=2.2.1,!=2.2.2 npe2 pydantic qtpy pyqt6 types-PyYAML types-setuptools types-requests napari-0.5.6/resources/requirements_mypy.txt000066400000000000000000000042441474413133200213560ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.11 --output-file napari_repo/resources/requirements_mypy.txt napari_repo/resources/requirements_mypy.in annotated-types==0.7.0 # via pydantic appdirs==1.4.4 # via npe2 build==1.2.2.post1 # via npe2 certifi==2024.12.14 # via requests charset-normalizer==3.4.1 # via requests click==8.1.8 # via typer docstring-parser==0.16 # via magicgui idna==3.10 # via requests magicgui==0.10.0 # via -r napari_repo/resources/requirements_mypy.in markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy==1.14.1 # via -r napari_repo/resources/requirements_mypy.in mypy-extensions==1.0.0 # via mypy npe2==0.7.7 # via -r napari_repo/resources/requirements_mypy.in numpy==2.1.3 # via -r napari_repo/resources/requirements_mypy.in packaging==24.2 # via # build # qtpy psygnal==0.11.1 # via # magicgui # npe2 pyconify==0.2 # via superqt pydantic==2.10.5 # via # -r napari_repo/resources/requirements_mypy.in # npe2 pydantic-core==2.27.2 # via pydantic pygments==2.19.1 # via # rich # superqt pyproject-hooks==1.2.0 # via build pyqt6==6.8.0 # via -r napari_repo/resources/requirements_mypy.in pyqt6-qt6==6.8.1 # via pyqt6 pyqt6-sip==13.9.1 # via pyqt6 pyyaml==6.0.2 # via npe2 qtpy==2.4.2 # via # -r napari_repo/resources/requirements_mypy.in # magicgui # superqt requests==2.32.3 # via pyconify rich==13.9.4 # via # npe2 # typer shellingham==1.5.4 # via typer superqt==0.7.1 # via magicgui tomli-w==1.2.0 # via npe2 typer==0.15.1 # via npe2 types-pyyaml==6.0.12.20241230 # via -r napari_repo/resources/requirements_mypy.in types-requests==2.32.0.20241016 # via -r napari_repo/resources/requirements_mypy.in types-setuptools==75.8.0.20250110 # via -r napari_repo/resources/requirements_mypy.in typing-extensions==4.12.2 # via # magicgui # mypy # pydantic # pydantic-core # superqt # typer urllib3==2.3.0 # via # requests # types-requests napari-0.5.6/tools/000077500000000000000000000000001474413133200141365ustar00rootroot00000000000000napari-0.5.6/tools/check_updated_packages.py000066400000000000000000000115061474413133200211340ustar00rootroot00000000000000from __future__ import annotations import argparse import logging import os import re import subprocess # nosec import sys from pathlib import Path from typing import Optional from tomllib import loads REPO_DIR = Path(__file__).parent.parent DEFAULT_NAME = 'auto-dependency-upgrades' def get_base_branch_name(ref_name, event): if ref_name == DEFAULT_NAME: return 'main' if ref_name.startswith(DEFAULT_NAME): if event in {'pull_request', 'pull_request_target'}: return os.environ.get('GITHUB_BASE_REF') return ref_name[len(DEFAULT_NAME) + 1 :] return ref_name def main(): parser = argparse.ArgumentParser() parser.add_argument('--main-packages', action='store_true') args = parser.parse_args() ref_name = get_ref_name() event = os.environ.get('GITHUB_EVENT_NAME', '') base_branch = get_base_branch_name(ref_name, event) try: res = get_changed_dependencies(base_branch, not args.main_packages) except ValueError as e: print(e) sys.exit(1) if args.main_packages: print('\n'.join(f' * {x}' for x in sorted(res))) elif res: print(', '.join(f'`{x}`' for x in res)) else: print('only indirect updates') def get_branches() -> list[str]: """ Get all branches from the repository. """ out = subprocess.run( # nosec ['git', 'branch', '--list', '--format', '%(refname:short)', '-a'], capture_output=True, check=True, ) return out.stdout.decode().split('\n') def calc_changed_packages( base_branch: str, src_dir: Path, python_version: str ) -> list[str]: """ Calculate a list of changed packages based on python_version Parameters ---------- base_branch: str branch against which to compare src_dir: Path path to the root of the repository python_version: str python version to use Returns ------- list[str] list of changed packages """ changed_name_re = re.compile(r'\+([\w-]+)') command = [ 'git', 'diff', base_branch, str( src_dir / 'resources' / 'constraints' / f'constraints_py{python_version}.txt' ), ] logging.info('Git diff call: %s', ' '.join(command)) try: out = subprocess.run( # nosec command, capture_output=True, check=True, ) except subprocess.CalledProcessError as e: raise ValueError( f'git diff failed with return code {e.returncode}' ' stderr: {e.stderr.decode()!r}' ' stdout: {e.stdout.decode()!r}' ) from e return [ changed_name_re.match(x)[1].lower() for x in out.stdout.decode().split('\n') if changed_name_re.match(x) ] def get_ref_name() -> str: """ Get the name of the current branch. """ ref_name = os.environ.get('GITHUB_REF_NAME') if ref_name: return ref_name out = subprocess.run( # nosec ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], capture_output=True, check=True, ) return out.stdout.decode().strip() def calc_only_direct_updates( changed_packages: list[str], src_dir: Path ) -> list[str]: name_re = re.compile(r'[\w-]+') metadata = loads((src_dir / 'pyproject.toml').read_text())['project'] optional_dependencies = metadata['optional-dependencies'] packages = ( metadata['dependencies'] + optional_dependencies['pyqt5'] + optional_dependencies['pyqt6'] + optional_dependencies['pyside2'] + optional_dependencies['pyside6_experimental'] + optional_dependencies['testing'] + optional_dependencies['all'] ) packages = [ name_re.match(package).group().lower() for package in packages if name_re.match(package) ] return sorted(set(packages) & set(changed_packages)) def get_changed_dependencies( base_branch: str, all_packages=False, python_version='3.10', src_dir: Optional[Path] = None, ): """ Get the changed dependencies. all_packages: bool If True, return all packages, not just the direct dependencies. """ if src_dir is None: src_dir = Path(__file__).parent.parent branches = get_branches() if base_branch not in branches: if f'origin/{base_branch}' not in branches: raise ValueError( f'base branch {base_branch} not found in {branches!r}' ) base_branch = f'origin/{base_branch}' changed_packages = calc_changed_packages( base_branch, src_dir, python_version=python_version ) if all_packages: return sorted(set(changed_packages)) return calc_only_direct_updates(changed_packages, src_dir) if __name__ == '__main__': main() napari-0.5.6/tools/check_vendored_modules.py000066400000000000000000000067451474413133200212170ustar00rootroot00000000000000""" Check state of vendored modules. """ import shutil import sys from pathlib import Path from subprocess import check_output from typing import List 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: List[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") vendored_modules = [] for org, reponame, tag, source, target in [ ("albertosottile", "darkdetect", "master", None, None), ( "matplotlib", "matplotlib", "main", [ # 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 source is None: diff = check_vendored_module(org, reponame, tag) else: diff = check_vendored_files( org, reponame, tag, [Path(s) for s in source], Path(target) ) if diff: vendored_modules.append((org, reponame, diff)) if CI: with open(TOOLS_PATH / "vendored_modules.txt", "w") as f: f.write(" ".join(f"{org}/{reponame}" for org, reponame, _ in vendored_modules)) sys.exit(0) if vendored_modules: print("\n\nThe following vendored modules are not up to date:\n") for org, reponame, _diff in vendored_modules: print(f"\n * {org}/{reponame}\n") sys,exit(1) if __name__ == "__main__": main() napari-0.5.6/tools/create_pr_or_update_existing_one.py000066400000000000000000000256361474413133200233050ustar00rootroot00000000000000""" This file contains the code to create a PR or update an existing one based on the state of the current branch. """ import logging import os import subprocess # nosec from contextlib import contextmanager from os import chdir, environ, getcwd from pathlib import Path import requests from check_updated_packages import get_changed_dependencies REPO_DIR = Path(__file__).parent.parent / 'napari_repo' # GitHub API base URL BASE_URL = 'https://api.github.com' DEFAULT_BRANCH_NAME = 'auto-update-dependencies' @contextmanager def cd(path: Path): """ Change directory to the given path and return to the previous one afterwards. """ current_dir = getcwd() try: chdir(path) yield finally: chdir(current_dir) def _setup_git_author(): subprocess.run( ['git', 'config', '--global', 'user.name', 'napari-bot'], check=True ) # nosec subprocess.run( [ 'git', 'config', '--global', 'user.email', 'napari-bot@users.noreply.github.com', ], check=True, ) # nosec def create_commit(message: str, branch_name: str = ''): """ Create a commit calling git. """ with cd(REPO_DIR): if branch_name: subprocess.run(['git', 'checkout', '-B', branch_name], check=True) subprocess.run(['git', 'add', '-u'], check=True) # nosec subprocess.run(['git', 'commit', '-m', message], check=True) # nosec def push(branch_name: str, update: bool = False): """ Push the current branch to the remote. """ with cd(REPO_DIR): logging.info('go to dir %s', REPO_DIR) if update: logging.info('Pushing to %s', branch_name) subprocess.run( [ 'git', 'push', '--force', '--set-upstream', 'napari-bot', branch_name, ], check=True, capture_output=True, ) else: logging.info('Force pushing to %s', branch_name) subprocess.run( [ 'git', 'push', '--force', '--set-upstream', 'origin', branch_name, ], check=True, capture_output=True, ) # nosec def commit_message(branch_name) -> str: with cd(REPO_DIR): changed_direct = get_changed_dependencies( all_packages=False, base_branch=branch_name, python_version='3.11', src_dir=REPO_DIR, ) if not changed_direct: return 'Update indirect dependencies' return 'Update ' + ', '.join(f'`{x}`' for x in changed_direct) def long_description(branch_name: str) -> str: with cd(REPO_DIR): all_changed = get_changed_dependencies( all_packages=True, base_branch=branch_name, python_version='3.11', src_dir=REPO_DIR, ) return 'Updated packages: ' + ', '.join(f'`{x}`' for x in all_changed) def create_pr_with_push(branch_name: str, access_token: str, repo=''): """ Create a PR. """ if branch_name == 'main': new_branch_name = DEFAULT_BRANCH_NAME else: new_branch_name = f'{DEFAULT_BRANCH_NAME}-{branch_name}' if not repo: repo = os.environ.get('GITHUB_REPOSITORY', 'napari/napari') with cd(REPO_DIR): subprocess.run(['git', 'checkout', '-B', new_branch_name], check=True) create_commit(commit_message(branch_name)) push(new_branch_name) logging.info('Create PR for branch %s', new_branch_name) if pr_number := list_pr_for_branch( new_branch_name, access_token, repo=repo ): update_own_pr(pr_number, access_token, branch_name, repo) else: create_pr( base_branch=branch_name, new_branch=new_branch_name, access_token=access_token, repo=repo, ) def update_own_pr(pr_number: int, access_token: str, base_branch: str, repo): headers = {'Authorization': f'token {access_token}'} payload = { 'title': commit_message(base_branch), 'body': long_description(base_branch), } url = f'{BASE_URL}/repos/{repo}/pulls/{pr_number}' logging.info('Update PR with payload: %s in %s', str(payload), url) response = requests.post(url, headers=headers, json=payload) response.raise_for_status() url_labels = f'{BASE_URL}/repos/{repo}/issues/{pr_number}/labels' response = requests.get(url_labels, headers=headers) response.raise_for_status() remove_label_url = ( f'{BASE_URL}/repos/{repo}/issues/{pr_number}/labels/ready%20to%20merge' ) # following lines is to check if "ready to merge" label is added to PR, # if it is present, then remove it to point that PR was changed for label in response.json(): if label['name'] == 'ready to merge': response = requests.delete(remove_label_url, headers=headers) response.raise_for_status() break def list_pr_for_branch(branch_name: str, access_token: str, repo=''): """ check if PR for branch exists """ org_name = repo.split('/')[0] url = f'{BASE_URL}/repos/{repo}/pulls?state=open&head={org_name}:{branch_name}' response = requests.get(url) response.raise_for_status() if response.json(): return response.json()[0]['number'] return None def create_pr( base_branch: str, new_branch: str, access_token: str, repo, source_user='' ): # Prepare the headers with the access token headers = {'Authorization': f'token {access_token}'} # publish the comment payload = { 'title': commit_message(base_branch), 'body': long_description(base_branch), 'head': new_branch, 'base': base_branch, 'maintainer_can_modify': True, } if source_user: payload['head'] = f'{source_user}:{new_branch}' pull_request_url = f'{BASE_URL}/repos/{repo}/pulls' logging.info( 'Create PR with payload: %s in %s', str(payload), pull_request_url ) response = requests.post(pull_request_url, headers=headers, json=payload) response.raise_for_status() logging.info('PR created: %s', response.json()['html_url']) add_label(repo, response.json()['number'], 'maintenance', access_token) def add_label(repo, pr_num, label, access_token): pull_request_url = f'{BASE_URL}/repos/{repo}/issues/{pr_num}/labels' headers = {'Authorization': f'token {access_token}'} payload = {'labels': [label]} logging.info('Add labels: %s in %s', str(payload), pull_request_url) response = requests.post(pull_request_url, headers=headers, json=payload) response.raise_for_status() logging.info('Labels added: %s', response.json()) def add_comment_to_pr( pull_request_number: int, message: str, repo='napari/napari', ): """ Add a comment to an existing PR. """ # Prepare the headers with the access token headers = {'Authorization': f'token {os.environ.get("GITHUB_TOKEN")}'} # publish the comment payload = {'body': message} comment_url = ( f'{BASE_URL}/repos/{repo}/issues/{pull_request_number}/comments' ) response = requests.post(comment_url, headers=headers, json=payload) response.raise_for_status() def update_pr(branch_name: str): """ Update an existing PR. """ pr_number = get_pr_number() target_repo = os.environ.get('FULL_NAME') new_branch_name = f'auto-update-dependencies/{target_repo}/{branch_name}' if ( target_repo == os.environ.get('GITHUB_REPOSITORY', 'napari/napari') and branch_name == DEFAULT_BRANCH_NAME ): new_branch_name = DEFAULT_BRANCH_NAME create_commit(commit_message(branch_name), branch_name=new_branch_name) comment_content = long_description(f'origin/{branch_name}') try: push(new_branch_name, update=branch_name != DEFAULT_BRANCH_NAME) except subprocess.CalledProcessError as e: if 'create or update workflow' in e.stderr.decode(): logging.info('Workflow file changed. Skip PR create.') comment_content += ( '\n\n This PR contains changes to the workflow file. ' ) comment_content += 'Please download the artifact and update the constraints files manually. ' comment_content += f'Artifact: https://github.com/{os.environ.get("GITHUB_REPOSITORY", "napari/napari")}/actions/runs/{os.environ.get("GITHUB_RUN_ID")}' else: raise else: if new_branch_name != DEFAULT_BRANCH_NAME: comment_content += update_external_pr_comment( target_repo, branch_name, new_branch_name ) add_comment_to_pr( pr_number, comment_content, repo=os.environ.get('GITHUB_REPOSITORY', 'napari/napari'), ) logging.info('PR updated: %s', pr_number) def update_external_pr_comment( target_repo: str, branch_name: str, new_branch_name: str ) -> str: comment = '\n\nThis workflow cannot automatically update your PR or create PR to your repository. ' comment += 'But you could open such PR by clicking the link: ' comment += f'https://github.com/{target_repo}/compare/{branch_name}...napari-bot:{new_branch_name}.' comment += '\n\n' comment += 'You could also get the updated files from the ' comment += f'https://github.com/napari-bot/napari/tree/{new_branch_name}/resources/constraints. ' comment += 'Or ask the maintainers to provide you the contents of the constraints artifact ' comment += f'from the run https://github.com/{os.environ.get("GITHUB_REPOSITORY", "napari/napari")}/actions/runs/{os.environ.get("GITHUB_RUN_ID")}' return comment def get_pr_number() -> int: """ Get the PR number from the environment based on the PR_NUMBER variable. Returns ------- pr number: int """ pr_number = environ.get('PR_NUMBER') logging.info('PR_NUMBER: %s', pr_number) return int(pr_number) def main(): event_name = environ.get('GITHUB_EVENT_NAME') branch_name = environ.get('BRANCH') access_token = environ.get('GHA_TOKEN_MAIN_REPO') _setup_git_author() logging.basicConfig(level=logging.INFO) logging.info('Branch name: %s', branch_name) logging.info('Event name: %s', event_name) if event_name in {'schedule', 'workflow_dispatch'}: logging.info('Creating PR') create_pr_with_push(branch_name, access_token) elif event_name == 'issue_comment': logging.info('Updating PR') update_pr(branch_name) elif event_name == 'pull_request': logging.info( 'Pull request run. We cannot add comment or create PR. Please download the artifact.' ) else: raise ValueError(f'Unknown event name: {event_name}') if __name__ == '__main__': main() napari-0.5.6/tools/perfmon/000077500000000000000000000000001474413133200156045ustar00rootroot00000000000000napari-0.5.6/tools/perfmon/README.md000066400000000000000000000040001474413133200170550ustar00rootroot00000000000000# 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.6/tools/perfmon/compare_callable.py000066400000000000000000000033741474413133200214320ustar00rootroot00000000000000import 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( 'Running compare_callable.py with the following arguments.\n{args_}', extra={'args_': 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.6/tools/perfmon/plot_callable.py000066400000000000000000000024721474413133200207600ustar00rootroot00000000000000import 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( 'Running plot_callable.py with the following arguments.\n{args_}', extra={'args_': 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.6/tools/perfmon/run.py000066400000000000000000000024511474413133200167640ustar00rootroot00000000000000import 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( 'Running run.py with the following arguments.\n{args_}', extra={'args_': 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.6/tools/perfmon/slicing/000077500000000000000000000000001474413133200172345ustar00rootroot00000000000000napari-0.5.6/tools/perfmon/slicing/config.json000066400000000000000000000007261474413133200214010ustar00rootroot00000000000000{ "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.6/tools/remove_html_comments_from_pr.py000066400000000000000000000061331474413133200224650ustar00rootroot00000000000000""" Edit pull request description to remove HTML comments We might want to remove section with markdown task lists that are completely empty """ import re import sys from os import environ import requests REPO = 'napari/napari' def remove_html_comments(text): # Regular expression to remove HTML comments # [^\S\r\n] is whitespace but not new line html_comment_pattern = r'[^\S\r\n]*[^\S\r\n]*\s*' return re.sub(html_comment_pattern, '\n', text, flags=re.DOTALL) def edit_pull_request_description(repo, pull_request_number, access_token): # GitHub API base URL base_url = 'https://api.github.com' # Prepare the headers with the access token headers = {'Authorization': f'token {access_token}'} # Get the current pull request description pr_url = f'{base_url}/repos/{repo}/pulls/{pull_request_number}' response = requests.get(pr_url, headers=headers) response.raise_for_status() response_json = response.json() current_description = response_json['body'] # Remove HTML comments from the description edited_description = remove_html_comments(current_description) if edited_description == current_description: print('No HTML comments found in the pull request description') return # Update the pull request description update_pr_url = f'{base_url}/repos/{repo}/pulls/{pull_request_number}' payload = {'body': edited_description} response = requests.patch(update_pr_url, json=payload, headers=headers) response.raise_for_status() if response.status_code == 200: print( f'Pull request #{pull_request_number} description has been updated successfully!' ) else: print( f'Failed to update pull request description. Status code: {response.status_code}' ) if __name__ == '__main__': print('Will inspect PR description to remove html comments.') # note that the env between pull_request and pull_request_target are different # and the github documentation is incorrect (or at least misleading) # and likely varies between pull request intra-repository and inter-repository # thus we log many things to try to understand what is going on in case of failure. # among other: # - github.event.repository.name is not the full slug, but just the name # - github.event.repository.org is empty if the repo is a normal user. repository_url = environ.get('GH_REPO_URL') print(f'Current repository is {repository_url}') repository_parts = repository_url.split('/')[-2:] slug = '/'.join(repository_parts) print(f'Current slug is {slug}') if slug != REPO: print('Not on main repo, aborting with success') sys.exit(0) # get current PR number from github actions number = environ.get('GH_PR_NUMBER') print(f'Current PR number is {number}') access_token = environ.get('GH_TOKEN') if access_token is None: print('No access token found in the environment variables') # we still don't want fail status sys.exit(0) edit_pull_request_description(slug, number, access_token) napari-0.5.6/tools/split_qt_backend.py000066400000000000000000000003271474413133200200200ustar00rootroot00000000000000import sys names = ['MAIN', 'SECOND', 'THIRD', 'FOURTH'] num = int(sys.argv[1]) values = sys.argv[2].split(',') if num < len(values): print(f'{names[num]}={values[num]}') else: print(f'{names[num]}=none') napari-0.5.6/tools/string_list.json000066400000000000000000013204741474413133200174050ustar00rootroot00000000000000{ "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/_help_actions.py": [ "dev", "examples_gallery", "getting_started", "github_issue", "group", "homepage", "https://github.com/napari/napari/issues", "https://napari.org", "https://napari.org/{VERSION}/gallery.html", "https://napari.org/{VERSION}/howtos/layers/index.html", "https://napari.org/{VERSION}/release/release_{VERSION.replace(\".\", \"_\")}.html", "https://napari.org/{VERSION}/tutorials/index.html", "https://napari.org/{VERSION}/tutorials/start_index.html", "id", "layers_guide", "release_notes", "tutorials", "when" ], "napari/_app_model/actions/_layer_actions.py": [ "when", "id", "group", "int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64", "LAYER_CONVERT_TO_{_dtype.upper()}", "id", "max", "min", "std", "sum", "mean", "median", "LAYER_PROJECT_{mode.upper()}" ], "napari/_app_model/actions/_view_actions.py": [ "1_render", "arrows", "axes", "colored", "dashed", "group", "id", "labels", "order", "scale_bar", "ticks", "visible" ], "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:window:help:bug_report_opt_in", "napari:window:help:examples", "napari:window:help:getting_started", "napari:window:help:github_issue", "napari:window:help:homepage", "napari:window:help:info", "napari:window:help:layers_guide", "napari:window:help:release_notes", "napari:window:help:tutorials", "napari:window:view:toggle_activity_dock", "napari:window:view:toggle_fullscreen", "napari:window:view:toggle_layer_tooltips", "napari:window:view:toggle_menubar", "napari:window:view:toggle_play", "napari:window:view:toggle_viewer_axes", "napari:window:view:toggle_viewer_axes_arrows", "napari:window:view:toggle_viewer_axes_colored", "napari:window:view:toggle_viewer_axes_labels", "napari:window:view:toggle_viewer_axesdashed", "napari:window:view:toggle_viewer_scale_bar", "napari:window:view:toggle_viewer_scale_bar_colored", "napari:window:view:toggle_viewer_scale_bar_ticks" ], "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", "1_render", "napari/help", "napari/view", "napari/view/axes", "napari/view/scalebar" ], "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", "6.3.1", "PySide6" ], "napari/_qt/_constants.py": [], "napari/_qt/_qapp_model/_menus.py": [ "QWidget" ], "napari/_qt/_qapp_model/qactions/__init__.py": [ "QtViewer", "Window" ], "napari/_qt/_qapp_model/qactions/_help.py": [ "group", "id" ], "napari/_qt/_qapp_model/qactions/_view.py": [ "darwin", "group", "id", "linux", "order", "primary", "when", "win" ], "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", "loaded" ], "napari/_qt/containers/qt_list_model.py": [ "ItemType", "QMimeData", "application/x-list-index", "text/plain", "dropMimeData: indices %s ➡ %s" ], "napari/_qt/containers/qt_list_view.py": ["ItemType"], "napari/_qt/containers/qt_tree_model.py": [ "NodeMimeData", "NodeType", "application/x-tree-node", "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", "error_icon_btn" ], "napari/_qt/dialogs/preferences_dialog.py": [ "BaseModel", "ModelField", "QCloseEvent", "QKeyEvent", "preferences_exclude", "schema_version", "shortcuts", "NapariConfig", "call_order", "highlight", "highlight_thickness", "plugins", "properties", "ui:widget", "enum", "string", "type", "extension2reader", "ShortcutsSettings", "description", "object", "title" ], "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" ], "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", "info", "none", "warning", "QPushButton{padding: 4px 12px 4px 12px; font-size: 11px;min-height: 18px; border-radius: 0;}", "python", "resized", "NapariQtNotification" ], "napari/_qt/dialogs/qt_package_installer.py": [ "\n - ", "&", "--extra-index-url", "--no-warn-script-location", "--override-channels", "--prefix", "--upgrade", "--version", "-c", "-m", "-napari-constraints.txt", "-vvv", "-y", ".bat", "3", "Action '{self.action}' not supported!", "CONDA", "CONDA_EXE", "CONDA_PINNED_PACKAGES", "CONDA_VERBOSITY", "Cannot unregister %s, not a known napari plugin.", "HOME", "InstallerTool {tool} not recognized!", "MAMBA_EXE", "No job with id {job_id}. Current queue:\n - ", "PIP_USER_AGENT_USER_DATA", "PYTHONPATH", "Prefix has not been specified!", "TEMP", "TMP", "USERPROFILE", "bin", "conda-forge", "conda-meta", "condabin", "conda{bat}", "darwin", "dev", "install", "linux", "napari=={_napari_version}", "napari={version}", "nt", "pip", "python3", "rc", "uninstall", "w", "{item.ident} -> {item.executable()} {item.arguments()}", "~" ], "napari/_qt/dialogs/qt_plugin_dialog.py": [ "cancel", "close_button", "install_button", "#33F0FF", "help_button", "logo_silhouette", "npe2", "small_italic_text", "UNKNOWN", "__main__", "author", "error_label", "license", "linux", "napari_plugin_engine", "outdated", "plugin_manager_process_status", "remove_button", "small_text", "summary", "uninstall", "url", "version", "warning_icon", "shim", "warning", "#E3B617", "{pkg_name} {project_info.summary}", "latest_version", "=={item.latest_version}", "1.0", "shim", "0-{item.text()}", "unavailable", "current_job_id", "success_label" ], "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}", "\n\n\n\n
    \nTraceback from napari\n\n```\n{err}\n```\n
    " ], "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", "fill", "paint", "picker", "shuffle", "napari.layers.Labels", "napari:activate_labels_erase_mode", "napari:activate_labels_fill_mode", "napari:activate_labels_paint_mode", "napari:activate_labels_pan_zoom_mode", "napari:activate_labels_picker_mode" ], "napari/_qt/layer_controls/qt_layer_controls_base.py": ["close", "layer"], "napari/_qt/layer_controls/qt_layer_controls_container.py": [ "emphasized", "empty_controls_widget" ], "napari/_qt/layer_controls/qt_points_controls.py": [ "add_points", "delete_shape", "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_shape", "direct", "ellipse", "line", "move_back", "move_front", "path", "polygon", "rectangle", "select", "vertex_insert", "vertex_remove", "activate_add_ellipse_mode", "activate_add_line_mode", "activate_add_polyline_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", "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.Vectors" ], "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", "Shortcuts" ], "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", "Window" ], "napari/_qt/qt_viewer.py": [ "bottom", "expanded", "layerList", "left", "right", "top", ";;", "action_manager", "image", "napari", "points", "*{val}", "Saved %s", "ignore", "Ready", "caption", "dir", "directory", "options", "parent", "pyside" ], "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" ], "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", "transparent" ], "napari/_qt/widgets/qt_dict_table.py": [ "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", "mailto:{text}" ], "napari/_qt/widgets/qt_dims.py": [ "8", "axis_label", "last_used", "slice_label", "Internal C++ object", "wrapped C/C++ object of type" ], "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", "No filename preferences found" ], "napari/_qt/widgets/qt_highlight_preview.py": [ "border: 1px solid white;", "px", "white" ], "napari/_qt/widgets/qt_keyboard_settings.py": [ "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:rotate_layers", "napari:reset_view", "napari:toggle_grid", "napari:toggle_ndisplay", "napari:delete_selected_layers", "perspective", "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}: ", "coordinates status", "help status", "layer_base status", "plugin-reader status", "source-type status" ], "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", "experimental_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", "o" ], "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": [], "napari/_vispy/overlays/interaction_box.py": [], "napari/_vispy/overlays/scale_bar.py": [ "{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", "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/axes.py": [ "1", "center", "gl", "segments" ], "napari/_vispy/visuals/bounding_box.py": [ "red" ], "napari/_vispy/visuals/clipping_planes_mixin.py": ["_PVisual"], "napari/_vispy/visuals/interaction_box.py": [ "diamond", "disc", "square" ], "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/scale_bar.py": [ "1px", "center", "gl", "segments", "top" ], "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 if (discard_fragment)\n discard;\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 color = applyColormap(color.g);\n if (floatNotEqual(color.a, 0) ) {\n // when the value mapped to non-transparent color is reached\n // calculate the color (apply lighting effects)\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 " ], "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": [ "CI", "Skip on CI (not enough memory)" ], "napari/benchmarks/benchmark_labels_layer.py": [ "CI", "Skip on CI (not enough memory)" ], "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_qt_slicing.py": [ "chunk_shape", "dtype", "jrc_hela-2 (scale 3)", "shape", "skin_data", "uint16", "uint8" ], "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_tracks_layer.py": [ "n_tracks", "size" ], "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/_layer_slicer.py": [ "Found existing task for %s", "Slicing {len(not_done_futures)} tasks did not complete within timeout ({timeout}s).", "_SliceResponse", "napari.components._layer_slicer" ], "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__}", "system" ], "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", "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": [ "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": [ "ignore", "WriterContribution", "link_layers", "unlink_layers", "extent", "_extent_world", "_step_size" ], "napari/components/overlays/bounding_box.py": [ "blue", "red" ], "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}], ...]", "Ready", "axes", "scale_bar", "text" ], "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/_multiscale_data.py": [ "" ], "napari/layers/_source.py": ["_LAYER_SOURCE", "parent"], "napari/layers/base/__init__.py": [], "napari/layers/base/_base_constants.py": [ "{handle} has no opposite handle." ], "napari/layers/base/_base_mouse_bindings.py": [ "Control", "Shift", "data2physical", "ignore", "mouse_move", "transform_box" ], "napari/layers/base/base.py": [ "_round_index", "blending", "constant", "data", "metadata", "name", "opacity", "rotate", "scale", "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", "Ready", "bounding_box", "pan_zoom", "selection_box", "transform_box" ], "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/_slice.py": [ "tile2data" ], "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": [ "attenuation", "colormap", "contrast_limits", "data", "gamma", "gray", "interpolation", "iso_threshold", "mip", "multiscale", "nearest", "rendering", "rgb", "tile2data", "translucent", "ndim", "dtype", "plane", "slice", "interpolation2d", "0.6.0", "linear", "volume", "select", "bilinear", "interpolation3d", "depiction", "bicubic", "cubic" ], "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_key_bindings.py": [ "mode", "preserve_labels" ], "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", "v", "|" ], "napari/layers/points/_points_key_bindings.py": ["mode"], "napari/layers/points/_points_mouse_bindings.py": [ "Control", "Shift" ], "napari/layers/points/_points_utils.py": [], "napari/layers/points/points.py": [ "_{attribute}", "current_value", "data", "border", "border_color", "border_color_cycle", "border_colormap", "border_contrast_limits", "border_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", "border_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/_polygon_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", "border", "border_color", "border_color_cycle", "border_colormap", "border_contrast_limits", "border_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/_surface_key_bindings.py": [ "mode" ], "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/_tracks_key_bindings.py": [ "mode" ], "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]", "extent" ], "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/interaction_box.py": [ "only 2D coordinates are accepted" ], "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.", "", "=", "{\"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/io.py": [ "napari_write_{layer._type_string}", "Falling back to original plugin engine.", "builtins", "Writing to %s. Hook caller: %s" ], "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", "loading.gif", "error", "plugin.txt", "w" ], "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", "{env_name}_{subf.name}", "{field}{event._type}", "{path_.stem}.BAK{path_.suffix}" ], "napari/settings/_experimental.py": [ "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", "Failed to migrate settings from v{migration.from_} to v{migration.to_}. Error: {e}. ", "Migrator must increase the version.", "Settings rollback also failed. Please run `napari --reset`.", "You may need to reset your settings with `napari --reset`. " ], "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/settings/_shortcuts.py": ["schema_version"], "napari/settings/_utils.py": ["*", "*{pattern}"], "napari/settings/_yaml.py": ["Model", "yaml_dumper", "sort_keys"], "napari/types.py": [ "FunctionGui", "ImageData", "LabelsData", "LayerDataTuple", "PointsData", "QWidget", "ShapesData", "SurfaceData", "TracksData", "VectorsData", "dask.array.Array", "zarr.Array" ], "napari/utils/__init__.py": [], "napari/utils/_appdirs.py": [], "napari/utils/_base.py": ["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", "__", "0", "False", "NAPARI_ENSURE_PLUGIN_MAIN_THREAD", "Qt libs are available but no QtApplication instance is created", "Setting attributes on a napari object is only allowed from the main Qt thread." ], "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}", "

    During handling of the above exception, another exception occurred:
    ", "

    The above exception was the direct cause of the following exception:
    ", "

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

    " ], "napari/utils/action_manager.py": [ "bind_key", "bind_key", " or ", "[{name}]", "destroyed", "{Shortcut(s)}", " ({shorts})", "{event.tooltip} {extra_tooltip_text}", "{self._build_tooltip(name)} {extra_tooltip_text}" ], "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}\"", "Color data out of range", "ignore" ], "napari/utils/colormaps/standardize_color.py": [ "O", "U", "f", "i", "u", "{v.lower()}ff", "|U9", "...", "#{\"%02x\" * 4}" ], "napari/utils/config.py": [ "0", "NAPARI_MON" ], "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", "new_index", "Not supported index type {type(index)}", "index out of range: {key}", "move(src_index=%s, dest_index=%s)" ], "napari/utils/events/containers/_selectable_list.py": ["_T"], "napari/utils/events/containers/_selection.py": [ "ModelField", "[{i}]", "_S", "_T", "_current", "current", "selection" ], "napari/utils/events/containers/_set.py": [ "[{i}]", "_T", "changed", "events" ], "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", "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", " - {get_settings().config_path}", " - {os.getenv('NAPARI_CONFIG', user_config_dir())}", "
    Settings path:
    " ], "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}", "csv_to_layer_data", "imsave_extensions", "module {__name__} has no attribute {name}", "np.ndarray", "read_csv", "read_zarr_dataset", "write_csv" ], "napari/utils/key_bindings.py": [ "Alt", "Option", "Control", "Down", "Left", "Right", "Shift", "Up", "class_keymap", "Ctrl" ], "napari/utils/misc.py": [ "((?<=[a-z])[A-Z]|(?", "The lxml library is not installed, and is required to sanitize alt text for napari screenshots. Alt-text will be stripped altogether without lxml.", "The provided alt text does not constitute valid html, so it was discarded." ], "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", "An error occurred while trying to format an error and show it in console.\nYou can try to uninstall IPython to disable rich traceback formatting\nAnd/or report a bug to napari" ], "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", "PerfEvent" ], "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)", "Default Dark", "Default Light", "builtin", "colors", "rgb(227, 182, 23)" ], "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", "The `language` setting defined in the napari configuration file could not be read.\n\nThe default language will be used.\n\nError:\n{err}" ], "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_", "nearest", "linear", "mip", "volume", "Image" ], "napari/viewer.py": ["Window", "napari", "Viewer"], "napari/window.py": [] }, "SKIP_WORDS_GLOBAL": [", ", ".", "-", "|", "_", ":", "[", "]", "napari"] } napari-0.5.6/tools/strings_list.py000066400000000000000000000004121474413133200172310ustar00rootroot00000000000000import 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.6/tools/validate_strings.py000066400000000000000000000523171474413133200200620ustar00rootroot00000000000000""" 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 Run manually with $ pytest -Wignore tools/ --tb=short 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 Optional 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: Optional[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 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 # the prefix can be more than one letter, # like rf, rb... trailing_quote = tokstr[-1] start_quote_index = tokstr.find(trailing_quote) prefix = tokstr[:start_quote_index] suffix = tokstr[start_quote_index:] assert suffix[0] == suffix[-1] assert suffix[0] in ('"', "'") if 'b' in prefix: print( 'not translating bytestring', tokstr, file=sys.stderr ) continue # we remove the f as we do not want to evaluate the string # if it contains variable. IT will crash as it evaluate in # the context of this function. safe_tokstr = prefix.replace('f', '') + suffix acc.append(eval(safe_tokstr)) 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(list(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: module = importlib.import_module(module_name) except ModuleNotFoundError: 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) if module is None: raise RuntimeError(f'Error loading {fpath}') try: __all__strings = module.__all__ if __all__strings is None: __all__strings = [] 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{value!r}') 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' {value!r},') else: print( 'List below can be copied directly to `tools/strings_list.py` file:\n' ) print(f' {fpath!r}: [') for value in sorted(unique_values): print(f' {value!r},') 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{string!r}') 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' def print_colored_diff(old, new): lines = list(difflib.unified_diff(old.splitlines(), new.splitlines())) for line in lines[2:]: if line.startswith('-'): print(f'{RED}{line}{NORMAL}') elif line.startswith('+'): print(f'{GREEN}{line}{NORMAL}') else: print(line) def clear_screen(): print(chr(27) + '[2J') def _compute_autosugg(raw_code, text): raw_code[:] start = raw_code.find(f"'{text}'") if start == -1: start = raw_code.find(f'"{text}"') if start == -1: return None, False stop = start + len(text) + 2 rawt = raw_code[start:stop] sugg = raw_code[:start] + 'trans._(' + rawt + ')' + raw_code[stop:] if sugg[start - 1] == 'f': return None, False return sugg, True if __name__ == '__main__': issues, outdated_strings, trans_errors = _checks() import difflib import json import pathlib edit_cmd = sys.argv[1] if len(sys.argv) > 1 else 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 n_issues = sum([len(m) for m in issues.values()]) for file, missing in issues.items(): raw_code = Path(file).read_text() code = raw_code.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 sugg, autosugg = _compute_autosugg(raw_code, text) clear_screen() print( f'{RED}=== About {n_issues} items in {len(issues)} files to review ==={NORMAL}' ) print() print(f'{RED}{file}:{line}{NORMAL}', GREEN, repr(text), NORMAL) if autosugg: print_colored_diff(raw_code, sugg) else: print(f'{RED}f-string nedds manual intervention{NORMAL}') 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() print( f'{RED}i{NORMAL} : ignore - add to ignored localised strings' ) print(f'{RED}c{NORMAL} : continue - go to next') if autosugg: print(f'{RED}a{NORMAL} : Apply Auto suggestion') else: print('- : Auto suggestion not available here') if edit_cmd: print(f'{RED}e{NORMAL} : EDIT - using {edit_cmd!r}') else: print( "- : Edit not available, call with python tools/validate_strings.py '$COMMAND {filename} {linenumber} '" ) print(f'{RED}s{NORMAL} : save and quit') print('> ', end='') sys.stdout.flush() val = getch() if val == 'a' and autosugg: content = Path(file).read_text() new_content, _ = _compute_autosugg(content, text) Path(file).write_text(new_content) 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=4, sort_keys=True)) # test_outdated_string_skips(issues, outdated_strings, trans_errors) napari-0.5.6/tox.ini000066400000000000000000000124511474413133200143140ustar00rootroot00000000000000# 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) envlist = py{39,310,311,312,313}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6,headless,pyqt6_no_numba}-{cov,no_cov},mypy isolated_build = true toxworkdir={env:TOX_WORK_DIR:/tmp/.tox} [gh-actions] python = 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 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] RUNNER_OS = Linux: linux Windows: windows macOS: macos BACKEND = pyqt5: pyqt5 pyqt6: pyqt6 pyside2: pyside2 pyside6: pyside6 pyqt6_no_numba: pyqt6_no_numba headless: headless COVERAGE = cov: cov no_cov: no_cov # 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 FORCE_COLOR QT_QPA_PLATFORM NAPARI_TEST_SUBSET COVERAGE_* COVERAGE # Set various environment variables, depending on the factors in # the tox environment being run setenv = PYTHONPATH = {toxinidir} # Avoid pyside6 6.4.3 due to issue described in: # https://github.com/napari/napari/issues/5657 deps = pytest-json-report pytest-pystack ; sys_platform == 'linux' objgraph lxml_html_clean # use extras specified in pyproject.toml for certain test envs extras = testing {env:TOX_EXTRAS} pyqt5: pyqt5 pyside2: pyside2 pyqt6: pyqt6 pyqt6_no_numba: pyqt6 pyside6: pyside6_experimental allowlist_externals = echo mypy dot indexserver = # we use Spec 4 index server that contain nightly wheel. # this will be used only when using --pre with tox/pip as it only contains nightly. extra = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple commands = echo "COVERAGE: {env:COVERAGE:}" cov: coverage run --parallel-mode \ !cov: python \ -m pytest {env:PYTEST_PATH:} --color=yes --basetemp={envtmpdir} \ --ignore tools --maxfail=5 --json-report \ linux: --pystack-threshold=60 --pystack-args="--native-all" \ --json-report-file={toxinidir}/report-{envname}.json {posargs} \ --save-leaked-object-graph [testenv:py{39,310,311,312,313}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}-{cov,no_cov}] extras = {[testenv]extras} optional-numba [testenv:py{39,310,311,312,313}-{linux,macos,windows}-headless-{cov,no_cov}] commands_pre = pip uninstall -y pyautogui pytest-qt pyqt5 pyside2 pyside6 pyqt6 extras = {[testenv]extras} optional-numba commands = cov: coverage run --parallel-mode \ !cov: python \ -m pytest -v --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} \ --save-leaked-object-graph [testenv:py{39,310,311,312,313}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}-examples-{cov,no_cov}] deps = # For surface_timeseries_.py example nilearn # Due to nilearn incompatibility with numpy >=1.24 constraint numpy and add packaging for version check packaging commands = cov: coverage run --parallel-mode \ !cov: 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:ruff-format] skip_install = True deps = pre-commit commands = pre-commit run ruff-format --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/* [testenv:mypy] deps = -r resources/requirements_mypy.txt commands = mypy --config-file pyproject.toml --pretty --show-error-codes napari skip_install = true
    {keycodes}{keymap[key]}