././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.050885 photutils-1.13.0/0000755000175100001770000000000014637570322013245 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.flake80000644000175100001770000000005714637570305014423 0ustar00runnerdocker[flake8] max-line-length = 88 exclude = extern ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595217.9988852 photutils-1.13.0/.github/0000755000175100001770000000000014637570322014605 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/dependabot.yml0000644000175100001770000000105014637570305017432 0ustar00runnerdocker# Keep dependencies updated with Dependabot version updates # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" # checks files in .github/workflows schedule: interval: "weekly" ignore: - dependency-name: "actions/checkout" versions: ["1"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595217.9988852 photutils-1.13.0/.github/workflows/0000755000175100001770000000000014637570322016642 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/workflows/ci_cron_daily.yml0000644000175100001770000000436114637570305022170 0ustar00runnerdockername: Daily Cron Tests on: schedule: # run at 6am UTC on Tue-Fri (complete tests are run every Monday) - cron: '0 6 * * 2-5' pull_request: # We also want this workflow triggered if the 'Daily CI' label is added # or present when PR is updated types: - synchronize - labeled push: tags: - '*' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' permissions: contents: read jobs: tests: if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Daily CI'))) name: ${{ matrix.prefix }} ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: matrix: include: - os: ubuntu-latest python: '3.12' tox_env: 'linkcheck' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install base dependencies run: python -m pip install --upgrade pip setuptools tox - name: Print Python, pip, setuptools, and tox versions run: | python -c "import sys; print(f'Python {sys.version}')" python -c "import pip; print(f'pip {pip.__version__}')" python -c "import setuptools; print(f'setuptools {setuptools.__version__}')" python -c "import tox; print(f'tox {tox.__version__}')" - name: Run tests run: tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} - name: Upload coverage to codecov if: ${{ contains(matrix.tox_env, '-cov') }} uses: codecov/codecov-action@v4 with: files: ./coverage.xml verbose: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/workflows/ci_cron_weekly.yml0000644000175100001770000001050314637570305022361 0ustar00runnerdockername: Weekly Cron Tests on: schedule: # run every Monday at 5am UTC - cron: '0 5 * * 1' pull_request: # We also want this workflow triggered if the 'Weekly CI' label is added # or present when PR is updated types: - synchronize - labeled push: tags: - '*' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' IS_CRON: 'true' permissions: contents: read jobs: tests: if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Weekly CI'))) name: ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-alldeps-devinfra' allow_failure: false steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install base dependencies run: python -m pip install --upgrade pip setuptools tox - name: Print Python, pip, setuptools, and tox versions run: | python -c "import sys; print(f'Python {sys.version}')" python -c "import pip; print(f'pip {pip.__version__}')" python -c "import setuptools; print(f'setuptools {setuptools.__version__}')" python -c "import tox; print(f'tox {tox.__version__}')" - name: Run tests run: tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} test_more_architectures: # The following architectures are emulated and are therefore slow, so # we include them just in the weekly cron. These also serve as a test # of using system libraries and using pytest directly. runs-on: ubuntu-20.04 name: More architectures if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Arch CI'))) env: ARCH_ON_CI: ${{ matrix.arch }} strategy: fail-fast: false matrix: include: - arch: aarch64 - arch: s390x - arch: ppc64le - arch: armv7 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: uraimo/run-on-arch-action@v2 name: Run tests id: build with: arch: ${{ matrix.arch }} distro: ubuntu_rolling shell: /bin/bash env: | ARCH_ON_CI: ${{ env.ARCH_ON_CI }} IS_CRON: ${{ env.IS_CRON }} install: | apt-get update -q -y apt-get install -q -y --no-install-recommends \ git \ g++ \ pkg-config \ python3 \ python3-astropy \ python3-erfa \ python3-extension-helpers \ python3-numpy \ python3-pytest-astropy \ python3-setuptools-scm \ python3-scipy \ python3-skimage \ python3-sklearn \ python3-venv \ python3-wheel \ wcslib-dev run: | uname -a echo "LONG_BIT="$(getconf LONG_BIT) python3 -m venv --system-site-packages tests source tests/bin/activate # cython and pyerfa versions in ubuntu repos are too old currently pip install -U cython pip install -U --no-build-isolation pyerfa ASTROPY_USE_SYSTEM_ALL=1 pip3 install -v --no-build-isolation -e .[test] pip3 list python3 -m pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/workflows/ci_tests.yml0000644000175100001770000000665314637570305021215 0ustar00runnerdockername: CI Tests on: push: branches: - main tags: - '*' pull_request: schedule: # run every Monday at 6am UTC - cron: '0 6 * * 1' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' permissions: contents: read jobs: tests: name: ${{ matrix.prefix }} ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: matrix: include: - os: ubuntu-latest python: '3.10' tox_env: 'py310-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.11' tox_env: 'py311-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-alldeps-cov' toxposargs: --remote-data=any allow_failure: false prefix: '' - os: macos-latest python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: '' - os: macos-14 python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: 'M1' - os: windows-latest python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test' toxposargs: --remote-data=any allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'codestyle' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'pep517' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'bandit' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.10' tox_env: 'py310-test-oldestdeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install base dependencies run: python -m pip install --upgrade pip setuptools tox - name: Print Python, pip, setuptools, and tox versions run: | python -c "import sys; print(f'Python {sys.version}')" python -c "import pip; print(f'pip {pip.__version__}')" python -c "import setuptools; print(f'setuptools {setuptools.__version__}')" python -c "import tox; print(f'tox {tox.__version__}')" - name: Run tests run: tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} - name: Upload coverage to codecov if: ${{ contains(matrix.tox_env, '-cov') }} uses: codecov/codecov-action@v4 with: files: ./coverage.xml verbose: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/workflows/codeql.yml0000644000175100001770000000563414637570305020645 0ustar00runnerdocker# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "main" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main" ] schedule: - cron: '40 5 * * 4' permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.github/workflows/publish.yml0000644000175100001770000000375214637570305021043 0ustar00runnerdockername: Wheel building on: schedule: # run every Monday at 5am UTC - cron: '0 5 * * 1' pull_request: # We also want this workflow triggered if the 'Build all wheels' # label is added or present when PR is updated types: - synchronize - labeled push: branches: - '*' tags: - '*' - '!*dev*' - '!*pre*' - '!*post*' workflow_dispatch: permissions: contents: read jobs: build_and_publish: # This job builds the wheels and publishes them to PyPI for all # tags, except those ending in ".dev". For PRs with the "Build all # wheels" label, wheels are built, but are not uploaded to PyPI. permissions: contents: none uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@v1 if: (github.repository == 'astropy/photutils' && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Build all wheels'))) with: # We upload to PyPI for all tag pushes, except tags ending in .dev upload_to_pypi: ${{ startsWith(github.ref, 'refs/tags/') && !endsWith(github.ref, '.dev') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} test_extras: test test_command: pytest -p no:warnings --pyargs photutils targets: | # Linux wheels - cp310-manylinux_x86_64 - cp311-manylinux_x86_64 - cp312-manylinux_x86_64 # MacOS X wheels # Note that the arm64 wheels are not actually tested so we rely # on local manual testing of these to make sure they are ok. - cp310*macosx_x86_64 - cp311*macosx_x86_64 - cp312*macosx_x86_64 - cp310*macosx_arm64 - cp311*macosx_arm64 - cp312*macosx_arm64 # Windows wheels - cp310*win_amd64 - cp311*win_amd64 - cp312*win_amd64 secrets: pypi_token: ${{ secrets.pypi_token }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.gitignore0000644000175100001770000000130714637570305015237 0ustar00runnerdocker# Compiled files *.py[cod] *.a *.o *.so __pycache__ # Ignore .c files by default to avoid including generated code. If you want to # add a non-generated .c extension, use `git add -f filename.c`. *.c # Other generated files */version.py */cython_version.py MANIFEST htmlcov .coverage* .ipynb_checkpoints .pytest_cache # Sphinx docs/_build docs/api # Packages/installer info *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg distribute-*.tar.gz pip-wheel-metadata # Other .cache .tox .tmp .*.sw[op] *~ # Eclipse editor project files .project .pydevproject .settings # PyCharm editor project files .idea # Visual Studio Code project files .vscode # Mac OSX .DS_Store ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.pep8speaks.yml0000644000175100001770000000016014637570305016127 0ustar00runnerdockerscanner: linter: flake8 flake8: max-line-length: 100 exclude: - _astropy_init.py - version.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.pre-commit-config.yaml0000644000175100001770000000622214637570305017531 0ustar00runnerdockerci: autofix_prs: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-added-large-files # Prevent giant files from being committed. - id: check-ast # Simply check whether files parse as valid python. - id: check-case-conflict # Check for files with names that would conflict on a case-insensitive # filesystem like MacOS HFS+ or Windows FAT. - id: check-json # Attempts to load all json files to verify syntax. - id: check-merge-conflict # Check for files that contain merge conflict strings. - id: check-symlinks # Checks for symlinks which do not point to anything. - id: check-toml # Attempts to load all TOML files to verify syntax. - id: check-xml # Attempts to load all xml files to verify syntax. - id: check-yaml # Attempts to load all yaml files to verify syntax. - id: debug-statements # Check for debugger imports and py37+ breakpoint() calls in python # source. - id: detect-private-key # Checks for the existence of private keys. - id: double-quote-string-fixer # Replace double-quoted strings with single-quoted strings. - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. exclude: ".*(svg.*|extern.*)$" - id: trailing-whitespace # Trims trailing whitespace. exclude: ".*(data.*|extern.*)$" - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: python-check-mock-methods # Prevent common mistakes of assert mck.not_called(), assert # mck.called_once_with(...) and mck.assert_called. - id: rst-directive-colons # Detect mistake of rst directive not ending with double colon. - id: rst-inline-touching-normal # Detect mistake of inline code touching normal text in rst. - id: text-unicode-replacement-char # Forbid files which have a UTF-8 Unicode replacement character. - id: python-check-blanket-noqa # Enforce that all noqa annotations always occur with specific codes. - repo: https://github.com/asottile/pyupgrade rev: v3.15.2 hooks: - id: pyupgrade args: ["--py39-plus"] exclude: ".*(extern.*)$" - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort name: isort (python) additional_dependencies: [toml] - id: isort name: isort (cython) types: [cython] additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 rev: 7.0.0 hooks: - id: flake8 args: ["--ignore", "E501,W503"] - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: - id: codespell args: ["--write-changes", "--ignore-words-list", "exten, conver, fom"] additional_dependencies: - tomli # - repo: https://github.com/MarcoGorelli/absolufy-imports # rev: v0.3.1 # hooks: # - id: absolufy-imports ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.pycodestyle0000644000175100001770000000006414637570305015613 0ustar00runnerdocker[pycodestyle] max-line-length = 88 exclude = extern ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/.readthedocs.yaml0000644000175100001770000000070214637570305016474 0ustar00runnerdockerversion: 2 build: os: ubuntu-20.04 apt_packages: - graphviz tools: python: "3.11" jobs: post_checkout: - git fetch --shallow-since=2023-05-01 || true pre_install: - git update-index --assume-unchanged docs/conf.py sphinx: builder: html configuration: docs/conf.py fail_on_warning: true python: install: - method: pip path: . extra_requirements: - docs - all formats: [] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/CHANGES.rst0000644000175100001770000026153114637570305015060 0ustar00runnerdocker1.13.0 (2024-06-28) ------------------- General ^^^^^^^ - ``scikit-learn`` has been removed as an optional dependency. [#1774] New Features ^^^^^^^^^^^^ - ``photutils.datasets`` - Added a ``make_model_image`` function for generating simulated images with model sources. This function has more options and is significantly faster than the now-deprecated ``make_model_sources_image`` function. [#1759, #1790] - Added a ``make_model_params`` function to make a table of randomly generated model positions and fluxes for simulated sources. [#1766, #1796] - ``photutils.detection`` - The ``find_peaks`` function now supports input arrays with units. [#1743] - The ``Table`` returned from ``find_peaks`` now has an ``id`` column that contains unique integer IDs for each peak. [#1743] - The ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` classes now support input arrays with units. [#1746] - ``photutils.profiles`` - Added an ``unnormalize`` method to ``RadialProfile`` and ``CurveOfGrowth`` to return the profile to the state before any ``normalize`` calls were run. [#1732] - Added ``calc_ee_from_radius`` and ``calc_radius_from_ee`` methods to ``CurveOfGrowth``. [#1733] - ``photutils.psf`` - Added an ``include_localbkg`` keyword to the ``IterativePSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods. [#1756] - Added "x_fit", "xfit", "y_fit", "yfit", "flux_fit", and "fluxfit" as allowed column names in the ``init_params`` table input to the PSF photometry objects. [#1765] - Added a ``make_psf_model_image`` function to generate a simulated image from PSF models. [#1785, #1796] - ``PSFPhotometry`` now has a new ``fit_params`` attribute containing a table of the fit model parameters and errors. [#1789] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``init_params`` table now allows the user to input columns for model parameters other than x, y, and flux. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. [#1793] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue in ``ApertureStats`` where in very rare cases the ``covariance`` calculation could take a long time. [#1788] - ``photutils.background`` - No longer warn about NaNs in the data if those NaNs are masked in ``coverage_mask`` passed to ``Background2D``. [#1729] - ``photutils.psf`` - Fixed an issue where ``IterativePSFPhotometry`` would fail if the input data was a ``Quantity`` array. [#1746] - Fixed the ``IntegratedGaussianPRF`` class ``bounding_box`` limits to always be symmetric. [#1754] - Fixed an issue where ``IterativePSFPhotometry`` could sometimes issue a warning when merging tables if ``mode='all'``. [#1761] - Fixed a bug where the first matching column in the ``init_params`` table was not used in ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1765] - Fixed an issue where ``IterativePSFPhotometry`` could sometimes raise an error about non-overlapping data. [#1778] - Fixed an issue with unit handling in ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1792] - Fixed an issue in ``IterativePSFPhotometry`` where the ``fit_results`` attribute was not cleared between repeated calls. [#1793] - ``photutils.segmentation`` - Fixed an issue in ``SourceCatalog`` where in very rare cases the ``covariance`` calculation could take a long time. [#1788] API Changes ^^^^^^^^^^^ - The ``photutils.test`` function has been removed. Instead use the ``pytest --pyargs photutils`` command. [#1725] - ``photutils.datasets`` - The ``photutils.datasets`` subpackage has been reorganized and the ``make`` module has been deprecated. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. [#1726] - The ``make_model_sources_image`` function has been deprecated in favor of the new ``make_model_image`` function. The new function has more options and is significantly faster. [#1759] - The randomly-generated optional noise in the simulated example images ``make_4gaussians_image`` and ``make_100gaussians_image`` is now slightly different. The noise sigma is the same, but the pixel values differ. [#1760] - The ``make_gaussian_prf_sources_image`` function is now deprecated. Use the ``make_model_psf_image`` function or the new ``make_model_image`` function instead. [#1762] - The ``make_gaussian_sources_table`` function now includes an "id" column and always returns both ``'flux'`` and ``'amplitude'`` columns. [#1763] - The ``make_model_sources_table`` function now includes an "id" column. [#1764] - The ``make_gaussian_sources_table`` function is now deprecated. Use the ``make_model_sources_table`` function instead. [#1764] - The ``make_test_psf_data`` function is now deprecated. Use the new ``make_model_psf_image`` function instead. [#1785] - ``photutils.detection`` - The ``sky`` keyword in ``DAOStarFinder`` and ``IRAFStarFinder`` is now deprecated and will be removed in a future version. [#1747] - Sources that have non-finite properties (e.g., centroid, roundness, sharpness, etc.) are automatically excluded from the output table in ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``. [#1750] - ``photutils.psf`` - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise a ``ValueError`` if the input ``psf_model`` is not two-dimensional with ``n_inputs=2`` and ``n_outputs=1``. [#1741] - The ``IntegratedGaussianPRF`` class ``bounding_box`` is now a method instead of an attribute for consistency with Astropy models. The method has a ``factor`` keyword to scale the bounding box. The default scale factor is 5.5 times ``sigma``. [#1754] - The ``IterativePSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default. This is a backwards-incompatible change. If the previous behavior is desired, set ``include_localbkg=True``. [#1756] - ``IterativePSFPhotometry`` will now only issue warnings after all iterations are completed. [#1767] - The ``IterativePSFPhotometry`` ``psfphot`` attribute has been removed. Instead, use the ``fit_results`` attribute, which contains a list of ``PSFPhotometry`` instances for each fit iteration. [#1771] - The ``group_size`` column has been moved to come immediately after the ``group_id`` column in the output table from ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1772] - The ``PSFPhotometry`` ``init_params`` table was moved from the ``fit_results`` dictionary to an attribute. [#1773] - Removed ``local_bkg``, ``psfcenter_indices``, ``fit_residuals``, ``npixfit``, and ``nmodels`` keys from the ``PSFPhotometry`` ``fit_results`` dictionary. [#1773] - Removed the deprecated ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, ``DAOPhotPSFPhotometry``, ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase``, and ``NonNormalizable`` classes and the ``prepare_psf_model``, ``get_grouped_psf_model``, and ``subtract_psf`` functions. [#1774] - A ``ValueError`` is now raised if the shape of the ``error`` array does not match the ``data`` array when calling the PSF-fitting classes. [#1777] - The ``fit_param_errs`` key was removed from the ``PSFPhotometry`` ``fit_results`` dictionary. The fit parameter errors are now stored in the ``fit_params`` table. [#1789] - The ``cfit`` column in the ``PSFPhotometry`` and ``IterativePSFPhotometry`` result table will now be NaN for sources whose initial central pixel is masked. [#1789] 1.12.0 (2024-04-12) ------------------- General ^^^^^^^ - The minimum required Python is now 3.10. [#1719] - The minimum required NumPy is now 1.23. [#1719] - The minimum required SciPy is now 1.8. [#1719] - The minimum required scikit-image is now 0.20. [#1719] - The minimum required scikit-learn is now 1.1. [#1719] - The minimum required pytest-astropy is now 0.11. [#1719] - The minimum required sphinx-astropy is now 1.9. [#1719] - NumPy 2.0 is supported. Bug Fixes ^^^^^^^^^ - ``photutils.background`` - No longer warn about NaNs in the data if those NaNs are masked in ``mask`` passed to ``Background2D``. [#1712] API Changes ^^^^^^^^^^^ - ``photutils.utils`` - The default value for the ``ImageDepth`` ``mask_pad`` keyword is now set to 0. [#1714] 1.11.0 (2024-02-16) ------------------- New Features ^^^^^^^^^^^^ - ``photutils.psf`` - An ``init_params`` table is now included in the ``PSFPhotometry`` ``fit_results`` dictionary. [#1681] - Added an ``include_localbkg`` keyword to the ``PSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods. [#1691] - Significantly reduced the memory usage of PSF photometry when using a ``GriddedPSFModel`` PSF model. [#1679] - Added a ``mode`` keyword to ``IterativePSFPhotometry`` for controlling the fitting mode. [#1708] - ``photutils.datasets`` - Improved the performance of ``make_test_psf_data`` when generating random coordinates with a minimum separation. [#1668] - ``photutils.segmentation`` - The ``SourceFinder`` ``npixels`` keyword can now be a tuple corresponding to the values used for the source finder and source deblender, respectively. [#1688] - ``photutils.utils`` - Improved the performance of ``ImageDepth`` when generating random coordinates with a minimum separation. [#1668] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed an issue where PSF models produced by ``make_psf_model`` would raise an error with ``PSFPhotometry`` if the fit did not converge. [#1672] - Fixed an issue where ``GriddedPSFModel`` fixed model parameters were not respected when copying the model or fitting with the PSF photometry classes. [#1679] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - ``PixelAperture`` instances now raise an informative error message when ``positions`` is input as a ``zip`` object containing Astropy ``Quantity`` objects. [#1682] - ``photutils.psf`` - The ``GridddedPSFModel`` string representations now include the model ``flux``, ``x_0``, and ``y_0`` parameters. [#1680] - The ``PSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default. This is a backwards-incompatible change. If the previous behavior is desired, set ``include_localbkg=True``. [#1703] - The PSF photometry ``finder_results`` attribute is now returned as a ``QTable`` instead of a list of ``QTable``. [#1704] - Deprecated the ``NonNormalizable`` custom warning class in favor of ``AstropyUserWarning``. [#1710] - ``photutils.segmentation`` - The ``SourceCatalog`` ``get_label`` and ``get_labels`` methods now raise a ``ValueError`` if any of the input labels are invalid. [#1694] 1.10.0 (2023-11-21) ------------------- General ^^^^^^^ - The minimum required Astropy is now 5.1. [#1627] New Features ^^^^^^^^^^^^ - ``photutils.datasets`` - Added a ``border_size`` keyword to ``make_test_psf_data``. [#1665] - Improved the generation of random PSF positions in ``make_test_psf_data``. [#1665] - ``photutils.detection`` - Added a ``min_separation`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder``. [#1663] - ``photutils.morphology`` - Added a ``wcs`` keyword to ``data_properties``. [#1648] - ``photutils.psf`` - The ``GriddedPSFModel`` ``plot_grid`` method now returns a ``matplotlib.figure.Figure`` object. [#1653] - Added the ability for the ``GriddedPSFModel`` ``read`` method to read FITS files generated by WebbPSF. [#1654] - Added "flux_0" and "flux0" as allowed flux column names in the ``init_params`` table input to the PSF photometry objects. [#1656] - PSF models output from ``prepare_psf_model`` can now be input into the PSF photometry classes. [#1657] - Added ``make_psf_model`` function for making a PSF model from a 2D Astropy model. Compound models are also supported. [#1658] - The ``GriddedPSFModel`` oversampling can now be different in the x and y directions. The ``oversampling`` attribute is now stored as a 1D ``numpy.ndarray`` with two elements. [#1664] - ``photutils.segmentation`` - The ``SegmentationImage`` ``make_source_mask`` method now uses a much faster implementation of binary dilation. [#1638] - Added a ``scale`` keyword to the ``SegmentationImage.to_patches()`` method to scale the sizes of the polygon patches. [#1641, #1646] - Improved the ``SegmentationImage`` ``imshow`` method to ensure that labels are plotted with unique colors. [#1649] - Added a ``imshow_map`` method to ``SegmentationImage`` for plotting segmentation images with a small number of non-consecutive labels. [#1649] - Added a ``reset_cmap`` method to ``SegmentationImage`` for resetting the colormap to a new random colormap. [#1649] - ``photutils.utils`` - Improved the generation of random aperture positions in ``ImageDepth``. [#1666] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where the aperture ``plot`` method ``**kwargs`` were not reset to the default values when called multiple times. [#1655] - ``photutils.psf`` - Fixed a bug where ``SourceGrouper`` would fail if only one source was input. [#1617] - Fixed a bug in ``GriddedPSFModel`` ``plot_grid`` where the grid could be plotted incorrectly if the input ``xygrid`` was not sorted in y then x order. [#1661] - ``photutils.segmentation`` - Fixed an issue where ``deblend_sources`` and ``SourceFinder`` would raise an error if the ``contrast`` keyword was set to 1 (meaning no deblending). [#1636] - Fixed an issue where the vertices of the ``SegmentationImage`` ``polygons`` were shifted by 0.5 pixels in both x and y. [#1646] API Changes ^^^^^^^^^^^ - The metadata in output tables now contains a timestamp. [#1640] - The order of the metadata in a table is now preserved when writing to a file. [#1640] - ``photutils.psf`` - Deprecated the ``prepare_psf_model`` function. Use the new ``make_psf_model`` function instead. [#1658] - The ``GriddedPSFModel`` now stores the ePSF grid such that it is first sorted by y then by x. As a result, the order of the ``data`` and ``xygrid`` attributes may be different. [#1661] - The ``oversampling`` attribute is now stored as a 1D ``numpy.ndarray`` with two elements. [#1664] - A ``ValueError`` is raised if ``GriddedPSFModel`` is called with x and y arrays that have more than 2 dimensions. [#1662] - ``photutils.segmentation`` - Removed the deprecated ``kernel`` keyword from ``SourceCatalog``. [#1613] 1.9.0 (2023-08-14) ------------------ General ^^^^^^^ - The minimum required Python is now 3.9. [#1569] - The minimum required NumPy is now 1.22. [#1572] New Features ^^^^^^^^^^^^ - ``photutils.background`` - Added ``LocalBackground`` class for computing local backgrounds in a circular annulus aperture. [#1556] - ``photutils.datasets`` - Added new ``make_test_psf_data`` function. [#1558, #1582, #1585] - ``photutils.psf`` - Propagate measurement uncertainties in PSF fitting. [#1543] - Added new ``PSFPhotometry`` and ``IterativePSFPhotometry`` classes for performing PSF-fitting photometry. [#1558, #1559, #1563, #1566, #1567, #1581, #1586, #1590, #1594, #1603, #1604] - Added a new ``SourceGrouper`` class. [#1558, #1605] - Added a ``GriddedPSFModel`` ``fill_value`` attribute. [#1583] - Added a ``grid_from_epsfs`` function to make a ``GriddedPSFModel`` from ePSFs. [#1596] - Added a ``read`` method to ``GriddedPSFModel`` for reading "STDPSF" FITS files containing grids of ePSF models. [#1557] - Added a ``plot_grid`` method to ``GriddedPSFModel`` for plotting ePSF grids. [#1557] - Added a ``STDPSFGrid`` class for reading "STDPSF" FITS files containing grids of ePSF models and plotting the ePSF grids. [#1557] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in the validation of ``PixelAperture`` positions. [#1553] API Changes ^^^^^^^^^^^ - ``photutils.psf`` - Deprecated the PSF photometry classes ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry``. Use the new ``PSFPhotometry`` or ``IterativePSFPhotometry`` class instead. [#1578] - Deprecated the ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase`` classes. Use the new ``SourceGrouper`` class instead. [#1578] - Deprecated the ``get_grouped_psf_model`` and ``subtract_psf`` function. [#1578] 1.8.0 (2023-05-17) ------------------ General ^^^^^^^ - The minimum required Numpy is now 1.21. [#1528] - The minimum required Scipy is now 1.7.0. [#1528] - The minimum required Matplotlib is now 3.5.0. [#1528] - The minimum required scikit-image is now 0.19.0. [#1528] - The minimum required gwcs is now 0.18. [#1528] New Features ^^^^^^^^^^^^ - ``photutils.profiles`` - The ``RadialProfile`` and ``CurveOfGrowth`` radial bins can now be directly input, which also allows for non-uniform radial spacing. [#1540] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed an issue with the local model cache in ``GriddedPSFModel``, significantly improving performance. [#1536] API Changes ^^^^^^^^^^^ - Removed the deprecated ``axes`` keyword in favor of ``ax`` for consistency with other packages. [#1523] - ``photutils.aperture`` - Removed the ``ApertureStats`` ``unpack_nddata`` method. [#1537] - ``photutils.profiles`` - The API for defining the radial bins for the ``RadialProfile`` and ``CurveOfGrowth`` classes was changed. While the new API allows for more flexibility, unfortunately, it is not backwards-compatible. [#1540] - ``photutils.segmentation`` - Removed the deprecated ``kernel`` keyword from ``detect_sources`` and ``deblend_sources``. [#1524] - Deprecated the ``kernel`` keyword in ``SourceCatalog``. [#1525] - Removed the deprecated ``outline_segments`` method from ``SegmentationImage``. [#1526] - The ``SourceCatalog`` ``kron_params`` attribute is no longer returned as a ``ndarray``. It is returned as a ``tuple``. [#1531] 1.7.0 (2023-04-05) ------------------ General ^^^^^^^ - The ``rasterio`` and ``shapely`` packages are now optional dependencies. [#1509] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Significantly improved the performance of ``aperture_photometry`` and the ``PixelAperture`` ``do_photometry`` method for large arrays. [#1485] - Significantly improved the performance of the ``PixelAperture`` ``area_overlap`` method, especially for large arrays. [#1490] - ``photutils.profiles`` - Added a new ``profiles`` subpackage containing ``RadialProfile`` and ``CurveOfGrowth`` classes. [#1494, #1496, #1498, #1499] - ``photutils.psf`` - Significantly improved the performance of evaluating and fitting ``GriddedPSFModel`` instances. [#1503] - ``photutils.segmentation`` - Added a ``size`` keyword to the ``SegmentationImage`` ``make_source_mask`` method. [#1506] - Significantly improved the performance of ``SegmentationImage`` ``make_source_mask`` when using square footprints for source dilation. [#1506] - Added the ``polygons`` property and ``to_patches`` and ``plot_patches`` methods to ``SegmentationImage``. [#1509] - Added ``polygon`` keyword to the ``Segment`` class. [#1509] Bug Fixes ^^^^^^^^^ - ``photutils.centroids`` - Fixed an issue where ``centroid_quadratic`` would sometimes fail if the input data contained NaNs. [#1495] - ``photutils.detection`` - Fixed an issue with the starfinders (``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``) where an exception was raised if ``exclude_border=True`` and there were no detections. [#1512]. - ``photutils.isophote`` - Fixed a bug where the upper harmonics (a3, a4, b3, and b4) had the incorrect sign. [#1501] - Fixed a bug in the calculation of the upper harmonic errors (a3_err, a4_err, b3_err, and b4_err). [#1501]. - ``photutils.psf`` - Fixed an issue where the PSF-photometry progress bar was not shown. [#1517] - Fixed an issue where all PSF uncertainties were excluded if the last star group had no covariance matrix. [#1519] - ``photutils.utils`` - Fixed a bug in the calculation of ``ImageCutout`` ``xyorigin`` when using the ``'partial'`` mode when the cutout extended beyond the right or top edge. [#1508] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureStats`` ``local_bkg`` keyword can now be broadcast for apertures with multiple positions. [#1504] - ``photutils.centroids`` - The ``centroid_sources`` function will now raise an error if the cutout mask contains all ``True`` values. [#1516] - ``photutils.datasets`` - Removed the deprecated ``load_fermi_image`` function. [#1479] - ``photutils.psf`` - Removed the deprecated ``sandbox`` classes ``DiscretePRF`` and ``Reproject``. [#1479] - ``photutils.segmentation`` - Removed the deprecated ``make_source_mask`` function in favor of the ``SegmentationImage.make_source_mask`` method. [#1479] - The ``SegmentationImage`` ``imshow`` method now uses "nearest" interpolation instead of "none" to avoid rendering issues with some backends. [#1507] - The ``repr()`` notebook output for the ``Segment`` class now includes a SVG polygon representation of the segment if the ``rasterio`` and ``shapely`` packages are installed. [#1509] - Deprecated the ``SegmentationImage`` ``outline_segments`` method. Use the ``plot_patches`` method instead. [#1509] 1.6.0 (2022-12-09) ------------------ General ^^^^^^^ - Following NEP 29, the minimum required Numpy is now 1.20. [#1442] - The minimum required Matplotlib is now 3.3.0. [#1442] - The minimum required scikit-image is now 0.18.0. [#1442] - The minimum required scikit-learn is now 1.0. [#1442] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureStats`` class now accepts astropy ``NDData`` objects as input. [#1409] - Improved the performance of aperture photometry by 10-25% (depending on the number of aperture positions). [#1438] - ``photutils.psf`` - Added a progress bar for fitting PSF photometry [#1426] - Added a ``subshape`` keyword to the PSF-fitting classes to define the shape over which the PSF is subtracted. [#1477] - ``photutils.segmentation`` - Added the ability to slice ``SegmentationImage`` objects. [#1413] - Added ``mode`` and ``fill_value`` keywords to ``SourceCatalog`` ``make_cutouts`` method. [#1420] - Added ``segment_area`` source property and ``wcs``, ``localbkg_width``, ``apermask_method``, and ``kron_params`` attributes to ``SourceCatalog``. [#1425] - Added the ability to use ``Quantity`` arrays with ``detect_threshold``, ``detect_sources``, ``deblend_sources``, and ``SourceFinder``. [#1436] - The progress bar used when deblending sources now is prepended with "Deblending". [#1439] - Added "windowed" centroids to ``SourceCatalog``. [#1447, #1468] - Added quadratic centroids to ``SourceCatalog``. [#1467, #1469] - Added a ``progress_bar`` option to ``SourceCatalog`` for displaying progress bars when calculating some source properties. [#1471] - ``photutils.utils`` - Added ``xyorigin`` attribute to ``CutoutImage``. [#1419] - Added ``ImageDepth`` class. [#1434] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in the ``PixelAperture`` ``area_overlap`` method so that the returned value does not inherit the data units. [#1408] - Fixed an issue in ``ApertureStats`` ``get_ids`` for the case when the ID numbers are not sorted (due to slicing). [#1423] - ``photutils.datasets`` - Fixed a bug in the various ``load`` functions where FITS files were not closed. [#1455] - ``photutils.segmentation`` - Fixed an issue in the ``SourceCatalog`` ``kron_photometry``, ``make_kron_apertures``, and ``plot_kron_apertures`` methods where the input minimum Kron and circular radii would not be applied. Instead the instance-level minima would always be used. [#1421] - Fixed an issue where the ``SourceCatalog`` ``plot_kron_apertures`` method would raise an error for a scalar ``SourceCatalog``. [#1421] - Fixed an issue in ``SourceCatalog`` ``get_labels`` for the case when the labels are not sorted (due to slicing). [#1423] API Changes ^^^^^^^^^^^ - Deprecated ``axes`` keyword in favor of ``ax`` for consistency with other packages. [#1432] - Importing tools from all subpackages now requires including the subpackage name. - ``photutils.aperture`` - Inputting ``PixelAperture`` positions as an Astropy ``Quantity`` in pixel units is no longer allowed. [#1398] - Inputting ``SkyAperture`` shape parameters as an Astropy ``Quantity`` in pixel units is no longer allowed. [#1398] - Removed the deprecated ``BoundingBox`` ``as_patch`` method. [#1462] - ``photutils.centroids`` - Removed the deprecated ``oversampling`` keyword in ``centroid_com``. [#1398] - ``photutils.datasets`` - Deprecated the ``load_fermi_image`` function. [#1455] - ``photutils.psf`` - Removed the deprecated ``flux_residual_sigclip`` keyword in ``EPSFBuilder``. Use ``sigma_clip`` instead. [#1398] - PSF photometry classes will no longer emit a RuntimeWarning if the fitted parameter variance is negative. [#1458] - ``photutils.segmentation`` - Removed the deprecated ``sigclip_sigma`` and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``sigma_clip`` keyword instead. [#1398] - Removed the ``mask_value``, ``sigclip_sigma``, and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``mask`` or ``sigma_clip`` keywords instead. [#1398] - Removed the deprecated the ``filter_fwhm`` and ``filter_size`` keywords in ``make_source_mask``. Use the ``kernel`` keyword instead. [#1398] - If ``detection_cat`` is input to ``SourceCatalog``, then the detection catalog source centroids and morphological/shape properties will be returned instead of calculating them from the input data. Also, if ``detection_cat`` is input, then the input ``wcs``, ``apermask_method``, and ``kron_params`` keywords will be ignored. [#1425] 1.5.0 (2022-07-12) ------------------ General ^^^^^^^ - Added ``tqdm`` as an optional dependency. [#1364] New Features ^^^^^^^^^^^^ - ``photutils.psf`` - Added a ``mask`` keyword when calling the PSF-fitting classes. [#1350, #1351] - The ``EPSFBuilder`` progress bar will use ``tqdm`` if the optional package is installed. [#1367] - ``photutils.segmentation`` - Added ``SourceFinder`` class, which is a convenience class combining ``detect_sources`` and ``deblend_sources``. [#1344] - Added a ``sigma_clip`` keyword to ``detect_threshold``. [#1354] - Added a ``make_source_mask`` method to ``SegmentationImage``. [#1355] - Added a ``make_2dgaussian_kernel`` convenience function. [#1356] - Allow ``SegmentationImage.make_cmap`` ``background_color`` to be in any matplotlib color format. [#1361] - Added an ``imshow`` convenience method to ``SegmentationImage``. [#1362] - Improved performance of ``deblend_sources``. [#1364] - Added a ``progress_bar`` keyword to ``deblend_sources``. [#1364] - Added a ``'sinh'`` mode to ``deblend_sources``. [#1368] - Improved the resetting of cached ``SegmentationImage`` properties so that custom (non-cached) attributes can be kept. [#1368] - Added a ``nproc`` keyword to enable multiprocessing in ``deblend_sources`` and ``SourceFinder``. [#1372] - Added a ``make_cutouts`` method to ``SourceCatalog`` for making custom-shaped cutout images. [#1376] - Added the ability to set a minimum unscaled Kron radius in ``SourceCatalog``. [#1381] - ``photutils.utils`` - Added a ``circular_footprint`` convenience function. [#1355] - Added a ``CutoutImage`` class. [#1376] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed a warning message in ``EPSFFitter``. [#1382] - ``photutils.segmentation`` - Fixed an issue in generating watershed markers used for source deblending. [#1383] API Changes ^^^^^^^^^^^ - ``photutils.centroids`` - Changed the axes order of ``oversampling`` keyword in ``centroid_com`` when input as a tuple. [#1358] - Deprecated the ``oversampling`` keyword in ``centroid_com``. [#1377] - ``photutils.psf`` - Invalid data values (i.e., NaN or inf) are now automatically masked when performing PSF fitting. [#1350] - Deprecated the ``sandbox`` classes ``DiscretePRF`` and ``Reproject``. [#1357] - Changed the axes order of ``oversampling`` keywords when input as a tuple. [#1358] - Removed the unused ``shift_val`` keyword in ``EPSFBuilder`` and ``EPSFModel``. [#1377] - Renamed the ``flux_residual_sigclip`` keyword (now deprecated) to ``sigma_clip`` in ``EPSFBuilder``. [#1378] - The ``EPSFBuilder`` progress bar now requires that the optional ``tqdm`` package be installed. [#1379] - The tools in the PSF package now require keyword-only arguments. [#1386] - ``photutils.segmentation`` - Removed the deprecated ``circular_aperture`` method from ``SourceCatalog``. [#1329] - The ``SourceCatalog`` ``plot_kron_apertures`` method now sets a default ``kron_apers`` value. [#1346] - ``deblend_sources`` no longer allows an array to be input as a segmentation image. It must be a ``SegmentationImage`` object. [#1347] - ``SegmentationImage`` no longer allows array-like input. It must be a numpy ``ndarray``. [#1347] - Deprecated the ``sigclip_sigma`` and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``sigma_clip`` keyword instead. [#1354] - Deprecated the ``make_source_mask`` function in favor of the ``SegmentationImage.make_source_mask`` method. [#1355] - Deprecated the ``kernel`` keyword in ``detect_sources`` and ``deblend_sources``. Instead, if filtering is desired, input a convolved image directly into the ``data`` parameter. [#1365] - Sources with a data minimum of zero are now treated the same as negative minima (i.e., the mode is changed to "linear") for the "exponential" deblending mode. [#1368] - A single warning (as opposed to 1 per source) is now raised about negative/zero minimum data values using the 'exponential' deblending mode. The affected labels is available in a new "info" attribute. [#1368] - If the mode in ``deblend_sources`` is "exponential" or "sinh" and there are too many potential deblended sources within a given source (watershed markers), a warning will be raised and the mode will be changed to "linear". [#1369] - The ``SourceCatalog`` ``make_circular_apertures`` and ``make_kron_apertures`` methods now return a single aperture (instead of a list with one item) for a scalar ``SourceCatalog``. [#1376] - The ``SourceCatalog`` ``kron_params`` keyword now has an optional third item representing the minimum circular radius. [#1381] - The ``SourceCatalog`` ``kron_radius`` is now set to the minimum Kron radius (the second element of ``kron_params``) if the data or radially weighted data sum to zero. [#1381] - ``photutils.utils`` - The colormap returned from ``make_random_cmap`` now has colors in RGBA format. [#1361] 1.4.0 (2022-03-25) ------------------ General ^^^^^^^ - The minimum required Python is now 3.8. [#1279] - The minimum required Numpy is now 1.18. [#1279] - The minimum required Astropy is now 5.0. [#1279] - The minimum required Matplotlib is now 3.1. [#1279] - The minimum required scikit-image is now 0.15.0 [#1279] - The minimum required gwcs is now 0.16.0 [#1279] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added a ``copy`` method to ``Aperture`` objects. [#1304] - Added the ability to compare ``Aperture`` objects for equality. [#1304] - The ``theta`` keyword for ``EllipticalAperture``, ``EllipticalAnnulus``, ``RectangularAperture``, and ``RectangularEllipse`` can now be an Astropy ``Angle`` or ``Quantity`` in angular units. [#1308] - Added an ``ApertureStats`` class for computing statistics of unmasked pixels within an aperture. [#1309, #1314, #1315, #1318] - Added a ``dtype`` keyword to the ``ApertureMask`` ``to_image`` method. [#1320] - ``photutils.background`` - Added an ``alpha`` keyword to the ``Background2D.plot_meshes`` method. [#1286] - Added a ``clip`` keyword to the ``BkgZoomInterpolator`` class. [#1324] - ``photutils.segmentation`` - Added ``SegmentationImage`` ``cmap`` attribute containing a default colormap. [#1319] - Improved the performance of ``SegmentationImage`` and ``SourceCatalog``, especially for large data arrays. [#1320] - Added a ``convolved_data`` keyword to ``SourceCatalog``. This is recommended instead of using the ``kernel`` keyword. [#1321] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in ``aperture_photometry`` where an error was not raised if the data and error arrays have different units. [#1285]. - ``photutils.background`` - Fixed a bug in ``Background2D`` where using the ``pad`` edge method would result in incorrect image padding if only one of the axes needed padding. [#1292] - ``photutils.centroids`` - Fixed a bug in ``centroid_sources`` where setting ``error``, ``xpeak``, or ``ypeak`` to ``None`` would result in an error. [#1297] - Fixed a bug in ``centroid_quadratic`` where inputting a mask would alter the input data array. [#1317] - ``photutils.segmentation`` - Fixed a bug in ``SourceCatalog`` where a ``UFuncTypeError`` would be raised if the input ``data`` had an integer ``dtype`` [#1312]. API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - A ``ValueError`` is now raised if non-positive sizes are input to sky-based apertures. [#1295] - The ``BoundingBox.plot()`` method now returns a ``matplotlib.patches.Patch`` object. [#1305] - Inputting ``PixelAperture`` positions as an Astropy ``Quantity`` in pixel units is deprecated. [#1310] - Inputting ``SkyAperture`` shape parameters as an Astropy ``Quantity`` in pixel units is deprecated. [#1310] - ``photutils.background`` - Removed the deprecated ``background_mesh_ma`` and ``background_rms_mesh_ma`` ``Background2D`` properties. [#1280] - By default, ``BkgZoomInterpolator`` uses ``clip=True`` to prevent the interpolation from producing values outside the given input range. If backwards-compatibility is needed with older Photutils versions, set ``clip=False``. [#1324] - ``photutils.centroids`` - Removed the deprecated ``centroid_epsf`` and ``gaussian1d_moments`` functions. [#1280] - Importing tools from the centroids subpackage now requires including the subpackage name. [#1280] - ``photutils.morphology`` - Importing tools from the morphology subpackage now requires including the subpackage name. [#1280] - ``photutils.segmentation`` - Removed the deprecated ``source_properties`` function and the ``SourceProperties`` and ``LegacySourceCatalog`` classes. [#1280] - Removed the deprecated the ``filter_kernel`` keyword in the ``detect_sources``, ``deblend_sources``, and ``make_source_mask`` functions. [#1280] - A ``TypeError`` is raised if the input array to ``SegmentationImage`` does not have integer type. [#1319] - A ``SegmentationImage`` may contain an array of all zeros. [#1319] - Deprecated the ``mask_value`` keyword in ``detect_threshold``. Use the ``mask`` keyword instead. [#1322] - Deprecated the ``filter_fwhm`` and ``filter_size`` keywords in ``make_source_mask``. Use the ``kernel`` keyword instead. [#1322] 1.3.0 (2021-12-21) ------------------ General ^^^^^^^ - The metadata in output tables now contains version information for all dependencies. [#1274] New Features ^^^^^^^^^^^^ - ``photutils.centroids`` - Extra keyword arguments can be input to ``centroid_sources`` that are then passed on to the ``centroid_func`` if supported. [#1276, #1278] - ``photutils.segmentation`` - Added ``copy`` method to ``SourceCatalog``. [#1264] - Added ``kron_photometry`` method to ``SourceCatalog``. [#1264] - Added ``add_extra_property``, ``remove_extra_property``, ``remove_extra_properties``, and ``rename_extra_property`` methods and ``extra_properties`` attribute to ``SourceCatalog``. [#1264, #1268] - Added ``name`` and ``overwrite`` keywords to ``SourceCatalog`` ``circular_photometry`` and ``fluxfrac_radius`` methods. [#1264] - ``SourceCatalog`` ``fluxfrac_radius`` was improved for cases where the source flux doesn't monotonically increase with increasing radius. [#1264] - Added ``meta`` and ``properties`` attributes to ``SourceCatalog``. [#1268] - The ``SourceCatalog`` output table (using ``to_table``) ``meta`` dictionary now includes a field for the date/time. [#1268] - Added ``SourceCatalog`` ``make_kron_apertures`` method. [#1268] - Added ``SourceCatalog`` ``plot_circular_apertures`` and ``plot_kron_apertures`` methods. [#1268] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - If ``detection_catalog`` is input to ``SourceCatalog`` then the detection centroids are used to calculate the ``circular_aperture``, ``circular_photometry``, and ``fluxfrac_radius``. [#1264] - Units are applied to ``SourceCatalog`` ``circular_photometry`` output if the input data has units. [#1264] - ``SourceCatalog`` ``circular_photometry`` returns scalar values if catalog is scalar. [#1264] - ``SourceCatalog`` ``fluxfrac_radius`` returns a ``Quantity`` with pixel units. [#1264] - Fixed a bug where the ``SourceCatalog`` ``detection_catalog`` was not indexed/sliced when ``SourceCatalog`` was indexed/sliced. [#1268] - ``SourceCatalog`` ``circular_photometry`` now returns NaN for completely-masked sources. [#1268] - ``SourceCatalog`` ``kron_flux`` is always NaN for sources where ``kron_radius`` is NaN. [#1268] - ``SourceCatalog`` ``fluxfrac_radius`` now returns NaN if ``kron_flux`` is zero. [#1268] API Changes ^^^^^^^^^^^ - ``photutils.centroids`` - A ``ValueError`` is now raised in ``centroid_sources`` if the input ``xpos`` or ``ypos`` is outside of the input ``data``. [#1276] - A ``ValueError`` is now raised in ``centroid_quadratic`` if the input ``xpeak`` or ``ypeak`` is outside of the input ``data``. [#1276] - NaNs are now returned from ``centroid_sources`` where the centroid failed. This is usually due to a ``box_size`` that is too small when using a fitting-based centroid function. [#1276] - ``photutils.segmentation`` - Renamed the ``SourceCatalog`` ``circular_aperture`` method to ``make_circular_apertures``. The old name is deprecated. [#1268] - The ``SourceCatalog`` ``kron_params`` keyword must have a minimum circular radius that is greater than zero. The default value is now 1.0. [#1268] - ``detect_sources`` now uses ``astropy.convolution.convolve``, which allows for masking pixels. [#1269] 1.2.0 (2021-09-23) ------------------ General ^^^^^^^ - The minimum required scipy version is 1.6.0 [#1239] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added a ``mask`` keyword to the ``area_overlap`` method. [#1241] - ``photutils.background`` - Improved the performance of ``Background2D`` by up to 10-50% when the optional ``bottleneck`` package is installed. [#1232] - Added a ``masked`` keyword to the background classes ``MeanBackground``, ``MedianBackground``, ``ModeEstimatorBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightScaleBackgroundRMS``. [#1232] - Enable all background classes to work with ``Quantity`` inputs. [#1233] - Added a ``markersize`` keyword to the ``Background2D`` method ``plot_meshes``. [#1234] - Added ``__repr__`` methods to all background classes. [#1236] - Added a ``grid_mode`` keyword to ``BkgZoomInterpolator``. [#1239] - ``photutils.detection`` - Added a ``xycoords`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder``. [#1248] - ``photutils.psf`` - Enabled the reuse of an output table from ``BasicPSFPhotometry`` and its subclasses as an initial guess for another photometry run. [#1251] - Added the ability to skip the ``group_maker`` step by inputing an initial guess table with a ``group_id`` column. [#1251] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug when converting between pixel and sky apertures with a ``gwcs`` object. [#1221] - ``photutils.background`` - Fixed an issue where ``Background2D`` could fail when using the ``'pad'`` edge method. [#1227] - ``photutils.detection`` - Fixed the ``DAOStarFinder`` import deprecation message. [#1195] - ``photutils.morphology`` - Fixed an issue in ``data_properties`` where a scalar background input would raise an error. [#1198] - ``photutils.psf`` - Fixed an issue in ``prepare_psf_model`` when ``xname`` or ``yname`` was ``None`` where the model offsets were applied in the wrong direction, resulting in the initial photometry guesses not being improved by the fit. [#1199] - ``photutils.segmentation`` - Fixed an issue in ``SourceCatalog`` where the user-input ``mask`` was ignored when ``apermask_method='correct'`` for Kron-related calculations. [#1210] - Fixed an issue in ``SourceCatalog`` where the ``segment`` array could incorrectly have units. [#1220] - ``photutils.utils`` - Fixed an issue in ``ShepardIDWInterpolator`` to allow its initialization with scalar data values and coordinate arrays having more than one dimension. [#1226] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureMask.get_values()`` function now returns an empty array if there is no overlap with the data. [#1212] - Removed the deprecated ``BoundingBox.slices`` and ``PixelAperture.bounding_boxes`` attributes. [#1215] - ``photutils.background`` - Invalid data values (i.e., NaN or inf) are now automatically masked in ``Background2D``. [#1232] - The background classes ``MeanBackground``, ``MedianBackground``, ``ModeEstimatorBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightScaleBackgroundRMS`` now return by default a ``numpy.ndarray`` with ``np.nan`` values representing masked pixels instead of a masked array. A masked array can be returned by setting ``masked=True``. [#1232] - Deprecated the ``Background2D`` attributes ``background_mesh_ma`` and ``background_rms_mesh_ma``. They have been renamed to ``background_mesh_masked`` and ``background_rms_mesh_masked``. [#1232] - By default, ``BkgZoomInterpolator`` now uses ``grid_mode=True``. For zooming 2D images, this keyword should be set to True, which makes the interpolator's behavior consistent with ``scipy.ndimage.map_coordinates``, ``skimage.transform.resize``, and ``OpenCV (cv2.resize)``. If backwards-compatibility is needed with older Photutils versions, set ``grid_mode=False``. [#1239] - ``photutils.centroids`` - Deprecated the ``gaussian1d_moments`` and ``centroid_epsf`` functions. [#1240] - ``photutils.datasets`` - Removed the deprecated ``random_state`` keyword in the ``apply_poisson_noise``, ``make_noise_image``, ``make_random_models_table``, and ``make_random_gaussians_table`` functions. [#1244] - ``make_random_models_table`` and ``make_random_gaussians_table`` now return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.detection`` - ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now return an astropy ``QTable`` with version metadata. [#1247] - The ``StarFinder`` ``label`` column was renamed to ``id`` for consistency with the other star finder classes. [#1254] - ``photutils.isophote`` - The ``Isophote`` ``to_table`` method nows return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.psf`` - ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry`` now return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.segmentation`` - Deprecated the ``filter_kernel`` keyword in the ``detect_sources``, ``deblend_sources``, and ``make_source_mask`` functions. It has been renamed to simply ``kernel`` for consistency with ``SourceCatalog``. [#1242] - Removed the deprecated ``random_state`` keyword in the ``make_cmap`` method. [#1244] - The ``SourceCatalog`` ``to_table`` method nows return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.utils`` - Removed the deprecated ``check_random_state`` function. [#1244] - Removed the deprecated ``random_state`` keyword in the ``make_random_cmap`` function. [#1244] 1.1.0 (2021-03-20) ------------------ General ^^^^^^^ - The minimum required python version is 3.7. [#1120] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``PixelAperture.plot()`` method now returns a list of ``matplotlib.patches.Patch`` objects. [#923] - Added an ``area_overlap`` method for ``PixelAperture`` objects that gives the overlapping area of the aperture on the data. [#874] - Added a ``get_overlap_slices`` method and a ``center`` attribute to ``BoundingBox``. [#1157] - Added a ``get_values`` method to ``ApertureMask`` that returns a 1D array of mask-weighted values. [#1158, #1161] - Added ``get_overlap_slices`` method to ``ApertureMask``. [#1165] - ``photutils.background`` - The ``Background2D`` class now accepts astropy ``NDData``, ``CCDData``, and ``Quantity`` objects as data inputs. [#1140] - ``photutils.detection`` - Added a ``StarFinder`` class to detect stars with a user-defined kernel. [#1182] - ``photutils.isophote`` - Added the ability to specify the output columns in the ``IsophoteList`` ``to_table`` method. [#1117] - ``photutils.psf`` - The ``EPSFStars`` class is now usable with multiprocessing. [#1152] - Slicing ``EPSFStars`` now returns an ``EPSFStars`` instance. [#1185] - ``photutils.segmentation`` - Added a modified, significantly faster, ``SourceCatalog`` class. [#1170, #1188, #1191] - Added ``circular_aperture`` and ``circular_photometry`` methods to the ``SourceCatalog`` class. [#1188] - Added ``fwhm`` property to the ``SourceCatalog`` class. [#1191] - Added ``fluxfrac_radius`` method to the ``SourceCatalog`` class. [#1192] - Added a ``bbox`` attribute to ``SegmentationImage``. [#1187] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Slicing a scalar ``Aperture`` object now raises an informative error message. [#1154] - Fixed an issue where ``ApertureMask.multiply`` ``fill_value`` was not applied to pixels outside of the aperture mask, but within the aperture bounding box. [#1158] - Fixed an issue where ``ApertureMask.cutout`` would raise an error if ``fill_value`` was non-finite and the input array was integer type. [#1158] - Fixed an issue where ``RectangularAnnulus`` with a non-default ``h_in`` would give an incorrect ``ApertureMask``. [#1160] - ``photutils.isophote`` - Fix computation of gradient relative error when gradient=0. [#1180] - ``photutils.psf`` - Fixed a bug in ``EPSFBuild`` where a warning was raised if the input ``smoothing_kernel`` was an ``numpy.ndarray``. [#1146] - Fixed a bug that caused photometry to fail on an ``EPSFmodel`` with multiple stars in a group. [#1135] - Added a fallback ``aperture_radius`` for PSF models without a FWHM or sigma attribute, raising a warning. [#740] - ``photutils.segmentation`` - Fixed ``SourceProperties`` ``local_background`` to work with Quantity data inputs. [#1162] - Fixed ``SourceProperties`` ``local_background`` for sources near the image edges. [#1162] - Fixed ``SourceProperties`` ``kron_radius`` for sources that are completely masked. [#1164] - Fixed ``SourceProperties`` Kron properties for sources near the image edges. [#1167] - Fixed ``SourceProperties`` Kron mask correction. [#1167] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Deprecated the ``BoundingBox`` ``slices`` attribute. Use the ``get_overlap_slices`` method instead. [#1157] - ``photutils.centroids`` - Removed the deprecated ``fit_2dgaussian`` function and ``GaussianConst2D`` class. [#1147] - Importing tools from the centroids subpackage without including the subpackage name is deprecated. [#1190] - ``photutils.detection`` - Importing the ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinderBase`` classes from the deprecated ``findstars.py`` module is now deprecated. These classes can be imported using ``from photutils.detection import ``. [#1173] - Importing the ``find_peaks`` function from the deprecated ``core.py`` module is now deprecated. This function can be imported using ``from photutils.detection import find_peaks``. [#1173] - ``photutils.morphology`` - Importing tools from the morphology subpackage without including the subpackage name is deprecated. [#1190] - ``photutils.segmentation`` - Deprecated the ``"mask_all"`` option in the ``SourceProperties`` ``kron_params`` keyword. [#1167] - Deprecated ``source_properties``, ``SourceProperties``, and ``LegacySourceCatalog``. Use the new ``SourceCatalog`` function instead. [#1170] - The ``detect_threshold`` function was moved to the ``segmentation`` subpackage. [#1171] - Removed the ability to slice ``SegmentationImage``. Instead slice the ``segments`` attribute. [#1187] 1.0.2 (2021-01-20) ------------------ General ^^^^^^^ - ``photutils.background`` - Improved the performance of ``Background2D`` (e.g., by a factor of ~4 with 2048x2048 input arrays when using the default interpolator). [#1103, #1108] Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed a bug with ``Background2D`` where using ``BkgIDWInterpolator`` would give incorrect results. [#1104] - ``photutils.isophote`` - Corrected calculations of upper harmonics and their errors [#1089] - Fixed bug that caused an infinite loop when the sample extracted from an image has zero length. [#1129] - Fixed a bug where the default ``fixed_parameters`` in ``EllipseSample.update()`` were not defined. [#1139] - ``photutils.psf`` - Fixed a bug where very incorrect PSF-fitting uncertainties could be returned when the astropy fitter did not return fit uncertainties. [#1143] - Changed the default ``recentering_func`` in ``EPSFBuilder``, to avoid convergence issues. [#1144] - ``photutils.segmentation`` - Fixed an issue where negative Kron radius values could be returned, which would cause an error when calculating Kron fluxes. [#1132] - Fixed an issue where an error was raised with ``SegmentationImage.remove_border_labels()`` with ``relabel=True`` when no segments remain. [#1133] 1.0.1 (2020-09-24) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed checks on ``oversampling`` factors. [#1086] 1.0.0 (2020-09-22) ------------------ General ^^^^^^^ - The minimum required python version is 3.6. [#952] - The minimum required astropy version is 4.0. [#1081] - The minimum required numpy version is 1.17. [#1079] - Removed ``astropy-helpers`` and updated the package infrastructure as described in Astropy APE 17. [#915] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``b_in`` as an optional ellipse annulus keyword. [#1070] - Added ``h_in`` as an optional rectangle annulus keyword. [#1070] - ``photutils.background`` - Added ``coverage_mask`` and ``fill_value`` keyword options to ``Background2D``. [#1061] - ``photutils.centroids`` - Added quadratic centroid estimator function (``centroid_quadratic``). [#1067] - ``photutils.psf`` - Added the ability to use odd oversampling factors in ``EPSFBuilder``. [#1076] - ``photutils.segmentation`` - Added Kron radius, flux, flux error, and aperture to ``SourceProperties``. [#1068] - Added local background to ``SourceProperties``. [#1075] Bug Fixes ^^^^^^^^^ - ``photutils.isophote`` - Fixed a typo in the calculation of the ``b4`` higher-order harmonic coefficient in ``build_ellipse_model``. [#1052] - Fixed a bug where ``build_ellipse_model`` falls into an infinite loop when the pixel to fit is outside of the image. [#1039] - Fixed a bug where ``build_ellipse_model`` falls into an infinite loop under certain image/parameters input combinations. [#1056] - ``photutils.psf`` - Fixed a bug in ``subtract_psf`` caused by using a fill_value of np.nan with an integer input array. [#1062] - ``photutils.segmentation`` - Fixed a bug where ``source_properties`` would fail with unitless ``gwcs.wcs.WCS`` objects. [#1020] - ``photutils.utils`` - The ``effective_gain`` parameter in ``calc_total_error`` can now be zero (or contain zero values). [#1019] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Aperture pixel positions can no longer be shaped as 2xN. [#953] - Removed the deprecated ``units`` keyword in ``aperture_photometry`` and ``PixelAperture.do_photometry``. [#953] - ``PrimaryHDU``, ``ImageHDU``, and ``HDUList`` can no longer be input to ``aperture_photometry``. [#953] - Removed the deprecated the Aperture ``mask_area`` method. [#953] - Removed the deprecated Aperture plot keywords ``ax`` and ``indices``. [#953] - ``photutils.background`` - Removed the deprecated ``ax`` keyword in ``Background2D.plot_meshes``. [#953] - ``Background2D`` keyword options can not be input as positional arguments. [#1061] - ``photutils.centroids`` - ``centroid_1dg``, ``centroid_2dg``, ``gaussian1d_moments``, ``fit_2dgaussian``, and ``GaussianConst2D`` have been moved to a new ``photutils.centroids.gaussian`` module. [#1064] - Deprecated ``fit_2dgaussian`` and ``GaussianConst2D``. [#1064] - ``photutils.datasets`` - Removed the deprecated ``type`` keyword in ``make_noise_image``. [#953] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in ``apply_poisson_noise``, ``make_noise_image``, ``make_random_models_table``, and ``make_random_gaussians_table`` functions. [#1080] - ``photutils.detection`` - Removed the deprecated ``snr`` keyword in ``detect_threshold``. [#953] - ``photutils.psf`` - Added ``flux_residual_sigclip`` as an input parameter, allowing for custom sigma clipping options in ``EPSFBuilder``. [#984] - Added ``extra_output_cols`` as a parameter to ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry`` and ``DAOPhotPSFPhotometry``. [#745] - ``photutils.segmentation`` - Removed the deprecated ``SegmentationImage`` methods ``cmap`` and ``relabel``. [#953] - Removed the deprecated ``SourceProperties`` ``values`` and ``coords`` attributes. [#953] - Removed the deprecated ``xmin/ymin`` and ``xmax/ymax`` properties. [#953] - Removed the deprecated ``snr`` and ``mask_value`` keywords in ``make_source_mask``. [#953] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in the ``make_cmap`` method. [#1080] - ``photutils.utils`` - Removed the deprecated ``random_cmap``, ``mask_to_mirrored_num``, ``get_version_info``, ``filter_data``, and ``std_blocksum`` functions. [#953] - Removed the deprecated ``wcs_helpers`` functions ``pixel_scale_angle_at_skycoord``, ``assert_angle_or_pixel``, ``assert_angle``, and ``pixel_to_icrs_coords``. [#953] - Deprecated the ``check_random_state`` function. [#1080] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in the ``make_random_cmap`` function. [#1080] 0.7.2 (2019-12-09) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.isophote`` - Fixed computation of upper harmonics ``a3``, ``b3``, ``a4``, and ``b4`` in the ellipse fitting algorithm. [#1008] - ``photutils.psf`` - Fix to algorithm in ``EPSFBuilder``, causing issues where ePSFs failed to build. [#974] - Fix to ``IterativelySubtractedPSFPhotometry`` where an error could be thrown when a ``Finder`` was passed which did not return ``None`` if no sources were found. [#986] - Fix to ``centroid_epsf`` where the wrong oversampling factor was used along the y axis. [#1002] 0.7.1 (2019-10-09) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fix to ``IterativelySubtractedPSFPhotometry`` where the residual image was not initialized when ``bkg_estimator`` was not supplied. [#942] - ``photutils.segmentation`` - Fixed a labeling bug in ``deblend_sources``. [#961] - Fixed an issue in ``source_properties`` when the input ``data`` is a ``Quantity`` array. [#963] 0.7 (2019-08-14) ---------------- General ^^^^^^^ - Any WCS object that supports the `astropy shared interface for WCS `_ is now supported. [#899] - Added a new ``photutils.__citation__`` and ``photutils.__bibtex__`` attributes which give a citation for photutils in bibtex format. [#926] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added parameter validation for all aperture classes. [#846] - Added ``from_float``, ``as_artist``, ``union`` and ``intersection`` methods to ``BoundingBox`` class. [#851] - Added ``shape`` and ``isscalar`` properties to Aperture objects. [#852] - Significantly improved the performance (~10-20 times faster) of aperture photometry, especially when using ``errors`` and ``Quantity`` inputs with many aperture positions. [#861] - ``aperture_photometry`` now supports ``NDData`` with ``StdDevUncertainty`` to input errors. [#866] - The ``mode`` keyword in the ``to_sky`` and ``to_pixel`` aperture methods was removed to implement the shared WCS interface. All WCS transforms now include distortions (if present). [#899] - ``photutils.datasets`` - Added ``make_gwcs`` function to create an example ``gwcs.wcs.WCS`` object. [#871] - ``photutils.isophote`` - Significantly improved the performance (~5 times faster) of ellipse fitting. [#826] - Added the ability to individually fix the ellipse-fitting parameters. [#922] - ``photutils.psf`` - Added new centroiding function ``centroid_epsf``. [#816] - ``photutils.segmentation`` - Significantly improved the performance of relabeling in segmentation images (e.g., ``remove_labels``, ``keep_labels``). [#810] - Added new ``background_area`` attribute to ``SegmentationImage``. [#825] - Added new ``data_ma`` attribute to ``Segment``. [#825] - Added new ``SegmentationImage`` methods: ``find_index``, ``find_indices``, ``find_areas``, ``check_label``, ``keep_label``, ``remove_label``, and ``reassign_labels``. [#825] - Added ``__repr__`` and ``__str__`` methods to ``SegmentationImage``. [#825] - Added ``slices``, ``indices``, and ``filtered_data_cutout_ma`` attributes to ``SourceProperties``. [#858] - Added ``__repr__`` and ``__str__`` methods to ``SourceProperties`` and ``SourceCatalog``. [#858] - Significantly improved the performance of calculating the ``background_at_centroid`` property in ``SourceCatalog``. [#863] - The default output table columns (source properties) are defined in a publicly-accessible variable called ``photutils.segmentation.properties.DEFAULT_COLUMNS``. [#863] - Added the ``gini`` source property representing the Gini coefficient. [#864] - Cached (lazy) properties can now be reset in ``SegmentationImage`` subclasses. [#916] - Significantly improved the performance of ``deblend_sources``. It is ~40-50% faster for large images (e.g., 4k x 4k) with a few thousand of sources. [#924] - ``photutils.utils`` - Added ``NoDetectionsWarning`` class. [#836] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where the ``ApertureMask.cutout`` method would drop the data units when ``copy=True``. [#842] - Fixed a corner-case issue where aperture photometry would return NaN for non-finite data values outside the aperture but within the aperture bounding box. [#843] - Fixed an issue where the ``celestial_center`` column (for sky apertures) would be a length-1 array containing a ``SkyCoord`` object instead of a length-1 ``SkyCoord`` object. [#844] - ``photutils.isophote`` - Fixed an issue where the linear fitting mode was not working. [#912] - Fixed the radial gradient computation [#934]. - ``photutils.psf`` - Fixed a bug in the ``EPSFStar`` ``register_epsf`` and ``compute_residual_image`` computations. [#885] - A ValueError is raised if ``aperture_radius`` is not input and cannot be determined from the input ``psf_model``. [#903] - Fixed normalization of ePSF model, now correctly normalizing on undersampled pixel grid. [#817] - ``photutils.segmentation`` - Fixed an issue where ``deblend_sources`` could fail for sources with labels that are a power of 2 and greater than 255. [#806] - ``SourceProperties`` and ``source_properties`` will no longer raise an exception if a source is completely masked. [#822] - Fixed an issue in ``SourceProperties`` and ``source_properties`` where inf values in the data array were not automatically masked. [#822] - ``error`` and ``background`` arrays are now always masked identically to the input ``data``. [#822] - Fixed the ``perimeter`` property to take into account the source mask. [#822] - Fixed the ``background_at_centroid`` source property to use bilinear interpolation. [#822] - Fixed ``SegmentationImage`` ``outline_segments`` to include outlines along the image boundaries. [#825] - Fixed ``SegmentationImage.is_consecutive`` to return ``True`` only if the labels are consecutive and start with label=1. [#886] - Fixed a bug in ``deblend_sources`` where sources could be deblended too much when ``connectivity=8``. [#890] - Fixed a bug in ``deblend_sources`` where the ``contrast`` parameter had little effect if the original segment contained three or more sources. [#890] - ``photutils.utils`` - Fixed a bug in ``filter_data`` where units were dropped for data ``Quantity`` objects. [#872] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Deprecated inputting aperture pixel positions shaped as 2xN. [#847] - Renamed the ``celestial_center`` column to ``sky_center`` in the ``aperture_photometry`` output table. [#848] - Aperture objects defined with a single (x, y) position (input as 1D) are now considered scalar objects, which can be checked with the new ``isscalar`` Aperture property. [#852] - Non-scalar Aperture objects can now be indexed, sliced, and iterated. [#852] - Scalar Aperture objects now return scalar ``positions`` and ``bounding_boxes`` properties and its ``to_mask`` method returns an ``ApertureMask`` object instead of a length-1 list containing an ``ApertureMask``. [#852] - Deprecated the Aperture ``mask_area`` method. [#853] - Aperture ``area`` is now an attribute instead of a method. [#854] - The Aperture plot keyword ``ax`` was deprecated and renamed to ``axes``. [#854] - Deprecated the ``units`` keyword in ``aperture_photometry`` and the ``PixelAperture.do_photometry`` method. [#866, #861] - Deprecated ``PrimaryHDU``, ``ImageHDU``, and ``HDUList`` inputs to ``aperture_photometry``. [#867] - The ``aperture_photometry`` function moved to a new ``photutils.aperture.photometry`` module. [#876] - Renamed the ``bounding_boxes`` attribute for pixel-based apertures to ``bbox`` for consistency. [#896] - Deprecated the ``BoundingBox`` ``as_patch`` method (instead use ``as_artist``). [#851] - ``photutils.background`` - The ``Background2D`` ``plot_meshes`` keyword ``ax`` was deprecated and renamed to ``axes``. [#854] - ``photutils.datasets`` - The ``make_noise_image`` ``type`` keyword was deprecated and renamed to ``distribution``. [#877] - ``photutils.detection`` - Removed deprecated ``subpixel`` keyword for ``find_peaks``. [#835] - ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now return ``None`` if no source/peaks are found. Also, for this case a ``NoDetectionsWarning`` is issued. [#836] - Renamed the ``snr`` (deprecated) keyword to ``nsigma`` in ``detect_threshold``. [#917] - ``photutils.isophote`` - Isophote central values and intensity gradients are now returned to the output table. [#892] - The ``EllipseSample`` ``update`` method now needs to know the fix/fit state of each individual parameter. This can be passed to it via a ``Geometry`` instance, e.g., ``update(geometry.fix)``. [#922] - ``photutils.psf`` - ``FittableImageModel`` and subclasses now allow for different ``oversampling`` factors to be specified in the x and y directions. [#834] - Removed ``pixel_scale`` keyword from ``EPSFStar``, ``EPSFBuilder``, and ``EPSFModel``. [#815] - Added ``oversampling`` keyword to ``centroid_com``. [#816] - Removed deprecated ``Star``, ``Stars``, and ``LinkedStar`` classes. [#894] - Removed ``recentering_boxsize`` and ``center_accuracy`` keywords and added ``norm_radius`` and ``shift_value`` keywords in ``EPSFBuilder``. [#817] - Added ``norm_radius`` and ``shift_value`` keywords to ``EPSFModel``. [#817] - ``photutils.segmentation`` - Removed deprecated ``SegmentationImage`` attributes ``data_masked``, ``max``, and ``is_sequential`` and methods ``area`` and ``relabel_sequential``. [#825] - Renamed ``SegmentationImage`` methods ``cmap`` (deprecated) to ``make_cmap`` and ``relabel`` (deprecated) to ``reassign_label``. The new ``reassign_label`` method gains a ``relabel`` keyword. [#825] - The ``SegmentationImage`` ``segments`` and ``slices`` attributes now have the same length as ``labels`` (no ``None`` placeholders). [#825] - ``detect_sources`` now returns ``None`` if no sources are found. Also, for this case a ``NoDetectionsWarning`` is issued. [#836] - The ``SegmentationImage`` input ``data`` array must contain at least one non-zero pixel and must not contain any non-finite values. [#836] - A ``ValueError`` is raised if an empty list is input into ``SourceCatalog`` or no valid sources are defined in ``source_properties``. [#836] - Deprecated the ``values`` and ``coords`` attributes in ``SourceProperties``. [#858] - Deprecated the unused ``mask_value`` keyword in ``make_source_mask``. [#858] - The ``bbox`` property now returns a ``BoundingBox`` instance. [#863] - The ``xmin/ymin`` and ``xmax/ymax`` properties have been deprecated with the replacements having a ``bbox_`` prefix (e.g., ``bbox_xmin``). [#863] - The ``orientation`` property is now returned as a ``Quantity`` instance in units of degrees. [#863] - Renamed the ``snr`` (deprecated) keyword to ``nsigma`` in ``make_source_mask``. [#917] - ``photutils.utils`` - Renamed ``random_cmap`` to ``make_random_cmap``. [#825] - Removed deprecated ``cutout_footprint`` function. [#835] - Deprecated the ``wcs_helpers`` functions ``pixel_scale_angle_at_skycoord``, ``assert_angle_or_pixel``, ``assert_angle``, and ``pixel_to_icrs_coords``. [#846] - Removed deprecated ``interpolate_masked_data`` function. [#895] - Deprecated the ``mask_to_mirrored_num`` function. [#895] - Deprecated the ``get_version_info``, ``filter_data``, and ``std_blocksum`` functions. [#918] 0.6 (2018-12-11) ---------------- General ^^^^^^^ - Versions of Numpy <1.11 are no longer supported. [#783] New Features ^^^^^^^^^^^^ - ``photutils.detection`` - ``DAOStarFinder`` and ``IRAFStarFinder`` gain two new parameters: ``brightest`` to keep the top ``brightest`` (based on the flux) objects in the returned catalog (after all other filtering has been applied) and ``peakmax`` to exclude sources with peak pixel values larger or equal to ``peakmax``. [#750] - Added a ``mask`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder`` that can be used to mask regions of the input image. [#759] - ``photutils.psf`` - The ``Star``, ``Stars``, and ``LinkedStars`` classes are now deprecated and have been renamed ``EPSFStar``, ``EPSFStars``, and ``LinkedEPSFStars``, respectively. [#727] - Added a ``GriddedPSFModel`` class for spatially-dependent PSFs. [#772] - The ``pixel_scale`` keyword in ``EPSFStar``, ``EPSFBuilder`` and ``EPSFModel`` is now deprecated. Use the ``oversampling`` keyword instead. [#780] API Changes ^^^^^^^^^^^ - ``photutils.detection`` - The ``find_peaks`` function now returns an empty ``astropy.table.Table`` instead of an empty list if the input data is an array of constant values. [#709] - The ``find_peaks`` function will no longer issue a RuntimeWarning if the input data contains NaNs. [#712] - If no sources/peaks are found, ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now will return an empty table with column names and types. [#758, #762] - ``photutils.psf`` - The ``photutils.psf.funcs.py`` module was renamed ``photutils.psf.utils.py``. The ``prepare_psf_model`` and ``get_grouped_psf_model`` functions were also moved to this new ``utils.py`` module. [#777] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - If a single aperture is input as a list into the ``aperture_photometry`` function, then the output columns will be called ``aperture_sum_0`` and ``aperture_sum_err_0`` (if errors are used). Previously these column names did not have the trailing "_0". [#779] - ``photutils.segmentation`` - Fixed a bug in the computation of ``sky_bbox_ul``, ``sky_bbox_lr``, ``sky_bbox_ur`` in the ``SourceCatalog``. [#716] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Updated background and detection functions that call ``astropy.stats.SigmaClip`` or ``astropy.stats.sigma_clipped_stats`` to support both their ``iters`` (for astropy < 3.1) and ``maxiters`` keywords. [#726] 0.5 (2018-08-06) ---------------- General ^^^^^^^ - Versions of Python <3.5 are no longer supported. [#702, #703] - Versions of Numpy <1.10 are no longer supported. [#697, #703] - Versions of Pytest <3.1 are no longer supported. [#702] - ``pytest-astropy`` is now required to run the test suite. [#702, #703] - The documentation build now uses the Sphinx configuration from ``sphinx-astropy`` rather than from ``astropy-helpers``. [#702] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``plot`` and ``to_aperture`` methods to ``BoundingBox``. [#662] - Added default theta value for elliptical and rectangular apertures. [#674] - ``photutils.centroids`` - Added a ``centroid_sources`` function to calculate centroid of many sources in a single image. [#656] - An n-dimensional array can now be input into the ``centroid_com`` function. [#685] - ``photutils.datasets`` - Added a ``load_simulated_hst_star_image`` function to load a simulated HST WFC3/IR F160W image of stars. [#695] - ``photutils.detection`` - Added a ``centroid_func`` keyword to ``find_peaks``. The ``subpixels`` keyword is now deprecated. [#656] - The ``find_peaks`` function now returns ``SkyCoord`` objects in the table instead of separate RA and Dec. columns. [#656] - The ``find_peaks`` function now returns an empty Table and issues a warning when no peaks are found. [#668] - ``photutils.psf`` - Added tools to build and fit an effective PSF (``EPSFBuilder`` and ``EPSFFitter``). [#695] - Added ``extract_stars`` function to extract cutouts of stars used to build an ePSF. [#695] - Added ``EPSFModel`` class to hold a fittable ePSF model. [#695] - ``photutils.segmentation`` - Added a ``mask`` keyword to the ``detect_sources`` function. [#621] - Renamed ``SegmentationImage`` ``max`` attribute to ``max_label``. ``max`` is deprecated. [#662] - Added a ``Segment`` class to hold the cutout image and properties of single labeled region (source segment). [#662] - Deprecated the ``SegmentationImage`` ``area`` method. Instead, use the ``areas`` attribute. [#662] - Renamed ``SegmentationImage`` ``data_masked`` attribute to ``data_ma``. ``data_masked`` is deprecated. [#662] - Renamed ``SegmentationImage`` ``is_sequential`` attribute to ``is_consecutive``. ``is_sequential`` is deprecated. [#662] - Renamed ``SegmentationImage`` ``relabel_sequential`` attribute to ``relabel_consecutive``. ``relabel_sequential`` is deprecated. [#662] - Added a ``missing_labels`` property to ``SegmentationImage``. [#662] - Added a ``check_labels`` method to ``SegmentationImage``. The ``check_label`` method is deprecated. [#662] - ``photutils.utils`` - Deprecated the ``cutout_footprint`` function. [#656] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug where quantity inputs to the aperture classes would sometimes fail. [#693] - ``photutils.detection`` - Fixed an issue in ``detect_sources`` where in some cases sources with a size less than ``npixels`` could be returned. [#663] - Fixed an issue in ``DAOStarFinder`` where in some cases a few too many sources could be returned. [#671] - ``photutils.isophote`` - Fixed a bug where isophote fitting would fail when the initial center was not specified for an image with an elongated aspect ratio. [#673] - ``photutils.segmentation`` - Fixed ``deblend_sources`` when other sources are in the neighborhood. [#617] - Fixed ``source_properties`` to handle the case where the data contain one or more NaNs. [#658] - Fixed an issue with ``deblend_sources`` where sources were not deblended where the data contain one or more NaNs. [#658] - Fixed the ``SegmentationImage`` ``areas`` attribute to not include the zero (background) label. [#662] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ``photutils.isophote`` - Corrected the units for isophote ``sarea`` in the documentation. [#657] 0.4 (2017-10-30) ---------------- General ^^^^^^^ - Dropped python 3.3 support. [#542] - Dropped numpy 1.8 support. Minimal required version is now numpy 1.9. [#542] - Dropped support for astropy 1.x versions. Minimal required version is now astropy 2.0. [#575] - Dropped scipy 0.15 support. Minimal required version is now scipy 0.16. [#576] - Explicitly require six as dependency. [#601] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``BoundingBox`` class, used when defining apertures. [#481] - Apertures now have ``__repr__`` and ``__str__`` defined. [#493] - Improved plotting of annulus apertures using Bezier curves. [#494] - Rectangular apertures now use the true minimal bounding box. [#507] - Elliptical apertures now use the true minimal bounding box. [#508] - Added a ``to_sky`` method for pixel apertures. [#512] - ``photutils.background`` - Mesh rejection now also applies to pixels that are masked during sigma clipping. [#544] - ``photutils.datasets`` - Added new ``make_wcs`` and ``make_imagehdu`` functions. [#527] - Added new ``show_progress`` keyword to the ``load_*`` functions. [#590] - ``photutils.isophote`` - Added a new ``photutils.isophote`` subpackage to provide tools to fit elliptical isophotes to a galaxy image. [#532, #603] - ``photutils.segmentation`` - Added a ``cmap`` method to ``SegmentationImage`` to generate a random matplotlib colormap. [#513] - Added ``sky_centroid`` and ``sky_centroid_icrs`` source properties. [#592] - Added new source properties representing the sky coordinates of the bounding box corner vertices (``sky_bbox_ll``, ``sky_bbox_ul`` ``sky_bbox_lr``, and ``sky_bbox_ur``). [#592] - Added new ``SourceCatalog`` class to hold the list of ``SourceProperties``. [#608] - The ``properties_table`` function is now deprecated. Use the ``SourceCatalog.to_table()`` method instead. [#608] - ``photutils.psf`` - Uncertainties on fitted parameters are added to the final table. [#516] - Fitted results of any free parameter are added to the final table. [#471] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureMask`` ``apply()`` method has been renamed to ``multiply()``. [#481]. - The ``ApertureMask`` input parameter was renamed from ``mask`` to ``data``. [#548] - Removed the ``pixelwise_errors`` keyword from ``aperture_photometry``. [#489] - ``photutils.background`` - The ``Background2D`` keywords ``exclude_mesh_method`` and ``exclude_mesh_percentile`` were removed in favor of a single keyword called ``exclude_percentile``. [#544] - Renamed ``BiweightMidvarianceBackgroundRMS`` to ``BiweightScaleBackgroundRMS``. [#547] - Removed the ``SigmaClip`` class. ``astropy.stats.SigmaClip`` is a direct replacement. [#569] - ``photutils.datasets`` - The ``make_poisson_noise`` function was renamed to ``apply_poisson_noise``. [#527] - The ``make_random_gaussians`` function was renamed to ``make_random_gaussians_table``. The parameter ranges must now be input as a dictionary. [#527] - The ``make_gaussian_sources`` function was renamed to ``make_gaussian_sources_image``. [#527] - The ``make_random_models`` function was renamed to ``make_random_models_table``. [#527] - The ``make_model_sources`` function was renamed to ``make_model_sources_image``. [#527] - The ``unit``, ``hdu``, ``wcs``, and ``wcsheader`` keywords in ``photutils.datasets`` functions were removed. [#527] - ``'photutils-datasets'`` was added as an optional ``location`` in the ``get_path`` function. This option is used as a fallback in case the ``'remote'`` location (astropy data server) fails. [#589] - ``photutils.detection`` - The ``daofind`` and ``irafstarfinder`` functions were removed. [#588] - ``photutils.psf`` - ``IterativelySubtractedPSFPhotometry`` issues a "no sources detected" warning only on the first iteration, if applicable. [#566] - ``photutils.segmentation`` - The ``'icrs_centroid'``, ``'ra_icrs_centroid'``, and ``'dec_icrs_centroid'`` source properties are deprecated and are no longer default columns returned by ``properties_table``. [#592] - The ``properties_table`` function now returns a ``QTable``. [#592] - ``photutils.utils`` - The ``background_color`` keyword was removed from the ``random_cmap`` function. [#528] - Deprecated unused ``interpolate_masked_data()``. [#526, #611] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - Fixed ``deblend_sources`` so that it correctly deblends multiple sources. [#572] - Fixed a bug in calculation of the ``sky_centroid_icrs`` (and deprecated ``icrs_centroid``) property where the incorrect pixel origin was being passed. [#592] - ``photutils.utils`` - Added a check that ``data`` and ``bkg_error`` have the same units in ``calc_total_error``. [#537] 0.3.2 (2017-03-31) ------------------ General ^^^^^^^ - Fixed file permissions in the released source distribution. 0.3.1 (2017-03-02) ------------------ General ^^^^^^^ - Dropped numpy 1.7 support. Minimal required version is now numpy 1.8. [#327] - ``photutils.datasets`` - The ``load_*`` functions that use remote data now retrieve the data from ``data.astropy.org`` (the astropy data repository). [#472] Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed issue with ``Background2D`` with ``edge_method='pad'`` that occurred when unequal padding needed to be applied to each axis. [#498] - Fixed issue with ``Background2D`` that occurred when zero padding needed to apply along only one axis. [#500] - ``photutils.geometry`` - Fixed a bug in ``circular_overlap_grid`` affecting 32-bit machines that could cause errors circular aperture photometry. [#475] - ``photutils.psf`` - Fixed a bug in how ``FittableImageModel`` represents its center. [#460] - Fix bug which modified user's input table when doing forced photometry. [#485] 0.3 (2016-11-06) ---------------- New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added new ``origin`` keyword to aperture ``plot`` methods. [#395] - Added new ``id`` column to ``aperture_photometry`` output table. [#446] - Added ``__len__`` method for aperture classes. [#446] - Added new ``to_mask`` method to ``PixelAperture`` classes. [#453] - Added new ``ApertureMask`` class to generate masks from apertures. [#453] - Added new ``mask_area()`` method to ``PixelAperture`` classes. [#453] - The ``aperture_photometry()`` function now accepts a list of aperture objects. [#454] - ``photutils.background`` - Added new ``MeanBackground``, ``MedianBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightMidvarianceBackgroundRMS`` classes. [#370] - Added ``axis`` keyword to new background classes. [#392] - Added new ``removed_masked``, ``meshpix_threshold``, and ``edge_method`` keywords for the 2D background classes. [#355] - Added new ``std_blocksum`` function. [#355] - Added new ``SigmaClip`` class. [#423] - Added new ``BkgZoomInterpolator`` and ``BkgIDWInterpolator`` classes. [#437] - ``photutils.datasets`` - Added ``load_irac_psf`` function. [#403] - ``photutils.detection`` - Added new ``make_source_mask`` convenience function. [#355] - Added ``filter_data`` function. [#398] - Added ``DAOStarFinder`` and ``IRAFStarFinder`` as OOP interfaces for ``daofind`` and ``irafstarfinder``, respectively, which are now deprecated. [#379] - ``photutils.psf`` - Added ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry`` classes to perform PSF photometry in crowded fields. [#427] - Added ``DAOGroup`` and ``DBSCANGroup`` classes for grouping overlapping sources. [#369] - ``photutils.psf_match`` - Added ``create_matching_kernel`` and ``resize_psf`` functions. Also, added ``CosineBellWindow``, ``HanningWindow``, ``SplitCosineBellWindow``, ``TopHatWindow``, and ``TukeyWindow`` classes. [#403] - ``photutils.segmentation`` - Created new ``photutils.segmentation`` subpackage. [#442] - Added ``copy`` and ``area`` methods and an ``areas`` property to ``SegmentationImage``. [#331] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Removed the ``effective_gain`` keyword from ``aperture_photometry``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``aperture_photometry`` now outputs a ``QTable``. [#446] - Renamed ``source_id`` keyword to ``indices`` in the aperture ``plot()`` method. [#453] - Added ``mask`` and ``unit`` keywords to aperture ``do_photometry()`` methods. [#453] - ``photutils.background`` - For the background classes, the ``filter_shape`` keyword was renamed to ``filter_size``. The ``background_low_res`` and ``background_rms_low_res`` class attributes were renamed to ``background_mesh`` and ``background_rms_mesh``, respectively. [#355, #437] - The ``Background2D`` ``method`` and ``backfunc`` keywords have been removed. In its place one can input callable objects via the ``sigma_clip``, ``bkg_estimator``, and ``bkgrms_estimator`` keywords. [#437] - The interpolator to be used by the ``Background2D`` class can be input as a callable object via the new ``interpolator`` keyword. [#437] - ``photutils.centroids`` - Created ``photutils.centroids`` subpackage, which contains the ``centroid_com``, ``centroid_1dg``, and ``centroid_2dg`` functions. These functions now return a two-element numpy ndarray. [#428] - ``photutils.detection`` - Changed finding algorithm implementations (``daofind`` and ``starfind``) from functional to object-oriented style. Deprecated old style. [#379] - ``photutils.morphology`` - Created ``photutils.morphology`` subpackage. [#428] - Removed ``marginalize_data2d`` function. [#428] - Moved ``cutout_footprint`` from ``photutils.morphology`` to ``photutils.utils``. [#428] - Added a function to calculate the Gini coefficient (``gini``). [#343] - ``photutils.psf`` - Removed the ``effective_gain`` keyword from ``psf_photometry``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``photutils.segmentation`` - Removed the ``effective_gain`` keyword from ``SourceProperties`` and ``source_properties``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``photutils.utils`` - Renamed ``calculate_total_error`` to ``calc_total_error``. [#368] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in ``aperture_photometry`` so that single-row output tables do not return a multidimensional column. [#446] - ``photutils.centroids`` - Fixed a bug in ``centroid_1dg`` and ``centroid_2dg`` that occurred when the input data contained invalid (NaN or inf) values. [#428] - ``photutils.segmentation`` - Fixed a bug in ``SourceProperties`` where ``error`` and ``background`` units were sometimes dropped. [#441] 0.2.2 (2016-07-06) ------------------ General ^^^^^^^ - Dropped numpy 1.6 support. Minimal required version is now numpy 1.7. [#327] - Fixed configparser for Python 3.5. [#366, #384] Bug Fixes ^^^^^^^^^ - ``photutils.detection`` - Fixed an issue to update segmentation image slices after deblending. [#340] - Fixed source deblending to pass the pixel connectivity to the watershed algorithm. [#347] - SegmentationImage properties are now cached instead of recalculated, which significantly improves performance. [#361] - ``photutils.utils`` - Fixed a bug in ``pixel_to_icrs_coords`` where the incorrect pixel origin was being passed. [#348] 0.2.1 (2016-01-15) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Added more robust version checking of Astropy. [#318] - ``photutils.detection`` - Added more robust version checking of Astropy. [#318] - ``photutils.segmentation`` - Fixed issue where ``SegmentationImage`` slices were not being updated. [#317] - Added more robust version checking of scikit-image. [#318] 0.2 (2015-12-31) ---------------- General ^^^^^^^ - Photutils has the following requirements: - Python 2.7 or 3.3 or later - Numpy 1.6 or later - Astropy v1.0 or later New Features ^^^^^^^^^^^^ - ``photutils.detection`` - ``find_peaks`` now returns an Astropy Table containing the (x, y) positions and peak values. [#240] - ``find_peaks`` has new ``mask``, ``error``, ``wcs`` and ``subpixel`` precision options. [#244] - ``detect_sources`` will now issue a warning if the filter kernel is not normalized to 1. [#298] - Added new ``deblend_sources`` function, an experimental source deblender. [#314] - ``photutils.morphology`` - Added new ``GaussianConst2D`` (2D Gaussian plus a constant) model. [#244] - Added new ``marginalize_data2d`` function. [#244] - Added new ``cutout_footprint`` function. [#244] - ``photutils.segmentation`` - Added new ``SegmentationImage`` class. [#306] - Added new ``check_label``, ``keep_labels``, and ``outline_segments`` methods for modifying ``SegmentationImage``. [#306] - ``photutils.utils`` - Added new ``random_cmap`` function to generate a colormap comprised of random colors. [#299] - Added new ``ShepardIDWInterpolator`` class to perform Inverse Distance Weighted (IDW) interpolation. [#307] - The ``interpolate_masked_data`` function can now interpolate higher-dimensional data. [#310] API Changes ^^^^^^^^^^^ - ``photutils.segmentation`` - The ``relabel_sequential``, ``relabel_segments``, ``remove_segments``, ``remove_border_segments``, and ``remove_masked_segments`` functions are now ``SegmentationImage`` methods (with slightly different names). [#306] - The ``SegmentProperties`` class has been renamed to ``SourceProperties``. Likewise, the ``segment_properties`` function has been renamed to ``source_properties``. [#306] - The ``segment_sum`` and ``segment_sum_err`` attributes have been renamed to ``source_sum`` and ``source_sum_err``, respectively. [#306] - The ``background_atcentroid`` attribute has been renamed to ``background_at_centroid``. [#306] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where ``np.nan`` or ``np.inf`` were not properly masked. [#267] - ``photutils.geometry`` - ``overlap_area_triangle_unit_circle`` handles correctly a corner case in some i386 systems where the area of the aperture was not computed correctly. [#242] - ``rectangular_overlap_grid`` and ``elliptical_overlap_grid`` fixes to normalization of subsampled pixels. [#265] - ``overlap_area_triangle_unit_circle`` handles correctly the case where a line segment intersects at a triangle vertex. [#277] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Updated astropy-helpers to v1.1. [#302] 0.1 (2014-12-22) ---------------- Photutils 0.1 was released on December 22, 2014. It requires Astropy version 0.4 or later. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/CITATION.md0000644000175100001770000000011214637570305014774 0ustar00runnerdockerSee https://github.com/astropy/photutils/blob/main/photutils/CITATION.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/CODE_OF_CONDUCT.rst0000644000175100001770000000025714637570305016261 0ustar00runnerdockerPhotutils is an `Astropy `_ affiliated package. We follow the `Astropy Community Code of Conduct `_. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/CONTRIBUTING.rst0000644000175100001770000001170714637570305015715 0ustar00runnerdockerContributing to Photutils ========================= Reporting Issues ---------------- When opening an issue to report a problem, please try to provide a minimal code example that reproduces the issue. Also, please include details of the operating system and the Python, NumPy, Astropy, and Photutils versions you are using. Contributing code ----------------- Contributions to Photutils are done via pull requests from GitHub users' forks of the `Photutils repository `_. If you're new to this style of development, you'll want to read the `Astropy Development documentation `_. Once you open a pull request (which should be opened against the ``main`` branch, not against any other branch), please make sure that you include the following: - **Code**: the code you are adding, which should follow as much as possible the `Astropy coding guidelines `_. - **Tests**: these are either tests to ensure code that previously failed now works (regression tests) or tests that cover as much as possible of the new functionality to make sure it doesn't break in the future. The tests are also used to ensure consistent results on all platforms, since we run these tests on many platforms/configurations. For more information about how to write tests, see the `Astropy testing guidelines `_. - **Documentation**: if you are adding new functionality, be sure to include a description in the main documentation (in ``docs/``). For more information, please see the detailed `Astropy documentation guidelines `_. - **Changelog entry**: if you are fixing a bug or adding new functionality, you should add an entry to the ``CHANGES.rst`` file that includes the PR number and if possible the issue number (if you are opening a pull request you may not know this yet, but you can add it once the pull request is open). If you're not sure where to put the changelog entry, wait until a maintainer has reviewed your PR and assigned it to a milestone. You do not need to include a changelog entry for fixes to bugs introduced in the developer version and therefore are not present in the stable releases. In general, you do not need to include a changelog entry for minor documentation or test updates. Only user-visible changes (new features/API changes, fixed issues) need to be mentioned. If in doubt, ask the core maintainer reviewing your changes. Checklist for Contributed Code ------------------------------ A pull request for a new feature will be reviewed to see if it meets the following requirements. For any pull request, a Photutils maintainer can help to make sure that the pull request meets the requirements for inclusion in the package. **Scientific Quality** (when applicable) * Is the submission relevant to this package? * Are references included to the original source for the algorithm? * Does the code perform as expected? * Has the code been tested against previously existing implementations? **Code Quality** * Are the `Astropy coding guidelines `_ followed? * Are there dependencies other than the Astropy core, the Python Standard Library, and NumPy? - Are additional dependencies handled appropriately? - Do functions and classes that require additional dependencies raise an `ImportError` if they are not present? **Testing** * Are the `Astropy testing guidelines `_ followed? * Are the inputs to the functions and classes sufficiently tested? * Are there tests for any exceptions raised? * Are there tests for the expected performance? * Are the sources for the tests documented? * Are the tests that require an `optional dependency `_ marked as such? * Does "``tox -e test``" run without failures? **Documentation** * Are the `Astropy documentation guidelines `_ followed? * Is there a `docstring `_ in the functions and classes describing: - What the code does? - The format of the inputs of the function or class? - The format of the outputs of the function or class? - References to the original algorithms? - Any exceptions which are raised? - An example of running the code? * Is there any information needed to be added to the docs to describe the function or class? * Does the documentation build without errors or warnings? * If applicable, has an entry been added into the changelog? **License** * Is the Photutils license included at the top of the file? * Are there any conflicts with this code and existing codes? ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/LICENSE.rst0000644000175100001770000000274714637570305015074 0ustar00runnerdockerCopyright (c) 2011-2023, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. 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. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.050885 photutils-1.13.0/PKG-INFO0000644000175100001770000001442614637570322014351 0ustar00runnerdockerMetadata-Version: 2.1 Name: photutils Version: 1.13.0 Summary: An Astropy package for source detection and photometry Author-email: Photutils Developers License: Copyright (c) 2011-2023, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. 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. Project-URL: Homepage, https://github.com/astropy/photutils Project-URL: Documentation, https://photutils.readthedocs.io/en/stable/ Keywords: astronomy,astrophysics,photometry,aperture,psf,source detection,background,segmentation,centroids,isophote,morphology,radial profiles Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: BSD License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Cython Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Scientific/Engineering :: Astronomy Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE.rst Requires-Dist: numpy>=1.23 Requires-Dist: astropy>=5.1 Provides-Extra: all Requires-Dist: scipy>=1.8; extra == "all" Requires-Dist: matplotlib>=3.5; extra == "all" Requires-Dist: scikit-image>=0.20; extra == "all" Requires-Dist: gwcs>=0.18; extra == "all" Requires-Dist: bottleneck; extra == "all" Requires-Dist: tqdm; extra == "all" Requires-Dist: rasterio; extra == "all" Requires-Dist: shapely; extra == "all" Provides-Extra: test Requires-Dist: pytest-astropy>=0.11; extra == "test" Provides-Extra: docs Requires-Dist: photutils[all]; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx-astropy>=1.9; extra == "docs" Requires-Dist: tomli; python_version < "3.11" and extra == "docs" ========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |Astropy| |CI Status| |Codecov Status| |Latest RTD Status| Photutils is an `Astropy`_ package for detection and photometry of astronomical sources. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/badge/latestdoi/2640766 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/README.rst0000644000175100001770000000573214637570305014744 0ustar00runnerdocker========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |Astropy| |CI Status| |Codecov Status| |Latest RTD Status| Photutils is an `Astropy`_ package for detection and photometry of astronomical sources. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/badge/latestdoi/2640766 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/codecov.yml0000644000175100001770000000031514637570305015412 0ustar00runnerdockercomment: off codecov: branch: main coverage: status: project: default: target: auto # this allows a small drop from the previous base commit coverage threshold: 0.05% ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.002885 photutils-1.13.0/docs/0000755000175100001770000000000014637570322014175 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/Makefile0000644000175100001770000001072714637570305015645 0ustar00runnerdocker# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest #This is needed with git because git doesn't create a dir if it's empty $(shell [ -d "_static" ] || mkdir -p _static) help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" clean: -rm -rf $(BUILDDIR) -rm -rf api -rm -rf generated html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: @echo "Run 'pytest' in the root directory to run doctests " \ @echo "in the documentation." ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.006885 photutils-1.13.0/docs/_static/0000755000175100001770000000000014637570322015623 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/favicon.ico0000644000175100001770000003535614637570305017761 0ustar00runnerdocker h6  ¨ž00 ¨%F(  ¨o1‘xm˛t,GŽp(Ļm.Ǥl,Ŗĸj*z h)1’\ ¨o/Šq4.­p*ÉĄk1˙šj9ūĨk*˙ĸj)˙Ÿh)˙f'üšc&ģ—a%4‘[Ļo35Ģo*ô–lF˙tfũŽjNübOüœe)üœd&ü™c'ū—a$˙–_!˙”]s“YĄm6Šo-Ėžg,˙‘ymúÍǃ˙Ņ­‚˙ĸƒi˙ƒ]>˙b˙–^˙•^üŽ\%û[$˙Y{ŽX¤l-ZĨl*˙—e0üŽp\˙Ķą‡˙ØŊž˙ą“v˙€]A˙’a+˙ƒeP˙Z˙uWDūmTJüˆU˙‰TIĄi* žg*˙ĸg#üƒ`E˙‹iN˙Ą}Z˙‹{{˙ŠV˙†eJ˙ƒ€”˙}R)˙†hQ˙“sTüqP6˙ˆQÍOGWf(Ŋže$˙^.ũŽ^-˙…[4˙}Y<˙ŠV˙“Z˙ŠX˙ƒT#˙…T˙sT?˙ĄxG˙s\Qũ~K˙NTœd%ĩ’]%˙„uuũng˙“Z˙‘[˙‚fU˙\;˙ŠT˙‰U˙‚Q˙pUE˙ÁŖ˙ƒoeürE˙~KĸŸd‰U/˙ē´ŗü’‚|˙ˆQ˙}U2˙™’š˙nh˙ƒO˙ƒO˙‚J˙lQ@˙Ư•˙xgũhA˙{H ˟`>ƒX/ū„|„ū|_J˙‹U˙‰T˙zT3˙}R&˙‚M˙zW6˙r\O˙fI6˙žd˙}[:ũeC&˙wDĶYģyQ-˙†Sü‰T˙zO#˙zN!˙€N˙xG˙vw˙ykk˙g?˙z`M˙š|]ũgI0˙q=¸‰U7ŠUüˆSūpM0ũzbS˙„l[˙jQD˙gI5˙nE˙sB ˙uC ˙aG8˙ubZücA#˙q<q…Q€Qg„P˙kK2ü`Gûŧ—i˙ÂĻ‚˙dJ˙eG2˙tC ˙qA ˙j?ûU:+˙h< Ün;ƒP~La}JųeG2˙pZOū–~jü˜yYü\HDük?üm> ũk<˙k;üh:E€NyH)zF ¯h?úeF-˙dJ8˙d=˙l=ūh;˙f9Ķc9 :h:M}G H*s<sn9 m<Ēg:‘f9Nb5g:ø˙āĀ€€€€Āāđø˙( @ Ēq0Šo.Ģq1Šp/&¨o.>§n-GĻm-AĨl,-¤l,›d&›d&Ēp/Ēp/Ŧs1Ēq0SŠp/Ѝo/Ū¨n.ø§n-˙Ļm-˙Ĩl,˙¤k+ûĸj+įĄi*ž h)|Ÿg(*—a$—`$Ēp/Šo/Ģq0YĒp0ߨo1˙Ēo,ūĒn)˙§m+˙Ŗl.˙¤k,ūŖj+ūĸi*˙Ąi*˙ h)˙Ÿg(˙f(ũœe'Ŋ›d&A˜a$”^"Ēp/Ēq0 Ēp/ŠŠp0˙Ēo,ū¯q&ü l6û—j?ũŸk3ūĒl!˙Ŗj*˙ i+˙ h)˙Ÿg(ūžf(üœe'û›d&ū›d&˙™c%˙˜b$°—`$“]!Ēp/Ēp/ Ēp/Ĩo0˙Ēn*úŖm2ũm]f˙ncr˙rag˙j_p˙scl˙ h)˙Ąg&˙f)˙e'˙œe'˙›d&˙šc%˙™b%ü˜a$ü—`$˙•_#đ”^"S[ Ĩm-Šo/ą¨o.˙¨n,ųĨn1˙aZs˙Ŧœ–˙ŗ|=˙Ģo)˙Ģr0˙}[D˙e_x˙Ÿf%˙œe'˙›d&˙šc&˙™b%˙˜a$˙—`$˙–`#˙”_"û“^"ũ’]!˙‘\ yŽY¨n.¨n.q§n.˙¤m/úŽn$˙q]_˙™„{˙Ų˛€˙š’g˙Ę­Ž˙Ɲ’˙˜`˙vZM˙v`]˙ĸd˙˜b'˙˜b$˙—a$˙–`#˙•_"˙”^"˙’]"˙“\ũ’\û[ ˙ZŒX§n-Ļm-ëĨl,˙¤k,ū¤m.˙fXd˙ÅĄx˙¸b˙Ņļ—˙ÉLj˙ļ_˙ËŽ˙Ŧ„Z˙bUc˙œc"˙—a%˙•`%˙–`"˙“^#˙“]!˙’]!˙“\˙[$˙‹Z$ū‘YûŒX˙ŒWjŠWĨl,uĨl,˙Ŗk+ûŖj)˙žj2˙iW\˙Ğs˙ģ•l˙ÕžŖ˙øôđ˙ØÃĢ˙Ž‚Q˙žy˙cQY˙˜b&˙•_$˙™_˙’_'˙–^˙’\˙‘[˙‹Z%˙\Wp˙XWv˙€W1ūŽWū‰Vü‰U<‰U¤k+ĮŖj+˙Ąi*ũ h+˙¤h$˙g]o˙k7˙͏š˙Āžy˙ČŠ‡˙ģ™s˙ģ“e˙¯”{˙_Q]˙›a˙—_˙vXD˙`d‰˙gYb˙ŽY˙•Z˙mXS˙oRB˙…O˙TTv˙ŠUüˆT˙‡S҃Q‡Tĸj+$ĸj*ôĄi*˙ h)ūg*˙¤g˙bO˙hZf˙Šj˙ž–f˙ŗ‰X˙ĩˆR˙ėÕˇ˙c\r˙[>˙˜^˙‘^%˙ab„˙ãÆ ˙‚˜˙uVA˙™[˙`QX˙ŒmQ˙ŗƒF˙gPH˙hSO˙Sû„R˙„Qx„RĄi*L h)˙Ÿg)ūžf(˙e'˙™d*˙Ÿd˙r__˙e[l˙V-˙‡V#˙™}h˙jj‡˙oWN˙™^˙\#˙”\˙gW[˙|}™˙daz˙„V%˙‘W˙`Ua˙Ļ‹r˙°Š_˙ŖY˙QI`˙†SūƒP˙‚Pį€NŸg)gžf(˙f'üœe'˙šd'˙Ąd˙žc˙b˙„_@˙k]f˙h\j˙fV[˙€W2˙—^˙[!˙Z˙ŽZ˙ŽX˙wU;˙…U#˙‹V˙ŒU˙_Tb˙W-˙zA˙ļ_˙cWc˙pN3˙†Oû€N˙Mif'qœe'˙›d&üšc&˙c ˙v\R˙v\Q˙˜`!˙™_˙›_˙—^˙˜^˙•]˙”]˙Z˙Y˙ŒX˙‹W˙X˙ŠV˙†T˙ŒT˙^R^˙yO&˙ŖzK˙¯‰[˙…na˙YHK˙‡O ü}M˙}Lž›d&i›d&˙˜b'üŸc˙o[Z˙rci˙lam˙xZI˙˜^˙\$˙\"˙‘[˙Ž[#˙}]E˙‹Y#˙ŽX˙ŠW˙‰V˙ˆU˙‡T˙…S˙‹S˙^MR˙_D˙ˇ–n˙¸›{˙Ÿ^˙PGY˙Nū|K˙{JōzI"™c%O˜b%˙šb!ūŽ_/˙`Zr˙Į˛“˙ĸƒ˙cRX˙˜^˙Ž["˙Z˙‰X$˙UOg˙wo˙TOi˙‚T%˙ŠU˙‡T˙†S˙…R˙ƒQ˙‰Q˙aKE˙zcV˙­†W˙Ō²˙°c˙SJY˙vK ˙|JũyI˙yHU˜a$*–`%øœa˙uWFūsw˙įãÚ˙¨œ—˙bPU˙•\˙ŒY"˙–\˙nZX˙€˙ûáˇ˙—†€˙gW\˙W˙„R˙„R˙ƒQ˙P˙†O˙jK6˙kZ\˙š–k˙áŲŌ˙ĩ‘e˙]NR˙jI/˙}H üwG˙wG˜b%“_%՛`˙kSMũ’…ƒ˙ëčā˙”‡„˙ePO˙•[˙ŠX!˙X˙vW@˙fby˙ÄĢ˙jez˙nSC˙ŠS˙ƒQ˙‚P˙P˙„P˙ƒN˙sL&˙[Q`˙ļ”j˙ĖģĒ˙Ē„V˙gRJ˙_G=˙|FüuF˙uEš‘]%•™^˙iRNû~z˙Ōžž˙gat˙xU8˙X˙ŠV˙‰U˙ŒU˙iOC˙[Xs˙eNF˙‡Q˙‚P˙O˙ƒO˙ƒM ˙lF%˙xK˙L˙OI^˙Ĩ†d˙ą•v˙˛‘h˙fH2˙YHJ˙{EüsD˙sCŖŽ\$?”\˙|X:ũb[p˙ze\˙[Sf˙W˙‰V˙‰U˙‡T˙…S˙ŠS˙ŒV˙‰Q˙‚P˙€O˙ƒO˙wJ˙RQo˙qtŽ˙QSv˙J ˙QCL˙wk˙vK˙‡Y"˙fC$˙VJS˙xCüqC˙qB ™‡TY őY˙pUHübRY˙ŠW˙ŠU˙ˆT˙†S˙†R˙ˆR˙‡R˙„Q˙€O˙N˙€N˙|K˙UUs˙Āģš˙ĩ°Ž˙NMi˙H˙aD2˙g^l˙ĻR˙m9˙xd˙RHT˙vAüoA˙o@ {ŒXXM‹W˙WüW˙ˆU˙‡T˙…S˙‰R˙ƒR˙tO.˙mG%˙vK˙ƒO˙„M ˙L˙oH%˙V]…˙€“˙MNp˙rE˙zG ˙uH˙FB]˙ž|Y˙Љb˙´Ģ¨˙J=F˙vBūm@ ˙n? L‰U‰V̇U˙†Tû†S˙„R˙ˆQ˙qQ9˙PMj˙_S]˙yv‹˙d`v˙MEX˙]LM˙xL˙‚I˙iG+˙_@+˙yF ˙xF ˙sE˙zD˙TFL˙]F>˙§|E˙‰xq˙N?Eūs?˙k> įl= ‡TˆU†Så…R˙„Rũ„Q˙P˙QNj˙€R˙ŗŒ]˙ĄvA˙™l8˙‡_3˙iRG˙FC_˙^JF˙€G˙{H ˙uF˙tD˙sD˙sC ˙pB˙CFl˙j6˙Q>C˙XB:üp=˙j=  …R…RC„Qü‚Pū‚Oũ€O˙QLd˙‡V˙Ē‹i˙€S!˙ž¨˙ĩ™y˙°Œ_˙žvG˙RFP˙RIW˙xE˙sD˙rC ˙qB ˙pA˙r@˙cA'˙DGl˙IE^˙j= ũj<˙i< 8„Q‚OYN˙Nũ„M ügNB˙OGZ˙šj0˙ą’m˙Äą˙ßÔČ˙Ŋ§˙›yS˙ĻzB˙NBM˙\G@˙vB˙pB˙pA ˙o@ ˙m? ˙o=˙h>˙i< új;˙h;›i;‚OMT~Lø|K˙Jû_LJūIE^˙|W1˙ uA˙ρU˙¤‚[˙l:˙̌g˙xI˙JE\˙sA˙n@ ˙n? ˙m> ˙l= ˙j= ˙j;úi;˙g:Ôh9f9€N|K4{JÜyI˙~GükJ.ûJF`ūNBN˙lQ?˙ŖŒv˙ģĻ‘˙ŽkD˙RAC˙QEQ˙r?˙l? ˙l> ˙k= ˙j<ũi;úg:˙f9āf9#g9~LzI xH“vGüyE˙yEūfG.ûUHQüVRiūXWpūE?T˙RDI˙n? ˙l> ūk= ūj<üi;ûh:ũg9˙f9Æe8f9|KyHvE,sD§rC÷uB˙vAūq>˙l<˙q@˙r=ūk= ˙j< ˙i;˙h:ūg:˙f9ėe8sc6m@ f9yIyHpAqB oAlm@ŗm@ ám? øk= ˙i= ˙i<˙h;úg:ãf9´f8ce7f9f9vEuEk= k= 'j<=i;Fh;@g:+e9 h:f9e8˙˙˙˙˙€?˙ū˙ø˙đ˙āāĀĀ€€€€€€€€€€ĀĀāāđøüū˙˙€˙ā˙ü˙˙˙˙˙(0` $Ģq0Ēp/Ēo.ļv.Ÿg)Ÿg)žf(Ģq0Ēq0Šo.Ēp/Ēp/UŠo/ˆ¨o.­¨n.ŧn-ŅĻm-ÔĻm-ĪĨl,Á¤k,¨Ŗk+ƒĸj+Sĸi* ¨q6œe'œd'Ēq0Ēq/Ģr1Ēq0gĒp/ÄĒp/ųŠo/˙¨o.˙§n.˙§m-˙Ļm-˙Ĩl,˙¤l,˙¤k,˙Ŗk+˙ĸj+˙ĸi*˙Ąi*ú h)͟g)~Ÿg('™c%™b%Ēp/Ŧr1Ģq0tĒq0éĒp/˙Šo/ū¨o.˙§n.ü§m.ûĻm-üĨl,üĨl,ũ¤k,ũŖk+ũĸj+üĸi*ûĄi*û h)ü h)˙Ÿg)˙žg(˙žf(ûe'ˇœd'B—a$–`#Ēp/Ģq09Ēq0×Ēp/˙Šo/ū¨o/üĨn1ü§n-ūŠn*˙§m+˙Ŗl.˙Ŗk.˙Ŗk+˙Ŗj+˙ĸj*˙Ąi*˙Ąi*˙ h)˙Ÿg)˙žg(ūžf(üf'ûœe'ūœd'˙›d&˙šc&ŋ™b%4—`#”_"Šp/Ēq0mĒp/ũŠp/˙Šo.ûĻn1ũŦo'˙ąp ˙Ĩm.˙œk8˙Ÿk2˙Ģl"˙Ŧl˙Ąj,˙Ąi+˙Ąi*˙ h)˙Ÿg)˙Ÿg(˙žf(˙f(˙œe'˙œd'˙›d&˙šc&üšc%ü™b%˙˜b$ū—a$’–`# “^!Šo/Ēp/ƒĒp/˙Šo/û¨o.üĨn0˙ąo ˙“kH˙\^…˙T^“˙W_˙U^’˙U^‘˙xca˙¨j ˙ĸi(˙Ÿh*˙Ÿg(˙žf(˙f(˙e'˙œe'˙›d&˙›d&˙šc%˙™b%˙˜b%˙˜a$ü—a$ü–`#˙•_#ڔ_"4’]!Šo.Šp/xŠo/˙¨o.ú¨n.ūĨm0˙°n ˙sep˙HV•˙Šo`˙ĸm2˙Ļl(˙ĸh'˙’hA˙^`†˙P\“˙ g(˙ g&˙f(˙e'˙œe'˙›d&˙›d&˙šc&˙™b%˙™b%˙˜a$˙—a$˙–`#˙–`#ū•_#û”^"˙”^"ú“]!_\ ZŠo.Šo/PŠo.˙¨n.ü§n-ū¤m0˙¯n ˙xfk˙LS†˙Í i˙ëĶŗ˙›^˙œc"˙Ŗm1˙ž`˙Šb ˙ube˙M[•˙Ŗg˙›e(˙œd'˙›d&˙šc&˙™c%˙™b%˙˜b$˙—a$˙—`$˙–`#˙•_#˙”_"˙”^"˙“^!ü’]!ü’\ ˙‘\ |Z¨o.¨o.¨o.æ§n.˙Ļm-ũĨm-˙¨m)˙›k:˙GX™˙ŗ~?˙îâÕ˙ĸn3˙ˇŒ[˙æ×Æ˙äÔÃ˙ãĶÁ˙˛Œc˙§_˙`_˙m^h˙Ĩe˙™c(˙šc%˙™b%˙˜b%˙˜a$˙—`$˙–`#˙•_#˙•_"˙”^"˙“^!˙“]!˙’\!˙‘\ ū[ û[˙ZƒX§n.§n.›§m-˙Ļm-ûĨl,˙Ŗk/˙Žm˙ndv˙mVU˙ŨšŒ˙Á {˙¨u:˙íâÖ˙­}G˙œc#˙¯N˙ėâ×˙ē‘b˙ŽZ$˙O\”˙c!˙˜b&˙˜b%˙˜a$˙—a$˙–`#˙–`#˙•_"˙”^"˙“^!˙“]!˙’] ˙\"˙‘\˙[˙ŽZ!ūZûŽY˙YwŒW§m-§m-0Ļm-ûĻm-˙Ĩl,˙¤k,˙ĸk.˙Ēk!˙\`Š˙‰]4˙áÉŦ˙Ŧ|G˙Ͱ˙ÃĄ{˙Ážv˙ėáÔ˙Ē{F˙Ĩs;˙äØĘ˙Ÿc˙TZ‡˙Œa7˙›b ˙—a$˙–`#˙–`#˙”_$˙“_$˙”^"˙“]!˙’]!˙’\ ˙\!˙”[˙Z ˙ŽZ ˙”Z˙ŒY ūXüŒX˙‹WXŠVĻm-Ļm-™Ĩl,˙¤k,ûŖk+˙Ŗj+˙Ąj,˙§j#˙Z_Œ˙Œ[)˙Üħ˙ŗˆY˙ē“g˙ūūũ˙˙˙˙˙÷ķî˙ãÔÃ˙”Z˙Ûʸ˙˛~A˙TTx˙†`@˙›a˙•`$˙•_#˙“^#˙›a˙›`˙‘\!˙‘\!˙‘\ ˙[!˙“[˙‚Y4˙DU”˙BT–˙sWF˙“X˙‰Wū‹W˙ŠVō‰U,‰UĨl,¤l,ę¤k+˙Ŗk+ūĸj+˙ĸi*˙Ÿi,˙¨i˙[_ˆ˙†[6˙Ŋ‘]˙äÖÅ˙–\˙ČLj˙ČŠ‡˙´‹]˙áĐž˙‘V˙Ûɡ˙°~E˙NR˙‹`5˙˜`˙”_#˙”^"˙˜^˙z_Q˙y_R˙–]˙‘\˙[ ˙ŽZ!˙–Z˙HUŒ˙uWD˙ŒY˙;SŸ˙zV7˙V˙ˆVü‰U˙ˆTƃO¤k+PŖk+˙ĸj+üĸi*˙Ąi*˙ h)˙žg+˙Šh˙l_l˙mam˙ĸ^ ˙Ëą–˙įŲĘ˙ē”i˙ŧ˜o˙ëāĶ˙ރS˙še+˙įÖÁ˙a6˙KWŽ˙š`˙“^$˙’]"˙˜^˙NNs˙Wa”˙Xb“˙KMt˙”[˙Y ˙Y˙ƒX.˙LU‡˙V˙ŒQ ˙}W6˙@S•˙ŽU˙‡U˙‡Tû‡S˙†Sy†Sĸj+ĸj*˙Ąi*ûĄi*˙ h)˙Ÿg)˙žg)˙Ÿf&˙˜e.˙EYŸ˙›e)˙˜[˙¯‡[˙Сœ˙Сœ˙Ŗr<˙Ąq<˙øøû˙Ė`˙MNu˙o]^˙œ_˙‘]%˙™_˙}_K˙Wb”˙ā™˙âě˙Yb’˙x]M˙”[˙’Y˙rWH˙SQp˙—\˙—oF˙ŽN˙RTw˙cSW˙T ˙…Sū…R˙…Rņ„Q"…RĄi*ÁĄi*˙ h)üŸg)˙Ÿg(˙žf(˙f'˙›e)˙¤e˙x_W˙GY›˙e%˙ž[ ˙’U˙T˙”[˙Ņą‰˙ęÍĨ˙e_u˙NV†˙›_˙‘]#˙‘\"˙—^˙~^G˙Q^•˙Øš‘˙Úģ“˙R^“˙y[H˙‘Z˙’X˙gUV˙\Sf˙ËŠ|˙É´˙ŗe˙vJ˙CT‘˙ŠS˙„R˙„QûƒQ˙ƒP˜ƒQĄi*  h)āŸh)˙Ÿg(ũžf(˙f(˙e'˙œe'˙›d&˙˜c)˙Ŗd˙u^Y˙CXŸ˙p^`˙b7˙”`'˙šn@˙…k[˙CIy˙RW€˙˜^˙’\ ˙‘\ ˙[ ˙ŽZ!˙•[˙RNj˙P^–˙Q^–˙OLl˙‘X˙ˆV ˙“X˙\LS˙wu˙Ášj˙u;˙ư™˙Ŗq3˙EJu˙nRB˙‰Q˙‚Pū‚P˙‚OõO%OŸh)Ÿg)ņžf(˙žf(ūe'˙œe'˙›d&˙šd'˙˜c(˙˜b(˙–b(˙ b˙Ž`1˙^Zu˙NXŒ˙NXŒ˙KU‰˙RV˙z]M˙›^˙‘\ ˙\ ˙[˙Z˙ŽZ˙ŽZ˙“Y˙|\C˙{[D˙‘W˙ŠW˙ˆV˙‘V ˙_R]˙bWe˙’X˙N˙•j;˙Ȩ€˙bH<˙PRv˙‹P˙€O˙Oû€N˙€N†€Nžf((žf(øf'˙œe'ūœd'˙›d&˙™c'˙›c#˙ĸc˙Ąc˙™a!˙”`'˙˜`˙Ÿ`˙™_˙’^#˙•^˙œ^˙˜]˙\#˙\!˙Z˙Z˙ŽY˙Y˙X˙ŠW˙‘Y˙‘Y˙ˆU˙‰V˙‡U˙T ˙bSZ˙[Q`˙R ˙€N˙w?˙ŋ¤†˙d7˙?K˙P˙N˙€NũM˙~MÜ|Kf'*œe'úœd'˙›d&ūšc&˙™c'˙œc ˙”a,˙Y[€˙WZ‚˙‘_+˙˜`˙”_$˙’^%˙’^#˙“] ˙‘\!˙\#˙["˙Ž["˙Ž\$˙Y ˙ŽY˙X˙ŒX˙‹W˙‹W˙‰V˙‰V˙ˆU˙ˆT˙†T˙S ˙fRP˙XSk˙‡J˙°’q˙ļ˜w˙Ą}W˙˛ˆU˙CDh˙mP>˙„M ˙~M˙~Lũ}L˙}KCœd'$›d&ö›d&˙šc%ū™b%˙™b#˙—a&˙CWœ˙rZR˙nZZ˙KW˙š_˙’^#˙“]!˙’]!˙‘\ ˙‘\˙[!˙‘Z˙™]˙œd ˙”X˙ŒX˙‹W˙‹W˙ŠV˙‰V˙‰U˙ˆT˙‡T˙†S˙…S˙‹R˙lRC˙NNo˙“\˙ŧ¤‰˙˛“q˙˛•v˙š”d˙YHJ˙WPd˙†L˙|L˙}Kû|K˙{J‰›d&šc&ë™b%˙™b%ū–a'˙Ąb˙XY|˙l^f˙ŧ™i˙˛†N˙PT€˙€\@˙—]˙‘\!˙‘\ ˙[˙Z!˙’Z˙†Y+˙WY~˙T`”˙dV^˙X˙ŠW˙ŠV˙‰U˙ˆU˙‡T˙‡S˙†S˙…R˙„R˙ˆQ˙tQ3˙EJu˙šh-˙°’r˙–l>˙ĪŊŠ˙¸—q˙uS7˙FMz˙ƒK ˙{K˙{Jü{J˙zIÛd&™b%טb$˙—a$ũ™a!˙Ž`1˙GS‹˙dž[˙ĮÎŲ˙Á­‘˙aYn˙nYV˙™]˙[!˙[˙ŽZ˙Y˙ˆX%˙8KŽ˙m[[˙¤Œx˙ACj˙USq˙U ˙†T˙ˆT˙‡T˙†S˙…R˙…R˙„Q˙ƒQ˙„P˙}P"˙@J|˙•i5˙¯m˙ģ ‚˙ęâŲ˙´—w˙‹`0˙>I|˙zK˙{J˙zIūyI˙yHéxG˜a$ĩ—a$˙•`%ü`˙q[X˙`Yp˙Ä­Ž˙Ũâë˙Čĩš˙c[o˙kXX˙˜\˙ŽZ!˙ŽZ˙ŒZ!˙™]˙a[p˙meu˙ҍp˙ėâ×˙¯B˙K]›˙‚Y2˙‹V˙†T˙†S˙…R˙„Q˙ƒQ˙ƒP˙‚P˙O˙ƒO˙AK}˙‡a:˙ŗ“n˙ů–˙ōíį˙ą•v˙šl4˙@Ep˙mK0˙}I ˙xH˙xH˙wGūwG4–`#ƒ–`#˙“_%ûž`˙^Wn˙udd˙ŅŞ˙ęīö˙ÄŽ˙YWs˙rXM˙•[˙Y ˙Y˙‹Y"˙š`˙_]{˙…xz˙âČĨ˙õöų˙ČĄo˙Xd—˙]@˙ŒW˙…S˙…R˙„Q˙ƒP˙‚P˙‚O˙O˙N˙‡M˙IMt˙qVC˙¸–m˙í•˙īčâ˙¨Ši˙Ŗv>˙GB[˙_LI˙~G˙wG˙wGũvF˙vFQ•_#G”_"˙’^$ũœ_˙VUv˙€ka˙ŌÉģ˙Ũáæ˙˛•r˙LQ~˙Y4˙Y˙ŒX˙ŒX˙ŠW˙U˙xT6˙4Fˆ˙‰_5˙ÃĄw˙U>;˙CPˆ˙‰Q ˙ƒQ˙„Q˙ƒQ˙‚P˙‚O˙O˙N˙~N˙}M˙†L˙XN]˙XJS˙š”e˙ģŖ‰˙ŌÁŽ˙{U˙ĨzE˙P@D˙SL^˙F˙uF˙vFũuE˙tEd”^"“^!č‘]$˙›^ūVTt˙~g]˙ČÁļ˙ÉÆÃ˙ŽpV˙FQ†˙“Z˙‹X˙‹W˙‹W˙ŠV˙ˆV˙U˙mTJ˙EW˜˙PbŸ˙HRƒ˙‚S!˙†R˙ƒQ˙ƒP˙‚P˙O˙N˙„N˙ˆN˙‡M˙L˙K ˙jN<˙BCf˙˛‹[˙Ž’t˙Ģk˙χd˙œq<˙Z@3˙JKm˙}E˙tE˙tEütD˙sDm‘\ ’\ ĸ\#˙š\üaWg˙hX^˙¸™n˙´Z˙PS|˙hVX˙“X˙‰W˙ŠV˙‰V˙‰U˙ˆT˙†T˙ŒS˙ŠV˙‰^1˙ŠP ˙…Q˙‚P˙‚P˙‚O˙O˙N˙ˆN˙pL+˙KGb˙BJx˙jL7˙€J ˙yL˙8Bu˙zQ˙¤ƒ^˙̌j˙Čĩ ˙|I ˙dE,˙CJv˙{D˙sD˙sDürC ˙rC i[\ D[ ˙’Zũ„Y0˙>S›˙„X-˙]Tg˙HSˆ˙‘W˙‰V˙ŠV˙‰U˙ˆU˙‡T˙‡S˙†S˙„R˙…R˙‡T˙P˙‚P˙‚O˙O˙€N˙N˙†M ˙QI]˙?U›˙‚™˙|qt˙9J‰˙{J˙I˙CIs˙pVE˙ŋĸ˙r@ ˙zK˙wC˙rQ3˙?Iz˙xD˙rC˙rC ũqB ˙qB YZĮY ˙“YũsWG˙DT‘˙VUs˙ŒW˙‹V˙‰U˙ˆU˙ˆT˙‡S˙†S˙…R˙„R˙ƒQ˙ƒQ˙ƒQ˙‚O˙O˙€N˙€N˙~M˙†M˙RK`˙QfĻ˙ĪÂą˙ėÜž˙ƒ|ƒ˙@Gu˙I ˙~H˙[LQ˙E?U˙Āžs˙Šd:˙n<˙zI˙‡jO˙=I~˙vB˙qB˙qB ˙pA ˙o@ ?YYVX˙‹XüX˙‘W˙’W˙ŠV˙ˆU˙ˆT˙‡T˙†S˙…S˙ƒR˙…Q˙‡Q˙†P˙„P˙O˙O˙N˙M˙~M˙€L˙tL%˙=NŒ˙Á¤y˙ÖËš˙€“˙2K–˙sI˙zH ˙xG˙rH˙5C|˙‘h8˙줋˙]%˙ÃŦ˙¯Ÿ“˙5>p˙uC˙pAūo@ ˙o@ đn@ ‰V‹WÁ‹W˙‰VüˆV˙‡U˙ˆT˙‡T˙†S˙†S˙„R˙†Q˙ŒQ ˙P˙vR2˙tP1˙wM$˙€N˙‡M˙†M˙~L˙{L˙~K˙uK ˙;N‘˙^bƒ˙FW“˙ ˙l> ‘ˆUˆUŠˆT˙‡Sû†S˙…R˙…R˙„Q˙„Q˙‚P˙˙k> ũl> ˙k= D†SŠU†SĮ†S˙…Rü„Q˙ƒQ˙‚P˙‰P ˙dOL˙RQn˙‡D˙Ÿ|W˙ģĸ‡˙~O˙{I˙šrE˙ĄvC˙“_˙sA ˙LCS˙7O™˙aI>˙F˙tG˙vF˙uE˙uE˙tD˙sC˙rC ˙qC˙tA˙fB#˙1J–˙qB˙~>˙?Ep˙UB?˙q=ũj= ˙j<Ôj< …R…R#„Qé„Q˙ƒPũ‚P˙O˙†O ˙gOC˙NQv˙~@˙Ēm˙žyR˙j1˙ŧŖ‡˙°”s˙ĸ\˙¯”w˙ģĸ…˙›k.˙i; ˙9M‘˙QI\˙}E˙sE˙tD˙sD˙sC ˙rC ˙qB ˙qA ˙nA˙u?˙TCF˙4H‹˙JDY˙6G‡˙m=˙j= ûj<˙i<ki<„QƒQ=‚P÷‚O˙Oũ€N˙N˙‚M˙;O“˙qJ&˙‡R˙ČļŖ˙‡Y&˙°“r˙ŨĐÂ˙ØÉš˙ŋ§˙”nC˙礌˙¯l˙v;˙˙]@-˙ED`˙g=˙l<ũi< ˙i;Ôi;h;ƒPOJ€Nų€N˙Mü~M˙‚L ˙lL4˙6Oœ˙uD˙Ž[#˙¯š˙Š‹h˙žĻ‹˙Öȸ˙ŪŌÅ˙ĖšĨ˙n:˙ļ‚˙Ą€Z˙o9˙5L•˙jD˙tB˙qB ˙pA ˙o@ ˙o@ ˙n? ˙m? ˙m> ˙k> ˙n<˙r;˙k<˙i;üh;˙h:Jh:OMDMķ~L˙}Lû{K˙ƒJ˙eK=˙5Ož˙dD.˙F˙§„[˙ą—|˙Ģo˙­‘q˙˛—y˙sC ˙o>˙ē¤Ž˙w@˙SGP˙LG\˙x@˙nA˙o@ ˙n? ˙n? ˙m> ˙l> ˙k= ˙k<˙i< ˙h< ˙h;úh:˙g:Œh:€N~L/}KŪ|K˙|JûyJũ€I˙oI%˙:M˙GKr˙g<˙}D˙\!˙Š_.˙ˆ`2˙vI˙œ|Y˙´…˙v9˙XFG˙GFd˙w?˙m@˙n? ˙m> ˙l> ˙l= ˙k= ˙j<˙i<˙i;˙h:úg:˙g9˛i9i;~M|K{J°zI˙yIũwHû{G ū}F˙WIO˙;N˙AHu˙dSS˙ŠlN˙ŲÉļ˙æ×Â˙ĩ•m˙wF˙^A-˙1J–˙dA!˙p?˙m? ˙l> ˙l= ˙k= ˙j<˙j<˙i;˙h:ũg:úg9˙f9še8 f9}L{JyHcxGîwG˙vFũuFû|EūwD ˙^F;˙EHl˙;I‚˙9F}˙IWŽ˙8@p˙7E~˙:H˙aA)˙q>˙l> ˙l> ˙k= ˙j<˙j<˙i;˙h;ūh:ûg:ũf9˙f8Ÿf7i=f9|KzIwGvF˜uEųuE˙sDūsDüxCüzBūtB˙kB˙b>˙dC&˙jA˙t>˙p>˙k> ˙l= ˙k= ˙j<˙i;˙i;ũh:ûg:ũf9˙f8đe8bf9f9yIvFtE%tD–sC īrC ˙pBūoB˙oA ũq@ ûr@ûq?ün>ũk> ũk= ũk=üj<üi<ûi;ûh:ūg:˙f9˙f9÷e8™e8f9f8wGwGqB qC qB dpA ¸o@ ņo@ ˙n? ˙m? ˙l> ˙l= ˙k= ˙j<˙i<˙i;˙h:˙g:˙g9ūf9Öf8€e8f9f8uEtDn@ n? Cm> tl> k= ēj<Ëj<Ōi;Ņi;Įh:ŗg:‘g9bf9+d8f8e8rC qB qB g:f9e8˙˙˙˙˙˙˙˙˙˙˙ø˙˙˙ā˙˙˙€˙˙˙ü˙ü˙ø˙đ˙ā˙āĀ?ĀĀ€€€€€€€€€€€ĀĀĀāāđđøøüū˙˙€˙Ā˙ā˙đ?˙ü˙ū˙˙˙€˙˙˙đ˙˙˙˙˙˙˙˙˙˙˙˙././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils.css0000644000175100001770000000040114637570305020364 0ustar00runnerdocker @import url("bootstrap-astropy.css"); div.topbar a.brand { background: transparent url("photutils_logo-32x32.png") no-repeat 8px 3px; background-image: url("photutils_logo.svg"), none; background-size: 32px 32px; } #logotext1 { color: #e8f2fc; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_banner-475x120.png0000644000175100001770000005045314637570305022651 0ustar00runnerdocker‰PNG  IHDRÛxē…XĀtEXtSoftwareAdobe ImageReadyqÉe<PÍIDATxÚė]`Uú˙fĶ„E^Xč+MITä°&6ÄFâY°œŗœåŧ$÷ˇßiĀV’xŠŠ%ÁÆY *EQ U9–* B =;˙÷Ŋ}3ûfvfwf[xLvgwö͛yoŪīũž÷"Dˆ!B"*ąâKâø[ŌAüīĒ_:×%îˆ!B„ V¤Ŗí‚“Īŧ=h*y›F´S|īÄ}Y–uGËĘļĢž+ĮOÉŪōž’ŧŠ û Ë_Ŧ]Jˆ!B„U`Û.ũ'ÂtŠ(¸‚œÎ!'˙VųW‹žú2č"đR.'ā+ą!B„9ōĀļ}ÆLd¨ĶČÛtĀzŠE°ôč*{¤˜ōZÖ¸B¯!B„Ø9ō5€UĻŨ`m€=+AuAČLđ¨ˆÃ–a)‡ĒæŲā}I¨›Ŗ,‰“ōŗ•ž@\uŸä‹;&DHĢ[YļmŗÚŦT‡ŗ˙ŠëŦŲ¤)fāē+mIRAN’$XJėŋ–›jāŽÄZPÆˇžc-”ãųwĀ•<Ÿ+ĶZ˜ Ŧ¸Ĩ"˛_7úFŨYtŖ*Šļ#”“M€­!BÂ*ŽļVá”sîJM9ûNZŌ˛[H@-ŅĖķE7Â!X*€éK‰"qûʏ5åp“(mėX+ehęBË!“)ė  ‹ĀëŨPˆ!BŽli3Ė6eâ]hܔGŲ‰$1†*)ŠZ/ĐÉvĒė%ĨF ÕËĩĮ–=û¤˜™d?™.ašųĸ; "¤­Šœ Ųā]ĒŠĘ Lܕ6ļ'ŪN^ȲUQYt:ЕÕš C¤jŲZ9˛Ęte™ŽœG՜9tËEˇ"DHÚ" ļügÅpsÄŨņHĢU#;Īũ[ĒķÜ{Š"-&[:¨°&éĖē STõmDTËËŅ(Ÿš‰{ëŖZÆw8#\,TËB„iƒŒ6ÛāĢlöÖļÎI÷:“ū–O°h  l-@yaK’´@æK)‚ ë=Ė|=7XāVAw&ųŗŠnščžB„i2!ČīŽ*iUjäNē×e‹dĘōŒÖa¨)jXÆũŠ–;Ju0"æwŗ†Įė%ûõpFĖÃķ¯i>֒íëĻ^đAã¨r'čÖs#ŠZVĘĄûФdšš+^*ŨTˆ!B؆˛“īs2㧙Ôh‰âŽ?ã'˙ ;Üņ;\û+œOļáŽŊô˜ĩîc ˜v%`ÚæÖŸ•JņøAAûxŌWđ|ōgđzÃ`¸§öLē‘[Ī•šĩfīÚ28ŅU(nôM#Wŧ˜+ēǐ#Y'å/†ŽYuŸäKâNŲŅ’K*‹ˆ[FĖËöķÖļĮLž°"tá‘yĐÆ?čzĘkl„`yUė:¸%îč+UÁ6š#|ÔtŧŪé|˜ŋŗTĘ Đą}"ŒØ†ėį“מŨ9ī.—ŽŖÅØ8P-ë‚ØMt—Āq›aōĄK PwąmDĨ ˆanũŦBŽ!˅™pp…Ą#YRÅ-ˆˆđūåårpę ˇ8Bā.ĀÖ6О÷@> ĨąžĄŌ}IXI9%˜ũĖŗßĮQ ÷Æ-Ĩ@[EõŖæãanc&Ŧ!Lße \uzOØļ{?ĖŊ{ Ü$õüU‡j጑Į‘īšM;U Ãr^o6 „ÛŊ‹Úŋ CĒs Š°a-C Ė"lĒåėø17áNŽHv D€­Ö&huLwIX´[;ŌųüŋŖĩm)AĄtoä'/ ¤Ŧ0Ëí먂ŋÅ"ČŽĨ,öֆķ(“E T0īĒI§Ā×Ģ…oVo‚ņ#Âļ=`8Û×?]IAö‚qÃāÖKΤėö–'Ū€JĀ XbYįž –ĩžHú nŽ™¨c¨Ņrĸû8kÄû–%ē­!BZ!ā pmM`ÛåüĶŒ,ĻĀ!Y]‡Õ‚.‚ėô˜•„i&Âmdį7÷Æ"VVuH9ķ?ũ^īoÖĀ^}ģuĸĀ;˙“•ô'žú aˇdŋ æŪ3ŽúĮ< Č!ā>R?OÄIÛÄĒEĐeĖŪ€-g†[DØ­P) 9b$qR~ē¸ áÕĐJ$ĒŽ?].x0›Įb#NÉÔ=G2uķAc§% E0=ö{xžųTHĢģ…ío9>žŦ )˙#ģĨ Ėš }Ŋz3\•WLYîđãzų¸įlsw¤ë¸Ąûįz™ģnN¨sÉõë*„*å"Ņu…bA„ĪūŅÆlŊđ3 a+TĒįEå€-Žo‰ųŽũ –ēû„†ëa›œâģžËĘpļK„áz°=Ą#y‚ëŗ-[OÁÖČUUÍøŨã†ÃÚMģ4uQP°ŖÔ@˜n|pĄ j™îÍK–ŋ Ô6Bû"D€­´y4”—ÄĀCæÔŠ5ŽĖ?iÃ-JđlĖ05f <Ņ|<ŪtŸČNU-wl—įĶŗÆS EAđÄ ?{ô–‹`né7pßÜ÷ ũs˜”AŪPīlÅ]¨u„~,Š{sEÃ˛*D7"ؗ!G9ØvŊ(ŋˆI6‡Ž`tééLXŽęāÕØˇa˜´Žmē >n>A)AKŲé™ãč†ō1a¯ŋö9|ŗf3NPmęēaÎ]WP†‹L–B:|\ĮEKeüŗ%VAõĒø đaĶqŪĨSh5ŠüJ āŽ"€+,”…´eé(nļ!­‡Ņ2+bH YŽt Ŋ¸ņZX'wÕ?Ąœ?f(<û×Kéûį˖ÂķĨKĄę°`Į }ģ9ŠAeĨx=ęaP?ãû1Âz€é1\V!\'ÆˇÔM­AWč kĖÂ×?W’LËHÅü¸äa0%¤-‹P# `4Đ^\āÍ!ËĒņ“UĐ}Öņh›ĐB7Ž{ÖsŸÍŊĻžsŲeđÄë_@%Ųņ`§gŽĨęd3Š:T/ßā5X"uAļ‹|F~ x -áÄrXëî ¯7ƒp§ō3fa ¸ŗÆN/Š_ö|ščÎaņFČ!rĻ˙ˆ\Bްí–ųĪ"‚Ų\¤'  Ģŗ˜,ũ W:VÃ_š/‚õrwāƒY ÚxáŖ×CŽ×>ô:|ŧlƒĘr˙ķāÕ~ëUu¸ŽųįĢô#J=2ũB¸jâÉp5Z\ßåUÔ÷Å-Ŗ1•Į×\įš„Ö›Ę¯(aÜôQõKŸo•ędæÚ›”\” Ž;¯&[YŨ'ų­^%NŽ'•ŧd’m$xœøŒU˛kQ@ģœ\Oy+Šo:ĢoĮ&Ŧž•l[ŨBu  D€­} ũ?Ŗ5\‹åršRlō fŅjᙘ÷á÷éđĻ<’‹å)ãÂhh/ž÷Xˇų7Z&6)ęd3ÅĩÜĮ^ûÂk05°' +ËÔŊčļÄ 83n'ũîĢĻ^đpÍŠØ2ĖHwKqũŌšŽ05§ĶãXÂeģ ëOpĀĪd ›Ķ˜.›xApk‹Šl1ƒ]OE„ëęũWÚÁ1›”WÎ& Ą‚n8|Ãí0ā%! ŧú‰f\#– Äā|)ÃHdsõã&5úöO#Į/ļYŊĢ‘Ąôe[Ŋ†YŧwĶô-=)Āßás<Į?r–ƨ°-ÚLĪÅ z(v˜?™î•Rŧ(†jHōYĪŊįĒ xķ‹U°l­‹oîāZ,2ŨĨkˇhØ˛‚K㇤ `9=v%<˙%Ú[ë'ŗŸNågtũ%'ģŪl˙8Ĩzx­~0=âöÄÕpaÜf]ue āV2'EĶXj‚n g} ,ŽXV:)3Ģ%Y.9?j ōÂP¨‹Iy‘\ĻŽ/‚đÆNgíĐj&?Q+ÍÖržTũ3˜ķ,ąŲWÚ “U&8é!<ĪøŦ’˛fĀ ¨U Øö¸äazb/CUĸÛŨžd‚Їl‹äA@ÂrútuŌí…÷Wø°åE+~"@|,yævxã‹ ˜VRëcØb@w7bU5¯%`|ÛSīĀŸũjV.īí#ņ_PVûxã8xŒlzëgSЕeŸD fëšf`92æwXÔū=ØęN1ÕSiđ üņkõƒāŋ)Ĩđ@ōwđpíiØrvâø[ 꾙ãŠâ Ą°Š"á.*'Š*ÁJvM8ˆ•Bx-eiLpRö¨pƒ)ŗ1ÚH‰2ųɈ4;"$ lļĐ)(¯ Оä‘fđÜĪ#e";Î! [1°íqé#JR§¯ZØčúŗ(ĐĸŦ‡î\Œ`(î:Û÷T‚Ū?wûž*ȸcÜ|Ņ7ŧ?^*Y.2^´VūxųOęz-į0KËīØĪÅ}ĄŽi¸„fō‚(ø&Jāüs•ē‡ĸZÆP˜?vōĄK š%SĀÃ1{Ņsui„áV×‘,¯?ļLՇŅĘ›ÆTŦĨŲŦ-¨†uE‰áV°kZ ‘ ļĘô°h "8Ņ1›,ŦŠōäGHāÉašŸöJĶkwĸä:’nÚ"€E†ZfĪ`Ī›S÷\/&ßį˜ŠŨC[ÉÃhSõja­ņ“¤2IŋÁ,€‰ŦîĨk]ÔĐéĘsFÁ‹ī/÷aËÂi‘6ē”ė-Y[gXv„:ø[ė74Іŧ¨ņ|x9†j=a}(ĒeĖ(ÔĪQíIã‡@Ģ fņl}ܟô-\ŋ…2ŨlYaˇŅR÷­ŠŌyö‚g¨Ų>Ž…ÎC\ Ņ÷SÅÉĀmy!;öĄ `HgũCHļ…uÔ­2ŅÕOVrŦî~c:A$4y:m’bXh(!%"čyŲŖäDRĻ&Ŧž¤†äg $>%.Ī€¤°„;ĀÃ^'K ËyņũđĐ ‚›ƒ•$É´bû$'@Ŋ—€lEÂ\˜ŗnkŧ.l¸šĻę“Ô2ė'JPĀ%90/Įķጨpuü˜^{ޚP-™‹ŸĐ8.ˆß &É ØåŅē8ŖÄrĸ-NÆ #-ŲQēža`ĩÁm… pE0 !mIôö$ŽVVē•Ėhn§-Čōg,4Øöŧė1ō I…> ĸ$; ģāÄ2č7IË ËųכKāÅ<€ûÃË3áîŠ0nx*ÛúĶ }g5ĩáę‡1Ž‘ÉŽJ˜76ûBķŠ0ĒūVxĶ=@W—đ€.— Čh@ū<Ÿô)|Ø8nū˛ á÷gÆî ”HŨ­Ŧ“ģ˜šĻ<5/éĖëHlĻhg†01¨āÚŖœ :AąęPŽAˆ(ŗÚtŨĮYŒŠ†¤Y Û(ÚåūŽ ZLÆvĒ÷ÖĒs9u)Į$Ũ|ŒÖseøL†Īa|īëdž?×0 ëšūUË÷Į/§ëĩ÷Ö§ûÍ4â¸Ū°vË>z,Ē›qm×ŋޜ–tÆmŠĩ_?įja€ĨĻņuŸäģ ĀBņMˇYnĄ?UM„Īģ|׹đPũœjŗ<ŧÅA­‘:ĖJ{€IāÆRgØpeŊ8ËÂą&ŸO°Ņ lÔ­\@Œ?Ģō@āht–Øöēâņ|2ļ§ų‚ĨĐ5 fÁ¯įJ°z'ūø?øž—ļĀŋå xOÅ÷öŊ•đā+ŸčĸPŠsjč4VrÁy1ŠJÕÆKå~4"Õî0¯ÅjĀ’Ŗ˙`ö×sŅúųÖøaNÃI4_Žd˛ž‹Á7.?ųu'-ģ_ĖAr|Š•T~Øšō[ Sã@žh=Ã(cÁėø`Ļ"P›͈ ČæM¸=ŸŠuí€Õ„`ĀėûŦ“ēįhē…kÉ`Īā }ĸ͝‘īķM&ųVÁÖŦ !B,>kŧ”DģļÁļ÷O ČæÉĪPuÆOf Đ"Ë}KËČŗ~,†ŲR)ŪE°”ė¯'@ŧ”Ž|Š=€>r%ô‘*aė†aŌnÕĒyМ ˙rO€Ũ'ĀvŲŠ‚1‡ú† ÕË t¯Žķ„™œĶx’Ļ.˜yčüqà_ˇcāĩOžƒ ĮPMŧÖ4 gÄ›z[‰Buq €-¸YvŒ˜”ŅØÆ&€L‹ģĩ4qāŽ%‡‹ÜdEl¯yrĄ/ôzĐE×$đžY\dŲŖÄx.¤KšĻ§uƒ­ĸžōF\ō’e–ktŅXj†| ü2`2ücÉŊÁ×ģ$ß(UR"uÚFõM9 –‹@ ĒE/•°~ŧc;(á™ÖĘ]ĄJN t5ŽBd˙Öø`~ãPO™ŦŒá{Á9pˇíŲ÷M›Dũ…?\ē–˛\ŊU€ĐiÉgū%ĩæĢgĸÕŠf‘:(—#¸l°BdSÎ[&S㉠,†s# ļ`/ĀF™ åÚŖƒ‰€uKķ´Đ6bGZÜļĀØöžō¯luV­€%ˆčzØōvšŧH öEŖ!ĻZīû`RųéÁr˜c/Mī×WĒŽĀíĪ7J˜ōņ4‘ŧFĩlƒ-_ģž”] sšNVkôéŪ >~ōVę 0<æwvÉ’Ž…xëge_šĨ~˛ŋ(6;āŠks ´œ,ŒĐŒÛŽ•ųl?kĖ–ĩ`ŨR9n ŌĨ" ZĨč€-šŅÕ ˜{ŠčjG|I‡§^p ‡ĢP° Ģ­‹AãĨEō‰ĐĐąœ›Öό?zN‡‡ÜWtžž?ū_ę‰:E˜ĻĐEļŒiû0ö˛â*tË%gPC¨[ūõTŗô(ãG¤¯#Čw’Xüē I-ÔĄB; Šks…A=Šp†é6Ž ™õŗû0ÛĻļAˆļļ3äĖčĒ–-mߊO:=ŗjIÃHMƒYØbšáŨĀl8ōÜĸí“âāøaÔ€Ž>ĸ\2î˜4v8ŧ}Üõpcō p^Üfx?~žp-úįŪ˙# žACB"­iŸ ÷];æž÷5ŦŨ´K-?G2ʇËÖy¯Aŗ`F ÛĻĀÖæš_kž6;`m‰ 2˛UæXV h_,Æt!md"ĪRa4+`‘ŲJ3ÉĀíԔ¸~XnXAąåđŠ–šO„)ŌjWÃo•ĩāFPLiĮõégŽ™cO€äácáö”› oĖ!¸uēēpuķĸ>œķ ‹Ŋėųî–Ŧņôõą×>ķ‰BĨ¨‘1ž3Oņ×č'‚”æÛĨĪHocEšÕ™eîQļ6YíÂp]mWę(DHԄE‰Ō-Ų„ŨEĢÁļīUO9ɘ=Ã,ÃēāZQĪP-˛ÜĄ¸A“Öãší?jہ÷ĀZ×>¨:\ 1ą1ĐũØÎ0ü¸Ū0é”ĐkäÉpKģ ¯ŖšFĻ …jxĖ^ĒFÆš ãžęÜS`né7Pų(T¸n{ū]ĪSÕ22^gû$-pKF ËŨ+ûƒykW[°#”gd*ŗhN€DG!­Ur &¸ĢXé–[ Y-HN/fø] ÔAnͧ° ņyēå7ŊŊåžę\ĶõÜđŠ–cĄŦįzŪUA\×|œëŪˇî}>­ØËÖo‡-;öÂÁC‡!6†€î1)pęņŨÁŅo0<“A“ô“Ēšž ‹éûÖēģÂ6čH?ŧ`ėPšÍˆæÕ5 ũ8˙Ķī)đ~ŗf“~*bûŲ‡åļ5°ŨjãØŖi`ˇÜŽȎ´5õ"$ĘėVIÚPi0Ž`ƞ"–Õ§eĀ–ŒØĶTāT­Ę ŪjaAÃøsķ×°ŪŅ V8Â$÷zXŪđ\Ūü}dUË ëÉĐøÉ˙zŽ—åŽ“ģÃ_ÜÃ%?ĀÍŋũžú~#|üŨ/°jãVØšgÔÖÕCûÄXH=ļĖKÔ ScÖø€%_´`~Ŗy¸zīĻVûҞõ°]Mũ§7ƒb,÷0§ĸ ´ Û¯=vT°GS\Ū–1;ā-˜­ļ¸.ƒ¯ŗÉļŠ1ŨėpPųõŗíwõŦlPŌįņ¸Q/5CŠ\ã ŒĨ0ö\ĘtŸjz‹ļ æ_ŋÚ0ųįōĄããTŦņ›Ę͏¯—åž%„íÍN(ˇā´}[ īĐe°`Ī8ĄWGčŅ)b ‹Ū[U MÍnXä>ÆIÛ||k•`ÃōRw?zÎŒ5f\ķĪ˙Lå§`§Į ėæĪmkLC$& lËÅ­"Ä?ā ňg…`ėۏF\Ë-$ĮáZīėP[ Y­lrž÷§ËŋÂh÷¯0%á6¨–’!†|ŒZ\7é c'Ņcž$€ģÃq ,—ŗ0]ŋĄ4É °Œ‰q@\L y×hÜöAÃFžÕ|<-ŋķj^„Ž0įXx-aĒ ŽĒi¨;D`XHžn<;EV‹VČërūØĄ”ą~´lOŗ A÷›Õ›i9ØčkĢū @ū\oĀ!B„Â.ÍcKĀc$cā–tÍ‚1˛\dÂčWė/^P`›zÍėTZÅđÉt/oZ Pu3ãЧSôp&ÂĄú&Øŧ÷0ĖŽ™cܛāÉÆˇ`lÂũ|ÁŌ Šē úÁDĄ"ûtsB%…lŲ?čĘēL>ÛĄdš¯ƒą°ĀĶÍ˙Ēē$XŨčqC{čq/¸Og_†zžƒ1›8 ĢũxŲz5‚TJûD¸jâÉôØųŸūā laPŽG ¯ %Fŗō\Ŗ,ÔzB„b ē¨ *g)ø”ŦWNÍR!cģÅäĩÄnÖ X?Ŧv†Ė€MG=čŽnūæÅI­z MÜú› ‡ë›aÅĻũđũ–J¸Sž ‹ęŸ¤jeT/›w8TËŨi’â=`¤”?ļŦɕcȖõ,7ËŨ&:+m…žl‚ķąûDxÃ=’†p4b¨˜uh˜´wŸáeēlo{ęz,æā}˙‰Šąʰ=āļ'ß6NäĀ@ųË*Äą\‘oTˆ!Büƒ.2W´VÎÅõZđøŒ›iQØ.‚mUĐõŖF–2%l2ŸÆŽŽ/ĘĐAŽ…Ūō~Âl{C)Ѕ€īŧØ3!…€ķåÍ+!(˙\ ŖÍ>ž …Ũ„AĪė} îûš äāOō†¨„~ôõÖ[?cö ­4ŸŽRæøá`Ũæß`ûžJę?;=s<_ļÖnŪ /÷Ŧá' Ģˇ8FĄ ī â-K’äC’Ā×]¨õ‹&î:ŠÆĢך*†K!BÂÃvɖO6dG˜ˌÁb(ãŁ܆ Á– Đ͌ũju Ë@×k;&ĮBÚÄÄHNN‚”vIp\÷Đŋk2ÔĮˇƒwbO…IîužÁ,ĐÕ H5qÂ$ņī4ŋŸ6? WČ?Bs—~3pŒ…-0O~>s?K€øˇĐAW_ũŨ PÆ8i+ŗBö|‚Ėv-[T'wl—/”-SŨ|0!Áø‘4A1” DÕrĸß$ēŲ ¤ė¸* °ØĻ‹6r”oŲĐe¨ŋ čĻ\3?ÛtcŋZtŊXã€øXÄÆÆ@bB´KN&[tII‚]ÛCJb|3Îm^GŨ„ŧ˜|* ¤ņvķ‹ĐĀe17Áč˜ŋAŲIwÃúŗîƒswĀé1wĶcßuŋLטĄšŗe C !ôŖ“LpŊÖÃl=eášėŌĩ.úÁåžĢĪV*2^,ņg˜’ķęŗđÃrۖØ ļ(Nb$Dˆ—í"čĮ@­\‘™ŽØœöúÕĻņ€cĖB2Ā@]oâã!))‰˛ÛöÉ ĐŗS]Įũ.Öx´ŧ Âúņ•ĻWi@scgĀ ‡‡NŲF čNÜĮĀeŽaģÔ æš_oąT~ØĨōzš;-§o7'e˛ë6īVY.Ęԉ'âŖ§.}U,ŅŧõT~ZĐm3b•EU†1Ø~[–ŒŦešŧD¯"¤5‚n9cšFŠûfZeļ鞥A›ķ´ã–š™å/†2Dv‹€‹¯Û'Āą) P“LŨ„†ēw‚ŋЏV@w˜ŧ ÆČ›!7æ ¨Ļ4˜ßī >0 į1ĐįX'ũ¨ZJ‚?;ŽĨėÕĖŅMåįy‡*dŒFU%%Ōũž]=ĮëļüÃö¤Ā‹‚¯ãF 0KyaĩAäĪm3ƒadvR´mÁ/ė´ãÅal“T`+’9šˇŒŖQĻīķ[L8.IĒ[Ëe‚jáĻfjÜĐėv{7.–mBBÔÎ`=ÃAî ŒCC*õģáŧS!ĨĮų†ķOQËDfģĀq2L!`ŨT~°¤`‹ū¸ŦŒqÃû3VK´Ģļ=¨ëÆËÁ€Ãj*?ßĩå6!v@báŅô`3Æh'‘{¸\žėL€Ģr4nŽîc§Ņscäú“ΉÄĢ×Į3ČëqcâŪë›Cum446CssuŠ! 7.ĻbĄSr$°EωVÉ<ČœĪĢ‘{Nb| Ä8ô€nÎöôøŒŨÛ`O—S û¤“ā´AŊaˆTh°ÅmÉę-dsAüú-Đû—ˇ ëíLŋß_] MÍôũáē“`,ÔúQuņąî*4TÚMũp=åH4L#u,ĄÅq°•ôČŦdļ)Ē4uņ fĄúįļ!Āe *[ ė~ĨĖâ=Röâ0œs†cK"ŨGZhé UŠ?€‹A1*t ‘~™íq9s„EĨú°7ĻģÃŅģwBcŗön„ÚúFhhh„úúhjjĸĮā:nĖøØø–0Û!TĖ3K_–‹1Ž‘ˇ#mųí€Į@×Nía`ĪcČ÷ąrlw3¤ MíĒZ^†ĻvƒSNė }ēv„Ž„ãoņ}Â(;&{ÔˇíãÉyČvÕ2Æ2ƀëĄģĘt‡õīËÖmSKa TÕ2?éQŒŦüe2ašKÚHŸÍŗqŦ+BiėZģØaķyĄ˛[ōûl@6‰čšØ ņŲL ĀlĨ4Fg=ŦSĖBe€ŒR}{<Œnúūã> öj€ĒšF¨Ģ¯‡ÚÚZp'$–ëĻĮŖĩ2%§\Juŗx˸ŋŽą‰‹Å@Ū¯ļ#Ü0ų$øķyžp‡Ë7l‡ü’/aũqR~ķrhzĀg?lō–ŠžøˇŦBÅŗî@A1†V‹˛”ŽŖËvj­Ąõ‚ÖĘŧ´H ũ8Ėą—~žĒ‘õÁ,ŧuņ(|ã$GABÔ3m˛ÚŲGãĶLĀŦŒÜ+—ÅÁ?•M`rƒllĶÂ(´Iš‰Vz44hq*딀¸lLÜH??BZ…œh:tŦ*I’ĀËũ6æx8Ŋųp`Ųw°öT×AM]=Ô°EĀmljˇÛíeÅ\9æĮúua_÷œå]ĐĐowü^ų¯.V‹/ @ģÁĩ—ū]„Žp˙Ÿ8††5•_ā`„ÅÂnØNÍęØaаnËnknŽtŨ 'ŦƒY`?s‹cSĻ[…ŽVDįô u%ÆUÁ¯ø(~¨ l;“ąĶ`€vą ĨM먴(ŪgW Ļa†4… 䈇v°—úI:Ŗ%%đŊč~{]ƒ=§q ¨i‚M{j`˙Á:8|¸†nõx›ššĄĄÉíąVÖŒß`’ŠÕō'Ža°ÁŅ^n,Ö÷Wk\xĢaũÖßUŦ*hū€ž_ā8lįĪåá^gü¤eŠÆĒå>R%[ pŠ>\gØÛöVĒŽ@ë™eēŽ=°Nîf1˜…wĀjéŠŗ]LéB;ĒKÆhí ę”A†Wy´>°äڋÁž/k‘Āå€Ö¸Û&6UĪŠ¤~3[lĶÃ}´‹!zjjÁ #'iú5rĒ @wGLgø,n$\Úø-Ô5ɰåZØūښ:ĘnQĨ\ߨë›āĘĐQ5Ž ‚åę@÷θŠĐ[> æŌWüđĢ5[aÍfę6Eǃ§šĀåîī!?öB8(%é"Q™ŗÂfĩ< öĀ2T!¸áū˛uÚöØNĀדҀĒĒĻNŊš –‚Y¨•¨Ŧüä1W;„[čĻųcŗd+%oKm8 ĪĪĩmÕ0îâmâd ŧÅ&ĐVĀ ĩMėh_ .3ļ Uô9 d,đ¨°s!TôQĢõÄę˜f*pAí=ã6[Ķ”ĩÖ¸ĘJįįl¯yz6˙ģĢŽ…õģA—ņЍ|šmÕ5ÂŪęÆnjL…ĒgœeļΝį˛7ÔgwJü­đRCüˇá)Ęvã×˙í÷õ€üĻ/āōæī額o;NVáŅ›Ę/ø„õV×sŅŋˇJÄY]kŲō:ļό‚ģlŨV¸{ĒĮ5ˆĪl€îC˜žO3!đ“lž[Īm #"']Ta"8V°­ŠuČ´fōšG3ĢåØ ŽŨ"ÃÍļņ3dfĢØš/‚$cBĖ-' —´ĐæųpŅJē„õ­JÖ§R•k!ߏ Ņ` īŋ–œ†ĄøÂ‘DÜh+#Á:M֞Ãr mIXB\ĒƊÄē;+?3ĐRļdŒNÕæ•U†o™a-oŒCGtx7~4ÜQ÷‹Š ũ`šUČæz.ūÃ`ÁÅ[Ö§ázŽĢå>l ¤Ė–n\—G˜Ŧjü4 ;U!˙÷۟a8Z7yËnõxũkĩuQŲĢõõTåGĀÃBAE¨MA*Ŗ6ܓŸ‚0_G8Ö'gÔŠ(Ā%ŋÉg@›ĘõëH°,ŊĖ6Đ6MÖĪF}ŠÚ–„2qâ€V/ËĖÚÔáÅ˙n>F Ëī˙­Ũ4Øéč Ī~5í QuÔ `p폪îc(NČ˙ BŨũ‡jáõĮ•‚Hågå]t9ŌN$<īŅ÷kx‚W$Á]WN @ģĒjęaėđT ŧÔ?˜•9Yú–ÉŠ`%•ŸŽ~û=­A¸,‚ŗōŒŖ,á€]ĀÅ{Ô?ŠZŒ‚­2qČ c‘ŠĄJąõ=#–‚€‹ )=Ā`œŠ2ŲĐč,O,IyD×m žĪlV˙TęW#6Ih5`čž\މHÉĐŗ…ĩ‘åžBŽÍÄûg´~5p:)P×ben UĖÂģô(ŠTՐ ×tø+ŧv°^?TīŏĄ–ʧ7ũ˛ëŋ¤īŸN8ŧKŋŪЏt3~ōœÖâz.ˇFŲHĀ}oåaŽęÉęÚ'+<š°įwáXĪE[j‰ŦĢ˲õ[áŽ)gÂ?æ%Âėŋ\Dŋyđ•OéQ¸vKÕÉėcž÷KÕ~ Ž °ũdaü,f!Îsš`´–ī?ŪŖQh&G 8hįD2ĢĒĨÉ5P0 S‘80‡hd°žEV4ėË6(ÕĘz#@” l0O3™@f1Œ–ä°ú:uõß –suīČęÎËŦô°2đjÚRR…­W`žY mXF~SÎ&<úį#•[…līĮjŽĪWrí8Ōā>jÆ+÷)V̇ôđđĮūâ$ķVËÕR2\”ō×Kę—ŅWdģÅ gÁ͉į{ĪdoŲty°ÔFŗ’LĀōp]Ŗ—Ėše8AW֘>i&ÚøÆžßŧĩx ÜxÁé°ņĩģŠÔ%ūĒkęčõÖūūĘ'^V ?ĶõÚĘz-ˇŲč–EyŸEĘrÖqĶC(Ē’ą'áâcŋ rI”„Ą ô큃ÛŦhL|8Ā-„Đ-t'@ւɚÃ@Õh"cĮpcZF˛"āËĢ0tR\n0ōiˇbdŽ8ÛÁČlØ:9-AšU­Á\Ôy`l$˜ĘļL›u, åÔÄj0‚ąZÕÅĮˆåZŨâÄŗ(ĀĒĀĻā’Eļėĩ8ļĮr×7A섨lŲ:čļOŒ‡C˜¨@ō^ŖOr3–+ķr¯Õrõáz8įΗ`X˙nÔõ§ŠŠŊ'>RÚ%ÂōuÛÔ{0VrÁ"yŠÛú$Jâ}ĸōâúãŖ‡*Z`°§j_æ¯9ė­›á,˛$ZƒúŽVÎ`ŊĻAđ–Į.6ĐGģ=ā–û­Öu ꅌu§Úüy[Ė`ĪMę?ŠÕ?Ũæ}„Ŧ7Z…›ôƒ´ ĘŖRfŖ2!8WÄJŽ]-Ũ#uéqĐMķŌ=3ŧ˜ÆąIYEX$īQ˛&žąöįrepĮęʑõuãž]bœ‡Ũrqe],d ^iN¨­]ģ„8OV “k4,‡ã÷˰z@žã|]]4?R7ëö‹(Ÿũ×é~šVJOAļ<Á e(,W}?k߇˙ŌúŦ[%“2ŌØ€Ÿfō€—3ĩÍôP~aĄŌËSXŒĶDŊéb Ģŧĩ$z`뮙\ũSũ¨ųúG $˜Uąŋ~­¨–œËĸŦ2ļS˙tĀR꾄ÕŊĸ•Ô9ŸÕ9U×gCVÉŗû}l$+?ÍB+ŗĢZ5Â_o0 Ķ•%-Xš2TŖ€ ņĪĩ–m¯į$NđĪ–ũŗÜDÛúÆ ÖsQĒYÂxŊŠÚ(•ßäĶO€ŋõ•w_ú‰ē ũĢuãË0fš čJ%­áaļH(Ū˛m ĖĀËÚhũqp›­(r 6Û¯Ûbũ™š6ŋ-߇œ$#Đå°œÕ˛xËfIëĩ<\›Ø[FM}“íЏfŽB•Ô}ũsu•MĩŠĢДŒ‘T…ŒëšJ]ƒ Ô"Z Šüt~žû>ügT¤0&*"Dˆ#Rb9`¨ôˇŽõ\#Ļ˧ōЎhÁŒ 7o§¯;¤Î°Ķq >ĩ"æxO*9ø(TælYËt{vN-ģ+MŦŸ ØŧOl Õg @‹ëšø›žŌø“üäĀU‡m ˜´ŪSˇ–`ĩipt&s"Dˆ{`ûĶ 9ƒ§ąq=< ë?ôŖ˛zËûáŽÚāœÆ Žü⧘>`ņũt{7˙Ų ;<āK>7n4ŧC5$…ē&ÆOĮ÷î K×oķkũėOĩė\sWĄąCúÂØĄũāŌˇ^WÁüOĀTČŌ`f„Ŧ1~Ō…~ä&’T)Ũéį„"¤uƒ­<€te?ŽB Đ`šp>px\Ō°œē=“t0å‰ŸĖ gD…l÷Ō†o!§a1ŨŠâ3`vÂdKëšFî9¸8–‹`žÛô œÛŧz/Üwā_îķbΠ¯v\…đ `ąâšépYū|XŽĀ­Ũ;§œA?_ž~Ģēž;ÅŊ Á0ôöĪrË~_Xō "DHk}ŠŊ -čĒĢŖžëšŠiëšį4Ŧ†Å•P•1FŸJīø0'œ ;c:›–ͧKú‚‡’.ƒ ū Åhp?<ü8 qīMĸGˆB… é—Ö?Dö•Ø3á‡ëæÃKŨŽĄŠßj˜ëÉ$d)ô#—†đ÷*ē MíúT~7ž*ŒÚūQôšú›ađ %Ûé$îzü§ōãÖs D—"DˆV”$}°|°`üdt‘ÉÎ94—ÆTž¸ãߥ4aŦΈ tûNÔz›ŊphŲ‰įÁ…íīŖûķkž&€ģ3¤Đ/Õ΃Ō109ņN˜w& š|),ízMrP; žl|Ƹ7yËŅŪ<^?ĀŨ‰°ÖíXOŅ€åĐūŨāÎ+΀—?Z \{Ô2n”—ҤËĨūvķįī]˜į]Zˆ!BZ?ŗ]Â@"ēYõËáąCÅđLō…poûl šŪrĀÔjų۟wŅ­ēļ^ŋ7æ“íܓĀGg¸ĒŨ Ē~ž_3†4ī *ŪōeÍ+éÚņM ×ÃAR§ĄũēBģÄx¸`ô‰ôwŗâ΅ށp}ķ×`%ÁŽšv„Zē˙Ô;KĄ÷ąá†ķ<€;´Wx'˙*ØNī“o/Uãw„:ø“ŧ^vŒĩ”´žgōäĨDtg!B„iĸ_ŗeĖH5¸ņė)áŽįōyo}×sOkÜH€ļž%@ûlŌ…>q’­øįîÜw˛\…íâ~ҧĢážO“áųš ¸ļģ—‚°‘ÅąYčĮŅÍŋÂWÉŖ Ķ€Á0ēwg¸ūOŖ< ū%¸ņŧ“aĶo`ķoûaīÃęąmJ]%dŖ˜%_Â'+1CûĀÛåkaAų:Đ[?_.˙@Xíxja-ŲKXŸ+ē˛!B„´°]?įēŠĄˇžZI†p§žĄÚgšZĐŊŊæ}úûû;\īëēîiƒzđëL@ÎxT}ŧq—Æ=įĄ7—’ãzĒĮܑy*\ũø.8H@ꞤkáƒCÂĨßÂģq§ûøÕÖ54̌´Ļžūøšžûy'ôĢOŅKŪ†ô•ũéēđŒKN‡ËÎBYé?_[Ÿ~ŋ ŽoZ3Ĩ$xiŅ~Â6z™mŽûS C]ąa;Ũŧ—âeܗģ€>ōXs˛—šđĪe [ŧģôA—čĘB„ŌFĀ–üådĪ”#4bšvAˇ§ûw¸Žös¸mĩ#Y•-#hN;{\wÎ0@ųËE'CuM”|žž^ø=¨ûÁ3ä}uM=<0uU'_:ūDx÷›đSlx/~4ÜQ˙1ŧK^­†~ü,v\߸„Ē“Wė;fŋ÷-ÛyŸT§?l†¨%ßŸÆ W ž¯ŽWĩŒžÂ( ÛĨN~]…Rä:(hū˛ÚxŦĒD˜Ę¯R–$Áj…"¤•‹Ãāŗ%Ā;yßj ŒQy ŧ¤nėŒéĨIã|ĘLXlŲ?.Û/:‰-ljK—ũž}˙GúēķƒôsŨ÷ķ/ƒ ĄHŠ(ūl-ŧˇôPōŲģŊøTĩ\Lį×ÛũœÛ¸Ú0ôŖ>ņ=eĐąĮĶčT/ÔŊL­šwė;Hҝ܏“úŪūģū ¸ŗâ&Y ũ¸Üq-w(ė č*tƒ{)Ũ*æ0L|/qÚž€‚Ũī= üj…"¤­1[ Ã2c"]ŽYy˜!cē:#ķõ\/sËŦ[ _$Œâ’Í{ĘÜįxõÎķ YØ÷ĀĒÂĘÍwÜ?e æšˇO‚kžø@]ĪEН6q]ģŌˇ ]ŋEã¨ocO€‰MkhŌzĶT~:–;=éxĄöeø¨æ ŧĮÁŽg7ÂĖëáÔÚīč÷W&ŪNËö2K˙Ą78zÂh÷&øÄ1Ė4ņũPyüĩų3ȏšČÃ†ÍØ˛/Ë­øíŊDÎW!B„i‹ĖvŨs׸ČK…žĖ ‚`šŊ›˙€^Íû 4q<đÜ2Ĩ]<<{ë9hŪūdũ_)”.˙ÅĐ?ˇ”0Øk˙õ!ũč´{Āé'öT™ē}‡kē€ÖÉŊT˙ÜĪ ČžC˜m cĻ+é2āē-Z'ī‡Ŧ‡2ŗVžũ`aÄ[U°D Ė€zåÆßT°DŸX…Ąz%ØŨf.,”Öø‰ã÷6XŽ˙\Đ­Øąāa},Dˆ!mTbũ}šö™ŠÅÃīx#dHĩAJ50ōŦ6nŒīŊĒ÷ŅDņ™E¯¤G[ō˙gfŒ„kšƒúvĻĒâiO."Œ÷8í„îtŨöۍģá;¨*Ū“ŠjštŲ/P]Û¨&ÜäÉo‹VÉæņ–eŋëšõڑ3Ä …ĘîzŽ™Ž.ÉA%šąv*¤Åä /t’—4˛U~đÁGØĩe“—T`­ũúŽäļ8ĒÁ–aZųS$ƒ¤ ė7˜5Üû9ޟ‡6ü_$žėąfk˛ĸsÔr$([ū+dŽ9îŋü4˜öÔ)!#ĶÕā ŗ~~4û ĘjQåŦO)ĶXAC7‚.˜…ov"Đd˛jĩ.ĐÕ'JĐ%' €œąũ­ģ…ĩ¯–܃Į:ũˆ˜ø1ĐZĖŽM }Z!ÚBH‹€íš§§¸ãdüO“%›q’É߃Ždø’€lfÍ7ôYųË^úģSīĻúÚĸ<÷Ņj8;­/œJ@ø™[΂ûKžĄ@ĒˇÜ›üæ´Ö(÷EŨ‡øį4Ŧ‚ĪãG¸Đ˜ąÜpŽėë*¤€ŽŦŗ’ĖrŗļŊqWE ÎØˇ°ŨYd&œÅÁŦˆŧdŗ]œ…w ãŦĨ‚”yÄNBČĩĘ&_U2@(!×_,†° %›õ%­,ŅÆBZl2ā Ŋ8Øä_& xz5˙Ac$īÚˆî ļ×f †ĮŪYIŋk˙axėí•đđuãāė‘}áķ‡/ƒRÂvWūo7MF€V˰Č(”Ņ.ø>¯Øæ%Øä_VŨRzŽ’¤sü‚ĨmĐ5p⸲O&ŸUË9ÛŪøkyˆí›É^]ė}4“drīd`É$GY˜fõĀfõåGÁ3Z Û Ô%ŌÉ=FîŠ`7ÁÉö:ģLÚZe“sãŗ–NÎ/!Ø|ō’‡cšĻ¨–ĀvÍĶW–˜ņæ,‚3­ŗPA—ŧ_˜|&ÜZ] ˇ|čt¤9­ĸ™áÚŗC؊M暭įũ¸c)¸bđ %/h<õ(ZT1KēēÜ^ķ|AXí.G Đų]™s6YĪžj63Úi hK°“‘ƍuVXn†ŽĮÅ@ƒĨą#¤ŊōMūb6g ö”89)ÚXHkaļęė+“`A*AĘ,˜…&@ųîqį50ûB(#Āģ2aŦüu|šf;œ5ĸįž ,eî>2ŪKų°Û>p"˛Y€Q02˛Ü/“ŨAUĪŪ5c…ĄŪvø‹ųēŽwÛ fYÕ˛ Е ĐÎh™ ™ā,ry |+,ū>]aÅäAwŲ<ũÅėgKØ"į8_:ĮÄËÆÁŠ§ņ,—|Žŧ§*evÍ¸ŠÆ#œL…žYŗIĒ–ŒĨÁWÎTëZ!•EwĀf׊Īc)ģ×ÅmŸĘß íĻLÂ9!ŗ[Ŗ6 pŧē´ ļėsĀ~ôŊ ÔÆ!ŪŗPžmŋm6—w”ēĩWΰ÷‹uŖĪD¸ú•-õĀČoĨËtļĨ‰8dŨž:(qFUŗ˙x N­˙ &u›EķÚvHŠƒĸ ƒíģxūåÅrĘj•Ŗ› <;‡št><Ûîbx6ų"mĘl’ü R‹Ų!ƒŨ÷LV?ũu™ļģg38ϧLvr”AF.ŨīKŲyņžÍâú€­ē°~PČM¸Lۙ;G!xí”ö›mÄ î›^4×fr_ĘŲuV˜”™ËŽ)åŽģ“PØmcũgė|ú{†ŸgĶę5)ęã ÛØÄ W_Ũ8“ÎŪ;õĪ›ķcésiŌo\ėø2Ũy I¤Ō_X{”ąqĢ”+3‹ŨˇtvoË ę1“ÕŖĀŦ˙9ė āĢgO)Gëä`ƒYüŊĶtØs,ĖÛ÷0¤¸kāPm#äĖūŒlĪÎíáŨû. ~ļš\Ž\9š9‚¤ Ú?¨y;ŧzā ø.~a¸#%ú@ŲKNp(Ļ™OžŸĀž‘žžėžd°ørVŸÅŦõ7¸-†÷#\ml YēēW°ûĨY˛ą{MĘ=%ßåąũ|“IrĒ“Ã2ˇ˛ēúéįũšöÁߌduœĻƒđģ~ėŪf2đS¤” xf^ÆîC!+̜•cØæãF…Ž~DŲũČ&¯šēįEY˛+÷§–wÕ$r%R!ŖR|äd†ē1>.īö(}˙ÎŪûáŦē¨ÛΌ—Āãīū`›-ßvh!xœ2ÚiĮü 9Úųa¨’Žû†[ôV;–k1•ųCēp-7xĄ,ä™26ãËô3ã\hĐY\ÜÃmE…œŠ?7zžõ2bxJ}ËCd*ëtŽ-ķę ö°ÎæŽÕ‡ ꁓ\ŽUbˇxũeV×yq‚ĄÛJb”rūž€K9ĮöyÉãîSĨ~Ŋ0@˛Ų@Va,6ë’fÄā¸ž˜ŠØ˛Ųš&%úY ÉãØœQũqK5Ņ~¸ ‹;mĖI…ūsî~9ÃxMfl6žĖ2YūŠdĀ”j2ÎčûØj?cĐVũuqļÅzĩ>+Ÿí4“ķ’“g „›TÁ˜æW[Lo\=kJåș 2h‡d§7.ƒd‰åîŠ=ūÜõīpKÕģÔhjeÂ`˜›r ŦŒ VŲrfí7pëÁRH‘kāšö™tķ2I_÷/C ‚åFĀ?—W†“–-¯Ū ãE…Ŧg°eÜ ą8BįUU㜧RĮ|ųsį€wũt›—°‡(”{Sn2k5šđŋÉãT‚vĪ—Ēpíæę€u†Ņ gađįôbŗŲ97áQfņéūT”vŨą¸52e-ËĘä+P]\č–ëuEÕīŖZ5Qį:uĮĨēõÃ|ƒ?Ŗūá ˛ĪÚjã(_“?õv…‰ö ’=ĮéŦM]k”:TųQë+ĮŲ:ŋĢYŦ­fč&ĶØWv°õî pĨÅ4œvķŪ$ ô‰N×Á—ɧPН÷ûÄ1đ‚īĪq}éúŽRΠ†­pbãV8ĩág8›°áî(Ks:dQõ1˜Ö0ˁŽ5ëį‚-¯ū%?wģņį4oU%lW.ÖŠ™ĖŦĒJf*ĮJN-•Æ6Tæ„Á7 đfĢRĨcģ•hËø‰ ŲĮ2äģ2ždąŨl ˜ÉMŦ–0ļ^jĀđƒāëÚ8+”ē0™Í&…ėˇĘÄň‘ĨsģĖ0?;vîKZ¸ÎI?Ö]“Ũ2#ņ Oā&*ya“ˆbÆØŠ%§BHbC99Ü ÆpÉ(9ƒI6˙}ÂÂr‡@ĪĻßᚃ‹ā”úŸāšC˙5='1,†~ÜÉųŅŒfЕũ†~TtĶ~Y.æŖÍÚôęmåė āsęŊŠŲhí4ԇÚicđĶ0k¨eŒ5Í`/–‡FũÃáž`°ĶĀP ģįÉŠÂx)âŽ5"įkŦX…âšôkJ>ĒCũg6ĀËÍPÚ ×nuÆQļęĸ0 ææ’§<+™J¯Ė@ûöˆh6īK›ˆKĄk*ˇp‘ēO Ίā„üL g0­œŅ˛YøÁVÜ´Ü‹˜û†čdΠH gžÉæ‘Õ"ĶU\|ÛÁ}ØsŒ§ÛÅÃ`Um4Į턐4 fÁų ›øį†¨Z.'edm*ž5b>™āUš­ĨéŒ ÂÉny Í2điŁz‹?fÍÔ79äØÕ3§Ē\7)҃Ä4ŨÃlGŌÁģæĢ°Û Âf+9CŠišĐ€ŲLiÕ8ī)ŽĶĨÛPUR eā˜ËÚ<Tš‚­ ëĘāUūíģ¸ū‰ˆhî‹ĸj] mGÂ}MJ¤ų™´§El]Üs5°e}ŋŧFhĶX_XG8*PQxEÁĸ Iâolhyo1ÁJÂzé–8vÅkģ}]ŒÜsôTawĒ$ŸæÍˆ$Đr3@į[Č1ÜH0j0bŒl0Vú‡j¤…Æ Ė˛Õ õF?Æ^`æJŨ02O!WÅēØÃcy"BĀ[IÕh Ŗ†RL…ŦSEMü¨cŽBŖ{hbXRĄ `sX+ ą.Š&ƒŽy2㸠“6t× '?ˇŦ€SOÕ?,ŦĪĩ2 öš\F}ĩA1kŗ™eæŗīĘè•Ō‹bđ™mCUîâ44Ą˛['ģNËjōØp]9Üʴܡp #‘ŗeĀ,9UW!ŋ Õ[ކjŲo926‚”ûkņôh °Ri⠌­‘RKŊ0EI嘭?ˇ‘…ÜŒ7“û]&3ĢįE(ƒ;`Å܀‘­XO’W;ąN VqÅLš­ŌŒ}˛`<¨2 Í“ ,Аā"É^J•Á€õ‡lvMN]?@Ģ׋ÁëgŠø!Ļ‚×÷vT€ÉKsŗČÔŠ“mՅŅ—Ōč•ëÚ7‹ĩasį˜ÍĘMUžĢæ‚6ĶØdl1Wv:7AÍjKI0B¸ĻrÖfEŦ8ÁëŌĮk6úq}{×ÎYŧ&\?Åōą-fkŠ%ĢÆz”č–8–°ēåą1Pą)â~r÷nļ•ß9Ây* /¯$ˤ ķ°ō‰Ö-&›ˇĖ‚dËĻu‘B`ē†l)ë×ĸé¸Ehu*ä@3­2ŽQ„[…ŧÄÂyéCÉüķ”āŠ˙)ā"Ã`FcĀvĶl<(XF0VĄ—ƒÎgĪ€! ˙,Z/ÆW6¸g˜šq › ‘1JãČÅÚb1xŖķĩiŧŋŗ8vĒø[V‚u‹é\v|įēeĢ.L‹ L´ō 6LW) œÂ(v?Ķ•ŧ‘Ȇ8ķ”˛•É⍖bp 暔”ƒé\;frm ĐH˙HOHX3¸IR‡BđÆe×÷ŗbÖ•{Ŧ†oļnōP"fGX.†OæRáĘÚhA†~ŒX9ēī Ë0ŲXIūÎū啛ōAH0ė8•c´|=Ķ9•¯+ČsZŽéŠáĨ2Ļņ¨„qsYQ ŗđŒ“cŗĸpĶ8•ŦËÆīBžŸÁօą„lŖšõ‹B6Č˜í{ŨŖQv >kļŽ)P;ļ†Ô—vęŽ8Ę\xÆ\Áč‚-ēųžŲƒė”uą‡[t–a'ŲtqÖ8ë/ß(ŊÂƒmŋÅYt‰—ŸŖl’…ņ›ŗŲÄŊĀ_Üc!BĸØoW1ĀļĖā‘ŽTEáåų•TėQįr8/…fDĨ)4ąĢFŗ “jŸdûÍ@+ÄhŠß°Ú€’éĮPKQõ•‹Û$¤m>cŅļr!G5)0aš¨žĀu‚t­jŲCmĒeßL>.ō§„ŧ›ĩņĨĀ fĢ?ÖɂZ`GV›ebŠ,ԄČ^‹XÅ?\‰—‹’#rē iÁ>Ē`W*Û|bЎ*°Õî4¯ÕrĀ2„r8ĀŁ äį¯,E€­ŋcķ؃é"[Ž`ĩ–ŗlΘ ‰N¤¸jÍ>RÖK…´y°EYhuļÅÁ–ŨTΌVžF0.5Ú kąŒ ‹…˛Ÿ^Čŧ!B„ą-RkŠHZî‚t‚qčF‘ ĒUj8@7¨2h,WRFŲOĪg €"Dˆ!GØō2ræ\|FÚ> ž:Ŋv–[IŲ+W(ß0÷ērŅ-„"DČļžāûV*eģ2ā~2ež˛Į¯Ę:č–ŗÖ*™îËŽõĪ]+˜Ģ!B„`kUFÜņFšėaÁŽĩĪL *Dˆ!B„"Dˆ!GƒüŋFŽÕTBIˆIENDŽB`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_banner.pdf0000644000175100001770000006065314637570305021711 0ustar00runnerdocker%PDF-1.5 %ĩíŽû 3 0 obj << /Length 4 0 R /Filter /FlateDecode >> stream xœå}ËŽ$IrŨ>ž" rÂßî[‚€ZŒ´ĐBЂHŠY nIh΂ķų˛sŽ™GÜŧÅâ¨ĩ$=uÍ3Âææöv‹ßß”Ö3ĪõHé|–•?ŠŦgĪé1ÛsŽa`~ŽRĩ?Ky¤ŗ>KœŸg¯×c´įčųq|ËĪ|ĻGéĪtæĮˇô\Ģ>~ėx¸åfĢĪ6u<Κų™ŌxĖūlŗ>Ęųgá,f;ėŅæĶ*Ík5ŸĢŲīĨ>Wʏ÷Yŋ߲’˙öøßĮųĖ#ŸÖõi˙ûvAĪUû˜ĶzLĪŗÍņxũ8ūôįãŸūōøĶ_žˇĮ_ž>Ō˙û/˙éĄ_›ō9Ī´˙zœ°˙ūųøī˙Ãú:˙ķ¨˙üøŨžæ8ø‡ˆKm办k{f{qg-ĶæVĪgoųąĘ3¯ņ(ĶPRËV ~ĢŨræįœÅWË3ۊŗ?×ŲGļ5đ<×Ŗîƒ€æf8Íxüğ5M c˙oĪõ‘s=O[īãŋ>b…˙ōOؔ56åT:Ûmāې“jTÛˇŌĩų¨ļ›Ãĩ.SÂäl%6ŧíL¯ųxõĖABčļöãDŋà L­aäŽõŽ Ļ (MÂDB#'Ѐ-3ۀ teĶ>5ŽM‘ķ­špĮŗ÷ų߁ãķO ”ˇĩūvíŗŅG)˨ū_­íėŋ>~ĩ§ļ]FŪs~pëm ąlš6Đß0€‘”ÍÉHmj€aLÁāldÂzÜoL›EũÛ0˛é†›8ĪŽuh$ūŒ$Øk‰pūo§ņW›TŸgę8Z‰7æ– ĪöĘļėkŖ˛ci„Q:—œįnØCv`rĩc‡SZöˇw’eYÕg­ mõĖ:ÃŖ€LqvqD“íŒŊ:2Āõ,´m Ë%ĀėԜŗž‹:ØÉfuV ŖÆl†'ˆ5ŲæžgÂĻķ×ܧƒß÷üīt2āVlDpM;ãÆY+–“6øâ2sĀĮ#ÛŲ)ûeA ¸ŌŸļXļ^šéY×îŌĄ—xDƒĪĮßŧfŠ“zŸ÷ÅŽ¸öšĪÖméŲÉŋÃ{{šŗ ļŲąœÆz ÎÆĀl6XØ=ãjÆ/—í&˜ %–ŊšŒesŗũ8}ũw0ÂjÔ?‰+7vMYȎm}3?F›5ĄTMĖØ‹í48ɰ7æÛ›ßFá: ĢļG†mČOcéŨ¤ŸÍüD;ėšfÄ4AqU›5Hī`‹1[!Ø^o†žÖ(ĐŅ}įãŸāŸA‡ƒUw ;#ļvĖ}%6†ęŲ6w ä\l-ų”Ŧ˛šĘēwoČÉYK]ZjNz~¯Ũ~Ļqˆ(Ūąõ‡x/Tm°GdŨ¨ÚNv>Ÿā1ã4Č~ĩÆ~mãBHÛžƒėûũõ?Ėž ‰ÃD‘u”HĄļߐĄ ÜÕāe#^㏰o;GÉ6ÍvÉ˙ĀŽųkqi6¨NƅėÜūöm[­'tЧLŅ×ÁRöū}Í4lYŋdߝë|dCMËÚĸe:d–@3'ÁNĻoƒŦNęQî ÷48C0ņl=wō0CEĒE-ƨ{€oö*„„Zč%Öģ€fH;x"ŒđĐ`ŌÕ é?›Đ°1Á9å›qį^f˙ġM–ŒFÅÔ˙ø.2ŧ }¨ú.‚˜ ËdäڛMĶŌ:N+ đRčnxˆŋTÉΝ:äŋHĄõL¯B 3Yīĸ–æãúĪ>'žšŸgĖÃû\Ŋ÷õˇąu°˜b´ZĢāvx(ÜģôF0ˆl˛uã¯ųPŧŒK7PĮÂ&?FyV¨ÔĻ,ÛĄˇŽĘÔŖË Iūŋ$ƍ~mņ“ŌĐđ!?%ĩSĄđ¨Đ)ŽwÍĶ€œŗ‘˛Mú{iYL5>™ŗ& …ĀxK§–Æ Ŗ€˛*´{°QÄÅfd Ŧˇ†Ÿƒ‡āÉįM¤Ũv Y &7N) Ō#›{)އn‡Čp¸š˜z“üyĄšú§•˙¤ ÷ŽĒ?ĖŲą%cŅք>l– -QSc!ŋ?¨ĪäBĶ#åä Ķ0B“„Üũ­‹?ĖÜŲ3H~ŌĒÃdL\OZ)} ŽŨ)YØPūĮûFžYCÔārŠXžũEC ˙J! ÁČ>ŅSŪc°ĄĨō˙ÄåAHĀĢQaJ#ÎČZÁÂA–yŠÕUiÍĐ ›|i§ą#ĶN ”〞Ļכ†oÕU;-äH^¤L;ö‰–0´˙ž?‚ynLlRƒÃIJæĪ0zė´7šōoĻú˙VŅąD04ÖD@ X`ĨƯVŖÉoë;×™ŅK~tˇ=Ėž¯F9öĪŦõĀÔZ‘‚؆ÔH˜7Ö°örü5ŦËJŠŅÚī‹",Aã1͍ÚD¯‘ĩ@<6Xē‰Ō’úãmæ:Čo‹qR(u¤NRø¤5(–ŧcå› í eĪpQ@Ųjp-UãSߌ@˛1(›`5ÍâįZ‚}3 kjéÃOz}8˜Õũäķh°]5%vņm“&Ž—w 5âIåB#ķW“ūšVĮ{ėI!_Ö„üūøĶ_:=Nŋ˙?Ū2Õ˛Ô˛Åœ СA 8~PÔ!ė„OhĢm‡€ą/:&Œžũ3änöˇũŗąųEūāo/´įŊ÷ũû÷ã}üß~EėŋÖšģu`˙ę0ÚøĻtˆA–‘s,I5 肂ēSÎũj@ė÷ ŦëŪßl^Fī×!3Æ|Xođ)ų̟æû’Ö_@ld’īđ¯ĻTî­4ļ¨ļōļ™C}–q<†fTí7lۘôTãbú2žß§TžÃ;¸0Ũfå[p›˛C ~˙~ŧOáë~ūbæ“Ö>¸í‡Ã+Ņö›F5™žMđTŸŠC´’áō|ôĪčU‡ŧã׆}Žū*¸ņ™ĸcA`S>Ž˙sÔ̟gŒ}|_ƒXTĘe I+ŦĖĸ­rũņßF& €č"‡l CĘoą\͍ŗA˛°aß =ėTņ×Ã@ŗBl2ƒšž ‚Žŗ\ésĶĢĻģ“ĩGĮ€Č˛c\X–šŌā‹éņyždNo+I˙‡^ūŸŋƒžĀ^ēJƒī[’Ēū/˙‹Ą…ßļ÷ĻāŦލ6KmŨxÎ-Žđ×Tę=ÁwoTZ5BŖkņ›‰ÔœÚm´į•4„~=•éOښfy­ü6Hģ9ŅĢN´-qւ=5ÜÃL66äaQžh˛RŠ735č0ÛĮļŧ~m`Ё s‚iY ioMōK-8‹ŗ70q5Lj0°ž* Ģîq Ķ}ÖiÜ!›Mt7wô'¸FW$jᓚ=Ķ91#Č,v)ØŪ0&˛Ú8nôíAĒĐlˇæR%Wž2“B*ų™āx¯¨]Ķ”6!Ũ×R¨7¤IĢl 8j†ÜʅčJSvŠ)™mĒûeęG{ö\3=đ\$†ŒyhŖæĪŖÁæ]ô3ų_χŊ'¸ėL.jĸū4ŲmęX „̤œYšt_,|!Đ- ąG˛ÃԌé@§J…n,“qTÔ Ú í•)I 'SÆĢ)ãf<0ö Ú#ÜjđLÄöuwî^Ļ Ęú’ €c˛¸eŧ+ö­tøg9ĶŅ%ƒTmŒI­Ųž„7ļ#˜4Ø4ex%;GCDĻC}æ`…úŠÁ'v՞Ē4ŊTJëâÄÁ) æCü>2šöRũV8°q/tö"âđo@:ŦN_ļ­đŅųzā¤E› ãŅh4ģ€|GíoG>W”˛­•jlN™':Z>ŽŗšÎ‘Ŋ…lĀE…6sdcœ0$ā‚ąŠgãf œúbį3›˛ãūîÖéJ$!ētPļ÷ÍF=âDį<č‘ŋ}6!Ĩ3îOëB, gčĘ;"c<¤e¯ÔD7Žâa#Ķ3)LȰxáãj€ĒŦ•hË&~Ū[pŽëœä\Ą•Rõiļ†Ĩ0˛b ō°ã&ÄŲë1wc§ĢbķÂķ´aŽ`ŽÅĪpK#vRŸcMŲ6vėˇd„mԜ/Æöl•^LFQ 7[˙+ہ›ZßŲČÁįĩšÉYģYq†÷œF?™ĩÁ•ė2˜ų‘a1´tc÷ϝĐÕ!ƒĩ΋vFã7ę¤ĐK¤dãĄP3wļå4­¯1Ļ|1l˜UŒĮeJÆÉū´l•ŧX¨m,7ÔŅ`ÍŨöų“ŊxBf" †ļhvСáu5Øâ )éuH•dÜŪģgČkéq ßäÚsŖx{îđJR‹¯ėz-ū€L:=ÆÎ1Økã2…qEqąâhh‡âwßŋxÛ7øęŨI Fw ŅäŽMB×ėÄbuĸĀXēčFŒĶ¯wlōŧ~&0VNĮ(ķ:Ņ`ĸŧÉ‚š â‹}0Mĸcƒē“kØ=pßcč&SÕú l9jU^Iü>ŗŒm*(&~õ…ļöøP'LˆŲ¸ ÉfĨļsÄëÆæęŠîÁWJ×ãr‹‡K“C~Ž=ydËžēx@‹ß¯;rŽîy>ēcöđÉŊĄū§ģņÛQ’Q#F0Ô bĄyEÃĮnab-qô·ōkz7BKŊģ°gŦ)+Ąį'-×kŒŖä¯-ĮmxëK41æwDƒkA%7ōh€Ë8„YŠ9Ft¨œ Ž{C)†ÍFMî‚b˛Ąō­ ÅĮF€n:ˇaįĢ Čô3xĖs dBĮUH­ ˛Ē!ę–:˜Ļą eĻ´5LͯJŠėČ2ÁH‡ŽALoo•ŗ˜l¤Z$ĒŧđÛ—´ÄčԀK‚jŌ™8…¸_ĮíäV°1ÎčENÚH|Ą ŋD56J*‚œŖ„ŗÚ–?¨f’ž`PŖD˙ /Đq ĀwôøF|o~ŗÚJ Y#õČęķjģåcˇ ei] 8äHh€~°ƒĐËËH3ã–ÅÖFj|ŧÃđË4Wņ0Ø{°w ŸÃ´‘ø=l‚ÅĢ2`{]6\1CDĪTuːžj9Ōl’ëāGŅp[ŪėŌ3ž´le¤›ŠĢŸ[΋Č?ށv,f¤@#*0|ÅDkFV…vŨ„+i)Œ— G}_ŒëŅA\œëZEĻÍą0ōBŌŲu”^9D&2o2UTH̜÷ļãL#ö(đ€ÜiYûWĘøí¨§{`lrŦ˛Š3Ŗác7ā­Ō™m…¸Ūš7ôĸeÁ]ŪUŲ°@@_áë;ƒ`"_[4ōq{kĪ%:âTåŪĐé׈ŖKÍč´"΃´$#čH/ļ uĘö~€Đ’@×­˜t4CŌŖq$HŨ5eŌä¯ĒđéˆĒ—÷^Ĩˆ Ä5 ė2'Ļš3Ã2ƒŅ,äípš"K1ŸâJ“ š=ml CÜÎYLnA˛Är*ļņk`ۿǁ_đ5­ģåcÅp$?ˆõ§ƒ9¸*ücČ/i›`ąĻDũbņA85WđJ€ĢæLTBđÚp­*1t1€5 qTDįģ2$Sjö!ėYq " éŦEŽwC*@Ø|HÁ˛ã Œ$“Ë?s7dĖ™Č7ÚP‡ŠØō—s`GŖ(Ѕx–§ŊlčÚ-ĩĐWÁė7ĐCŠ<ą˜ežšZ*„g14{ Pgp.&’7l‡lÄ+jYŒr{f3!ßųĒ1Š5Lš1Œ<°qÍŌāšđu8Œč;n0˙ņęa1/÷6ŌxaakËöYˇ†ŊŨâK.6*|ļ0‹ã†O÷ŨmtŪî›aã.Û~°¨#7Æâ-›ÕĮK7Ķôk?Fā°ŌčX-Ė{N<ņŅōą[:×÷†“‘ĮJcâØ,2­Ü8”‚Ü6Į€I‘ĘfPŠÎ âq†Vhų` }ŧQŠjQ^ŖŋF¯8ĘäÎ#¤Õ8äĮĄqäĮú1‡ŨAQtÅJ0΃X™˜ ΤĢV{ū›)@)jÛšeåØāV+ž˙įļđu\ÜļÁ1|UČ.Db ŦރmW3i‘hؐ3LŦMĻįkp1UøĖųlBÄ`eFņ´į—‚ ûw%ãB[Ã5‹|Ā“ yÍømC^eQrøåŠ O82Ąr8¸Å¯ĢaRč€qârX2{ƒ?–É ĪË5I-Æ_Ŋ)5Éc Ģ…ģoQģÅ|‘#°ƒŦ…%ƒ'×7@+ĻKBfZ´ÕĒdãAc'v æ¸ôøÉ”!3Áé.‘L3ÍÄP ËÜĀ%Ą5Špdˆ-æJLd÷6¤‹AĮ‚/ÛÖĖĸ ¯v]ĘąāFC\['ˆüTÄMšm$6´+¯_iģHĸ?AčXpK1Ņ_āļūvCĄšKĘB˜ •ũ3ãŖ‡ē0Ļų&1ŦB™Ë6yhŒđŅäŗéNBö¤fļˆá FB9DŠ’´—˛˜z’į‘K€3˙\ ”Ŧ’šy• kĮ”áOČË´;' ‰æĒUŖī$D… ŧ $V%!ā\BŽ"„gÍ!üDĸHėBJČĖŧ›āĻ`îø?ˆyakŠ3Ã+_太#ʆi#P.øE *hęØ "+$ėGYTũ"ĶS™f fD÷3Mo,C؇čŦˇp¤ yĖ^܈„­ë´ē*Cät+āĸɔyŽ“pę ŲÖrøBiP™Œ.g(ãæ†ŗ}7œL8pq5€Īįp:(F‚Ųœ…&ĘE$ļ$2I¸’ģ¨pbœjŠŧfŅB Ļ-˛NFû˛ĩ-)lÍ7Ģķ3ųq@1뇛 ‚Ķ…„VĐ&ŌėEڋäŌČ0Ļ͊ Sq¤YČxwv„§•P ëၺ9NęÚt Ÿ#F!€į;œî@‡ ŦdOqDsKÄŪ1ÜB ĨR‹ SĶŲ§|‰§Ø%E:!.0ÚZčRČČĨAÛ6ÚĻĶKžŨõí9ĀÎPž5&2Į>#%8‹=éūōÔā[ŽŖņ%ä7•yĀ[’|˙*ë~CžB˛_˙@ū¨_%É'Õølƒ}@Ŗ yDÆMũīP,K&Ëˏ˛Q f0čÜüŽîŧ1‰ēSĨŊ " vĨ2åIŊ[Cõ†ãC—æJēŪ8ŠĘīūLwî$÷ŠųäÅí×lÂpĻųrėų<ēō3ŗTŪĖA`ˆa;ŲÆË!Fá°Bûh Ž&iš‰×xeYLktēĖ‘`*ÅÉÕJŧ?uãYŋ›Á Miôc*„‰Ūi;#Įfc°“`M™0ā`čųuoČĻÜ0EŖ„yˆŅņáFp!_fh8ž&ĩ?NŽ“‹NŸ=ō"æ /“ø@ÅĻ1'–׏Z¯ŽxĻ’ė›6ä… XP…Čäe(÷`ÕíŸęĢo`‰h‡‡SnĀWŒ;āpD ­ĻH\Ųɝ"d.ę@!n œB\áö@#Ãáä‘8„đjŌ•Á gtöē ­7€05ít@UÂl –æˆD˜;| 8¸×†œŠžŗ)iƒ7Ū ŗpėßķŠ™qbÆy_ã=ŋfå%gĻæT17Ūũ˜I‡ygÉī<Ė™nžõØxš)šŨ sãę¯=@ö–äķƒWÍffîd  'Ŧg^ į´Ėëg˜)\+Cr€ĘĸÔœ"ČŽĸ,mÜÁ^eŽ7°ŸÅéÂāâÆÛ'YŗIŠĄÎp=/)đĘá2@LP#COĮ•ĪĒô@ĸČH´xąä›dĨ:„äĮgPB⊍įí€_ŸŸ<Ú$Īéée‹÷ʐh¤ †Ŧ=ÉČęÜR†“âķ™§ĸė Ž2í7íļĩé!‘[Ŧ€tąÁŋ!Æ5ŊĖ“Ô99ÃŲFBƒYŒ€)H¨ :wfö˸%Cęôî-û×5ŧ˛Š ‚&Ëk‹kÁæÁˇ—ƒ–Áô‘&ŠÉœ´‰Raŧcķ4rq'ÅE Ž9ÚÉQŗC!Í×am?•ąx éĘa!1‘§, –˛ŸķŲ8[RŽG0áЇ|2ŠyUƒī_ˆÉäfÍæ™2ËŗÁķôW}]ųÍ{Š’ƒČÂ*ōĸ6ōÁė=Ō◆xe!øÅxĮ~úšĀúyMu7 N€?ĩŖiôø 䁅•>?=āh!ũ,ŨL Ûęq0¤¤ËŊ˜]b”¨)Š ~ įbĪč-QŌ™¯§JŸĸĀHdÆØÄüŽåų J¤ôų 6đ|ɗ˛i€ŲJëF#°æ†Ã#—Ô58 :)P‹,ÎÅÎɞĶ )UÅ&ā“‚DĄ :]¸›c)yw„ŪŽģ%Ki—•)ŽLJŅã¨ķ!ßô8¯ÛG |'7ŖĀUyDEF€fŨR\mú€vü]0$ņ­a°l JāAM ō Û=EkôŊ'ÔL(ümōvō’¨(Ëp-!ĢvË:$šöˆ5ČėĀ ˜Ō˙2ē™é@ãTt<”OĻûē5O@ą/ŸĀ!5ĩéũ“ąNĀ8a?Šœōú†ƒžčwŪŠ8úŠ…gV­y+´˜āö%wL? ä™ᚂ€˜ņ<#!Ą ú.ŨÔˇ}C.Ĩ.Ōgā.C­AöáÉ ;g°?ÎKkĄ&Ÿēņ-}RŠŦB…/# ­')1LŠ9uązQۘ -ŖėôK aÎMÔw žBT;Ģjˇ_ …ũ;b°)ûõtŌßĩĩûņĶøI>՘âŠ}\ŗ7š]˜ŒËKbåZ<3Ąū^ČĄy8ąk9f ķ¯ ž;˛]Ęsôž”ÎúÚŖ#ƒJML†ÕėÁĻfããJ ,Öî!.Į åÚėBΊđVĄPÜ:O Üo{ã GlŨ~]{{uī{ã;eÄėœr4ûÃ)M:Wįtkwēŧ#˛ ܍ŗĮã3Í3ų ŗŲ{ņãąáŠ$G¤Po~ōhô|ŌÅZ‘J…åäĄŧų„͏(:mk7ĶtUđu‚Süyfe#¤ÅkxėœVÚ¸É(ņë ŪÍĖė9vīā)ąÍô‚ĀßöÔ JC#&đĩ—vė´ôũēPŗ{wÄiôÖ˜›Āãņ5í ÅŧVŸũ–H4L˛ëĻmŖöÁ; yĸĖpX‡ųøÔ€Œw¸fx‹ XÍŊúBŽ čS"…y\Sžs%ÉÚ`Ŧˇ‹“ĄcøŨW‚^ Jæ 7˜_Įõ@§FËū˜TŽˆIUYčĘĮļnPå`%û"[sPHõîõČ”Ÿ%] Ė`ām˙ᡐ0Mę.Ž˜‡.ÖãØą‚ZgęÅVOŅĀbgJąÃB¨'Uš0]ĶÖĄ?1'ZpčtՊŠĐ +7Ļ l^L‰)“ũĩ>…÷$&$ˆCįũ/ØļEK.ŧM8•6Š-ščm)É]R°{°Ĩ*_ æ@; ĐP&uv%ūdZ‡YhF–=—’xqĄ^CūæbšRŸčÃĀz=œ8 Š1ĒņējLrčĖĪ‚nģēīšC§ÆtHšä]ÖĶđ[?¤'Ųfˤņ5iÛođĸ6ĐŌl§ Z@÷â`k<˙ŋƒŠI$ĶšJēU9Ų\`¯ŊÕ*B6’ģM‰ ĩˆB+~p›ĒHŠ:å8FpŨžÍ!|FņkæõĶŦ,ŪTRO’„LVÍ4Ļm:S—L‹û8~F?ĻN•S9ēOÖ'č7 éËöŠL Fđ÷lÎ|´Â%÷ ÄW1,Ž@ZɊ'û¯EAHZ+€čWC “~ĩ\*#5dũ… Ģä$å"†ĀŊĨ;+JĶÅ1û+,cp㘘+“ĘŽbÆi”4&÷+JTwáœY;ņ !ČČŊ†›AĐŲ šá‚J“SÉĶGē™7lZ÷]ôũŠžßmŧ.Í(&ģčJ?öb<čZ\×ŅõL5°x¸Qņ #ŋ1VGŪČöiŸt师,Ėq×åzÂæ1+’\n%ĘÛL‰Oä;Ŗ~^\ÁåĸÛū•";SCœ>Œ(fúe<Ā L3~FŦ or'¤žJÂūLÆ˙^l0Ŗ¨HÚ1ŦŧŒ@wĩp[EĘÔ€Ģ tŅ-ÖÎĘÍ3ķr;)3s‡—xG MXm‹ūnjŒ0•“+ë1!23‡Ļą&ÚŠË9n¸˙ ôvr€ŗÉFëēĀsH‹–Ē}‰×•ąģģĪq'+†Ībąšŋ4ĒbúIålxĪcŌwמåĘŖ‡ˇ‹áž%D]"?æ}­Āė)í10/ĪģĒ†ŨāVáąwī2Ņ ŠŦ`ž#q?++yoĸš:'-s=Q¯ŠÅ‚ Ŋ“… ,€‡4ø.7HüúķlčĖLŌë,ˇģGîn×ņ"jfšJŪņųJ—"Ã`Đ1ûéŽÔ5|=?]ŗ1s$ÛĨŠūĮn˜ēœÄ|2LƒQ‚•â s_÷™ā,'#ΚËGŸ˜Ļ€ $Ôâ"‚yk tBږ*ÜByíŧL´h>eŪ-ÔŊƒŒîĶũqM*_V?€']îĀå'įĨ"ЏÍ2Ė–pa@v<Â>!š„Z×Ĩ÷íWÁëem<`4^1ĸpaOG%0Ũ )Ķ•qŧũ ŧ(ÕZXâ|‚*o•Äķœ‰Ú:o]ۃ„ Lž;x=8ŲžÕX͐ŖSqęÜ…Úŋ/]8Do8¤¸~+CÎn4xÚà ËIGn x:ŠŪG´ÄqãMĸY>Á˞ԅ^50õj˜ L_Ü.J˜JR˜¤Uë2GöH&ܙ`(@ĀĨfxsgį¨&?–[ōđŽ$Ö`ōĄíYyÁĮą ÛĪįGˇœօv*Îr5ВīēŽ–HGe ÔĒD\áP$ŨĻÅĐX+sÃZeĄąō´Úõ{'á“,4<^Ø< GLVYæÜûɉÔũŧ¯ˆÕĒ&ånXŦoƂ{׃-Sœ Ŗ ?˙]ĩ8Ž[”#Ô˛&78Ŗ—×Ĩ¨O Åî> éûXär™Ė"ęôëéČ*ēzEŒdâ¨w}ŧíÄO7ĮX Ķ=W„Hmŋŧ GIēäūē˜.E&åÖ#B°­l÷"k°AĢįŖĄĩĐß3Ŗ|3ė`†Ãx•ßĘۙ sĀôĀ >¸ŅãÜN¤šŪŽõtˇpüĖģP—͍”*uVãz gĢ =8#ĸŌ››ü‘ÅíšDz|Ō̧ĒslŠō#Åë†âyȗĀđ],zI¤MUm&Ëå䙜5âuÁrąXdRÆī¨&YMčd¨.ōŽ—ha÷JŪ.§˛ę)ÔŪüÎÄ{SÁt ģƒnč“lu+Fž‚šh¨ …ƒnōAŠhcېSĐk7IÉéņ6ȸ§Ũû@pøN•)–3 >ĪÂ+H@Î5ÍßäŗgŅæŗ™ÁN)VĮŒØa] kAŪ9lܑ‡W–•ƒd$Ī]ØĢôģGFqš äMJr”Ã)ŖŒn‰Pø8­mf‹Ō×į÷ĢX}}JmV 8Æ.iƒ:U†×SÄDļWuψ>rį0ג73Gäš”ˇgūĮūŨpSĮåŗJēzg ŽļGĮÆąFõÃīé›û˛Ž—ëˁ%QŪÅ+ʸ˛KČl(^´õ›÷¨Jû+"ÃzįNŋņÖ¯íŦđËŧgųôÉ' kf–ŸŸˇ”7H‘1w°EŌb‡Q3^yßdyņĖ(ÖpÁåt&jo˜…˙ŽH9ņ9tš ÄŖš÷M˙ŧ[ŲōzË[×F׊ƒm´œ§¤‡kŖ™ÕFZĄÖé_ĄŽxnåĮ‘:zkā]W€ŠEAXpcëŖhĄŽI}į˜>ZN•üÛ )n @æŌēB uÄļBŠ›ÎŦi-…´°:i0ŋÂĐ@Ŋ˜#”ddņ'骁‰­ĸ!—k2ĒÕ"u” lĶÍ—ę^ę(ԜĪę(;Č)ÔQô^ûŽgœŽgRŽÎÉfĪÅ)™K“ŗZb‹˜ēÔSāu*¨ĸ4dAô¨—6ZNe¤ē6 °Ë…6ēÁĐFŊáØ„ĮZū“‡ōɆœ>7xq*8Ã1aj9RFyGŊ_ē(7KĘggáã ˆ;Õ#r~øŪŋÆįsĪxĮ’­Ž"RœÛVGã|P=oˆ7U‘´=ŌĨņÜ Žbtr ŠŖœĖœ—>ІÅĢdŌG9˙š‚bu§ôMqD4x(HŋŖük¨Ŗ€Ė0šŠŖÄĨ|Ŋƒ›/ŗ×ÕŅ8Ą[Ũ [ÅœÕQ ĐÚÖGÉJŋôQžąŒB‘t}0ŨˆĄbõČbw}`QÕKčŖ“~=YŧP´õŅkęŖo[ņĶŨAI„žK`äĮãÖ`Ę ÁÉ{1*‡;R,N¯\€kĐûō+ķtŊĩĘģ÷‰VĮ%r•JŨŋ*{eŅķ‚IUēį~`ņŅÅ~Ö3õ5ũO“Ĩ_„Ą3ãNp.áŠ;ā1DÕˆÖՌŗ_ūIģ†@ROÖ0ģ+ŐĪÎT ö&Á‚ŧg€ Ŧŋ!ÂÉ…`ŠjøqCöVÚ\chúŧü¨)ā/Rp{ĄéƒđAęé–7s ^–8ū<YEë&ˇËöę:Lļ;n?Ķ>›ģšÄęgˆæ ēDƒ „xx$H8Œ ŋŽH‡k™ēįYŽu_•É:xžõxÖãČg? -/i0$ŧ 6U(š¨ °@öˆ´H€ŦE×T}YĪŗ|fã <ŽfĢ2°Ieŧ=øQ?Ho0ž!o Å^–;+qŅĀĖÚĘ|5ĮÁŠæ)‚K—h÷‰á}€'å"ęĘą3ĘĐɈN‰čûÉû¯œ÷HŠĢ"qkĶc€Aņēií yL2Dõõg÷UwnRĩ“¤r†“ <ˆGˆ”ŋ6ĒC¸(ęŸę˜zVĢrĸ, BÆ ŧ´;íą3^r%bČũpyXɲ,ųįV•č¨}2íä×7™'1Øã^põCüjî€ŌãV–ĸ„/ā**Š”ô5ĸqJgpS(#w*9`— ¸5Īã[(ČKxWŖ‘šŽĶÎÄJŸËz*ų@((Ą`ø­Ôīŗxî[~§>GSY‚Ëԕ *ZÁÅwą7nīė×Mžx‡ũŪŊK‘žJ`rĮ^sß ņxfR¤¯[ŦᆖøV ng ­šÍ í-\%ڕ&{Öt‘øļĢēžŋwŊKõ’øD2?Ĩ"SÖŗž<ˇŨ3ҐUŠäwfāÜ8”<–§ÎtĶĩXĪt,ģFš-xŖ.ŗ?w&>ĀGËSŊĨ.<¸g=ļƒÅģxãPūߒ= Ėe4n‡ŪŧĮ¨üÅĖ˙ŲY!&đT cfI\÷Eƒ #¸”¸zŠ”+€¸ŠīÉĪ/V*;Ģ.˙Û(Ŋ2RøK6Åé‡Ŧ¤\TÅ´GÄg î|}ůļ‚Nš|8čHßGRâZÉžĻŠ†Čúîš0)YâLūJ*^yĐt‘ĘéHõä Zߏ0‡ÕwˇøLĄ{Ģo{¸¤HėGĒ žÉ>VņO4°$Ў ¯— ´*ØŗŧœœEŽĨy>øĀTBXŠūöTíÔh]YûĘ&Ãl"MÔsuO’&Ō_\, ZÄŲ=ÍōĻWŪ›ŧŠ…‰UÛ< õNåįĸîãJā%õëR`y8Ļg 1?<đĻēŌVCĪÁrŽÃ(oęÛŲä FŅ É|?ģAš]@ ŋ!ÍŨi‘8/t!Ëņ`FV%‚\ĨŠŸ™[įĐÕt|ãgĢÂËOä1ÚS=Ĩˇ0mQAęĶŅéWČz%žw#°ĢorĄ8Ęf´ré‰^~˛+Ķ‹åĖÃ3NũîI•ČrãšÉQsü]ÆđđET]"mü"Ī, W !=PH_î2Ēté4Æ"p+\7ĀU4lŨã]7Ž[‹\Š×;ī ÛãW‹égø¸ŋ‘â6Į[ƒBsWˇ‰y§~Ü[”ÐŲö‹WUK•ãĻl§IÕ§yp᤭^ŒLĐP qéā‹ Š—˙ޞ˛äÛuc%Tä-ƒšīŠ|æUߚĨ|Ŧrų"go¸ķā}¯ąũHžž‰;Á*НBĨĨ\P„JûũrĶãÂS=šĘîņū~„JŅ@œ3TŠ"}nSbšpßĒ›Må+%âL9E7q‡œŖ~9Ĩ[÷ė“xŨ–"‹ēˆfÄđ3üáœÚŠd˜š9%–†ë­ÉBŋīoÄ ‡ŽQo(ÄĻč"ą*đ¸Y<ŒÎ_w{0zßQ§ŨŖR‡Ī.‚V1ûKdųâ<č+÷˜X`F\ūpTÎĩCrŲŽųKŠøÎėßšoĮõz w›Kßø=ž¨bĪÎ]˛1û#Čj/OTįĢĸŧ°ãDØs 'jĮg‚Ōôąī3 ör~Jž„ÔĀėyŅtë-ëΎG{šŧV"ÚËZ”=_Á^āƒoėXĮö\Y )Õ ģ*:ĄhoáGXw0wīF{KÛŠĨh8 ŋQ¯×{ŧŨIÕD°Yŗ* ĀŲNLüŪ„¯N×céķŽz§ŧT/Ü-ĮęĄŋ÷Īa-OŊáųčqî}@ö}2G„ũcļ;îĢņ¤X̧.ޜƒ@–'%.#(b/¯ūc¯÷øĸ„=;QĘmö¤¤ÛōTMx/^”xÍ(uãNtˆõŊũŠôM t–ŖÚU8P¯6›TD&ŌaĒ 1w}3ũS?Ŧ›fģjģNŠ -*wUŗŸNZÖ/6 Iŗßj˛ ĨÍ~=āuX­ lÕ¨æ¯×-|cË(ø\†Į=Uâ #Æ[ƒCᚥëú8oC pˆīžIÔe”ÂOxŒĨÁƒ‚ąR'čõaä¸Cé ‘œ"HŧŌîŠŋ„"Xí [ĮŅF]Ņ÷÷ŗŊô{á;Įšđ›9ķĘq.^OËĘTŪ¸į8Ŗ20ŋ7ŊsœËÔG1=Į ę¤zŽsÁá|Ĩ8&,D†3 ēüCØP‡įyF†sÁęU#Ã`öę<ü°÷6:v‹2œ÷ë^‰;ē g?2œcrIWcîžßĖ•õųÍ\ˇ{Āt•rá9đöZwĢĘ_ŪXüæ2UĻpį7GÃŪ[mÜqß]Ô0SĒ­r}ËÔįjŊS*9Ō›1ßSÕw”ŪŒ”:ŠŦ6,׿ˇŧ%jBŗøÖϤTBRĸᨌß^×oŨ7~bøÆ•O ú[ ƒFõĩ=CŌW–†F÷u'÷M^’ Úā†>•˜EŦ:ŧU‹Ũ ƒ&^wŊ0zß*ģ~„N“s?fŋM‚Xœ› ąō¨…,ĖÜ.†ęÜ" ĖēÁ˜ßMėL\ Û¯Ÿrcíî}ãc|§Š˜ˆ&НMTą:§šXģ“ä…'Ų@žúظũLņ*Ø­›ˇC0ŖËŪb‚īš “CkĮzŒpWëšžļBįˆräÛĸAŲ •ׯļEpæyŗhXK~l‹Ļú'ĻŨbŲÛ ĢĨ—ËĸÁö ÔÛHOžw^Yåm^#ŋ^sĢJöØöLet¯nÚŊđÔî¨wÂË۞¸Ō6iŧNaéßņĒ+įŅõVŪ}äČXöišæĶŪĻA,ËM‡X´ŽiŊ‡€’ÃŦ „ēŲ˛9O˜5ą!ûwm׹_į†^fMlxŒîässršæ.rÚf“[˜5AŽfœ\sNĖŽVAĮ/éßä‚ßÃv˙ßVđ× ųw`ÍĒā´ |T@åĐĻŲˇfOÁøŌpŊ3"mō­Ã÷wöDŧ,qŽ{ƒræđ킓õ UW•Îh]wņmÚ !КũfŦ|ō•vŌē\ô5÷đF á¨ēíŦÃļøsSōwâ5^4x+Ŧ!܁˛îÁ)Ÿ"<8šå,šscĀ ĸ3îb+{éCŽ­+û ĖmPÕĸšu*87Ô´qįÜŲCŠņ{Uú“2Ŋ*=ī3Ôę˛jR"Õ[<•ĘÎ:Ë ĪžŖĀĮ‹ YŒ×ņ+ öˆX?Ņ÷= }”‚ŸSSbf įrnp¸ģøøū•JA¸ķÆSŒn‡āÍúūKČe^mąw ;:hkŲJ)ôqeļßÔčQkĢ3%…ž}ס×Ũ?‹¯/¸žA}į‡:S”=pŠ3Ĩí"ūÎ/˛×ë=Šš:S†ō~}øŠâŖ>5e3ŪfŽōë¸VļÂōĻ:ƒĪAįË? ¸ÔKŠ§âĒŽT7–„Čõ×C"{ī—ÄöŅÃ?ëŗÛßgŠ3ž¸P|åûĶ=ōŨ?ë¸Ûúˆc6ˆŪ1 ߙũ;÷í¸^×Î^ęŒoü?…vĨŲ‘hn“'M{uNsĄÎDļT÷™…Ė÷Š_JAŲUy¨4øÂCĨ ^Ž›Ęáx •ÄŅK0Ÿ­ŅøļxÃģļ_/Ę\šēßuÁ5ŧˆbONDŖÉNdc\‹#É]KIŪp#’Ũ¸Kۚ"fĶgcę'gĀäB-qAXîžĘ/ÔßT ˇĐáYįŊ5GwņlĀĖ•'¤"ɏ†ē'¤ÖŠđ´Eļ+Ē-“€ûŠJ‚Nāü¸öE⌜ėė°:˛Ē<îAđŗą5ŌÃ*JEĢŌÃzEéažß€ÄRg7=U€ß ÁEJŲŗR9ükžĐŨMnĄ {†XĀž"F0E†ûRpQbĀŖWÎUFZæNõjAm}č–g¤ˆŗEYZ•ŋûGd]&Û¸ës(—0¯ŸĶSņ]~ŧmxáāHC§'ˆŗŌƒ!f×ē2İ<ž4ĪĢŖÄŒRũõŲ"CŒÛĻë,ūĩ2T„¯QlOÕUÃq>¯ģˇ/Ž´ŽąķÃĒ—ėÍąĢØįĨ9öŠ,Üú$āéaNߡô0€s×HSTéaq~vzXœ¯HÛįŅuĮĪĮS_ )b‚‘ī´”Vų!úéaÜŨ8wepđŗ‚JéJÚéÕwĪ”„€RrĐāUöâw¯FõK‘Î[‚ K^WDĈOŠ!´C@‡Š\ bD¯ĖC&ˆ>ĨD!A f™ŲH#ū•uɯF%ˆÕŪ#?w'ˆ}Až06Ŗļįdč낗Į"ļīdL5ļ.‚[Úä|ÛU‚bëķ+ŧߘ.pž4\žeOCđå7qØŨ&ķô´}æ ¯`W!´°4žģMPĄÛĢp€ŌŖž÷&]Ö^¸˙ÜXuŲŊ&*­pķšĖ9 %ø—žÂm‚ĩ•p›øãĢĘDsˇÉô{bî6™ÛqAˇ ÆĶ53w› ’ƒę#Aĩ;ø§}°ų§ëŊīâPŋŸQÃP UŸĄp$ŽÆŠK÷t› åA†ÛßLHwˇ ęžĢ’ Ũ&(”_‰+ēMPėĀ3bW<žŽō*Aá6 0Ü&ßŋR¨-ĸĄĩŪ´ãŠšč7zÚåŋ•ĸTí́ žĮ€¯1›S.ˆŠO4î<|ĨKáwZ ËSž\ēŽ…@â;eŲ?vß1cĩ }rkųg"‹?ßžƒ¸—âéęKōvãGŅ”oá\feiCJb¨^IܓöŦēJb@ qœR˕ŀ…/%€øGÚV8q6§Žš~nFüRY Ұ/ߨ›âõžu¤3^:yYÎvs1˞ М?{/4ŋĘ×Tį .žTbˇUqI đh~ī&dŽŌΤåDŠß‡n4Ŧ} PNÄä_„ÇŗøIå$;jMųS2‚x1¯cęĒ>/æwW‡xåBr€Ô8§ĖÕï ęC÷/Gå‡&—ŽŖđëĸM_0ķ’ÅÉgŠēbįÕšĘĪ•í A%€~ûTž Æšˇ|°%>š‚Ī…Ą~y!U ŨU1ŋ9bBøˆã)5AķÅ'ÕxM#îmđ‹zē.¨ã3fõîGgLąĶŊYĩĐį•)Ë8CL)I“ˆ5%æĨT\NŪ-9ˆ#~E‰Q^ŽÖ§É–îã-qClīDWÁ’C{ōjŌņrō@Ã(ú„$Â:ĒKéGQĨ”Į;SúUˇq âļéëDĶ/אčĮØũV~(lJˇ_úžöäį#ČߏČoĮŸ˙ íŧÅ­ endstream endobj 4 0 obj 15625 endobj 2 0 obj << /ExtGState << /a0 << /CA 1 /ca 1 >> /s6 6 0 R /s8 8 0 R /s10 10 0 R /s12 12 0 R >> /Shading << /sh5 5 0 R >> /XObject << /x7 7 0 R /x9 9 0 R /x11 11 0 R /x13 13 0 R /x14 14 0 R /x15 15 0 R >> >> endobj 16 0 obj << /Type /Page /Parent 1 0 R /MediaBox [ 0 0 512 132.915924 ] /Contents 3 0 R /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources 2 0 R >> endobj 17 0 obj << /Type /XObject /Length 47 /Filter /FlateDecode /Subtype /Form /BBox [ 41 52.915924 73 85.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 0.19 /ca 0.19 >> >> >> >> stream xœ3P0ÂĸtũD…ôb.CS#=KCSK#c#cc…ĸT…4.¯Ų˜ endstream endobj 7 0 obj << /Type /XObject /Length 48 /Filter /FlateDecode /Subtype /Form /BBox [ 41 52.915924 73 85.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 1 /ca 1 >> >> /XObject << /x18 18 0 R >> >> >> stream xœ+ä2T0BŠk g`ab`njАœËĨŸh ^Ŧ _ahĄā’ĪČŊ & endstream endobj 19 0 obj << /Type /Mask /S /Alpha /G 17 0 R >> endobj 6 0 obj << /Type /ExtGState /SMask 19 0 R /ca 1 /CA 1 /AIS false >> endobj 20 0 obj << /Type /XObject /Length 47 /Filter /FlateDecode /Subtype /Form /BBox [ 46 57.915924 68 80.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 0.595 /ca 0.595 >> >> >> >> stream xœ3P0ÂĸtũD…ôb.3Ss=KCSK####c…ĸT…4.°  endstream endobj 9 0 obj << /Type /XObject /Length 48 /Filter /FlateDecode /Subtype /Form /BBox [ 46 57.915924 68 80.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 1 /ca 1 >> >> /XObject << /x21 21 0 R >> >> >> stream xœ+ä2T0BŠk g`ab`njАœËĨŸh ^Ŧ _ad¨ā’ĪČŧô endstream endobj 22 0 obj << /Type /Mask /S /Alpha /G 20 0 R >> endobj 8 0 obj << /Type /ExtGState /SMask 22 0 R /ca 1 /CA 1 /AIS false >> endobj 23 0 obj << /Type /XObject /Length 47 /Filter /FlateDecode /Subtype /Form /BBox [ 65 31.915924 91 56.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 0.19 /ca 0.19 >> >> >> >> stream xœ3P0ÂĸtũD…ôb.3ScC=KCSK##3#S…ĸT…4.°JŸ endstream endobj 11 0 obj << /Type /XObject /Length 48 /Filter /FlateDecode /Subtype /Form /BBox [ 65 31.915924 91 56.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 1 /ca 1 >> >> /XObject << /x24 24 0 R >> >> >> stream xœ+ä2T0BŠk g`ab`njАœËĨŸh ^Ŧ _adĸā’ĪČŊ # endstream endobj 25 0 obj << /Type /Mask /S /Alpha /G 23 0 R >> endobj 10 0 obj << /Type /ExtGState /SMask 25 0 R /ca 1 /CA 1 /AIS false >> endobj 26 0 obj << /Type /XObject /Length 47 /Filter /FlateDecode /Subtype /Form /BBox [ 69 35.915924 87 52.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 0.595 /ca 0.595 >> >> >> >> stream xœ3P0ÂĸtũD…ôb.3KcS=KCSK#C Cs…ĸT…4.ąŠ endstream endobj 13 0 obj << /Type /XObject /Length 48 /Filter /FlateDecode /Subtype /Form /BBox [ 69 35.915924 87 52.915924 ] /Group << /Type /Group /S /Transparency /I true /CS /DeviceRGB >> /Resources << /ExtGState << /a0 << /CA 1 /ca 1 >> >> /XObject << /x27 27 0 R >> >> >> stream xœ+ä2T0BŠk g`ab`njАœËĨŸh ^Ŧ _adŽā’ĪČŊ & endstream endobj 28 0 obj << /Type /Mask /S /Alpha /G 26 0 R >> endobj 12 0 obj << /Type /ExtGState /SMask 28 0 R /ca 1 /CA 1 /AIS false >> endobj 29 0 obj << /FunctionType 2 /Domain [ 0 1 ] /C0 [ 0.188235 0.443137 0.670588 ] /C1 [ 0.0196078 0.219608 0.396078 ] /N 1 >> endobj 5 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [ 121.590797 450.859406 367.959412 24.1366 ] /Domain [ 0 1 ] /Extend [ true true ] /Function 29 0 R >> endobj 30 0 obj << /Length 31 0 R /Filter /FlateDecode /Type /XObject /Subtype /Image /Width 19 /Height 18 /ColorSpace /DeviceGray /Interpolate true /BitsPerComponent 8 >> stream xœc`Ā1DXyÄØ9Q„™$=’œ5XBæĢ.ž?;+Ū„&ÄîžũûīßīnĪŽ–`‚kœųņīŋŋ>ī)ԃŠã=ôųīŋ˙˙ž]o2္Hä\ũú˙˙˙ߟvËB-aTÉ;õũ/Pė÷…"I˜qĘé›>ũü˙īߡÍœP1ž€ §?}˙ķķRĒÜOšŅ“.^ēš6^áf6-ķŦÂp5d¯1rōJ ŗ(8ģ‘E endstream endobj 31 0 obj 185 endobj 14 0 obj << /Length 32 0 R /Filter /FlateDecode /Type /XObject /Subtype /Image /Width 19 /Height 18 /ColorSpace /DeviceGray /Interpolate true /BitsPerComponent 8 /SMask 30 0 R >> stream xœÁ ‚@EũÉūĮ/h´h%´›y:øP0ŅPHŠÅlĸo°ˆ#há].÷<žeMˊŦķ2ė(Đ0đ’/r(Dä!#1ˆŌŧ,$r:hdģ<ĢÎ][J€™f„bŲ)ukdė;ēFDV÷įũ$Ŗ­>ČēWęzLÁ5sdE{éšŊŒ6Yr!U!ãYƒŗåˆ)Fœ˜KŪ^Â(§cëĶ$~ŗ‰ų›xi endstream endobj 32 0 obj 180 endobj 33 0 obj << /Length 34 0 R /Filter /FlateDecode /Type /XObject /Subtype /Image /Width 19 /Height 33 /ColorSpace /DeviceGray /Interpolate true /BitsPerComponent 8 >> stream xœc`ĀŅ˜ØšØ˜PDXøä´äųX5ņG寘‰"‰ąŠæŦÛŗļÎN˜nĮü{¯Ēĩā‡YÂĸœwč͟/ļeǰÁôôŪúų˙ߏķ]xaBĻ+^˙ũ˙īß÷ķų2P—°Zo˙ôī˙˙˙žĖ˛å˜Æéž ,ô÷ÞD)ˆ2nĪ­ŸAB˙~\kŅ€¸ËiÛW°Đß7+MX!Ž0Ųú˙˙ë>°aŦĻ‹_ū }?ž&ö›aßŨ_`ĄŸ—kTÁ†ą¨–_ū ų÷û~:XŖh˰Đ˙ŋ/gjCŦätŲúá/HčĪķšæė`!vãI÷~Ũ˙lŽ5ÄJyĮŋ@„æ™AƒQĀmĐã˙˙ũy>SŒĖJuWž˙û÷˙÷ Vhā ų-}øķßŋ¯Į •`ĀĄ™ˇãŏŸ¯Ö‹Â⎉߲îāƒGg[Œ8q+dWŊ|ÍD_qx$"jí"Ήãârĸė¨Š€‰™•…yĒģ_ endstream endobj 34 0 obj 381 endobj 15 0 obj << /Length 35 0 R /Filter /FlateDecode /Type /XObject /Subtype /Image /Width 19 /Height 33 /ColorSpace /DeviceGray /Interpolate true /BitsPerComponent 8 /SMask 33 0 R >> stream xœ’IRÂ@…š¤÷á î˛bßUĄįt:&LA4&R(Pę "&aĨ˙ōĢ×˙đ^×j˙ޜ( ~FĖļq`ž" ĸœč$œ1‚¯@Eę3!uÎČĢN„ęĻm:W+Ų^$Ũ^āšR+ÔáV0Ež›‡ NĪâБčđPéîI˜B-eœ~ŧ\eÉ +x‰ —ū4]ŋfOÃJšūôqģ{ēíKŪ*ÛwtoēÚ}Žī#›“b ‹ŗí׿aäpX43ĄæŽ–o›lėjôĸ0 P;LžˇīéĝŌ"Ŋiúōŧģ“]"á įiš 4Ę-šÜęĮÉ|9‚•'å-ĸ;Ņä&žļx§ē1Ķ ‡Ą'Tū0*-¯įw­ėŠ5ĐŪX˒ ŗĢ ʸ.‚ŋÉåīSÂđč~‘ ϤŨ> stream xœ]ą €0 {Oņ;ÁN2#ĐĀū„H Ą¯^§ŋßIQsĖčFÁ|R¯Ŧ^ØĖŲÔkÕąÂKa‘ŗhë–>œ mM/Nr+ķˇÎÆ[ŗŋ|ųŊO4ĐŸ@ ö endstream endobj 37 0 obj 101 endobj 36 0 obj << /ExtGState << /a0 << /CA 1 /ca 1 >> >> >> endobj 21 0 obj << /Length 39 0 R /Filter /FlateDecode /Type /XObject /Subtype /Form /BBox [ 46 58 68 81 ] /Resources 38 0 R >> stream xœ]ģ Ã@ C{MÁ hų>’nŒŒ&vaļ÷ŒK‚°"yȌžsÁôT,—cJÖh†Õ¨‘`A׌ ÕŠ%ŖŖô™3jûšōfŽ´Ų‡ĘŊ~Vxũŋ}ÉCnåcØ endstream endobj 39 0 obj 102 endobj 38 0 obj << /ExtGState << /a0 << /CA 1 /ca 1 >> >> >> endobj 24 0 obj << /Length 41 0 R /Filter /FlateDecode /Type /XObject /Subtype /Form /BBox [ 65 32 91 57 ] /Resources 40 0 R >> stream xœeŽ;€0 C÷œÂ'é'M{ ŽĀB`î/Q¨ōYΞŗ“íŖb˜õ¤¤\BDŒœDąÁŒMÂí};+,ŗ“‚ā9ļØãÕĨ΋°Ī z z@YœīšØōÃ߁/ŋféÃ"= endstream endobj 41 0 obj 105 endobj 40 0 obj << /ExtGState << /a0 << /CA 1 /ca 1 >> >> >> endobj 27 0 obj << /Length 43 0 R /Filter /FlateDecode /Type /XObject /Subtype /Form /BBox [ 69 36 87 53 ] /Resources 42 0 R >> stream xœeŽ1Ã0 wŊ‚/`­Jļägô ]šíä˙@Ō…‡Bq AqÅįļ—{Á˛KëĖRáÎvĘ´8ؘĄx!’Z:ŦŅĘāôœ~6ē:dĖ@ĨšĪ‚ze×/?˙ų7á!79’Î"o endstream endobj 43 0 obj 106 endobj 42 0 obj << /ExtGState << /a0 << /CA 1 /ca 1 >> >> >> endobj 1 0 obj << /Type /Pages /Kids [ 16 0 R ] /Count 1 >> endobj 44 0 obj << /Creator (cairo 1.14.0 (http://cairographics.org)) /Producer (cairo 1.14.0 (http://cairographics.org)) >> endobj 45 0 obj << /Type /Catalog /Pages 1 0 R >> endobj xref 0 46 0000000000 65535 f 0000023749 00000 n 0000015741 00000 n 0000000015 00000 n 0000015717 00000 n 0000020004 00000 n 0000017040 00000 n 0000016587 00000 n 0000017951 00000 n 0000017498 00000 n 0000018861 00000 n 0000018407 00000 n 0000019774 00000 n 0000019320 00000 n 0000020650 00000 n 0000021702 00000 n 0000015996 00000 n 0000016218 00000 n 0000022307 00000 n 0000016980 00000 n 0000017127 00000 n 0000022665 00000 n 0000017891 00000 n 0000018038 00000 n 0000023024 00000 n 0000018801 00000 n 0000018949 00000 n 0000023386 00000 n 0000019714 00000 n 0000019862 00000 n 0000020228 00000 n 0000020627 00000 n 0000021061 00000 n 0000021084 00000 n 0000021679 00000 n 0000022284 00000 n 0000022592 00000 n 0000022569 00000 n 0000022951 00000 n 0000022928 00000 n 0000023313 00000 n 0000023290 00000 n 0000023676 00000 n 0000023653 00000 n 0000023815 00000 n 0000023943 00000 n trailer << /Size 46 /Root 45 0 R /Info 44 0 R >> startxref 23996 %%EOF ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_banner.svg0000644000175100001770000012633014637570305021732 0ustar00runnerdocker photutils logoimage/svg+xmlphotutils logoLarry Bradley ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_banner_original.svg0000644000175100001770000003743614637570305023626 0ustar00runnerdocker photutils logoimage/svg+xmlphotutils logoLarry Bradleyphot utils An Astropy Package for Photometry ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_logo-32x32.png0000644000175100001770000000407114637570305022166 0ustar00runnerdocker‰PNG  IHDR szzôtEXtSoftwareAdobe ImageReadyqÉe<ÛIDATxÚ¤W PT×ūÎaaewwQpäĩ ō¨ƒŠąļ¤˜„Ņ’ĻĐ´3u™Nf´“Nb§í$ĶÎ$mÆ´i’JĶv:MšJ§6ŽŽŒB4JRĖĄ€°°,( ¸ŧ–ĮŊũīšû¸ ¤ŅĘđīŊ÷<ūĮ÷?Îųîá/jįadXeYĸ/˛ŦũØį.˙Ö~'<ØŨ šŋļŒ„|‹Hyn„ėTÂ7f§G=}ürūĘëö{VĀøĀ3ÕÄô9­PU ¤úŽĀ{-Ž%EÜw­@ėŪcVâđą)Ķ2/ë‚[Ž€qU$ܓ1~q!ų3•Ą7=w‘-Zūa˙Kx\ųŗÕ`ėΌą EW#ķbŋŽ)|)_LJjž­ÍŖ ũˇnã‹:'ē¤5Ē]ŒŅŋúī@$M 3\_ø¨ãsˆ˙Ús'IãjŋŲlŲ¸…FnGIÎ&¸§<ÂúĶM­(ÍMÅĨëŨ„J7NΧ.ą^Ō¸Kâ†]sŧ&Đ­$<áĄĢ–ĖHđ˘DkĘ^”oJÄW‹2‰ļÂvc,wÃ-Ŧo•Ö‘ą\$Õ<™ƒib†A6ŅûIúČ_҉ûr„;*PôTœx[PaÆŠK6Ü_Ž÷.ˇãüG]đÎ/ÂûīsÂ=ÍR’r¨đC%›ÃŖú.äęF`“â•ÉDe[ßBsKIŧPYzID¯BL…‘ÉŠ*ĻÕQČIKÂ÷_>%"Ü,ßF ëƒ ëĐ(Yˆ¯o­õīUčąđœđZ‘Ç‘6…žÅeŨĶôSɕ/öŌ†K#8KÂŒ‰oķ:Ē=ŅF8zz) "ŅD‚…ŸĄ‰xM”ō~ôIäĄ4/ §ķ .Î'Ķeō˜üây‘ãøŠÜ¯2k—“Đ&¯W… û‹eĒë¸,üüĸņCŒ7ap`œË `âs°ėŪ [ˇš›7 ‘žÕėāĘö2%bRõ˛‰qū4įY¸‰Jųcšį" ÄũÄŠ™ãDũēė<øMl~đ Ē"ÚĸÉ'lbzŽa7”ĒĘ7đf)"cķžr€T:`Ä,ōųë P:qí<9˜ĪœeHKŽÃ؄'˜ãDÍ3ąˆļÁz6 ׈ŸČ ØÍ{Â ´ö AŽÆë7 ÷ŒÂŖīŦ@€´Q­'_;,%8\ą ąņkEdsŸ?ÅĖŽ‡×Œĸ}ËúŨT:ŽÚbđ§Åŧā>ĸōâl‚?׸o,ˆOĢ~Ußęƒúđ0¤lHÂdæ.d“Bčiیq)ļ=\%2ÂÂÆU8y!Ķ(ėGįgO–cÂãEn˜ Ģq§˛‡q+÷Yā [ ĶXĻ<3H4EÃĀfã(}Ėņ&ėKÆŪĘJeY°÷@%*ļŦFjRÅD„ˆ •T^FĘũI…Ē’>”aá“@Ŧšm*ķ§Ŋĸ@‘û Î܋›î™€%Ū ƒcSxutžŨųsŧĶÛ‚ļ†ŗxƒ—*gŒHˇ`T3(‡ ŖIN…ĄĩO~}‡@Ė(uSj‹Rĸҧ lƒ2øZt^¸ ƒäAO˜ÚyeĮÉđ2 ĐD\ÔíG.ėõׅŒ˛W/ňŊyF¸ÃÄŊjuUôûĐÂĩ>Včjø,HŒĘN4uņǟ‚i#¨Õî ņŖßMÚ TāWįÚėð÷Đ7îą÷~ÚĀų˛<į衠CgãJjîY´a‡ÔMĪV‘SŗķĄu€öěV•¨–î3×ē`I0ÁâēŽ÷ŲV­ąõE5C ‹2ļcą {>Áš+:Œš0?ø8jbûąč F?Ņ—Ĩ6ŧįÁw¤FŸ1*RÅŲ)ČN]ׄP\˛č¸8Žia‹z|ųũ§ÁÉ .ąáTd ĶE$;G§ņë_€ŗęqŠæ8Ė„¯‚‰î% Xŋq†h=Î^ŅÁ5> ãŒm}#4v&¨¨)1ዑē[õĪÚÅ-ë{¸Mƒ&íA˛oĻįbËpŦ˛Ÿ:FÅx 1û´D ļëqV—+Î\ĀŖŅhjëĮYž-Ό­„Ō8ôTõbņÄÂ%ʘēi>čŨÚ÷„ÂG2 Ģöe=ČÛqŽuŪÄöoGų}iÂÂSt!Ķgw7œŪxjđ¯Įü‡ ÎW$~OĒUCs0`†Uz\ëē%”8ī™ÃÕ˙ â/lÄߚ:‘ė;Tü¨OĪRVš(ãī†eâF$Õ ŧ}´N{gí•ƒ u|IJ6ˇtĸĸhŗPBņeEqēˆæęŠéʞIÆŌ”¤ŽzŠ68ūōLÍŌ˜.x;eĩ´ēŒTŪ¨ p!z;ǜ˙Âäh":œŗ¨ŋrCX#Mcß| ŪŠÚ­ZåŗN^v|€ŠĀ#Ģyíį6&yGŪļŌã 2ųo´ģfš… *,Šp…é9}~čŨ+4%Bņ~ŧį­ÃĪßqgd­}ĮĒŪZeëŠMäĪh>¤ŽHiÍč§ĻûäS wŨ’&zQZą•„.ˇ^ZŌĘĮ;ßønŨ=7§¤ˆrG¤žgërČV+mW=Ņß;^ĢŠŋ›f÷Ž›SŠ“Pb9ö֏Ų˙ß˙ŋ æ(HUŋŽ’ŠIENDŽB`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/_static/photutils_logo.svg0000644000175100001770000003625214637570305021430 0ustar00runnerdocker photutils logoimage/svg+xmlphotutils logoLarry Bradley././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/aperture.rst0000644000175100001770000010720514637570305016564 0ustar00runnerdocker.. _photutils-aperture: Aperture Photometry (`photutils.aperture`) ========================================== Introduction ------------ The :func:`~photutils.aperture.aperture_photometry` function and the :class:`~photutils.aperture.ApertureStats` class are the main tools to perform aperture photometry on an astronomical image for a given set of apertures. Photutils provides several apertures defined in pixel or sky coordinates. The aperture classes that are defined in pixel coordinates are: * `~photutils.aperture.CircularAperture` * `~photutils.aperture.CircularAnnulus` * `~photutils.aperture.EllipticalAperture` * `~photutils.aperture.EllipticalAnnulus` * `~photutils.aperture.RectangularAperture` * `~photutils.aperture.RectangularAnnulus` Each of these classes has a corresponding variant defined in sky coordinates: * `~photutils.aperture.SkyCircularAperture` * `~photutils.aperture.SkyCircularAnnulus` * `~photutils.aperture.SkyEllipticalAperture` * `~photutils.aperture.SkyEllipticalAnnulus` * `~photutils.aperture.SkyRectangularAperture` * `~photutils.aperture.SkyRectangularAnnulus` To perform aperture photometry with sky-based apertures, one will need to specify a WCS transformation. Users can also create their own custom apertures (see :ref:`custom-apertures`). .. _creating-aperture-objects: Creating Aperture Objects ------------------------- The first step in performing aperture photometry is to create an aperture object. An aperture object is defined by a position (or a list of positions) and parameters that define its size and possibly, orientation (e.g., an elliptical aperture). We start with an example of creating a circular aperture in pixel coordinates using the :class:`~photutils.aperture.CircularAperture` class:: >>> from photutils.aperture import CircularAperture >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) The positions should be either a single tuple of ``(x, y)``, a list of ``(x, y)`` tuples, or an array with shape ``Nx2``, where ``N`` is the number of positions. The above example defines two circular apertures located at pixel coordinates ``(30, 30)`` and ``(40, 40)`` with a radius of 3 pixels. Creating an aperture object in sky coordinates is similar. One first uses the :class:`~astropy.coordinates.SkyCoord` class to define sky coordinates and then the :class:`~photutils.aperture.SkyCircularAperture` class to define the aperture object:: >>> from astropy import units as u >>> from astropy.coordinates import SkyCoord >>> from photutils.aperture import SkyCircularAperture >>> positions = SkyCoord(l=[1.2, 2.3] * u.deg, b=[0.1, 0.2] * u.deg, ... frame='galactic') >>> aperture = SkyCircularAperture(positions, r=4.0 * u.arcsec) .. note:: Sky apertures are not defined completely in sky coordinates. They simply use sky coordinates to define the central position, and the remaining parameters are converted to pixels using the pixel scale of the image at the central position. Projection distortions are not taken into account. They are **not** defined as apertures on the celestial sphere, but rather are meant to represent aperture shapes on an image. If the apertures were defined completely in sky coordinates, their shapes would not be preserved when converting to or from pixel coordinates. Converting Between Pixel and Sky Apertures ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The pixel apertures can be converted to sky apertures, and vice versa, given a WCS object. To accomplish this, use the :meth:`~photutils.aperture.PixelAperture.to_sky` method for pixel apertures, e.g.,: .. doctest-skip:: >>> aperture = CircularAperture((10, 20), r=4.0) >>> sky_aperture = aperture.to_sky(wcs) and the :meth:`~photutils.aperture.SkyAperture.to_pixel` method for sky apertures, e.g.,: .. doctest-skip:: >>> position = SkyCoord(1.2, 0.1, unit='deg', frame='icrs') >>> aperture = SkyCircularAperture(position, r=4.0 * u.arcsec) >>> pix_aperture = aperture.to_pixel(wcs) Performing Aperture Photometry ------------------------------ After the aperture object is created, we can then perform the photometry using the :func:`~photutils.aperture.aperture_photometry` function. We start by defining the aperture (at two positions) as described above:: >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) We then call the :func:`~photutils.aperture.aperture_photometry` function with the data and the apertures. Note that :func:`~photutils.aperture.aperture_photometry` assumes that the input data have been background subtracted. For simplicity, we define the data here as an array of all ones:: >>> import numpy as np >>> from photutils.aperture import aperture_photometry >>> data = np.ones((100, 100)) >>> phot_table = aperture_photometry(data, aperture) >>> phot_table['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum pix pix --- ------- ------- ------------ 1 30.0 30.0 28.274334 2 40.0 40.0 28.274334 This function returns the results of the photometry in an Astropy `~astropy.table.QTable`. In this example, the table has four columns, named ``'id'``, ``'xcenter'``, ``'ycenter'``, and ``'aperture_sum'``. Since all the data values are 1.0, the aperture sums are equal to the area of a circle with a radius of 3:: >>> print(np.pi * 3.0 ** 2) # doctest: +FLOAT_CMP 28.2743338823 .. _photutils-aperture-overlap: Aperture and Pixel Overlap -------------------------- The overlap of the aperture with the data pixels can be handled in different ways. The default method (``method='exact'``) calculates the exact intersection of the aperture with each pixel. The other options, ``'center'`` and ``'subpixel'``, are faster, but with the expense of less precision. With ``'center'``, a pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. With ``'subpixel'``, pixels are divided into a number of subpixels, which are in or out of the aperture based on their centers. For this method, the number of subpixels needs to be set with the ``subpixels`` keyword. This example uses the ``'subpixel'`` method where pixels are resampled by a factor of 5 (``subpixels=5``) in each dimension:: >>> phot_table = aperture_photometry(data, aperture, method='subpixel', ... subpixels=5) >>> print(phot_table) # doctest: +SKIP id xcenter ycenter aperture_sum pix pix --- ------- ------- ------------ 1 30.0 30.0 27.96 2 40.0 40.0 27.96 Note that the results differ from the exact value of 28.274333 (see above). For the ``'subpixel'`` method, the default value is ``subpixels=5``, meaning that each pixel is equally divided into 25 smaller pixels (this is the method and subsampling factor used in `SourceExtractor `_). The precision can be increased by increasing ``subpixels``, but note that computation time will be increased. Aperture Photometry with Multiple Apertures at Each Position ------------------------------------------------------------ While the `~photutils.aperture.Aperture` objects support multiple positions, they must have a fixed size and shape (e.g., radius and orientation). To perform photometry in multiple apertures at each position, one may input a list of aperture objects to the :func:`~photutils.aperture.aperture_photometry` function. In this case, the apertures must all have identical position(s). Suppose that we wish to use three circular apertures, with radii of 3, 4, and 5 pixels, on each source:: >>> radii = [3.0, 4.0, 5.0] >>> apertures = [CircularAperture(positions, r=r) for r in radii] >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum_0 aperture_sum_1 aperture_sum_2 pix pix --- ------- ------- -------------- -------------- -------------- 1 30 30 28.274334 50.265482 78.539816 2 40 40 28.274334 50.265482 78.539816 For multiple apertures, the output table column names are appended with the ``positions`` index. Other apertures have multiple parameters specifying the aperture size and orientation. For example, for elliptical apertures, one must specify ``a``, ``b``, and ``theta``:: >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAperture >>> a = 5.0 >>> b = 3.0 >>> theta = Angle(45, 'deg') >>> apertures = EllipticalAperture(positions, a, b, theta) >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum pix pix --- ------- ------- ------------ 1 30 30 47.12389 2 40 40 47.12389 Again, for multiple apertures one should input a list of aperture objects, each with identical positions:: >>> a = [5.0, 6.0, 7.0] >>> b = [3.0, 4.0, 5.0] >>> theta = Angle(45, 'deg') >>> apertures = [EllipticalAperture(positions, a=ai, b=bi, theta=theta) ... for (ai, bi) in zip(a, b)] >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum_0 aperture_sum_1 aperture_sum_2 pix pix --- ------- ------- -------------- -------------- -------------- 1 30 30 47.12389 75.398224 109.95574 2 40 40 47.12389 75.398224 109.95574 .. _photutils-aperture-stats: Aperture Statistics ------------------- The :class:`~photutils.aperture.ApertureStats` class can be used to create a catalog of statistics and properties for pixels within an aperture, including aperture photometry. It can calculate many properties, including statistics like :attr:`~photutils.aperture.ApertureStats.min`, :attr:`~photutils.aperture.ApertureStats.max`, :attr:`~photutils.aperture.ApertureStats.mean`, :attr:`~photutils.aperture.ApertureStats.median`, :attr:`~photutils.aperture.ApertureStats.std`, :attr:`~photutils.aperture.ApertureStats.sum_aper_area`, and :attr:`~photutils.aperture.ApertureStats.sum`. It also can be used to calculate morphological properties like :attr:`~photutils.aperture.ApertureStats.centroid`, :attr:`~photutils.aperture.ApertureStats.fwhm`, :attr:`~photutils.aperture.ApertureStats.semimajor_sigma`, :attr:`~photutils.aperture.ApertureStats.semiminor_sigma`, :attr:`~photutils.aperture.ApertureStats.orientation`, and :attr:`~photutils.aperture.ApertureStats.eccentricity`. Please see :class:`~photutils.aperture.ApertureStats` for the complete list of properties that can be calculated. The properties can be accessed using `~photutils.aperture.ApertureStats` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.aperture.ApertureStats.to_table` method. Most of the source properties are calculated using the "center" :ref:`aperture-mask method `, which gives aperture weights of 0 or 1. This avoids the need to compute weighted statistics --- the ``data`` pixel values are directly used. The ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method when calculating the sum-related properties: ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. The optional ``local_bkg`` keyword can be used to input the per-pixel local background of each source, which will be subtracted before computing the aperture statistics. The optional ``sigma_clip`` keyword can be used to sigma clip the pixel values before computing the source properties. This keyword could be used, for example, to compute a sigma-clipped median of pixels in an annulus aperture to estimate the local background level. Here is a simple example using a circular aperture at one position. Note that like :func:`~photutils.aperture.aperture_photometry`, :class:`~photutils.aperture.ApertureStats` expects the input data to be background subtracted. For simplicity, here we roughly estimate the background as the sigma-clipped median value:: >>> from astropy.stats import sigma_clipped_stats >>> from photutils.aperture import ApertureStats, CircularAperture >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> _, median, _ = sigma_clipped_stats(data, sigma=3.0) >>> data -= median # subtract background from the data >>> aper = CircularAperture((150, 25), 8) >>> aperstats = ApertureStats(data, aper) # doctest: +FLOAT_CMP >>> print(aperstats.xcentroid) # doctest: +FLOAT_CMP 149.98963482915323 >>> print(aperstats.ycentroid) # doctest: +FLOAT_CMP 24.97165265459083 >>> print(aperstats.centroid) # doctest: +FLOAT_CMP [149.98963483 24.97165265] >>> print(aperstats.mean, aperstats.median, aperstats.std) # doctest: +FLOAT_CMP 42.38192194155781 26.53270189818481 39.19365538349298 >>> print(aperstats.sum) # doctest: +FLOAT_CMP 8204.777345704442 Similar to `~photutils.aperture.aperture_photometry`, the input aperture can have multiple positions:: >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.xcentroid) # doctest: +FLOAT_CMP [149.98175939 89.97793821] >>> print(aperstats2.sum) # doctest: +FLOAT_CMP [ 8487.10695247 34963.45850824] >>> columns = ('id', 'mean', 'median', 'std', 'var', 'sum') >>> stats_table = aperstats2.to_table(columns) >>> for col in stats_table.colnames: ... stats_table[col].info.format = '%.8g' # for consistent table output >>> print(stats_table) # doctest: +FLOAT_CMP id mean median std var sum --- --------- --------- --------- --------- --------- 1 27.915818 12.582676 36.628464 1341.6444 8487.107 2 113.18737 112.11505 49.756626 2475.7218 34963.459 Each row of the table corresponds to a single aperture position (i.e., a single source). Background Subtraction ---------------------- Global Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.aperture.aperture_photometry` and :class:`~photutils.aperture.ApertureStats` assume that the input data have been background-subtracted. If ``bkg`` is a float value or an array representing the background of the data (e.g., determined by `~photutils.background.Background2D` or an external function), simply subtract the background from the data:: >>> phot_table = aperture_photometry(data - bkg, aperture) # doctest: +SKIP In the case of a constant global background, you can pass in the background value using ``local_bkg`` in :class:`~photutils.aperture.ApertureStats`. This would avoid reading an entire memory-mapped array into memory beforehand, as would happen if you manually subtract the background as shown above. So instead you could do this:: >>> aperstats = ApertureStats(data, aperture, local_bkg=bkg) # doctest: +SKIP Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ One often wants to also estimate the local background around each source using a nearby aperture or annulus aperture surrounding each source. A simple method for doing this is to use the :class:`~photutils.aperture.ApertureStats` class (see :ref:`photutils-aperture-stats`) to compute the mean background level within the background aperture. This class can also be used to calculate more advanced statistics (e.g., a sigma-clipped median) within the background aperture (e.g., a circular annulus). We show examples of both below. Let's start by generating a more realistic example dataset:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() This artificial image has a known constant background level of 5. In the following examples, we'll leave this global background in the image to be estimated using local backgrounds. For this example we perform the photometry for three sources in a circular aperture with a radius of 5 pixels. The local background level around each source is estimated using a circular annulus of inner radius 10 pixels and outer radius 15 pixels. Let's define the apertures:: >>> from photutils.aperture import CircularAnnulus, CircularAperture >>> positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] >>> aperture = CircularAperture(positions, r=5) >>> annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) Now let's plot the circular apertures (white) and circular annulus apertures (red) on a cutout from the image containing the three sources: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) norm = simple_norm(data, 'sqrt', percent=99) plt.imshow(data, norm=norm, interpolation='nearest') plt.xlim(0, 170) plt.ylim(130, 250) ap_patches = aperture.plot(color='white', lw=2, label='Photometry aperture') ann_patches = annulus_aperture.plot(color='red', lw=2, label='Background annulus') handles = (ap_patches[0], ann_patches[0]) plt.legend(loc=(0.17, 0.05), facecolor='#458989', labelcolor='white', handles=handles, prop={'weight': 'bold', 'size': 11}) Simple mean within a circular annulus """"""""""""""""""""""""""""""""""""" We can use the :class:`~photutils.aperture.ApertureStats` class to compute the mean background level within the annulus aperture at each position:: >>> from photutils.aperture import ApertureStats >>> aperstats = ApertureStats(data, annulus_aperture) >>> bkg_mean = aperstats.mean >>> print(bkg_mean) # doctest: +FLOAT_CMP [4.99411764 5.1349344 4.86894665] Now let's use :func:`~photutils.aperture.aperture_photometry` to perform the photometry in the circular aperture (in the next example, we'll use :class:`~photutils.aperture.ApertureStats` to perform the photometry):: >>> from photutils.aperture import aperture_photometry >>> phot_table = aperture_photometry(data, aperture) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum pix pix --- ------- ------- ------------ 1 145.1 168.3 1128.1245 2 84.5 224.1 735.739 3 48.3 200.3 1299.6341 The total background within the circular aperture is the mean local per-pixel background times the circular aperture area. If you are using the default "exact" aperture (see :ref:`aperture-mask methods `) and there are no masked pixels, the exact analytical aperture area can be accessed via the aperture ``area`` attribute:: >>> aperture.area # doctest: +FLOAT_CMP 78.53981633974483 However, in general you should use the :meth:`photutils.aperture.PixelAperture.area_overlap` method where a ``mask`` keyword can be input. This ensures you are using the same area over which the photometry was performed. If using a :class:`~photutils.aperture.SkyAperture`, you will first need to convert it to a :class:`~photutils.aperture.PixelAperture`. Since we are not using a mask, the results are identical:: >>> aperture_area = aperture.area_overlap(data) >>> print(aperture_area) # doctest: +FLOAT_CMP [78.53981634 78.53981634 78.53981634] The total background within the circular aperture is then:: >>> total_bkg = bkg_mean * aperture_area >>> print(total_bkg) # doctest: +FLOAT_CMP [392.23708187 403.29680431 382.40617574] Thus, the background-subtracted photometry is:: >>> phot_bkgsub = phot_table['aperture_sum'] - total_bkg Finally, let's add these as columns to the photometry table:: >>> phot_table['total_bkg'] = total_bkg >>> phot_table['aperture_sum_bkgsub'] = phot_bkgsub >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum total_bkg aperture_sum_bkgsub pix pix --- ------- ------- ------------ --------- ------------------- 1 145.1 168.3 1128.1245 392.23708 735.88739 2 84.5 224.1 735.739 403.2968 332.44219 3 48.3 200.3 1299.6341 382.40618 917.22792 Sigma-clipped median within a circular annulus """""""""""""""""""""""""""""""""""""""""""""" For this example, the local background level around each source is estimated as the sigma-clipped median value within the circular annulus. We'll use the :class:`~photutils.aperture.ApertureStats` class to compute both the photometry (aperture sum) and the background level:: >>> from astropy.stats import SigmaClip >>> sigclip = SigmaClip(sigma=3.0, maxiters=10) >>> aper_stats = ApertureStats(data, aperture, sigma_clip=None) >>> bkg_stats = ApertureStats(data, annulus_aperture, sigma_clip=sigclip) The sigma-clipped median values in the background annulus apertures are:: >>> print(bkg_stats.median) # doctest: +FLOAT_CMP [4.89374178 5.05655328 4.83268958] The total background within the circular apertures is then the per-pixel background level multiplied by the circular-aperture areas:: >>> total_bkg = bkg_stats.median * aper_stats.sum_aper_area.value >>> print(total_bkg) # doctest: +FLOAT_CMP [384.35358069 397.14076611 379.5585524 ] Finally, the local background-subtracted sum within the circular apertures is:: >>> apersum_bkgsub = aper_stats.sum - total_bkg >>> print(apersum_bkgsub) # doctest: +FLOAT_CMP [743.77088731 338.59823118 920.07553956] Note that if you want to compute all the source properties (i.e., in addition to only :attr:`~photutils.aperture.ApertureStats.sum`) on the local-background-subtracted data, you may input the *per-pixel* local background values to :class:`~photutils.aperture.ApertureStats` via the ``local_bkg`` keyword:: >>> aper_stats_bkgsub = ApertureStats(data, aperture, ... local_bkg=bkg_stats.median) >>> print(aper_stats_bkgsub.sum) # doctest: +FLOAT_CMP [743.77088731 338.59823118 920.07553956] Note these background-subtracted values are the same as those above. .. _error_estimation: Aperture Photometry Error Estimation ------------------------------------ If and only if the ``error`` keyword is input to :func:`~photutils.aperture.aperture_photometry`, the returned table will include a ``'aperture_sum_err'`` column in addition to ``'aperture_sum'``. ``'aperture_sum_err'`` provides the propagated uncertainty associated with ``'aperture_sum'``. For example, suppose we have previously calculated the error on each pixel value and saved it in the array ``error``:: >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) >>> data = np.ones((100, 100)) >>> error = 0.1 * data >>> phot_table = aperture_photometry(data, aperture, error=error) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum aperture_sum_err pix pix --- ------- ------- ------------ ---------------- 1 30 30 28.274334 0.53173616 2 40 40 28.274334 0.53173616 ``'aperture_sum_err'`` values are given by: .. math:: \Delta F = \sqrt{\sum_{i \in A} \sigma_{\mathrm{tot}, i}^2} where :math:`A` are the non-masked pixels in the aperture, and :math:`\sigma_{\mathrm{tot}, i}` is the input ``error`` array. In the example above, it is assumed that the ``error`` keyword specifies the *total* error --- either it includes Poisson noise due to individual sources or such noise is irrelevant. However, it is often the case that one has calculated a smooth "background-only error" array, which by design doesn't include increased noise on bright pixels. To include Poisson noise from the sources, we can use the :func:`~photutils.utils.calc_total_error` function. Let's assume we have a background-only image called ``bkg_error``. If our data are in units of electrons/s, we would use the exposure time as the effective gain:: >>> from photutils.utils import calc_total_error >>> effective_gain = 500 # seconds >>> error = calc_total_error(data, bkg_error, effective_gain) # doctest: +SKIP >>> phot_table = aperture_photometry(data - bkg, aperture, error=error) # doctest: +SKIP Aperture Photometry with Pixel Masking -------------------------------------- Pixels can be ignored/excluded (e.g., bad pixels) from the aperture photometry by providing an image mask via the ``mask`` keyword:: >>> data = np.ones((5, 5)) >>> aperture = CircularAperture((2, 2), 2.0) >>> mask = np.zeros(data.shape, dtype=bool) >>> data[2, 2] = 100.0 # bad pixel >>> mask[2, 2] = True >>> t1 = aperture_photometry(data, aperture, mask=mask) >>> t1['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(t1['aperture_sum']) aperture_sum ------------ 11.566371 The result is very different if a ``mask`` image is not provided:: >>> t2 = aperture_photometry(data, aperture) >>> t2['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(t2['aperture_sum']) aperture_sum ------------ 111.56637 Aperture Photometry Using Sky Coordinates ----------------------------------------- As mentioned in :ref:`creating-aperture-objects`, performing photometry using apertures defined in sky coordinates simply requires defining a "sky" aperture at positions defined by a :class:`~astropy.coordinates.SkyCoord` object. Here we show an example of photometry on real data using a `~photutils.aperture.SkyCircularAperture`. We start by loading a Spitzer 4.5 micron image of a region of the Galactic plane:: >>> import astropy.units as u >>> from astropy.wcs import WCS >>> from photutils.datasets import load_spitzer_catalog, load_spitzer_image >>> hdu = load_spitzer_image() # doctest: +REMOTE_DATA >>> data = u.Quantity(hdu.data, unit=hdu.header['BUNIT']) # doctest: +REMOTE_DATA >>> wcs = WCS(hdu.header) # doctest: +REMOTE_DATA >>> catalog = load_spitzer_catalog() # doctest: +REMOTE_DATA The catalog contains (among other things) the Galactic coordinates of the sources in the image as well as the PSF-fitted fluxes from the official Spitzer data reduction. We define the apertures positions based on the existing catalog positions:: >>> positions = SkyCoord(catalog['l'], catalog['b'], frame='galactic') # doctest: +REMOTE_DATA >>> aperture = SkyCircularAperture(positions, r=4.8 * u.arcsec) # doctest: +REMOTE_DATA Now perform the photometry in these apertures on the ``data``. The ``wcs`` object contains the WCS transformation of the image obtained from the FITS header. It includes the coordinate frame of the image and the projection from sky to pixel coordinates. The `~photutils.aperture.aperture_photometry` function uses the WCS information to automatically convert the apertures defined in sky coordinates into pixel coordinates:: >>> phot_table = aperture_photometry(data, aperture, wcs=wcs) # doctest: +REMOTE_DATA The Spitzer catalog also contains the official fluxes for the sources, so we can compare to our fluxes. Because the Spitzer catalog fluxes are in units of mJy and the data are in units of MJy/sr, we need to convert units before comparing the results. The image data have a pixel scale of 1.2 arcsec/pixel. >>> import astropy.units as u >>> factor = (1.2 * u.arcsec) ** 2 / u.pixel >>> fluxes_catalog = catalog['f4_5'] # doctest: +REMOTE_DATA >>> converted_aperture_sum = (phot_table['aperture_sum'] * ... factor).to(u.mJy / u.pixel) # doctest: +REMOTE_DATA Finally, we can plot the comparison of the photometry: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.scatter(fluxes_catalog, converted_aperture_sum.value) >>> plt.xlabel('Spitzer catalog PSF-fit fluxes ') >>> plt.ylabel('Aperture photometry fluxes') .. plot:: import matplotlib.pyplot as plt from astropy import units as u from astropy.coordinates import SkyCoord from astropy.wcs import WCS from photutils.aperture import SkyCircularAperture, aperture_photometry from photutils.datasets import load_spitzer_catalog, load_spitzer_image # Load dataset hdu = load_spitzer_image() data = u.Quantity(hdu.data, unit=hdu.header['BUNIT']) wcs = WCS(hdu.header) catalog = load_spitzer_catalog() # Set up apertures positions = SkyCoord(catalog['l'], catalog['b'], frame='galactic') aperture = SkyCircularAperture(positions, r=4.8 * u.arcsec) phot_table = aperture_photometry(data, aperture, wcs=wcs) # Convert to correct units factor = (1.2 * u.arcsec) ** 2 / u.pixel fluxes_catalog = catalog['f4_5'] converted_aperture_sum = (phot_table['aperture_sum'] * factor).to(u.mJy / u.pixel) # Plot plt.scatter(fluxes_catalog, converted_aperture_sum.value) plt.xlabel('Spitzer catalog PSF-fit fluxes ') plt.ylabel('Aperture photometry fluxes') plt.plot([40, 100, 450], [40, 100, 450], color='black', lw=2) Despite using different methods, the two catalogs are in good agreement. The aperture photometry fluxes are based on a circular aperture with a radius of 4.8 arcsec. The Spitzer catalog fluxes were computed using PSF photometry. Therefore, differences are expected between the two measurements. Aperture Masks -------------- All `~photutils.aperture.PixelAperture` objects have a :meth:`~photutils.aperture.PixelAperture.to_mask` method that returns a `~photutils.aperture.ApertureMask` object (for a single aperture position) or a list of `~photutils.aperture.ApertureMask` objects, one for each aperture position. The `~photutils.aperture.ApertureMask` object contains a cutout of the aperture mask weights and a `~photutils.aperture.BoundingBox` object that provides the bounding box where the mask is to be applied. Let's start by creating a circular-annulus aperture:: >>> from photutils.aperture import CircularAnnulus >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() >>> positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] >>> aperture = CircularAnnulus(positions, r_in=10, r_out=15) Now let's create a list of `~photutils.aperture.ApertureMask` objects using the :meth:`~photutils.aperture.PixelAperture.to_mask` method using the aperture mask "exact" method:: >>> masks = aperture.to_mask(method='exact') Let's plot the first aperture mask: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.imshow(masks[0]) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks = annulus_aperture.to_mask(method='exact') plt.imshow(masks[0]) Let's now use the "center" aperture mask method and plot the resulting aperture mask: .. doctest-skip:: >>> masks2 = aperture.to_mask(method='center') >>> plt.imshow(masks2[0]) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks2 = annulus_aperture.to_mask(method='center') plt.imshow(masks2[0]) We can also create an aperture mask-weighted cutout from the data, properly handling the cases of partial or no overlap of the aperture mask with the data. Let's plot the aperture mask weights (using the mask generated above with the "exact" method) multiplied with the data: .. doctest-skip:: >>> data_weighted = masks[0].multiply(data) >>> plt.imshow(data_weighted) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks = annulus_aperture.to_mask(method='exact') plt.imshow(masks[0].multiply(data)) To get a 1D `~numpy.ndarray` of the non-zero weighted data values, use the :meth:`~photutils.aperture.ApertureMask.get_values` method: .. doctest-skip:: >>> data_weighted_1d = masks[0].get_values(data) The :class:`~photutils.aperture.ApertureMask` class also provides a :meth:`~photutils.aperture.ApertureMask.to_image` method to obtain an image of the aperture mask in a 2D array of the given shape and a :meth:`~photutils.aperture.ApertureMask.cutout` method to create a cutout from the input data over the aperture mask bounding box. Both of these methods properly handle the cases of partial or no overlap of the aperture mask with the data. .. _custom-apertures: Defining Your Own Custom Apertures ---------------------------------- The :func:`~photutils.aperture.aperture_photometry` function can perform aperture photometry in arbitrary apertures. This function accepts any `~photutils.aperture.Aperture`-derived objects, such as `~photutils.aperture.CircularAperture`. This makes it simple to extend functionality: a new type of aperture photometry simply requires the definition of a new `~photutils.aperture.Aperture` subclass. All `~photutils.aperture.PixelAperture` subclasses must define a ``bounding_boxes`` property and ``to_mask()`` and ``plot()`` methods. They may also optionally define an ``area`` property. All `~photutils.aperture.SkyAperture` subclasses must only implement a ``to_pixel()`` method. * ``bounding_boxes``: The minimal bounding box for the aperture. If the aperture is scalar, then a single `~photutils.aperture.BoundingBox` is returned. Otherwise, a list of `~photutils.aperture.BoundingBox` is returned. * ``area``: An optional property defining the exact analytical area (in pixels**2) of the aperture. * ``to_mask()``: Return a mask for the aperture. If the aperture is scalar, then a single `~photutils.aperture.ApertureMask` is returned. Otherwise, a list of `~photutils.aperture.ApertureMask` is returned. * ``plot()``: A method to plot the aperture on a `matplotlib.axes.Axes` instance. Reference/API ------------- .. automodapi:: photutils.aperture :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/background.rst0000644000175100001770000005167214637570305017062 0ustar00runnerdocker.. _background: Background Estimation (`photutils.background`) ============================================== Introduction ------------ To accurately measure the photometry and morphological properties of astronomical sources, one requires an accurate estimate of the background, which can be from both the sky and the detector. Similarly, having an accurate estimate of the background noise is important for determining the significance of source detections and for estimating photometric errors. Unfortunately, accurate background and background noise estimation is a difficult task. Further, because astronomical images can cover a wide variety of scenes, there is not a single background estimation method that will always be applicable. Photutils provides tools for estimating the background and background noise in your data, but they will likely require some tweaking to optimize the background estimate for your data. Scalar Background and Noise Estimation -------------------------------------- Simple Statistics ^^^^^^^^^^^^^^^^^ If the background level and noise are relatively constant across an image, the simplest way to estimate these values is to derive scalar quantities using simple approximations. When computing the image statistics one must take into account the astronomical sources present in the images, which add a positive tail to the distribution of pixel intensities. For example, one may consider using the image median as the background level and the image standard deviation as the 1-sigma background noise, but the resulting values are biased by the presence of real sources. A slightly better method involves using statistics that are robust against the presence of outliers, such as the biweight location for the background level and biweight scale or normalized `median absolute deviation (MAD) `__ for the background noise estimation. However, for most astronomical scenes these methods will also be biased by the presence of astronomical sources in the image. As an example, we load a synthetic image comprised of 100 sources with a Gaussian-distributed background whose mean is 5 and standard deviation is 2:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() Let's plot the image: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data, norm=norm, origin='lower', cmap='Greys_r', ... interpolation='nearest') .. plot:: import matplotlib.pyplot as plt from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Data') The image median and biweight location are both larger than the true background level of 5:: >>> import numpy as np >>> from astropy.stats import biweight_location >>> print(np.median(data)) # doctest: +FLOAT_CMP 5.222396450477202 >>> print(biweight_location(data)) # doctest: +FLOAT_CMP 5.187556942771537 Similarly, using the median absolute deviation to estimate the background noise level gives a value that is larger than the true value of 2:: >>> from astropy.stats import mad_std >>> print(mad_std(data)) # doctest: +FLOAT_CMP 2.1497096320053166 Sigma Clipping Sources ^^^^^^^^^^^^^^^^^^^^^^ The most widely used technique to remove the sources from the image statistics is called sigma clipping. Briefly, pixels that are above or below a specified sigma level from the median are discarded and the statistics are recalculated. The procedure is typically repeated over a number of iterations or until convergence is reached. This method provides a better estimate of the background and background noise levels:: >>> from astropy.stats import sigma_clipped_stats >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> print(np.array((mean, median, std))) # doctest: +FLOAT_CMP [5.19968673 5.15244174 2.09423739] Masking Sources ^^^^^^^^^^^^^^^ An even better procedure is to exclude the sources in the image by masking them. This technique requires one to :ref:`identify the sources in the data `, which in turn depends on the background and background noise. Therefore, this method for estimating the background and background RMS requires an iterative procedure. One method to create a source mask is to use a :ref:`segmentation image `. Here we use the `~photutils.segmentation.detect_threshold` convenience function to get a rough estimate of the threshold at the 2-sigma background noise level. Then we use the `~photutils.segmentation.detect_sources` function to generate a `~photutils.segmentation.SegmentationImage`. Finally, we use the :meth:`~photutils.segmentation.SegmentationImage.make_source_mask` method with a circular dilation footprint to create the source mask: .. doctest-requires:: scipy >>> from astropy.stats import sigma_clipped_stats, SigmaClip >>> from photutils.segmentation import detect_threshold, detect_sources >>> from photutils.utils import circular_footprint >>> sigma_clip = SigmaClip(sigma=3.0, maxiters=10) >>> threshold = detect_threshold(data, nsigma=2.0, sigma_clip=sigma_clip) >>> segment_img = detect_sources(data, threshold, npixels=10) >>> footprint = circular_footprint(radius=10) >>> mask = segment_img.make_source_mask(footprint=footprint) >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0, mask=mask) >>> print(np.array((mean, median, std))) # doctest: +FLOAT_CMP [5.00257401 4.99641799 1.97009566] The source detection and masking procedure can be iterated further. Even with one iteration we are within 0.2% of the true background value and 1.5% of the true background RMS. .. _scipy: https://scipy.org/ 2D Background and Noise Estimation ---------------------------------- If the background or the background noise varies across the image, then you will generally want to generate a 2D image of the background and background RMS (or compute these values locally). This can be accomplished by applying the above techniques to subregions of the image. A common procedure is to use sigma-clipped statistics in each mesh of a grid that covers the input data to create a low-resolution background image. The final background or background RMS image can then be generated by interpolating the low-resolution image. Photutils provides the :class:`~photutils.background.Background2D` class to estimate the 2D background and background noise in an astronomical image. :class:`~photutils.background.Background2D` requires the size of the box (``box_size``) in which to estimate the background. Selecting the box size requires some care by the user. The box size should generally be larger than the typical size of sources in the image, but small enough to encapsulate any background variations. For best results, the box size should also be chosen so that the data are covered by an integer number of boxes in both dimensions. If that is not the case, the ``edge_method`` keyword determines whether to pad or crop the image such that there is an integer multiple of the ``box_size`` in both dimensions. The background level in each of the meshes is calculated using the function or callable object (e.g., class instance) input via ``bkg_estimator`` keyword. Photutils provides a several background classes that can be used: * `~photutils.background.MeanBackground` * `~photutils.background.MedianBackground` * `~photutils.background.ModeEstimatorBackground` * `~photutils.background.MMMBackground` * `~photutils.background.SExtractorBackground` * `~photutils.background.BiweightLocationBackground` The default is a `~photutils.background.SExtractorBackground` instance. For this method, the background in each mesh is calculated as ``(2.5 * median) - (1.5 * mean)``. However, if ``(mean - median) / std > 0.3`` then the ``median`` is used instead. Likewise, the background RMS level in each mesh is calculated using the function or callable object input via the ``bkgrms_estimator`` keyword. Photutils provides the following classes for this purpose: * `~photutils.background.StdBackgroundRMS` * `~photutils.background.MADStdBackgroundRMS` * `~photutils.background.BiweightScaleBackgroundRMS` For even more flexibility, users may input a custom function or callable object to the ``bkg_estimator`` and/or ``bkgrms_estimator`` keywords. By default, the ``bkg_estimator`` and ``bkgrms_estimator`` are applied to sigma clipped data. Sigma clipping is defined by inputting a :class:`astropy.stats.SigmaClip` object to the ``sigma_clip`` keyword. The default is to perform sigma clipping with ``sigma=3`` and ``maxiters=10``. Sigma clipping can be turned off by setting ``sigma_clip=None``. After the background level has been determined in each of the boxes, the low-resolution background image can be median filtered, with a window of size of ``filter_size``, to suppress local under or over estimations (e.g., due to bright galaxies in a particular box). Likewise, the median filter can be applied only to those boxes where the background level is above a specified threshold (``filter_threshold``). The low-resolution background and background RMS images are resized to the original data size using the function or callable object input via the ``interpolator`` keyword. Photutils provides two interpolator classes: :class:`~photutils.background.BkgZoomInterpolator` (default), which performs spline interpolation, and :class:`~photutils.background.BkgIDWInterpolator`, which uses inverse-distance weighted (IDW) interpolation. For this example, we will create a test image by adding a strong background gradient to the image defined above:: >>> ny, nx = data.shape >>> y, x = np.mgrid[:ny, :nx] >>> gradient = x * y / 5000.0 >>> data2 = data + gradient >>> plt.imshow(data2, norm=norm, origin='lower', cmap='Greys_r', ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data2, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Data with added background gradient') We start by creating a `~photutils.background.Background2D` object using a box size of 50x50 and a 3x3 median filter. We will estimate the background level in each mesh as the sigma-clipped median using an instance of :class:`~photutils.background.MedianBackground`. .. doctest-requires:: scipy >>> from astropy.stats import SigmaClip >>> from photutils.background import Background2D, MedianBackground >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg_estimator = MedianBackground() >>> bkg = Background2D(data2, (50, 50), filter_size=(3, 3), ... sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) The 2D background and background RMS images are retrieved using the ``background`` and ``background_rms`` attributes, respectively, on the returned object. The low-resolution versions of these images are stored in the ``background_mesh`` and ``background_rms_mesh`` attributes, respectively. The global median value of the low-resolution background and background RMS image can be accessed with the ``background_median`` and ``background_rms_median`` attributes, respectively: .. doctest-requires:: scipy >>> print(bkg.background_median) # doctest: +FLOAT_CMP 10.852487630351824 >>> print(bkg.background_rms_median) # doctest: +FLOAT_CMP 2.262996981325314 Let's plot the background image: .. doctest-skip:: >>> plt.imshow(bkg.background, origin='lower', cmap='Greys_r', ... interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import SigmaClip from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient sigma_clip = SigmaClip(sigma=3.0) bkg_estimator = MedianBackground() bkg = Background2D(data2, (50, 50), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) plt.imshow(bkg.background, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Estimated Background') and the background-subtracted image: .. doctest-skip:: >>> plt.imshow(data2 - bkg.background, norm=norm, origin='lower', ... cmap='Greys_r', interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import SigmaClip from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient sigma_clip = SigmaClip(sigma=3.0) bkg_estimator = MedianBackground() bkg = Background2D(data2, (50, 50), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data2 - bkg.background, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Background-subtracted Data') Masking ^^^^^^^ Masks can also be input into `~photutils.background.Background2D`. The ``mask`` keyword can be used to mask sources or bad pixels in the image prior to estimating the background levels. Additionally, the ``coverage_mask`` keyword can be used to mask blank regions without data coverage (e.g., from a rotated image or an image from a mosaic). Otherwise, the data values in the regions without coverage (usually zeros or NaNs) will adversely affect the background statistics. Unlike ``mask``, ``coverage_mask`` is applied to the output background and background RMS maps. The ``fill_value`` keyword defines the value assigned in the output background and background RMS maps where the input ``coverage_mask`` is `True`. Let's create a rotated image that has blank areas and plot it (NOTE: this example requires `scipy`_): .. doctest-requires:: scipy >>> from scipy.ndimage import rotate >>> data3 = rotate(data2, -45.0) >>> norm = ImageNormalize(stretch=SqrtStretch()) # doctest: +SKIP >>> plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image from scipy.ndimage.interpolation import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Data with added background gradient') Now we create a coverage mask and input it into `~photutils.background.Background2D` to exclude the regions where we have no data. For this example, we set the ``fill_value`` to 0.0. For real data, one can usually create a coverage mask from a weight or noise image. In this example we also use a smaller box size to help capture the strong gradient in the background. We also increase the value of the ``exclude_percentile`` keyword to include more boxes around the edge of the rotated image: .. doctest-requires:: scipy >>> coverage_mask = (data3 == 0) >>> bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), ... coverage_mask=coverage_mask, fill_value=0.0, ... exclude_percentile=50.0) Note that the ``coverage_mask`` is applied to the output background image (values assigned to ``fill_value``): .. doctest-requires:: scipy >>> norm = ImageNormalize(stretch=SqrtStretch()) # doctest: +SKIP >>> plt.imshow(bkg3.background, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage.interpolation import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(bkg3.background, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Estimated Background') Finally, let's subtract the background from the image and plot it: .. doctest-skip:: >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data3 - bkg3.background, origin='lower', cmap='Greys_r', ... norm=norm, interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage.interpolation import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3 - bkg3.background, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Background-subtracted Data') If there is any small residual background still present in the image, the background subtraction can be improved by masking the sources and/or through further iterations. Plotting Meshes ^^^^^^^^^^^^^^^ Finally, the meshes that were used in generating the 2D background can be plotted on the original image using the :meth:`~photutils.background.Background2D.plot_meshes` method. Here we zoom in on a small portion of the image to show the background meshes. Meshes without a center marker were excluded. .. doctest-skip:: >>> plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') >>> bkg3.plot_meshes(outlines=True, marker='.', color='cyan', alpha=0.3) >>> plt.xlim(0, 250) >>> plt.ylim(0, 250) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage.interpolation import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') bkg3.plot_meshes(outlines=True, marker='.', color='cyan', alpha=0.3) plt.xlim(0, 250) plt.ylim(0, 250) Reference/API ------------- .. automodapi:: photutils.background :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/centroids.rst0000644000175100001770000001313614637570305016726 0ustar00runnerdockerCentroids (`photutils.centroids`) ================================= Introduction ------------ `photutils.centroids` provides several functions to calculate the centroid of a single source: * :func:`~photutils.centroids.centroid_com`: Calculates the object "center of mass" from 2D image moments. * :func:`~photutils.centroids.centroid_quadratic`: Calculates the centroid by fitting a 2D quadratic polynomial to the data. * :func:`~photutils.centroids.centroid_1dg`: Calculates the centroid by fitting 1D Gaussians to the marginal ``x`` and ``y`` distributions of the data. * :func:`~photutils.centroids.centroid_2dg`: Calculates the centroid by fitting a 2D Gaussian to the 2D distribution of the data. Masks can be input into each of these functions to mask bad pixels. Error arrays can be input into the two Gaussian fitting methods to weight the fits. To calculate the centroids of many sources in an image, use the :func:`~photutils.centroids.centroid_sources` function. This function can be used with any of the above centroiding functions or a custom user-defined centroiding function. Getting Started --------------- Let's extract a single object from a synthetic dataset and find its centroid with each of these methods. For this simple example we will not subtract the background from the data (but in practice, one should subtract the background):: >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import (centroid_1dg, centroid_2dg, ... centroid_com, centroid_quadratic) >>> data = make_4gaussians_image()[43:79, 76:104] >>> x1, y1 = centroid_com(data) >>> print(np.array((x1, y1))) # doctest: +FLOAT_CMP [13.87628017 17.0965772 ] >>> x2, y2 = centroid_quadratic(data) >>> print(np.array((x2, y2))) # doctest: +FLOAT_CMP [13.94009505 17.06884997] .. doctest-requires:: scipy >>> x3, y3 = centroid_1dg(data) >>> print(np.array((x3, y3))) # doctest: +FLOAT_CMP [13.97702781 17.01026203] .. doctest-requires:: scipy >>> x4, y4 = centroid_2dg(data) >>> print(np.array((x4, y4))) # doctest: +FLOAT_CMP [13.98397984 17.01241918] Now let's plot the results. Because the centroids are all very similar, we also include an inset plot zoomed in near the centroid: .. plot:: :include-source: import matplotlib.pyplot as plt from mpl_toolkits.axes_grid1.inset_locator import (mark_inset, zoomed_inset_axes) from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic) from photutils.datasets import make_4gaussians_image data = make_4gaussians_image()[43:79, 76:104] # extract single object xycen1 = centroid_com(data) xycen2 = centroid_quadratic(data) xycen3 = centroid_1dg(data) xycen4 = centroid_2dg(data) xycens = [xycen1, xycen2, xycen3, xycen4] fig, ax = plt.subplots(1, 1, figsize=(4, 5)) ax.imshow(data, origin='lower', interpolation='nearest') marker = '+' ms, mew = 15, 2.0 colors = ('white', 'black', 'red', 'blue') for xycen, color in zip(xycens, colors): plt.plot(*xycen, color=color, marker=marker, ms=ms, mew=mew) ax2 = zoomed_inset_axes(ax, zoom=6, loc=9) ax2.imshow(data, vmin=190, vmax=220, origin='lower', interpolation='nearest') ms, mew = 30, 2.0 for xycen, color in zip(xycens, colors): ax2.plot(*xycen, color=color, marker=marker, ms=ms, mew=mew) ax2.set_xlim(13, 15) ax2.set_ylim(16, 18) mark_inset(ax, ax2, loc1=3, loc2=4, fc='none', ec='0.5') ax2.axes.get_xaxis().set_visible(False) ax2.axes.get_yaxis().set_visible(False) ax.set_xlim(0, data.shape[1] - 1) ax.set_ylim(0, data.shape[0] - 1) Centroiding several sources in an image --------------------------------------- The :func:`~photutils.centroids.centroid_sources` function can be used to calculate the centroids of many sources in a single image given initial guesses for their positions. This function can be used with any of the above centroiding functions or a custom user-defined centroiding function. Here is a simple example using :func:`~photutils.centroids.centroid_com`. A cutout image is made centered at each initial position of size ``box_size``. A centroid is then calculated within the cutout image for each source: .. doctest-requires:: scipy >>> from photutils.centroids import centroid_sources >>> data = make_4gaussians_image() >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> x, y = centroid_sources(data, x_init, y_init, box_size=21, ... centroid_func=centroid_com) >>> print(x) # doctest: +FLOAT_CMP [ 25.01202863 90.39269745 150.22699671 160.02054966] >>> print(y) # doctest: +FLOAT_CMP [39.8963082 60.5920161 24.76769128 70.38147784] Let's plot the results: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.centroids import centroid_com, centroid_sources from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) x, y = centroid_sources(data, x_init, y_init, box_size=21, centroid_func=centroid_com) plt.figure(figsize=(8, 4)) plt.imshow(data, origin='lower', interpolation='nearest') plt.scatter(x, y, marker='+', s=80, color='red', label='Centroids') plt.legend() plt.tight_layout() Reference/API ------------- .. automodapi:: photutils.centroids :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/changelog.rst0000644000175100001770000000011314637570305016652 0ustar00runnerdocker.. _changelog: ********* Changelog ********* .. include:: ../CHANGES.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/citation.rst0000644000175100001770000000010014637570305016531 0ustar00runnerdocker.. _photutils_citation: .. include:: ../photutils/CITATION.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/conf.py0000644000175100001770000001511714637570305015502 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # # Documentation build configuration file. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # file. # # All configuration values have a default. Some values are defined in # the global Astropy configuration which is loaded here before anything # else. See astropy.sphinx.conf for which values are set there. import os import sys from datetime import datetime, timezone from pathlib import Path if sys.version_info < (3, 11): import tomli as tomllib else: import tomllib try: from sphinx_astropy.conf.v1 import * # noqa: F403 except ImportError: print('ERROR: the documentation requires the sphinx-astropy package to ' 'be installed') sys.exit(1) # Get configuration information from pyproject.toml with (Path(__file__).parents[1] / 'pyproject.toml').open('rb') as fh: conf = tomllib.load(fh) project_meta = conf['project'] # -- General configuration ---------------------------------------------------- # By default, highlight as Python 3. highlight_language = 'python3' # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '3.0' # Extend astropy intersphinx_mapping with packages we use here intersphinx_mapping['skimage'] = ('https://scikit-image.org/docs/stable/', None) # noqa: F405 intersphinx_mapping['gwcs'] = ('https://gwcs.readthedocs.io/en/latest/', None) # noqa: F405 # Exclude astropy intersphinx_mapping for unused packages del intersphinx_mapping['h5py'] # noqa: F405 # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # Exclude template PSF block specification documentation exclude_patterns.append('psf_spec/*') # noqa: F405 plot_formats = ['png', 'hires.png', 'pdf', 'svg'] # This is added to the end of RST files - a good place to put # substitutions to be used globally. rst_epilog = """ .. _Astropy: https://www.astropy.org/ """ # Turn off table of contents entries for functions and classes toc_object_entries = False # -- Project information ------------------------------------------------------ project = project_meta['name'] author = project_meta['authors'][0]['name'] copyright = f'2011-{datetime.now(tz=timezone.utc).year}, {author}' # The version info for the project you're documenting, acts as # replacement for |version| and |release|, also used in various other # places throughout the built documents. __import__(project) package = sys.modules[project] # The short X.Y version. version = package.__version__.split('-', 1)[0] # The full version, including alpha/beta/rc tags. release = package.__version__ # -- Options for HTML output -------------------------------------------------- # The global astropy configuration uses a custom theme, # 'bootstrap-astropy', which is installed along with astropy. A # different theme can be used or the options for this theme can be # modified by overriding some of the variables set in the global # configuration. The variables set in the global configuration are # listed below, commented out. # Add any paths that contain custom themes here, relative to this # directory. # html_theme_path = [] # The theme to use for HTML and HTML Help pages. See the documentation # for a list of builtin themes. To override the custom theme, set this # to the name of a builtin theme or the name of a custom theme in # html_theme_path. # html_theme = None # Customized theme options html_theme_options = { 'logotext1': 'phot', # white, semi-bold 'logotext2': 'utils', # orange, light 'logotext3': '' # white, light } # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # The name of an image file (relative to this directory) to place at the # top of the sidebar. # html_logo = '' # The name of an image file (within the static path) to use as favicon # of the docs. This file should be a Windows icon file (.ico) being # 16x16 or 32x32 pixels large. html_favicon = os.path.join('_static', 'favicon.ico') # A "Last built" timestamp is inserted at every page bottom, using the # given strftime format. Set to '' to omit this timestamp. # html_last_updated_fmt = '%d %b %Y' # The name for this set of Sphinx documents. If None, it defaults to # " v". html_title = f'{project} {release}' # Output file base name for HTML help builder. htmlhelp_basename = project + 'doc' # Static files to copy after template files html_static_path = ['_static'] html_style = 'photutils.css' # -- Options for LaTeX output ------------------------------------------------- # Grouping the document tree into LaTeX files. List of tuples (source # start file, target name, title, author, documentclass [howto/manual]). latex_documents = [('index', project + '.tex', project + ' Documentation', author, 'manual')] latex_logo = '_static/photutils_banner.pdf' # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples (source start file, name, # description, authors, manual section). man_pages = [('index', project.lower(), project + ' Documentation', [author], 1)] # -- Resolving issue number to links in changelog ----------------------------- github_project = conf['tool']['build-sphinx']['github_project'] github_issues_url = f'https://github.com/{github_project}/issues/' # -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- nitpicky = True nitpick_ignore = [] # Some warnings are impossible to suppress, and you can list specific # references that should be ignored in a nitpick-exceptions file which # should be inside the docs/ directory. The format of the file should be: # # # # for example: # # py:class astropy.io.votable.tree.Element # py:class astropy.io.votable.tree.SimpleElement # py:class astropy.io.votable.tree.SimpleElementWithContent # # Uncomment the following lines to enable the exceptions: nitpick_filename = 'nitpick-exceptions.txt' if os.path.isfile(nitpick_filename): for line in open(nitpick_filename): if line.strip() == '' or line.startswith('#'): continue dtype, target = line.split(None, 1) target = target.strip() nitpick_ignore.append((dtype, target)) # -- Options for linkcheck output --------------------------------------------- linkcheck_retry = 5 linkcheck_ignore = ['http://data.astropy.org', r'https://iraf.net/*', r'https://github\.com/astropy/photutils/(?:issues|pull)/\d+'] linkcheck_timeout = 180 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/contributing.rst0000644000175100001770000000234314637570305017441 0ustar00runnerdockerReporting Issues and Contributing ================================= Reporting Issues ---------------- If you have found a bug, please report it by creating a new issue on the `Photutils GitHub issue tracker `_. That requires creating a `free GitHub account `_ if you do not have one. Please include a minimal example that demonstrates the issue and will allow the developers to reproduce and fix the problem. You may be also asked to provide information about your operating system and a full Python stack trace. The developers will walk you through obtaining a stack trace if it is necessary. Contributing ------------ Like the `Astropy`_ project, this package is made both by and for its users. We accept contributions at all levels, spanning the gamut from fixing a typo in the documentation to developing a major new feature. We welcome contributors who will abide by the `Python Software Foundation Code of Conduct `_. This package follows the same workflow and coding guidelines as `Astropy`_. Please read the `Astropy Development documentation `_ to get started. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/datasets.rst0000644000175100001770000000355414637570305016547 0ustar00runnerdocker.. _datasets: Datasets (`photutils.datasets`) =============================== Introduction ------------ `photutils.datasets` gives easy access to load or make a few example datasets. The datasets are mostly images, but they also include PSF models and a source catalog. These datasets are useful for the Photutils documentation, tests, and benchmarks, but also for users that would like to try out or implement new methods for Photutils. Functions that start with ``load_*`` load data files from disk. Very small data files are bundled in the Photutils code repository and are guaranteed to be available. Mid-sized data files are currently available from the `astropy-data`_ repository and loaded into the Astropy cache on the user's machine on first load. Functions that start with ``make_*`` generate simple simulated data (e.g., Gaussian sources on a flat background with Poisson or Gaussian noise). Note that there are other tools like `skymaker`_ that can simulate much more realistic astronomical images. Getting Started --------------- Let's load an example image of M67 with :func:`~photutils.datasets.load_star_image`:: >>> from photutils.datasets import load_star_image >>> hdu = load_star_image() # doctest: +REMOTE_DATA >>> print(hdu.data.shape) # doctest: +REMOTE_DATA (1059, 1059) ``hdu`` is a FITS `~astropy.io.fits.ImageHDU` object and ``hdu.data`` is a `~numpy.ndarray` object. Let's plot the image: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_star_image hdu = load_star_image() plt.imshow(hdu.data, origin='lower', interpolation='nearest') plt.tight_layout() plt.show() Reference/API ------------- .. automodapi:: photutils.datasets :no-heading: .. _astropy-data: https://github.com/astropy/astropy-data/ .. _skymaker: https://github.com/astromatic/skymaker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/detection.rst0000644000175100001770000002724114637570305016714 0ustar00runnerdocker.. _source_detection: Source Detection (`photutils.detection`) ======================================== Introduction ------------ One generally needs to identify astronomical sources in their data before they can perform photometry or morphological measurements. Photutils provides several tools designed specifically to detect point-like (stellar) sources in an astronomical image. Photutils also provides a function to identify local peaks in an image that are above a specified threshold value. For general-use source detection and extraction of both point-like and extended sources, please see :ref:`Image Segmentation `. Detecting Stars --------------- Photutils includes two widely-used tools that are used to detect stars in an image, `DAOFIND`_ and IRAF's `starfind`_, plus a third tool that allows input of a custom user-defined kernel. :class:`~photutils.detection.DAOStarFinder` is a class that provides an implementation of the `DAOFIND`_ algorithm (`Stetson 1987, PASP 99, 191 `_). It searches images for local density maxima that have a peak amplitude greater than a specified threshold (the threshold is applied to a convolved image) and have a size and shape similar to a defined 2D Gaussian kernel. :class:`~photutils.detection.DAOStarFinder` also provides an estimate of the objects' roundness and sharpness, whose lower and upper bounds can be specified. :class:`~photutils.detection.IRAFStarFinder` is a class that implements IRAF's `starfind`_ algorithm. It is fundamentally similar to :class:`~photutils.detection.DAOStarFinder`, but :class:`~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. One other difference in :class:`~photutils.detection.IRAFStarFinder` is that it calculates the objects' centroid, roundness, and sharpness using image moments. :class:`~photutils.detection.StarFinder` is a class similar to :class:`~photutils.detection.IRAFStarFinder`, but which allows input of a custom user-defined kernel as a 2D array. This allows for more generalization beyond simple Gaussian kernels. As an example, let's load an image from the bundled datasets and select a subset of the image. We will estimate the background and background noise using sigma-clipped statistics:: >>> import numpy as np >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import load_star_image >>> hdu = load_star_image() # doctest: +REMOTE_DATA >>> data = hdu.data[0:401, 0:401] # doctest: +REMOTE_DATA >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) # doctest: +REMOTE_DATA >>> print(np.array((mean, median, std))) # doctest: +REMOTE_DATA, +FLOAT_CMP [3668.09661146 3649. 204.41388592] Now we will subtract the background and use an instance of :class:`~photutils.detection.DAOStarFinder` to find the stars in the image that have FWHMs of around 3 pixels and have peaks approximately 5-sigma above the background. Running this class on the data yields an astropy `~astropy.table.Table` containing the results of the star finder: .. doctest-requires:: scipy >>> from photutils.detection import DAOStarFinder >>> daofind = DAOStarFinder(fwhm=3.0, threshold=5.*std) # doctest: +REMOTE_DATA >>> sources = daofind(data - median) # doctest: +REMOTE_DATA >>> for col in sources.colnames: # doctest: +REMOTE_DATA ... if col not in ('id', 'npix'): ... sources[col].info.format = '%.2f' # for consistent table output >>> sources.pprint(max_width=76) # doctest: +REMOTE_DATA, +FLOAT_CMP id xcentroid ycentroid sharpness roundness1 ... sky peak flux mag --- --------- --------- --------- ---------- ... ---- ------- ---- ----- 1 144.25 6.38 0.58 0.20 ... 0.00 6903.00 5.70 -1.89 2 208.67 6.82 0.48 -0.13 ... 0.00 7896.00 6.72 -2.07 3 216.93 6.58 0.69 -0.71 ... 0.00 2195.00 1.67 -0.55 4 351.63 8.55 0.49 -0.34 ... 0.00 6977.00 5.90 -1.93 5 377.52 12.07 0.52 0.37 ... 0.00 1260.00 1.12 -0.12 ... ... ... ... ... ... ... ... ... ... 282 267.90 398.62 0.27 -0.43 ... 0.00 9299.00 5.44 -1.84 283 271.47 398.91 0.37 0.19 ... 0.00 8028.00 5.07 -1.76 284 299.05 398.78 0.26 -0.67 ... 0.00 9072.00 5.56 -1.86 285 299.99 398.77 0.29 0.36 ... 0.00 9253.00 5.32 -1.82 286 360.45 399.52 0.37 -0.19 ... 0.00 8079.00 6.92 -2.10 Length = 286 rows Let's plot the image and mark the location of detected sources: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> from photutils.aperture import CircularAperture >>> positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) >>> apertures = CircularAperture(positions, r=4.0) >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data, cmap='Greys', origin='lower', norm=norm, ... interpolation='nearest') >>> apertures.plot(color='blue', lw=1.5, alpha=0.5) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.aperture import CircularAperture from photutils.datasets import load_star_image from photutils.detection import DAOStarFinder hdu = load_star_image() data = hdu.data[0:401, 0:401] mean, median, std = sigma_clipped_stats(data, sigma=3.0) daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) sources = daofind(data - median) positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) apertures = CircularAperture(positions, r=4.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, cmap='Greys', origin='lower', norm=norm, interpolation='nearest') apertures.plot(color='blue', lw=1.5, alpha=0.5) Masking Regions ^^^^^^^^^^^^^^^ Regions of the input image can be masked by using the ``mask`` keyword with the :class:`~photutils.detection.DAOStarFinder` or :class:`~photutils.detection.IRAFStarFinder` instance. This simple examples uses :class:`~photutils.detection.DAOStarFinder` and masks two rectangular regions. No sources will be detected in the masked regions: .. doctest-skip:: >>> from photutils.detection import DAOStarFinder >>> daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[50:151, 50:351] = True >>> mask[250:351, 150:351] = True >>> sources = daofind(data - median, mask=mask) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.aperture import CircularAperture, RectangularAperture from photutils.datasets import load_star_image from photutils.detection import DAOStarFinder hdu = load_star_image() data = hdu.data[0:401, 0:401] mean, median, std = sigma_clipped_stats(data, sigma=3.0) daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) mask = np.zeros(data.shape, dtype=bool) mask[50:151, 50:351] = True mask[250:351, 150:351] = True sources = daofind(data - median, mask=mask) positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) apertures = CircularAperture(positions, r=4.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, cmap='Greys', origin='lower', norm=norm, interpolation='nearest') plt.title('Star finder with a mask to exclude regions') apertures.plot(color='blue', lw=1.5, alpha=0.5) rect1 = RectangularAperture((200, 100), 300, 100, theta=0) rect2 = RectangularAperture((250, 300), 200, 100, theta=0) rect1.plot(color='salmon', ls='dashed') rect2.plot(color='salmon', ls='dashed') Local Peak Detection -------------------- Photutils also includes a :func:`~photutils.detection.find_peaks` function to find local peaks in an image that are above a specified threshold value. Peaks are the local maxima above a specified threshold that are separated by a specified minimum number of pixels, defined by a box size or a local footprint. The returned pixel coordinates for the peaks are always integer-valued (i.e., no centroiding is performed, only the peak pixel is identified). However, a centroiding function can be input via the ``centroid_func`` keyword to :func:`~photutils.detection.find_peaks` to also compute centroid coordinates with subpixel precision. As a simple example, let's find the local peaks in an image that are 5 sigma above the background and a separated by at least 5 pixels: .. doctest-requires:: scipy >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import make_100gaussians_image >>> from photutils.detection import find_peaks >>> data = make_100gaussians_image() >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> threshold = median + (5.0 * std) >>> tbl = find_peaks(data, threshold, box_size=11) >>> tbl['peak_value'].info.format = '%.8g' # for consistent table output >>> print(tbl[:10]) # print only the first 10 peaks id x_peak y_peak peak_value --- ------ ------ ---------- 1 233 0 27.786048 2 493 6 18.699406 3 208 9 22.499317 4 259 11 16.400909 5 365 11 17.789691 6 290 23 34.141532 7 379 29 16.058562 8 442 31 32.162038 9 471 37 24.141928 10 358 39 18.671565 And let's plot the location of the detected peaks in the image: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> from photutils.aperture import CircularAperture >>> positions = np.transpose((tbl['x_peak'], tbl['y_peak'])) >>> apertures = CircularAperture(positions, r=5.0) >>> norm = simple_norm(data, 'sqrt', percent=99.9) >>> plt.imshow(data, cmap='Greys_r', origin='lower', norm=norm, ... interpolation='nearest') >>> apertures.plot(color='#0547f9', lw=1.5) >>> plt.xlim(0, data.shape[1] - 1) >>> plt.ylim(0, data.shape[0] - 1) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_100gaussians_image from photutils.detection import find_peaks data = make_100gaussians_image() mean, median, std = sigma_clipped_stats(data, sigma=3.0) threshold = median + (5.0 * std) tbl = find_peaks(data, threshold, box_size=11) positions = np.transpose((tbl['x_peak'], tbl['y_peak'])) apertures = CircularAperture(positions, r=5.0) norm = simple_norm(data, 'sqrt', percent=99.9) plt.imshow(data, cmap='Greys_r', origin='lower', norm=norm, interpolation='nearest') apertures.plot(color='#0547f9', lw=1.5) plt.xlim(0, data.shape[1] - 1) plt.ylim(0, data.shape[0] - 1) Reference/API ------------- .. automodapi:: photutils.detection :no-heading: :inherited-members: .. _DAOFIND: https://iraf.net/irafhelp.php?val=daofind .. _starfind: https://iraf.net/irafhelp.php?val=starfind ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.006885 photutils-1.13.0/docs/dev/0000755000175100001770000000000014637570322014753 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/dev/releasing.rst0000644000175100001770000001441214637570305017461 0ustar00runnerdocker.. doctest-skip-all **************************** Package Release Instructions **************************** This document outlines the steps for releasing Photutils to `PyPI `_. This process requires admin-level access to the Photutils GitHub repository, as it relies on the ability to push directly to the ``main`` branch. These instructions assume the name of the git remote for the main repository is called ``upstream``. #. Check out the branch that you are going to release. This will usually be the ``main`` branch, unless you are making a bugfix release. For a bugfix release, check out the ``A.B.x`` branch. Use ``git cherry-pick `` (or ``git cherry-pick -m1 `` for merge commits) to backport fixes to the bugfix branch. Also, be sure to push all changes to the repository so that CI can run on the bugfix branch. #. Ensure that `CI tests `_ are passing for the branch you are going to release. Also, ensure that `Read the Docs builds `_ are passing. #. As an extra check, run the tests locally using ``tox`` to thoroughly test the code in isolated environments:: tox -e test-alldeps -- --remote-data=any tox -e build_docs tox -e linkcheck #. Update the ``CHANGES.rst`` file to make sure that all the changes are listed and update the release date from ``unreleased`` to the current date in ``yyyy-mm-dd`` format. Then commit the changes:: git add CHANGES.rst git commit -m'Finalizing changelog for version ' #. Create an annotated git tag (optionally signing with the ``-s`` option) for the version number you are about to release:: git tag -a -m'' git show # show the tag git tag # show all tags #. Optionally, :ref:`even more manual tests ` can be run. #. Push this new tag to the upstream repo:: git push upstream The new tag will trigger the automated `Publish workflow `_ to build the source distribution and wheels and upload them to `PyPI `_. #. Create a `GitHub Release `_ by clicking on "Draft a new release", select the tag of the released version, add a release title with the released version, and add the following description:: See the [changelog](https://photutils.readthedocs.io/en/stable/changelog.html) for release notes. Then click "Publish release". This step will trigger an automatic update of the package on Zenodo (see below). #. Check that `Zenodo `_ is updated with the released version. Zenodo is already configured to automatically update with a new published GitHub Release (see above). #. Open a new `GitHub Milestone `_ for the next release. If there are any open issues or pull requests for the new released version, then move them to the next milestone. After there are no remaining open issues or pull requests for the released version then close its GitHub Milestone. #. Go to `Read the Docs `_ and check that the "stable" docs correspond to the new released version. Hide any older released versions (i.e., check "Hidden"). #. Update ``CHANGES.rst``. After releasing a minor (bugfix) version, update its release date. After releasing a major version, add a new section to ``CHANGES.rst`` for the next ``x.y.z`` version, e.g.,:: x.y.z (unreleased) ------------------ General ^^^^^^^ New Features ^^^^^^^^^^^^ Bug Fixes ^^^^^^^^^ API Changes ^^^^^^^^^^^ Then commit the changes and push to the upstream repo:: git add CHANGES.rst git commit -m'Add version to the changelog' git push upstream main #. After the release, the conda-forge bot (``regro-cf-autotick-bot``) will automatically create a pull request to the `Photutils feedstock repository `_. The ``meta.yaml`` recipe may need to be edited to update dependencies or versions. Modify (if necessary), review, and merge the PR to create the `conda-forge package `_. The `Astropy conda channel `_ will automatically mirror the package from conda-forge. .. _manual_tests: Additional Manual Tests ----------------------- These additional manual checks can be run before pushing the release tag to the upstream repository. #. Remove any untracked files (WARNING: this will permanently remove any files that have not been previously committed, so make sure that you don't need to keep any of these files):: git clean -dfx #. Check out the release tag:: git checkout #. Ensure the `build `_ and `twine `_ packages are installed and up to date:: pip install build twine --upgrade #. Generate the source distribution tar file:: python -m build --sdist . and perform a preliminary check of the tar file:: python -m twine check --strict dist/* #. Run tests on the generated source distribution by going inside the ``dist`` directory, expanding the tar file, going inside the expanded directory, and running the tests with:: cd dist tar xvfz .tar.gz cd tox -e test-alldeps -- --remote-data=any tox -e build_docs Optionally, install and test the source distribution in a virtual environment:: pip install -e '.[all,test]' pytest --remote-data=any or:: pip install '../.tar.gz[all,test]' cd pytest --pyargs photutils --remote-data=any #. Go back to the package root directory and remove the generated files with:: git clean -dfx ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/epsf.rst0000644000175100001770000003064714637570305015677 0ustar00runnerdocker.. _build-epsf: Building an effective Point Spread Function (ePSF) ================================================== The ePSF -------- The instrumental PSF is a combination of many factors that are generally difficult to model. `Anderson and King (2000; PASP 112, 1360) `_ showed that accurate stellar photometry and astrometry can be derived by modeling the net PSF, which they call the effective PSF (ePSF). The ePSF is an empirical model describing what fraction of a star's light will land in a particular pixel. The constructed ePSF is typically oversampled with respect to the detector pixels. Building an ePSF ---------------- Photutils provides tools for building an ePSF following the prescription of `Anderson and King (2000; PASP 112, 1360) `_ and subsequent enhancements detailed mainly in `Anderson (2016; WFC3 ISR 2016-12) `_. The process involves iterating between the ePSF itself and the stars used to build it. To begin, we must first define a sample of stars used to build the ePSF. Ideally these stars should be bright (high S/N) and isolated to prevent contamination from nearby stars. One may use the star-finding tools in Photutils (e.g., :class:`~photutils.detection.DAOStarFinder` or :class:`~photutils.detection.IRAFStarFinder`) to identify an initial sample of stars. However, the step of creating a good sample of stars generally requires visual inspection and manual selection to ensure stars are sufficiently isolated and of good quality (e.g., no cosmic rays, detector artifacts, etc.). Let's start by loading a simulated HST/WFC3 image in the F160W band:: >>> from photutils.datasets import load_simulated_hst_star_image >>> hdu = load_simulated_hst_star_image() # doctest: +REMOTE_DATA >>> data = hdu.data # doctest: +REMOTE_DATA The simulated image does not contain any background or noise, so let's add those to the image:: >>> from photutils.datasets import make_noise_image >>> data += make_noise_image(data.shape, distribution='gaussian', ... mean=10.0, stddev=5.0, seed=123) # doctest: +REMOTE_DATA Let's show the image: .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) norm = simple_norm(data, 'sqrt', percent=99.0) plt.imshow(data, norm=norm, origin='lower', cmap='viridis') For this example we'll use the :func:`~photutils.detection.find_peaks` function to identify the stars and their initial positions. We will not use the centroiding option in :func:`~photutils.detection.find_peaks` to simulate the effect of having imperfect initial guesses for the positions of the stars. Here we set the detection threshold value to 500.0 to select only the brightest stars: .. doctest-requires:: scipy >>> from photutils.detection import find_peaks >>> peaks_tbl = find_peaks(data, threshold=500.0) # doctest: +REMOTE_DATA >>> peaks_tbl['peak_value'].info.format = '%.8g' # for consistent table output # doctest: +REMOTE_DATA >>> print(peaks_tbl) # doctest: +REMOTE_DATA id x_peak y_peak peak_value --- ------ ------ ---------- 1 849 2 1076.7026 2 182 4 1709.5671 3 324 4 3006.0086 4 100 9 1142.9915 5 824 9 1302.8604 ... ... ... ... 427 751 992 801.23834 428 114 994 1595.2804 429 299 994 648.18539 430 207 998 2810.6503 431 691 999 2611.0464 Length = 431 rows Note that the stars are sufficiently separated in the simulated image that we do not need to exclude any stars due to crowding. In practice this step will require some manual inspection and selection. Next, we need to extract cutouts of the stars using the :func:`~photutils.psf.extract_stars` function. This function requires a table of star positions either in pixel or sky coordinates. For this example we are using the pixel coordinates, which need to be in table columns called simply ``x`` and ``y``. We plan to extract 25 x 25 pixel cutouts of our selected stars, so let's explicitly exclude stars that are too close to the image boundaries (because they cannot be extracted): .. doctest-requires:: scipy >>> size = 25 >>> hsize = (size - 1) / 2 >>> x = peaks_tbl['x_peak'] # doctest: +REMOTE_DATA >>> y = peaks_tbl['y_peak'] # doctest: +REMOTE_DATA >>> mask = ((x > hsize) & (x < (data.shape[1] -1 - hsize)) & ... (y > hsize) & (y < (data.shape[0] -1 - hsize))) # doctest: +REMOTE_DATA Now let's create the table of good star positions: .. doctest-requires:: scipy >>> from astropy.table import Table >>> stars_tbl = Table() >>> stars_tbl['x'] = x[mask] # doctest: +REMOTE_DATA >>> stars_tbl['y'] = y[mask] # doctest: +REMOTE_DATA The star cutouts from which we build the ePSF must have the background subtracted. Here we'll use the sigma-clipped median value as the background level. If the background in the image varies across the image, one should use more sophisticated methods (e.g., `~photutils.background.Background2D`). Let's subtract the background from the image:: >>> from astropy.stats import sigma_clipped_stats >>> mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) # doctest: +REMOTE_DATA >>> data -= median_val # doctest: +REMOTE_DATA The :func:`~photutils.psf.extract_stars` function requires the input data as an `~astropy.nddata.NDData` object. An `~astropy.nddata.NDData` object is easy to create from our data array:: >>> from astropy.nddata import NDData >>> nddata = NDData(data=data) # doctest: +REMOTE_DATA We are now ready to create our star cutouts using the :func:`~photutils.psf.extract_stars` function. For this simple example we are extracting stars from a single image using a single catalog. The :func:`~photutils.psf.extract_stars` can also extract stars from multiple images using a separate catalog for each image or a single catalog. When using a single catalog, the star positions must be in sky coordinates (as `~astropy.coordinates.SkyCoord` objects) and the `~astropy.nddata.NDData` objects must contain valid `~astropy.wcs.WCS` objects. In the case of using multiple images (i.e., dithered images) and a single catalog, the same physical star will be "linked" across images, meaning it will be constrained to have the same sky coordinate in each input image. Let's extract the 25 x 25 pixel cutouts of our selected stars: .. doctest-requires:: scipy >>> from photutils.psf import extract_stars >>> stars = extract_stars(nddata, stars_tbl, size=25) # doctest: +REMOTE_DATA The function returns a `~photutils.psf.EPSFStars` object containing the cutouts of our selected stars. The function extracted 403 stars, from which we'll build our ePSF. Let's show the first 25 of them: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> nrows = 5 >>> ncols = 5 >>> fig, ax = plt.subplots(nrows=nrows, ncols=ncols, figsize=(20, 20), ... squeeze=True) >>> ax = ax.ravel() >>> for i in range(nrows * ncols): ... norm = simple_norm(stars[i], 'log', percent=99.0) ... ax[i].imshow(stars[i], norm=norm, origin='lower', cmap='viridis') .. plot:: import matplotlib.pyplot as plt from astropy.nddata import NDData from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import find_peaks from photutils.psf import extract_stars hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) peaks_tbl = find_peaks(data, threshold=500.0) size = 25 hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = x[mask] stars_tbl['y'] = y[mask] mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) data -= median_val nddata = NDData(data=data) stars = extract_stars(nddata, stars_tbl, size=25) nrows = 5 ncols = 5 fig, ax = plt.subplots(nrows=nrows, ncols=ncols, figsize=(20, 20), squeeze=True) ax = ax.ravel() for i in range(nrows * ncols): norm = simple_norm(stars[i], 'log', percent=99.0) ax[i].imshow(stars[i], norm=norm, origin='lower', cmap='viridis') With the star cutouts in hand, we are ready to construct the ePSF with the :class:`~photutils.psf.EPSFBuilder` class. We'll create an ePSF with an oversampling factor of 4.0. Here we limit the maximum number of iterations to 3 (to limit its run time), but in practice one should use about 10 or more iterations. The :class:`~photutils.psf.EPSFBuilder` class has many other options to control the ePSF build process, including changing the centering function, the smoothing kernel, and the centering accuracy. Please see the :class:`~photutils.psf.EPSFBuilder` documentation for further details. We first initialize an :class:`~photutils.psf.EPSFBuilder` instance with our desired parameters and then input the cutouts of our selected stars to the instance: .. doctest-requires:: scipy >>> from photutils.psf import EPSFBuilder >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... progress_bar=False) # doctest: +REMOTE_DATA >>> epsf, fitted_stars = epsf_builder(stars) # doctest: +REMOTE_DATA The returned values are the ePSF, as an :class:`~photutils.psf.EPSFModel` object, and our input stars fitted with the constructed ePSF, as a new :class:`~photutils.psf.EPSFStars` object with fitted star positions and fluxes. Finally, let's show the constructed ePSF: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> norm = simple_norm(epsf.data, 'log', percent=99.0) >>> plt.imshow(epsf.data, norm=norm, origin='lower', cmap='viridis') >>> plt.colorbar() .. plot:: import matplotlib.pyplot as plt from astropy.nddata import NDData from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import find_peaks from photutils.psf import EPSFBuilder, extract_stars hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) peaks_tbl = find_peaks(data, threshold=500.0) size = 25 hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = x[mask] stars_tbl['y'] = y[mask] mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) data -= median_val nddata = NDData(data=data) stars = extract_stars(nddata, stars_tbl, size=25) epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, progress_bar=False) epsf, fitted_stars = epsf_builder(stars) norm = simple_norm(epsf.data, 'log', percent=99.0) plt.imshow(epsf.data, norm=norm, origin='lower', cmap='viridis') plt.colorbar() The :class:`~photutils.psf.EPSFModel` object is a subclass of :class:`~photutils.psf.FittableImageModel`, thus it can be used as a PSF model for the :ref:`PSF-fitting machinery in Photutils ` (i.e., `~photutils.psf.PSFPhotometry` or `~photutils.psf.IterativePSFPhotometry`). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/geometry.rst0000644000175100001770000000050414637570305016562 0ustar00runnerdockerGeometry Functions (`photutils.geometry`) ========================================= Introduction ------------ The `photutils.geometry` package contains low-level geometry functions used mainly by `~photutils.aperture.aperture_photometry`. Reference/API ------------- .. automodapi:: photutils.geometry :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/getting_started.rst0000644000175100001770000001252514637570305020124 0ustar00runnerdockerGetting Started with Photutils ============================== The following example uses Photutils to find sources in an astronomical image and then perform circular aperture photometry on them. We start by loading an image from the bundled datasets and selecting a subset of the image:: >>> import numpy as np >>> from photutils.datasets import load_star_image >>> hdu = load_star_image() # doctest: +REMOTE_DATA >>> image = hdu.data[500:700, 500:700].astype(float) # doctest: +REMOTE_DATA We then subtract a rough estimate of the background, calculated using the image median:: >>> image -= np.median(image) # doctest: +REMOTE_DATA In the remainder of this example, we assume that the data is background-subtracted. Photutils supports several source detection algorithms. For this example, we use :class:`~photutils.detection.DAOStarFinder` to detect the stars in the image. We set the detection threshold at the 3-sigma noise level, estimated using the median absolute deviation (`~astropy.stats.mad_std`) of the image. The parameters of the detected sources are returned as an Astropy `~astropy.table.Table`: .. doctest-requires:: scipy >>> from photutils.detection import DAOStarFinder >>> from astropy.stats import mad_std >>> bkg_sigma = mad_std(image) # doctest: +REMOTE_DATA >>> daofind = DAOStarFinder(fwhm=4.0, threshold=3.0 * bkg_sigma) # doctest: +REMOTE_DATA >>> sources = daofind(image) # doctest: +REMOTE_DATA >>> for col in sources.colnames: # doctest: +REMOTE_DATA ... sources[col].info.format = '%.8g' # for consistent table output >>> print(sources) # doctest: +REMOTE_DATA id xcentroid ycentroid sharpness ... sky peak flux mag --- --------- ---------- ---------- ... --- ---- --------- ----------- 1 182.83866 0.16767019 0.85099873 ... 0 3824 2.8028346 -1.1189937 2 189.20431 0.26081353 0.7400477 ... 0 4913 3.8729185 -1.4700959 3 5.7946491 2.6125424 0.39589731 ... 0 7752 4.1029107 -1.5327302 4 36.847063 1.3220228 0.29594528 ... 0 8739 7.4315818 -2.1777032 5 3.2565602 5.418952 0.35985495 ... 0 6935 3.8126298 -1.4530616 ... ... ... ... ... ... ... ... ... 147 197.24864 186.16647 0.31211532 ... 0 8302 7.5814629 -2.1993825 148 124.31327 188.30523 0.5362742 ... 0 6702 6.6358543 -2.0547421 149 24.257207 194.71494 0.44169546 ... 0 8342 3.2671037 -1.2854073 150 116.45 195.05923 0.67080547 ... 0 3299 2.8775221 -1.1475467 151 18.958086 196.34207 0.56502139 ... 0 3854 2.3835296 -0.94305138 152 111.52575 195.73192 0.45827852 ... 0 8109 7.9278607 -2.24789 Length = 152 rows Using the source locations (i.e., the ``xcentroid`` and ``ycentroid`` columns), we now define circular apertures centered at these positions with a radius of 4 pixels and compute the sum of the pixel values within the apertures. The :func:`~photutils.aperture.aperture_photometry` function returns an Astropy `~astropy.table.QTable` with the results of the photometry: .. doctest-requires:: scipy >>> from photutils.aperture import aperture_photometry, CircularAperture >>> positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) # doctest: +REMOTE_DATA >>> apertures = CircularAperture(positions, r=4.0) # doctest: +REMOTE_DATA >>> phot_table = aperture_photometry(image, apertures) # doctest: +REMOTE_DATA >>> for col in phot_table.colnames: # doctest: +REMOTE_DATA ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) # doctest: +REMOTE_DATA id xcenter ycenter aperture_sum pix pix --- --------- ---------- ------------ 1 182.83866 0.16767019 18121.759 2 189.20431 0.26081353 29836.515 3 5.7946491 2.6125424 331979.82 4 36.847063 1.3220228 183705.09 5 3.2565602 5.418952 349468.98 ... ... ... ... 148 124.31327 188.30523 45084.874 149 24.257207 194.71494 355778.01 150 116.45 195.05923 31232.912 151 18.958086 196.34207 162076.26 152 111.52575 195.73192 82795.715 Length = 152 rows The sum of the pixel values within the apertures are given in the ``aperture_sum`` column. Finally, we plot the image and the defined apertures: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.imshow(image, cmap='gray_r', origin='lower') >>> apertures.plot(color='blue', lw=1.5, alpha=0.5) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import mad_std from photutils.aperture import CircularAperture, aperture_photometry from photutils.datasets import load_star_image from photutils.detection import DAOStarFinder hdu = load_star_image() image = hdu.data[500:700, 500:700].astype(float) image -= np.median(image) bkg_sigma = mad_std(image) daofind = DAOStarFinder(fwhm=4.0, threshold=3.0 * bkg_sigma) sources = daofind(image) positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) apertures = CircularAperture(positions, r=4.0) phot_table = aperture_photometry(image, apertures) brightest_source_id = phot_table['aperture_sum'].argmax() plt.imshow(image, cmap='gray_r', origin='lower') apertures.plot(color='blue', lw=1.5, alpha=0.5) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/grouping.rst0000644000175100001770000001460514637570305016570 0ustar00runnerdocker.. _psf-grouping: Source Grouping Algorithms ========================== Introduction ------------ In Point Spread Function (PSF) photometry, a grouping algorithm can be used to combine stars into optimum groups. The stars in each group are usually defined as those close enough together such that they need to be fit simultaneously, i.e., their profiles overlap. Stetson (`1987, PASP 99, 191 `_), provided a simple and powerful grouping algorithm to decide whether the profile of a given star extends into the fitting region of any other star. The paper defines this in terms of a "critical separation" parameter, which is defined as the minimal distance that any two stars must be separated by in order to be in different groups. The critical separation is generally defined as a multiple of the stellar full width at half maximum (FWHM). Getting Started --------------- Photutils provides the :class:`~photutils.psf.SourceGrouper` class to group stars. The groups are formed using hierarchical agglomerative clustering with a distance criterion, calling the `scipy.cluster.hierarchy.fclusterdata` function. First, let's create a simulated image containing 2D Gaussian sources using `~photutils.psf.make_psf_model_image`. .. doctest-requires:: scipy >>> from photutils.psf import IntegratedGaussianPRF, make_psf_model_image >>> shape = (256, 256) >>> sigma = 2.0 >>> psf_model = IntegratedGaussianPRF(sigma=sigma) >>> psf_shape = (11, 11) >>> n_sources = 100 >>> flux = (500, 1000) >>> border_size = (7, 7) >>> data, stars = make_psf_model_image(shape, psf_model, n_sources, ... model_shape=psf_shape, ... flux=flux, ... border_size=border_size, seed=123) Let's display the image: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.figure(figsize=(8, 8)) >>> plt.imshow(data, origin='lower', interpolation='nearest') .. plot:: import matplotlib.pyplot as plt from photutils.psf import IntegratedGaussianPRF, make_psf_model_image shape = (256, 256) sigma = 2.0 psf_model = IntegratedGaussianPRF(sigma=sigma) psf_shape = (11, 11) n_sources = 100 flux = (500, 1000) border_size = (7, 7) data, stars = make_psf_model_image(shape, psf_model, n_sources, flux=flux, model_shape=psf_shape, border_size=border_size, seed=123) plt.figure(figsize=(8, 8)) plt.imshow(data, origin='lower', interpolation='nearest') plt.show() The ``make_psf_model_image`` function returns the simulated image (``data``) and a table of the star positions and fluxes (``stars``). The star positions are stored in the 'x_0' and 'y_0' columns of the table. Now, let's find the stellar groups. We start by creating a `~photutils.psf.SourceGrouper` object. Here we set the ``min_separation`` parameter ``2.5 * fwhm``, where the ``fwhm`` is calculated from the 2D Gaussian standard deviation used to generate the stars. In general one will need to measure the FWHM of the stellar profiles. .. doctest-requires:: scipy >>> from astropy.stats import gaussian_sigma_to_fwhm >>> from photutils.psf import SourceGrouper >>> fwhm = sigma * gaussian_sigma_to_fwhm >>> min_separation = 2.5 * fwhm >>> grouper = SourceGrouper(min_separation) We then call the class instance on arrays of the star (x, y) positions. Here will use the known positions of the stars when we generated the image. In general, one can use a star finder (:ref:`source_detection`) to find the sources. .. doctest-requires:: scipy >>> import numpy as np >>> x = np.array(stars['x_0']) >>> y = np.array(stars['y_0']) >>> groups = grouper(x, y) The ``groups`` output is an array of integers (ordered the same as the (x, y) inputs) containing the group indices. Stars with the same group index are in the same group. For example, to find all the stars in group 3: .. doctest-requires:: scipy >>> mask = groups == 3 >>> x[mask], y[mask] (array([60.32708921, 58.73063714]), array([147.24184586, 158.0612346 ])) Here the grouping algorithm separated the 100 stars into 65 distinct groups: .. doctest-skip:: >>> print(max(groups)) 65 Finally, let's plot a circular aperture around each star, where stars in the same group have the same aperture color: .. doctest-skip:: >>> import numpy as np >>> from photutils.aperture import CircularAperture >>> from photutils.utils import make_random_cmap >>> plt.imshow(data, origin='lower', interpolation='nearest', ... cmap='Greys_r') >>> cmap = make_random_cmap(seed=123) >>> for i in np.arange(1, max(groups)): >>> mask = groups == i >>> xypos = zip(x[mask], y[mask]) >>> ap = CircularAperture(xypos, r=fwhm) >>> ap.plot(color=cmap.colors[i], lw=2) >>> plt.show() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import gaussian_sigma_to_fwhm from photutils.aperture import CircularAperture from photutils.psf import (IntegratedGaussianPRF, SourceGrouper, make_psf_model_image) from photutils.utils import make_random_cmap shape = (256, 256) psf_shape = (11, 11) border_size = (6, 6) flux = (500, 1000) sigma = 2.0 psf_model = IntegratedGaussianPRF(sigma=sigma) n_sources = 100 data, stars = make_psf_model_image(shape, psf_model, n_sources, flux=flux, model_shape=psf_shape, border_size=border_size, seed=123) fwhm = sigma * gaussian_sigma_to_fwhm min_separation = 2.5 * fwhm grouper = SourceGrouper(min_separation) x = np.array(stars['x_0']) y = np.array(stars['y_0']) groups = grouper(x, y) plt.figure(figsize=(8, 8)) plt.imshow(data, origin='lower', interpolation='nearest', cmap='Greys_r') cmap = make_random_cmap(seed=123) for i in np.arange(1, max(groups)): mask = groups == i xypos = zip(x[mask], y[mask]) ap = CircularAperture(xypos, r=fwhm) ap.plot(color=cmap.colors[i], lw=2) plt.show() Reference/API ------------- .. automodapi:: photutils.psf.groupers :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/index.rst0000644000175100001770000000426214637570305016043 0ustar00runnerdocker .. the "raw" directive below is used to hide the title in favor of just the logo being visible .. raw:: html .. |br| raw:: html
********* Photutils ********* .. raw:: html .. only:: latex .. image:: _static/photutils_banner.pdf **Photutils** is an `affiliated package `_ of `Astropy`_ that primarily provides tools for detecting and performing photometry of astronomical sources. It is an open source Python package and is licensed under a :ref:`3-clause BSD license `. |br| .. Important:: If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include an :doc:`acknowledgment and/or citation `. |br| Getting Started =============== .. toctree:: :maxdepth: 1 install.rst whats_new/index.rst overview.rst pixel_conventions.rst getting_started.rst contributing.rst citation.rst license.rst changelog User Documentation ================== Backgrounds ----------- .. toctree:: :maxdepth: 1 background.rst Source Detection ---------------- .. toctree:: :maxdepth: 1 detection.rst segmentation.rst Aperture Photometry ------------------- .. toctree:: :maxdepth: 1 aperture.rst PSF Photometry and Tools ------------------------ .. toctree:: :maxdepth: 1 psf.rst epsf.rst grouping.rst psf_matching.rst Source Measurements ------------------- .. toctree:: :maxdepth: 1 segmentation.rst centroids.rst morphology.rst Radial Profiles --------------- .. toctree:: :maxdepth: 1 profiles.rst Isophotes --------- .. toctree:: :maxdepth: 1 isophote.rst Utilities --------- .. toctree:: :maxdepth: 1 utils.rst datasets.rst geometry.rst Developer Documentation ======================= .. toctree:: :maxdepth: 1 dev/releasing.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/install.rst0000644000175100001770000001222514637570305016400 0ustar00runnerdocker************ Installation ************ Requirements ============ Photutils has the following strict requirements: * `Python `_ 3.10 or later * `NumPy `_ 1.23 or later * `Astropy`_ 5.1 or later Photutils also optionally depends on other packages for some features: * `SciPy `_ 1.8 or later: To power a variety of features in several modules (strongly recommended). * `Matplotlib `_ 3.5 or later: To power a variety of plotting features (e.g., plotting apertures). * `scikit-image `_ 0.20 or later: Used for deblending segmented sources. * `GWCS `_ 0.18 or later: Used in `~photutils.datasets.make_gwcs` to create a simple celestial gwcs object. * `Bottleneck `_: Improves the performance of sigma clipping and other functionality that may require computing statistics on arrays with NaN values. * `tqdm `_: Used to display optional progress bars. * `Rasterio `_: Used for converting source segments into polygon objects. * `Shapely `_: Used for converting source segments into polygon objects. Installing the latest released version ====================================== The latest released (stable) version of Photutils can be installed either with `pip`_ or `conda`_. Using pip --------- To install Photutils with `pip`_, run:: pip install photutils If you want to install Photutils along with all of its optional dependencies, you can instead do:: pip install "photutils[all]" In most cases, this will install a pre-compiled version (called a wheel) of Photutils, but if you are using a very recent version of Python or if you are installing Photutils on a platform that is not common, Photutils will be installed from a source file. In this case you will need a C compiler (e.g., ``gcc`` or ``clang``) to be installed for the installation to succeed (see :ref:`building_source` prerequisites). If you get a ``PermissionError``, this means that you do not have the required administrative access to install new packages to your Python installation. In this case you may consider using the ``--user`` option to install the package into your home directory. You can read more about how to do this in the `pip documentation `_. Do **not** install Photutils or other third-party packages using ``sudo`` unless you are fully aware of the risks. Using conda ----------- Photutils can be installed with `conda`_ if you have installed `Anaconda `_ or `Miniconda `_. To install Photutils using the `conda-forge Anaconda channel `_, run:: conda install -c conda-forge photutils .. _building_source: Building from Source ==================== Prerequisites ------------- You will need a compiler suite and the development headers for Python and Numpy in order to build Photutils from the source distribution. You do not need to install any other specific build dependencies (such as Cython) since these will be automatically installed into a temporary build environment by `pip`_. On Linux, using the package manager for your distribution will usually be the easiest route. On macOS you will need the `XCode`_ command-line tools, which can be installed using:: xcode-select --install Follow the onscreen instructions to install the command-line tools required. Note that you do not need to install the full `XCode`_ distribution (assuming you are using MacOS X 10.9 or later). Installing the development version ---------------------------------- Photutils is being developed on `GitHub`_. The latest development version of the Photutils source code can be retrieved using git:: git clone https://github.com/astropy/photutils.git Then to build and install Photutils (with all of its optional dependencies), run:: cd photutils pip install ".[all]" If you wish to install the package in "editable" mode, instead include the "-e" option:: pip install -e ".[all]" Alternatively, `pip`_ can be used to retrieve, build, and install the latest development version from `GitHub`_:: pip install "photutils[all] @ git+https://github.com/astropy/photutils.git" Testing an installed Photutils ============================== To test your installed version of Photutils, you can run the test suite using the `pytest`_ command. Running the test suite requires installing the `pytest-astropy `_ (0.11 or later) package. To run the test suite, use the following command:: pytest --pyargs photutils Any test failures can be reported to the `Photutils issue tracker `_. .. _pip: https://pip.pypa.io/en/latest/ .. _conda: https://docs.conda.io/en/latest/ .. _GitHub: https://github.com/astropy/photutils .. _Xcode: https://developer.apple.com/xcode/ .. _pytest: https://docs.pytest.org/en/latest/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/isophote.rst0000644000175100001770000002457614637570305016600 0ustar00runnerdockerElliptical Isophote Analysis (`photutils.isophote`) =================================================== Introduction ------------ The `~photutils.isophote` package provides tools to fit elliptical isophotes to a galaxy image. The isophotes in the image are measured using an iterative method described by `Jedrzejewski (1987; MNRAS 226, 747) `_. See the documentation of the :class:`~photutils.isophote.Ellipse` class for details about the algorithm. Please also see the :ref:`isophote-faq`. Getting Started --------------- For this example, let's create a simple simulated galaxy image:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.datasets import make_noise_image >>> g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) >>> ny = nx = 150 >>> y, x = np.mgrid[0:ny, 0:nx] >>> noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, ... stddev=2.0, seed=1234) >>> data = g(x, y) + noise .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise plt.imshow(data, origin='lower') We must provide the elliptical isophote fitter with an initial ellipse to be fitted. This ellipse geometry is defined with the `~photutils.isophote.EllipseGeometry` class. Here we'll define an initial ellipse whose position angle is offset from the data:: >>> from photutils.isophote import EllipseGeometry >>> geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, ... pa=20.0 * np.pi / 180.0) Let's show this initial ellipse guess: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from photutils.aperture import EllipticalAperture >>> aper = EllipticalAperture((geometry.x0, geometry.y0), geometry.sma, ... geometry.sma * (1 - geometry.eps), ... geometry.pa) >>> plt.imshow(data, origin='lower') >>> aper.plot(color='white') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.aperture import EllipticalAperture from photutils.datasets import make_noise_image from photutils.isophote import EllipseGeometry g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) aper = EllipticalAperture((geometry.x0, geometry.y0), geometry.sma, geometry.sma * (1 - geometry.eps), geometry.pa) plt.imshow(data, origin='lower') aper.plot(color='white') Next, we create an instance of the `~photutils.isophote.Ellipse` class, inputting the data to be fitted and the initial ellipse geometry object:: >>> from photutils.isophote import Ellipse >>> ellipse = Ellipse(data, geometry) To perform the elliptical isophote fit, we run the :meth:`~photutils.isophote.Ellipse.fit_image` method: .. doctest-requires:: scipy >>> isolist = ellipse.fit_image() The result is a list of isophotes as an `~photutils.isophote.IsophoteList` object, whose attributes are the fit values for each `~photutils.isophote.Isophote` sorted by the semimajor axis length. Let's print the fit position angles (radians): .. doctest-requires:: scipy >>> print(isolist.pa) # doctest: +SKIP [ 0. 0.16838914 0.18453378 0.20310945 0.22534975 0.25007781 0.28377499 0.32494582 0.38589202 0.40480013 0.39527698 0.38448771 0.40207495 0.40207495 0.28201524 0.28201524 0.19889817 0.1364335 0.1364335 0.13405719 0.17848892 0.25687327 0.35750355 0.64882699 0.72489435 0.91472008 0.94219702 0.87393299 0.82572916 0.7886367 0.75523282 0.7125274 0.70481612 0.7120097 0.71250791 0.69707669 0.7004807 0.70709823 0.69808124 0.68621341 0.69437566 0.70548293 0.70427021 0.69978326 0.70410887 0.69532744 0.69440413 0.70062534 0.68614488 0.7177538 0.7177538 0.7029571 0.7029571 0.7029571 ] We can also show the isophote values as a table, which is again sorted by the semimajor axis length (``sma``): .. doctest-requires:: scipy >>> print(isolist.to_table()) # doctest: +SKIP sma intens intens_err ... flag niter stop_code ... -------------- --------------- --------------- ... ---- ----- --------- 0.0 102.237692914 0.0 ... 0 0 0 0.534697261283 101.212218041 0.0280377938856 ... 0 10 0 0.588166987411 101.095404456 0.027821598428 ... 0 10 0 0.646983686152 100.971770355 0.0272405762608 ... 0 10 0 0.711682054767 100.842254551 0.0262991125932 ... 0 10 0 ... ... ... ... ... ... ... 51.874849202 3.44800874483 0.0881592058138 ... 0 50 2 57.0623341222 1.64031530995 0.0913122295433 ... 0 50 2 62.7685675344 0.692631010404 0.0786846787635 ... 0 32 0 69.0454242879 0.294659388337 0.0681758007533 ... 0 8 5 75.9499667166 0.0534892334515 0.0692483210903 ... 0 2 5 Length = 54 rows Let's plot the ellipticity, position angle, and the center x and y position as a function of the semimajor axis length: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image from photutils.isophote import Ellipse, EllipseGeometry g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() plt.figure(figsize=(8, 8)) plt.subplots_adjust(hspace=0.35, wspace=0.35) plt.subplot(2, 2, 1) plt.errorbar(isolist.sma, isolist.eps, yerr=isolist.ellip_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('Ellipticity') plt.subplot(2, 2, 2) plt.errorbar(isolist.sma, isolist.pa / np.pi * 180.0, yerr=isolist.pa_err / np.pi * 80.0, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('PA (deg)') plt.subplot(2, 2, 3) plt.errorbar(isolist.sma, isolist.x0, yerr=isolist.x0_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('x0') plt.subplot(2, 2, 4) plt.errorbar(isolist.sma, isolist.y0, yerr=isolist.y0_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('y0') We can build an elliptical model image from the `~photutils.isophote.IsophoteList` object using the :func:`~photutils.isophote.build_ellipse_model` function (NOTE: this function requires `scipy `_): .. doctest-requires:: scipy >>> from photutils.isophote import build_ellipse_model >>> model_image = build_ellipse_model(data.shape, isolist) >>> residual = data - model_image Finally, let's plot the original data, overplotted with some isophotes, the elliptical model image, and the residual image: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image from photutils.isophote import (Ellipse, EllipseGeometry, build_ellipse_model) g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() model_image = build_ellipse_model(data.shape, isolist) residual = data - model_image fig, (ax1, ax2, ax3) = plt.subplots(figsize=(14, 5), nrows=1, ncols=3) fig.subplots_adjust(left=0.04, right=0.98, bottom=0.02, top=0.98) ax1.imshow(data, origin='lower') ax1.set_title('Data') smas = np.linspace(10, 50, 5) for sma in smas: iso = isolist.get_closest(sma) x, y, = iso.sampled_coordinates() ax1.plot(x, y, color='white') ax2.imshow(model_image, origin='lower') ax2.set_title('Ellipse Model') ax3.imshow(residual, origin='lower') ax3.set_title('Residual') Additional Example Notebooks (online) ------------------------------------- Additional example notebooks showing examples with real data and advanced usage are available online: * `Basic example of the Ellipse fitting tool `_ * `Running Ellipse with sigma-clipping `_ * `Building an image model from results obtained by Ellipse fitting `_ * `Advanced Ellipse example: multi-band photometry and masked arrays `_ Reference/API ------------- .. automodapi:: photutils.isophote :no-heading: .. toctree:: :hidden: isophote_faq.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/isophote_faq.rst0000644000175100001770000002250114637570305017411 0ustar00runnerdocker.. _isophote-faq: Isophote Frequently Asked Questions ----------------------------------- .. _harmonic_ampl: 1. What are the basic equations relating harmonic amplitudes to geometrical parameter updates? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The basic elliptical isophote fitting algorithm, as described in `Jedrzejewski (1987; MNRAS 226, 747) `_, computes corrections for the current ellipse's geometrical parameters by essentially "projecting" the fitted harmonic amplitudes onto the image plane: .. math:: {\delta}_{X0} = \frac {-B_{1}} {I'} .. math:: {\delta}_{Y0} = \frac {-A_{1} (1 - {\epsilon})} {I'} .. math:: {\delta}_{\epsilon} = \frac {-2 B_{2} (1 - {\epsilon})} {I' a_0} .. math:: {\delta}_{\Theta} = \frac {2 A_{2} (1 - {\epsilon})} {I' a_0 [(1 - {\epsilon}) ^ 2 - 1]} where :math:`\epsilon` is the ellipticity, :math:`\Theta` is the position angle, :math:`A_i` and :math:`B_i` are the harmonic coefficients, and :math:`I'` is the derivative of the intensity along the major axis direction evaluated at a semimajor axis length of :math:`a_0`. 2. Why use "ellipticity" instead of the canonical ellipse eccentricity? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The main reason is that ellipticity, defined as .. math:: \epsilon = 1 - \frac{b}{a} better relates with the visual "flattening" of an ellipse. By looking at a flattened circle it is easy to guess its ellipticity, as say 0.1. The same ellipse has an eccentricity of 0.44, which is not obvious from visual inspection. The quantities relate as .. math:: Ecc = \sqrt{1 - (1 - {\epsilon})^2} 3. How is the radial gradient estimated? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The radial intensity gradient is the most critical quantity computed by the fitting algorithm. As can be seen from the above formulae, small :math:`I'` values lead to large values for the correction terms. Thus, :math:`I'` errors may lead to large fluctuations in these terms, when :math:`I'` itself is small. This usually happens at the fainter, outer regions of galaxy images. `Busko (1996; ASPC 101, 139) `_ found by numerical experiments that the precision to which a given ellipse can be fitted is related to the relative error in the local radial gradient. Because of the gradient's critical role, the algorithm has a number of features to allow its estimation even under difficult conditions. The default gradient computation, the one used by the algorithm when it first starts to fit a new isophote, is based on the extraction of two intensity samples: #1 at the current ellipse position, and #2 at a similar ellipse with a 10% larger semimajor axis. If the gradient so estimated is not meaningful, the algorithm extracts another #2 sample, this time using a 20% larger radius. In this context, a meaningful gradient means "shallower", but still close to within a factor 3 from the previous isophote's gradient estimate. If still no meaningful gradient can be measured, the algorithm uses the value measured at the last fitted isophote, but decreased (in absolute value) by a factor 0.8. This factor is roughly what is expected from semimajor-axis geometrical-sampling steps of 10 - 20% and a deVaucouleurs law or an exponential disk in its inner region (r <~ 5 req). When using the last isophote's gradient as estimator for the current one, the current gradient error cannot be computed and is set to `None`. As a last resort, if no previous gradient estimate is available, the algorithm just guesses the current value by setting it to be (minus) 10% of the mean intensity at sample #1. This case usually happens only at the first isophote fitted by the algorithm. The use of approximate gradient estimators may seem in contradiction with the fact that isophote fitting errors depend on gradient error, as well as with the fact that the algorithm itself is so sensitive to the gradient value. The rationale behind the use of approximate estimators, however, is based on the fact that the gradient value is used only to compute increments, not the ellipse parameters themselves. Approximate estimators are useful along the first steps in the iteration sequence, in particular when local image contamination (stars, defects, etc.) might make it difficult to find the correct path towards the solution. However, if the gradient is still not well determined at convergence, the subsequent error computations, and the algorithm's behavior from that point on, will take the fact into account properly. For instance, the 3rd and 4th harmonic amplitude errors depend on the gradient relative error, and if this is not computable at the current isophote, the algorithm uses a reasonable estimate (80% of the value at the last successful isophote) in order to generate sensible estimates for those harmonic errors. 4. How are the errors estimated? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Most parameters computed directly at each isophote have their errors computed by standard error propagation. Errors in the ellipse geometry parameters, on the other hand, cannot be estimated in the same way, since these parameters are not computed directly but result from a number of updates from a starting guess value. An error analysis based on numerical experiments (`Busko 1996; ASPC 101, 139 `_) showed that the best error estimators for these geometrical parameters can be found by simply "projecting" the harmonic amplitude errors that come from the least-squares covariance matrix by the same formulae in :ref:`Question 1 ` above used to "project" the associated parameter updates. In other words, errors for the ellipse center, ellipticity, and position angle are computed by the same formulae as in :ref:`Question 1 `, but replacing the least-squares amplitudes by their errors. This is empirical and difficult to justify in terms of any theoretical error analysis, but it produces sensible error estimators in practice. 5. How is the image sampled? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When sampling is done using elliptical sectors (mean or median modes), the algorithm described in `Jedrzejewski (1987; MNRAS 226, 747) `_ uses an elaborate, high-precision scheme to take into account partial pixels that lie along elliptical sector boundaries. In the current implementation of the `~photutils.isophote.Ellipse` algorithm, this method was not implemented. Instead, pixels at sector boundaries are either fully included or discarded, depending on the precise position of their centers in relation to the elliptical geometric locus corresponding to the current ellipse. This design decision is based on two arguments: (i) it would be difficult to include partial pixels in median computation, and (ii) speed. Even when the chosen integration mode is not bilinear, the sampling algorithm resorts to it in case the number of sampled pixels inside any given sector is less than 5. It was found that bilinear mode gives smoother samples in those cases. Tests performed with artificial images showed that cosmic rays and defective pixels can be very effectively removed from the fit by a combination of median sampling and sigma clipping. 6. How reliable are the fluxes computed by the `~photutils.isophote.Ellipse` algorithm? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The integrated fluxes and areas computed by `~photutils.isophote.Ellipse` were checked against results produced by the IRAF ``noao.digiphot.apphot`` tasks ``phot`` and ``polyphot``, using artificial images. Quantities computed by `~photutils.isophote.Ellipse` match the reference ones within < 0.1% in all tested cases. 7. How does the object centerer work? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.isophote.EllipseGeometry` class has a :meth:`~photutils.isophote.EllipseGeometry.find_center` method that runs an "object locator" around the input object coordinates. This routine performs a scan over a 10x10 pixel window centered on the input object coordinates. At each scan position, it extracts two concentric, roughly circular samples with radii 4 and 8 pixels. It then computes a signal-to-noise-like criterion using the intensity averages and standard deviations at each annulus: .. math:: c = \frac{f_{1} - f_{2}}{{\sqrt{\sigma_{1}^{2} + \sigma_{2}^{2}}}} and locates the pixel inside the scanned window where this criterion is a maximum. If the criterion so computed exceeds a given threshold, it assumes that a suitable object was detected at that position. The default threshold value is set to 0.1. This value and the annuli and window sizes currently used were found by trial and error using a number of both artificial and real galaxy images. It was found that very flattened galaxy images (ellipticity ~ 0.7) cannot be detected by such a simple algorithm. By increasing the threshold value the object locator becomes stricter, in the sense that it will not detect faint objects. To turn off the object locator, set the threshold to a value >> 1 in `~photutils.isophote.Ellipse`. This will prevent it from modifying whatever values for the center coordinates were given to the `~photutils.isophote.Ellipse` algorithm. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/license.rst0000644000175100001770000000017214637570305016352 0ustar00runnerdocker.. _photutils_license: License ======= Photutils is licensed under a 3-clause BSD license: .. include:: ../LICENSE.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/make.bat0000644000175100001770000001070514637570305015606 0ustar00runnerdocker@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* del /q /s api del /q /s generated goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/morphology.rst0000644000175100001770000000750214637570305017133 0ustar00runnerdockerMorphological Properties (`photutils.morphology`) ================================================= Introduction ------------ The :func:`~photutils.morphology.data_properties` function can be used to calculate the morphological properties of a single source in a cutout image. `~photutils.morphology.data_properties` returns a `~photutils.segmentation.SourceCatalog` object. Please see `~photutils.segmentation.SourceCatalog` for the list of the many properties that are calculated. Even more properties are likely to be added in the future. If you have a segmentation image, the :class:`~photutils.segmentation.SourceCatalog` class can be used to calculate the properties for all (or a specified subset) of the segmented sources. Please see :ref:`Source Photometry and Properties from Image Segmentation ` for more details. Getting Started --------------- Let's extract a single object from a synthetic dataset and find calculate its morphological properties. For this example, we will subtract the background using simple sigma-clipped statistics. First, we create the source image and subtract its background:: >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image()[43:79, 76:104] >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> data -= median # subtract background Then, calculate its properties: .. doctest-requires:: scipy >>> from photutils.morphology import data_properties >>> cat = data_properties(data) >>> columns = ['label', 'xcentroid', 'ycentroid', 'semimajor_sigma', ... 'semiminor_sigma', 'orientation'] >>> tbl = cat.to_table(columns=columns) >>> tbl['xcentroid'].info.format = '.10f' # optional format >>> tbl['ycentroid'].info.format = '.10f' >>> tbl['semiminor_sigma'].info.format = '.10f' >>> tbl['orientation'].info.format = '.10f' >>> print(tbl) label xcentroid ycentroid ... semiminor_sigma orientation ... pix deg ----- ------------- ------------- ... --------------- ------------- 1 13.9557547142 17.0049225325 ... 3.6800596993 59.6897216190 Now let's use the measured morphological properties to define an approximate isophotal ellipse for the source: .. doctest-skip:: >>> import astropy.units as u >>> from photutils.aperture import EllipticalAperture >>> position = (cat.xcentroid, cat.ycentroid) >>> r = 3.0 # approximate isophotal extent >>> a = cat.semimajor_sigma.value * r >>> b = cat.semiminor_sigma.value * r >>> theta = cat.orientation.to(u.rad).value >>> apertures = EllipticalAperture(position, a, b, theta=theta) >>> plt.imshow(data, origin='lower', cmap='viridis', ... interpolation='nearest') >>> apertures.plot(color='#d62728') .. plot:: import astropy.units as u import matplotlib.pyplot as plt from photutils.aperture import EllipticalAperture from photutils.datasets import make_4gaussians_image from photutils.morphology import data_properties data = make_4gaussians_image()[43:79, 76:104] # extract single object cat = data_properties(data) columns = ['label', 'xcentroid', 'ycentroid', 'semimajor_sigma', 'semiminor_sigma', 'orientation'] tbl = cat.to_table(columns=columns) r = 2.5 # approximate isophotal extent position = (cat.xcentroid, cat.ycentroid) a = cat.semimajor_sigma.value * r b = cat.semiminor_sigma.value * r theta = cat.orientation.to(u.rad).value apertures = EllipticalAperture(position, a, b, theta=theta) plt.imshow(data, origin='lower', cmap='viridis', interpolation='nearest') apertures.plot(color='#d62728') Reference/API ------------- .. automodapi:: photutils.morphology :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/overview.rst0000644000175100001770000000245014637570305016577 0ustar00runnerdockerOverview ======== Introduction ------------ Photutils contains functions for: * performing aperture photometry * performing PSF-fitting photometry * detecting and extracting point-like sources (e.g., stars) in astronomical images * detecting and extracting extended sources using image segmentation in astronomical images * estimating the background and background RMS in astronomical images * centroiding sources * creating radial profiles and curves of growth * building an effective Point Spread Function (ePSF) * matching PSF kernels * estimating morphological parameters of detected sources * estimating the limiting depths of images The code and issue tracker are available at the following links: * Code: https://github.com/astropy/photutils * Issue Tracker: https://github.com/astropy/photutils/issues Like much astronomy software, Photutils is an evolving package. The developers try to maintain backwards compatibility, but at times the API may change if there is a benefit to doing so. If there are specific areas you think API stability is important, please let us know as part of the development process. Contributors ------------ For the complete list of contributors please see the `Photutils contributors page on GitHub `_. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/pixel_conventions.rst0000644000175100001770000000214614637570305020501 0ustar00runnerdockerPixel Coordinate Conventions ---------------------------- In Photutils, integer pixel coordinates fall at the center of pixels, and they are 0-indexed, matching the Python 0-based indexing. That means the first pixel is considered pixel ``0``, but pixel coordinate ``0`` is the *center* of that pixel. Hence, the first pixel spans pixel values ``-0.5`` to ``0.5``. For a 2-dimensional array, ``(x, y) = (0, 0)`` corresponds to the *center* of the bottom, leftmost array element. That means the first pixel spans the ``x`` and ``y`` pixel values from ``-0.5`` to ``0.5``. Note that this differs from the IRAF, `FITS WCS `_, `ds9`_, and `SourceExtractor`_ conventions, in which the center of the bottom, leftmost array element is ``(x, y) = (1, 1)``. Following Python indexing, the ``x`` (column) coordinate corresponds to the second (fast) array index and the ``y`` (row) coordinate corresponds to the first (slow) index. ``image[y, x]`` gives the value at pixel coordinates ``(x, y)``. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ .. _ds9: http://ds9.si.edu/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/profiles.rst0000644000175100001770000003637414637570305016570 0ustar00runnerdocker.. _profiles: Profiles (`photutils.profiles`) =============================== Introduction ------------ `photutils.profiles` provides tools to calculate radial profiles and curves of growth using concentric circular apertures. Preliminaries ------------- Let’s start by making a synthetic image of a single source. Note that there is no background in this image. One should background-subtract the data before creating a radial profile or curve of growth. >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.datasets import make_noise_image >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) >>> data += error .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.datasets import make_noise_image # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) plt.imshow(data, norm=norm) Creating a Radial Profile ------------------------- First, we'll use the `~photutils.centroids.centroid_quadratic` function to find the source centroid from the simulated image defined above:: >>> from photutils.centroids import centroid_quadratic >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> print(xycen) # doctest: +FLOAT_CMP [47.61226319 52.04668132] We'll use this centroid position as the center of our radial profile. We create a radial profile using the `~photutils.profiles.RadialProfile` class. The radial bins are defined by inputing a 1D array of radii that represent the radial *edges* of circular annulus apertures. The radial spacing does not need to be constant. The input ``error`` array is the uncertainty in the data values. The input ``mask`` array is a boolean mask with the same shape as the data, where a `True` value indicates a masked pixel:: >>> from photutils.profiles import RadialProfile >>> edge_radii = np.arange(25) >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) The output `~photutils.profiles.RadialProfile.radius` attribute values are defined as the arithmetic means of the input radial-bins edges (``radii``). Note this is different from the input ``radii``, which represents the radial bin edges:: >>> print(rp.radii) # doctest: +FLOAT_CMP [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24] >>> print(rp.radius) # doctest: +FLOAT_CMP [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5] The `~photutils.profiles.RadialProfile.profile` and `~photutils.profiles.RadialProfile.profile_error` attributes contain the output 1D `~numpy.ndarray` objects containing the radial profile and propagated errors:: >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.69588246 0.81797694 0.61132694 0.44670831 0.49499835 0.38025361 0.40844702 0.32906672 0.36466713 0.33059274 0.29661894 0.27314739 0.25551933 0.27675376 0.25553986 0.23421017 0.22966813 0.21747036 0.23654884 0.22760386 0.23941711 0.20661313 0.18999134 0.17469024] If desired, the radial profile can be normalized using the :meth:`~photutils.profiles.RadialProfile.normalize` method. By default (``method='max'``), the profile is normalized such that its maximum value is 1. Setting ``method='sum'`` can be used to normalize the profile such that its sum (integral) is 1:: >> rp.normalize(method='max') There is also a method to "unnormalize" the radial profile back to the original values prior to running any calls to the :meth:`~photutils.profiles.RadialProfile.normalize` method:: >> rp.unnormalize() There are also convenience methods to plot the radial profile and its error. These methods plot ``rp.radius`` versus ``rp.profile`` (with ``rp.profile_error`` as error bars). The ``label`` keyword can be used to set the plot label. .. doctest-skip:: >>> rp.plot(label='Radial Profile') >>> rp.plot_error() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.plot(label='Radial Profile') rp.plot_error() plt.legend() The `~photutils.profiles.RadialProfile.apertures` attribute contains a list of the apertures. Let's plot a few of the annulus apertures (the 6th 11th, and 16th) for the `~photutils.profiles.RadialProfile` instance on the data: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) plt.imshow(data, norm=norm) rp.apertures[5].plot(color='C0', lw=2) rp.apertures[10].plot(color='C1', lw=2) rp.apertures[15].plot(color='C3', lw=2) Now let's fit a 1D Gaussian to the radial profile and return the `~astropy.modeling.functional_models.Gaussian1D` model using the `~photutils.profiles.RadialProfile.gaussian_fit` attribute: .. doctest-requires:: scipy >>> rp.gaussian_fit # doctest: +FLOAT_CMP The FWHM of the fitted 1D Gaussian model is stored in the `~photutils.profiles.RadialProfile.gaussian_fwhm` attribute: .. doctest-requires:: scipy >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.09260130738712 Finally, let's plot the fitted 1D Gaussian model for the class:`~photutils.profiles.RadialProfile` radial profile: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.plot(label='Radial Profile') rp.plot_error() plt.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') plt.legend() Creating a Curve of Growth -------------------------- Now let's create a curve of growth using the `~photutils.profiles.CurveOfGrowth` class. We use the simulated image defined above and the same source centroid. The curve of growth will be centered at our centroid position. It will be computed over the radial range given by the input ``radii`` array:: >>> from photutils.profiles import CurveOfGrowth >>> radii = np.arange(1, 26) >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) Here, the `~photutils.profiles.CurveOfGrowth.radius` attribute values are identical to the input ``radii``. Because these values are the radii of the circular apertures used to measure the profile, they can be used directly to measure the encircled energy/flux at a given radius. In other words, they are the radial values that enclose the given flux:: >>> print(cog.radius) # doctest: +FLOAT_CMP [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25] The `~photutils.profiles.CurveOfGrowth.profile` and `~photutils.profiles.CurveOfGrowth.profile_error` attributes contain output 1D `~numpy.ndarray` objects containing the curve-of-growth profile and propagated errors:: >>> print(cog.profile) # doctest: +FLOAT_CMP [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 5.32777186 9.37111012 13.41750992 16.62928904 21.7350922 25.39862532 30.3867526 34.11478867 39.28263973 43.96047829 48.11931395 52.00967328 55.7471834 60.48824739 64.81392778 68.71042311 72.71899201 76.54959872 81.33806741 85.98568713 91.34841248 95.5173253 99.22190499 102.51980185 106.83601366] If desired, the curve-of-growth profile can be normalized using the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method. By default (``method='max'``), the profile is normalized such that its maximum value is 1. Setting ``method='sum'`` can also be used to normalize the profile such that its sum (integral) is 1:: >> cog.normalize(method='max') There is also a method to "unnormalize" the radial profile back to the original values prior to running any calls to the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method:: >> cog.unnormalize() There are also convenience methods to plot the curve of growth and its error. These methods plot ``cog.radius`` versus ``cog.profile`` (with ``cog.profile_error`` as error bars). The ``label`` keyword can be used to set the plot label. .. doctest-skip:: >>> rp.plot(label='Curve of Growth') >>> rp.plot_error() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the radial profile cog.plot(label='Curve of Growth') cog.plot_error() plt.legend() The `~photutils.profiles.CurveOfGrowth.apertures` attribute contains a list of the apertures. Let's plot a few of the circular apertures (the 6th, 11th, and 16th) on the data: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) plt.imshow(data, norm=norm) cog.apertures[5].plot(color='C0', lw=2) cog.apertures[10].plot(color='C1', lw=2) cog.apertures[15].plot(color='C3', lw=2) Encircled Energy ^^^^^^^^^^^^^^^^ Often, one is interested in the encircled energy (or flux) within a given radius, where the encircled energy is generally expressed as a normalized value between 0 and 1. If the curve of growth is monotonically increasing and normalized such that its maximum value is 1 for an infinitely large radius, then the encircled energy is simply the value of the curve of growth at a given radius. To achieve this, one can input a normalized version of the ``data`` array (e.g., a normalized PSF) to the `~photutils.profiles.CurveOfGrowth` class. One can also use the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method to normalize the curve of growth profile to be 1 at the largest input ``radii`` value. If the curve of growth is normalized, the encircled energy at a given radius is simply the value of the curve of growth at that radius. The `~photutils.profiles.CurveOfGrowth` class provides two convenience methods to calculate the encircled energy at a given radius (or radii) and the radius corresponding to the given encircled energy (or energies). These methods are :meth:`~photutils.profiles.CurveOfGrowth.calc_ee_at_radius` and :meth:`~photutils.profiles.CurveOfGrowth.calc_radius_at_ee`, respectively. They are implemented as interpolation functions using the calculated curve-of-growth profile. The performance of these methods is dependent on the quality of the curve-of-growth profile (e.g., it's generally better to have a curve-of-growth profile with more radial bins): .. doctest-requires:: scipy >>> cog.normalize(method='max') >>> ee_vals = cog.calc_ee_at_radius([5, 10, 15]) # doctest: +FLOAT_CMP >>> ee_vals array([0.41923785, 0.87160376, 0.96902919]) .. doctest-requires:: scipy >>> cog.calc_radius_at_ee(ee_vals) # doctest: +FLOAT_CMP array([ 5., 10., 15.]) Reference/API ------------- .. automodapi:: photutils.profiles :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf.rst0000644000175100001770000006355614637570305015537 0ustar00runnerdocker.. _psf-photometry: PSF Photometry (`photutils.psf`) ================================ The `photutils.psf` subpackage contains tools for model-fitting photometry, often called "PSF photometry". .. _psf-terminology: Terminology ----------- Different astronomy subfields use the terms "PSF", "PRF", or related terms somewhat differently, especially when colloquial usage is taken into account. This package aims to be at the very least internally consistent, following the definitions described here. We take the Point Spread Function (PSF), or instrumental Point Spread Function (iPSF) to be the infinite-resolution and infinite-signal-to-noise flux distribution from a point source on the detector, after passing through optics, dust, atmosphere, etc. By contrast, the function describing the responsivity variations across individual *pixels* is the Pixel Response Function (sometimes called "PRF", but that acronym is not used here for reasons that will soon be apparent). The convolution of the PSF and pixel response function, when discretized onto the detector (i.e., a rectilinear CCD grid), is the effective PSF (ePSF) or Point Response Function (PRF) (this latter terminology is the definition used by `Spitzer `_). In many cases the PSF/ePSF/PRF distinction is unimportant, and the ePSF/PRF are simply called the "PSF", but the distinction can be critical when dealing carefully with undersampled data or detectors with significant intra-pixel sensitivity variations. For a more detailed description of this formalism, see `Anderson & King 2000 `_. All this said, in colloquial usage "PSF photometry" sometimes refers to the more general task of model-fitting photometry (with the effects of the PSF either implicitly or explicitly included in the models), regardless of exactly what kind of model is actually being fit. For brevity (e.g., ``photutils.psf``), we use "PSF photometry" in this way, as a shorthand for the general approach. PSF Photometry -------------- Photutils provides a modular set of tools to perform PSF photometry for different science cases. The tools are implemented as classes that perform various subtasks of PSF photometry. High-level classes are also provided to connect these pieces together. The two main PSF-photometry classes are `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. `~photutils.psf.PSFPhotometry` provides the framework for a flexible PSF photometry workflow that can find sources in an image, optionally group overlapping sources, fit the PSF model to the sources, and subtract the fit PSF models from the image. `~photutils.psf.IterativePSFPhotometry` is an iterative version of `~photutils.psf.PSFPhotometry` where after the fit sources are subtracted, the process repeats until no additional sources are detected or a maximum number of iterations has been reached. When used with the `~photutils.detection.DAOStarFinder`, `~photutils.psf.IterativePSFPhotometry` is essentially an implementation of the DAOPHOT algorithm described by Stetson in his `seminal paper `_ for crowded-field stellar photometry. The star-finding step is controlled by the ``finder`` keyword, where one inputs a callable function or class instance. Typically, this would be one of the star-detection classes implemented in the `photutils.detection` subpackage, e.g., `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, or `~photutils.detection.StarFinder`. After finding sources, one can optionally apply a clustering algorithm to group overlapping sources using the ``grouper`` keyword. Usually, groups are formed by a distance criterion, which is the case of the grouping algorithm proposed by Stetson. Stars that grouped are fit simultaneously. The reason behind the construction of groups and not fitting all stars simultaneously is illustrated as follows: imagine that one would like to fit 300 stars and the model for each star has three parameters to be fitted. If one constructs a single model to fit the 300 stars simultaneously, then the optimization algorithm will have to search for the solution in a 900-dimensional space, which is computationally expensive and error-prone. Having smaller groups of stars effectively reduces the dimension of the parameter space, which facilitates the optimization process. For more details see :ref:`psf-grouping`. The local background around each source can optionally be subtracted using the ``localbkg_estimator`` keyword. This keyword accepts a `~photutils.background.LocalBackground` instance that estimates the local statistics in a circular annulus aperture centered on each source. The size of the annulus and the statistic function can be configured in `~photutils.background.LocalBackground`. The next step is to fit the sources and/or groups. This task is performed using an astropy fitter, for example `~astropy.modeling.fitting.LevMarLSQFitter`, input via the ``fitter`` keyword. The shape of the region to be fitted can be configured using the ``fit_shape`` parameter. In general, ``fit_shape`` should be set to a small size (e.g., (5, 5)) that covers the central star region with the highest flux signal-to-noise. The initial positions are derived from the ``finder`` algorithm. The initial flux values for the fit are derived from measuring the flux in a circular aperture with radius ``aperture_radius``. The initial positions and fluxes can be alternatively input in a table via the ``init_params`` keyword when calling the class. After sources are fitted, a model image of the fit sources or a residual image can be generated using the :meth:`~photutils.psf.PSFPhotometry.make_model_image` and :meth:`~photutils.psf.PSFPhotometry.make_residual_image` methods, respectively. For `~photutils.psf.IterativePSFPhotometry`, the above steps can be repeated until no additional sources are detected (or until a maximum number of iterations). The `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes provide the structure in which the PSF-fitting steps described above are performed, but all the stages can be turned on or off or replaced with different implementations as the user desires. This makes the tools very flexible. One can also bypass several of the steps by directly inputting to ``init_params`` an astropy table containing the initial parameters for the source centers, fluxes, group identifiers, and local backgrounds. This is also useful if one is interested in fitting only one or a few sources in an image. Example Usage ------------- Let's start with a simple example using simulated stars whose PSF is assumed to be Gaussian. We'll create a synthetic image using tools provided by the :ref:`photutils.datasets ` module: .. doctest-requires:: scipy >>> import numpy as np >>> from photutils.datasets import make_noise_image >>> from photutils.psf import IntegratedGaussianPRF, make_psf_model_image >>> psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) >>> psf_shape = (9, 9) >>> n_sources = 10 >>> shape = (101, 101) >>> data, true_params = make_psf_model_image(shape, psf_model, n_sources, ... model_shape=psf_shape, ... flux=(500, 700), ... min_separation=10, seed=0) >>> noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) >>> data += noise >>> error = np.abs(noise) Let's plot the image: .. plot:: import matplotlib.pyplot as plt from photutils.datasets import make_noise_image from photutils.psf import IntegratedGaussianPRF, make_psf_model_image psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise plt.imshow(data, origin='lower') plt.title('Simulated Data') plt.colorbar() Fitting multiple stars ^^^^^^^^^^^^^^^^^^^^^^ Now let's use `~photutils.psf.PSFPhotometry` to perform PSF photometry on the stars in this image. Note that the input image must be background-subtracted prior to using the photometry classes. See :ref:`background` for tools to subtract a global background from an image. This is not needed for our synthetic image because it does not include background. We'll use the `~photutils.detection.DAOStarFinder` class for source detection. We'll estimate the initial fluxes of each source using a circular aperture with a radius 4 pixels. The central 5x5 pixel region of each star will be fit using an `~photutils.psf.IntegratedGaussianPRF` PSF model. First, let's create an instance of the `~photutils.psf.PSFPhotometry` class: .. doctest-requires:: scipy >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import PSFPhotometry >>> psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) >>> fit_shape = (5, 5) >>> finder = DAOStarFinder(6.0, 2.0) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... aperture_radius=4) To perform the PSF fitting, we then call the class instance on the data array, and optionally an error and mask array. A `~astropy.nddata.NDData` object holding the data, error, and mask arrays can also be input into the ``data`` parameter. Note that all non-finite (e.g., NaN or inf) data values are automatically masked. Here we input the data and error arrays: .. doctest-requires:: scipy >>> phot = psfphot(data, error=error) A table of initial PSF model parameter values can also be input when calling the class instance. An example of that is shown later. Equivalently, one can input an `~astropy.nddata.NDData` object with any uncertainty object that can be converted to standard-deviation errors: .. doctest-skip:: >>> from astropy.nddata import NDData, StdDevUncertainty >>> uncertainty = StdDevUncertainty(error) >>> nddata = NDData(data, uncertainty=uncertainty) >>> phot2 = psfphot(nddata) The result is an astropy `~astropy.table.Table` with columns for the source and group identification numbers, the x, y, and flux initial, fit, and error values, local background, number of unmasked pixels fit, the group size, quality-of-fit metrics, and flags. See the `~photutils.psf.PSFPhotometry` documentation for descriptions of the output columns. The full table cannot be shown here as it has many columns, but let's print the source ID along with the fit x, y, and flux values: .. doctest-requires:: scipy >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_fit y_fit flux_fit --- ------- ------- -------- 1 54.5658 7.7644 514.0129 2 29.0865 25.6111 536.5818 3 79.6281 28.7487 618.7551 4 63.2340 48.6408 563.3426 5 88.8848 54.1202 619.8874 6 79.8763 61.1380 648.1679 7 90.9606 72.0861 601.8609 8 7.8038 78.5734 635.6392 9 5.5350 89.8870 539.6850 10 71.8414 90.5842 692.3331 Let's create the residual image: .. doctest-requires:: scipy >>> resid = psfphot.make_residual_image(data, (9, 9)) and plot it: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import (IntegratedGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) resid = psfphot.make_residual_image(data, (9, 9)) fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15, 5)) norm = simple_norm(data, 'sqrt', percent=99) ax[0].imshow(data, origin='lower', norm=norm) ax[1].imshow(data - resid, origin='lower', norm=norm) im = ax[2].imshow(resid, origin='lower') ax[0].set_title('Data') ax[1].set_title('Model') ax[2].set_title('Residual Image') plt.tight_layout() The residual image looks like noise, indicating good fits to the sources. Further details about the PSF fitting can be obtained from attributes on the `~photutils.psf.PSFPhotometry` instance. For example, the results from the ``finder`` instance called during PSF fitting can be accessed using the ``finder_results`` attribute (the ``finder`` returns an astropy table): .. doctest-requires:: scipy >>> psfphot.finder_results['xcentroid'].info.format = '.4f' # optional format >>> psfphot.finder_results['ycentroid'].info.format = '.4f' # optional format >>> psfphot.finder_results['sharpness'].info.format = '.4f' # optional format >>> psfphot.finder_results['peak'].info.format = '.4f' >>> psfphot.finder_results['flux'].info.format = '.4f' >>> psfphot.finder_results['mag'].info.format = '.4f' >>> print(psfphot.finder_results) # doctest: +FLOAT_CMP id xcentroid ycentroid sharpness ... sky peak flux mag --- --------- --------- --------- ... --- ------- ------ ------- 1 54.5301 7.7460 0.6002 ... 0.0 53.4082 6.9430 -2.1039 2 29.0927 25.5994 0.5950 ... 0.0 56.9892 7.5179 -2.1902 3 79.6186 28.7516 0.5953 ... 0.0 65.4845 8.5872 -2.3346 4 63.2485 48.6135 0.5797 ... 0.0 58.1835 7.6933 -2.2153 5 88.8820 54.1311 0.5943 ... 0.0 68.9214 9.3947 -2.4322 6 79.8728 61.1207 0.6212 ... 0.0 73.8172 9.7648 -2.4742 7 90.9621 72.0803 0.6163 ... 0.0 68.1552 9.1005 -2.3977 8 7.7962 78.5467 0.5975 ... 0.0 65.9807 8.4028 -2.3111 9 5.5854 89.8663 0.5737 ... 0.0 54.1899 7.0039 -2.1134 10 71.8303 90.5626 0.6034 ... 0.0 73.3127 9.5152 -2.4460 The ``fit_results`` attribute contains a dictionary with detailed information returned from the ``fitter`` for each source: .. doctest-requires:: scipy >>> psfphot.fit_results.keys() dict_keys(['fit_infos', 'fit_error_indices']) The ``fit_error_indices`` key contains the indices of sources for which the fit reported warnings or errors. As an example, let's print the covariance matrix of the fit parameters for the first source (note that not all astropy fitters will return a covariance matrix): .. doctest-skip:: >>> psfphot.fit_results['fit_infos'][0]['param_cov'] # doctest: +FLOAT_CMP array([[ 7.27034774e-01, 8.86845334e-04, 3.98593038e-03], [ 8.86845334e-04, 2.92871525e-06, -6.36805464e-07], [ 3.98593038e-03, -6.36805464e-07, 4.29520185e-05]]) Fitting a single source ^^^^^^^^^^^^^^^^^^^^^^^ In some cases, one may want to fit only a single source (or few sources) in an image. We can do that by defining a table of the sources that we want to fit. For this example, let's fit the single star at ``(x, y) = (42, 36)``. We first define a table with this position and then pass that table into the ``init_params`` keyword when calling the PSF photometry class on the data: .. doctest-requires:: scipy >>> from astropy.table import QTable >>> init_params = QTable() >>> init_params['x'] = [63] >>> init_params['y'] = [49] >>> phot = psfphot(data, error=error, init_params=init_params) The PSF photometry class allows for flexible input column names using a heuristic to identify the x, y, and flux columns. See `~photutils.psf.PSFPhotometry` for more details. The output table contains only the fit results for the input source: .. doctest-requires:: scipy >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_fit y_fit flux_fit --- ------- ------- -------- 1 63.2340 48.6408 563.3426 Finally, let's show the residual image. The red circular aperture shows the location of the star that was fit and subtracted. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.table import QTable from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import (IntegratedGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] phot = psfphot(data, error=error, init_params=init_params) resid = psfphot.make_residual_image(data, (9, 9)) aper = CircularAperture(zip(phot['x_fit'], phot['y_fit']), r=4) fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15, 5)) norm = simple_norm(data, 'sqrt', percent=99) ax[0].imshow(data, origin='lower', norm=norm) ax[1].imshow(data - resid, origin='lower', norm=norm) im = ax[2].imshow(resid, origin='lower') ax[0].set_title('Data') aper.plot(ax=ax[0], color='red') ax[1].set_title('Model') aper.plot(ax=ax[1], color='red') ax[2].set_title('Residual Image') aper.plot(ax=ax[2], color='red') plt.tight_layout() Forced Photometry ^^^^^^^^^^^^^^^^^ In general, the three parameters fit for each source are the x and y positions and the flux. However, the astropy modeling and fitting framework allows any of these parameters to be fixed during the fitting. Let's say you want to fix the (x, y) position for each source. You can do that by setting the ``fixed`` attribute on the model parameters: .. doctest-requires:: scipy >>> psf_model2 = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) >>> psf_model2.x_0.fixed = True >>> psf_model2.y_0.fixed = True >>> psf_model2.fixed {'flux': False, 'x_0': True, 'y_0': True, 'sigma': True} Now when the model is fit, the flux will be varied but, the (x, y) position will be fixed at its initial position for every source. Let's just fit a single source (defined in ``init_params``): .. doctest-requires:: scipy >>> psfphot = PSFPhotometry(psf_model2, fit_shape, finder=finder, ... aperture_radius=4) >>> phot = psfphot(data, error=error, init_params=init_params) The output table shows that the (x, y) position was unchanged, with the fit values being identical to the initial values. However, the flux was fit: .. doctest-requires:: scipy >>> phot['flux_init'].info.format = '.4f' # optional format >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_init', 'y_init', 'flux_init', 'x_fit', ... 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_init y_init flux_init x_fit y_fit flux_fit --- ------ ------ --------- ----- ----- -------- 1 63 49 556.4444 63.0 49.0 500.4789 Source Grouping ^^^^^^^^^^^^^^^ Source grouping is an optional feature. To turn it on, create a `~photutils.psf.SourceGrouper` instance and input it via the ``grouper`` keyword. Here we'll group stars that are within 20 pixels of each other: .. doctest-requires:: scipy, sklearn >>> from photutils.psf import SourceGrouper >>> grouper = SourceGrouper(min_separation=20) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... grouper=grouper, aperture_radius=4) >>> phot = psfphot(data, error=error) The ``group_id`` column shows that six groups were identified (each with two stars). The stars in each group were simultaneously fit. .. doctest-requires:: scipy, sklearn >>> print(phot[('id', 'group_id', 'group_size')]) id group_id group_size --- -------- ---------- 1 1 1 2 2 1 3 3 1 4 4 1 5 5 3 6 5 3 7 5 3 8 6 2 9 6 2 10 7 1 Care should be taken in defining the star groups. As noted above, simultaneously fitting very large star groups is computationally expensive and error-prone. A warning will be raised if the number of sources in a group exceeds 25. Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To subtract a local background from each source, define a `~photutils.background.LocalBackground` instance and input it via the ``localbkg_estimator`` keyword. Here we'll use an annulus with an inner and outer radius of 5 and 10 pixels, respectively, with the `~photutils.background.MMMBackground` statistic (with its default sigma clipping): .. doctest-requires:: scipy, sklearn >>> from photutils.background import LocalBackground, MMMBackground >>> bkgstat = MMMBackground() >>> localbkg_estimator = LocalBackground(5, 10, bkgstat) >>> finder = DAOStarFinder(10.0, 2.0) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... grouper=grouper, aperture_radius=4, ... localbkg_estimator=localbkg_estimator) >>> phot = psfphot(data, error=error) The local background values are output in the table: .. doctest-requires:: scipy, sklearn >>> phot['local_bkg'].info.format = '.4f' # optional format >>> print(phot[('id', 'local_bkg')]) # doctest: +FLOAT_CMP id local_bkg --- --------- 1 -0.0840 2 0.1784 3 0.2593 4 -0.0574 5 0.2934 6 -0.0826 7 -0.1130 8 -0.2138 9 0.0089 10 0.3926 The local background values can also be input directly using the ``init_params`` keyword. Iterative PSF Photometry ^^^^^^^^^^^^^^^^^^^^^^^^ Now let's use the `~photutils.psf.IterativePSFPhotometry` class to iteratively fit the stars in the image. This class is useful for crowded fields where faint stars are very close to bright stars. The faint stars may not be detected until after the bright stars are subtracted. For this simple example, let's input a table of three stars for the first fit iteration. Subsequent iterations will use the ``finder`` to find additional stars: .. doctest-requires:: scipy >>> from photutils.background import LocalBackground, MMMBackground >>> from photutils.psf import IterativePSFPhotometry >>> fit_shape = (5, 5) >>> finder = DAOStarFinder(10.0, 2.0) >>> bkgstat = MMMBackground() >>> localbkg_estimator = LocalBackground(5, 10, bkgstat) >>> init_params = QTable() >>> init_params['x'] = [54, 29, 80] >>> init_params['y'] = [8, 26, 29] >>> psfphot2 = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, ... localbkg_estimator=localbkg_estimator, ... aperture_radius=4) >>> phot = psfphot2(data, error=error, init_params=init_params) The table output from `~photutils.psf.IterativePSFPhotometry` contains a column called ``iter_detected`` that returns the fit iteration in which the source was detected: .. doctest-requires:: scipy >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'iter_detected', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id iter_detected x_fit y_fit flux_fit --- ------------- ------- ------- -------- 1 1 54.5665 7.7641 514.2679 2 1 29.0883 25.6092 534.0753 3 1 79.6273 28.7479 613.0246 4 2 63.2340 48.6415 564.1535 5 2 88.8857 54.1203 614.6949 6 2 79.8765 61.1358 649.9802 7 2 90.9631 72.0881 603.7519 8 2 7.8202 78.5821 641.7541 9 2 5.5350 89.8869 539.5465 10 2 71.8485 90.5830 687.4396 References ---------- `Spitzer PSF vs. PRF `_ `The Kepler Pixel Response Function `_ `Stetson (1987 PASP 99, 191) `_ `Anderson and King (2000 PASP 112, 1360) `_ Reference/API ------------- .. automodapi:: photutils.psf :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_matching.rst0000644000175100001770000002204314637570305017373 0ustar00runnerdocker.. _psf_matching: PSF Matching (`photutils.psf.matching`) ======================================= Introduction ------------ This subpackage contains tools to generate kernels for matching point spread functions (PSFs). Matching PSFs ------------- Photutils provides a function called :func:`~photutils.psf.matching.create_matching_kernel` that generates a matching kernel between two PSFs using the ratio of Fourier transforms (see e.g., `Gordon et al. 2008`_; `Aniano et al. 2011`_). For this first simple example, let's assume our source and target PSFs are noiseless 2D Gaussians. The "high-resolution" PSF will be a Gaussian with :math:`\sigma=3`. The "low-resolution" PSF will be a Gaussian with :math:`\sigma=5`:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> y, x = np.mgrid[0:51, 0:51] >>> gm1 = Gaussian2D(100, 25, 25, 3, 3) >>> gm2 = Gaussian2D(100, 25, 25, 5, 5) >>> g1 = gm1(x, y) >>> g2 = gm2(x, y) >>> g1 /= g1.sum() >>> g2 /= g2.sum() For these 2D Gaussians, the matching kernel should be a 2D Gaussian with :math:`\sigma=4` (``sqrt(5**2 - 3**2)``). Let's create the matching kernel using a Fourier ratio method. Note that the input source and target PSFs must have the same shape and pixel scale:: >>> from photutils.psf import create_matching_kernel >>> kernel = create_matching_kernel(g1, g2) Let's plot the result: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf import create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() kernel = create_matching_kernel(g1, g2) plt.imshow(kernel, cmap='Greys_r', origin='lower') plt.colorbar() We quickly observe that the result is not as expected. This is because of high-frequency noise in the Fourier transforms (even though these are noiseless PSFs, there is floating-point noise in the ratios). Using the Fourier ratio method, one must filter the high-frequency noise from the Fourier ratios. This is performed by inputting a `window function `_, which may be a function or a callable object. In general, the user will need to exercise some care when defining a window function. For more information, please see `Aniano et al. 2011`_. Photutils provides the following window classes: * `~photutils.psf.matching.HanningWindow` * `~photutils.psf.matching.TukeyWindow` * `~photutils.psf.matching.CosineBellWindow` * `~photutils.psf.matching.SplitCosineBellWindow` * `~photutils.psf.matching.TopHatWindow` Here are plots of 1D cuts across the center of the 2D window functions: .. plot:: import matplotlib.pyplot as plt from photutils.psf import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) w1 = HanningWindow() w2 = TukeyWindow(alpha=0.5) w3 = CosineBellWindow(alpha=0.5) w4 = SplitCosineBellWindow(alpha=0.4, beta=0.3) w5 = TopHatWindow(beta=0.4) shape = (101, 101) y0 = (shape[0] - 1) // 2 plt.figure() plt.subplots_adjust(left=0.1, bottom=0.1, right=0.95, top=0.93, wspace=0.5, hspace=0.5) plt.subplot(2, 3, 1) plt.plot(w1(shape)[y0, :]) plt.title('Hanning') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 2) plt.plot(w2(shape)[y0, :]) plt.title('Tukey') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 3) plt.plot(w3(shape)[y0, :]) plt.title('Cosine Bell') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 4) plt.plot(w4(shape)[y0, :]) plt.title('Split Cosine Bell') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 5) plt.plot(w5(shape)[y0, :], label='Top Hat') plt.title('Top Hat') plt.xlabel('x') plt.ylim((0, 1.1)) However, the user may input any function or callable object to generate a custom window function. In this example, because these are noiseless PSFs, we will use a `~photutils.psf.matching.TopHatWindow` object as the low-pass filter:: >>> from photutils.psf import TopHatWindow >>> window = TopHatWindow(0.35) >>> kernel = create_matching_kernel(g1, g2, window=window) Note that the output matching kernel from :func:`~photutils.psf.matching.create_matching_kernel` is always normalized such that the kernel array sums to 1:: >>> print(kernel.sum()) # doctest: +FLOAT_CMP 1.0 Let's display the new matching kernel: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf import TopHatWindow, create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() window = TopHatWindow(0.35) kernel = create_matching_kernel(g1, g2, window=window) plt.imshow(kernel, cmap='Greys_r', origin='lower') plt.colorbar() As desired, the result is indeed a 2D Gaussian with a :math:`\sigma=4`. Here we will show 1D cuts across the center of the kernel images: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf import TopHatWindow, create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) gm3 = Gaussian2D(100, 25, 25, 4, 4) g1 = gm1(x, y) g2 = gm2(x, y) g3 = gm3(x, y) g1 /= g1.sum() g2 /= g2.sum() g3 /= g3.sum() window = TopHatWindow(0.35) kernel = create_matching_kernel(g1, g2, window=window) kernel /= kernel.sum() plt.plot(kernel[25, :], label='Matching kernel') plt.plot(g3[25, :], label='$\\sigma=4$ Gaussian') plt.xlabel('x') plt.ylabel('Flux') plt.legend() plt.ylim((0.0, 0.011)) Matching IRAC PSFs ------------------ For this example, let's generate a matching kernel to go from the Spitzer/IRAC channel 1 (3.6 microns) PSF to the channel 4 (8.0 microns) PSF. We load the PSFs using the :func:`~photutils.datasets.load_irac_psf` convenience function:: >>> from photutils.datasets import load_irac_psf >>> ch1_hdu = load_irac_psf(channel=1) # doctest: +REMOTE_DATA >>> ch4_hdu = load_irac_psf(channel=4) # doctest: +REMOTE_DATA >>> ch1 = ch1_hdu.data # doctest: +REMOTE_DATA >>> ch4 = ch4_hdu.data # doctest: +REMOTE_DATA Let's display the images: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import LogStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import load_irac_psf ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1 = ch1_hdu.data ch4 = ch4_hdu.data norm = ImageNormalize(stretch=LogStretch()) plt.figure(figsize=(9, 4)) plt.subplot(1, 2, 1) plt.imshow(ch1, norm=norm, cmap='viridis', origin='lower') plt.title('IRAC channel 1 PSF') plt.subplot(1, 2, 2) plt.imshow(ch4, norm=norm, cmap='viridis', origin='lower') plt.title('IRAC channel 4 PSF') For this example, we will use the :class:`~photutils.psf.matching.CosineBellWindow` for the low-pass window. Note that these Spitzer/IRAC channel 1 and 4 PSFs have the same shape and pixel scale. If that is not the case, one can use the :func:`~photutils.psf.matching.resize_psf` convenience function to resize a PSF image. Typically, one would interpolate the lower-resolution PSF to the same size as the higher-resolution PSF. .. doctest-skip:: >>> from photutils.psf import CosineBellWindow, create_matching_kernel >>> window = CosineBellWindow(alpha=0.35) >>> kernel = create_matching_kernel(ch1, ch4, window=window) Let's display the matching kernel result: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import LogStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import load_irac_psf from photutils.psf import CosineBellWindow, create_matching_kernel ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1 = ch1_hdu.data ch4 = ch4_hdu.data norm = ImageNormalize(stretch=LogStretch()) window = CosineBellWindow(alpha=0.35) kernel = create_matching_kernel(ch1, ch4, window=window) plt.imshow(kernel, norm=norm, cmap='viridis', origin='lower') plt.colorbar() plt.title('Matching kernel') The Spitzer/IRAC channel 1 image could then be convolved with this matching kernel to produce an image with the same resolution as the channel-4 image. Reference/API ------------- .. automodapi:: photutils.psf.matching :no-heading: :inherited-members: .. _Gordon et al. 2008: https://ui.adsabs.harvard.edu/abs/2008ApJ...682..336G/abstract .. _Aniano et al. 2011: https://ui.adsabs.harvard.edu/abs/2011PASP..123.1218A/abstract ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.006885 photutils-1.13.0/docs/psf_spec/0000755000175100001770000000000014637570322015777 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/background_estimator.rst0000644000175100001770000000415014637570305022740 0ustar00runnerdockerBackgroundEstimator =================== EJT: Existing code documented at https://photutils.readthedocs.io/en/stable/api/photutils.background.Back groundBase.html - while the ``__call__`` function has no docstring, the ``calc_background`` function is the actual block API. I'm providing this as an *example* block because it is heavily used in other parts of `photutils` and therefore probably should not be changed much unless absolutely necessary. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- data : array_like or `~numpy.ma.MaskedArray` The array for which to calculate the background value. axis : int or `None`, optional The array axis along which the background is calculated. If `None`, then the entire array is used. Returns ------- result : float or `~numpy.ma.MaskedArray` The calculated background value. If ``axis`` is `None` then a scalar will be returned, otherwise a `~numpy.ma.MaskedArray` will be returned. Methods ------- This block requires no methods beyond ``__call__()``. Example Usage ------------- A variety of implementations of this block already exist in ``photutils``. A canonical example is the mode estimation algorithm ``3 * median - 2 * mean``. This can be done on an array called ``image_data`` by using the block like so:: from photutils.background import ModeEstimatorBackground bkg_estimator = ModeEstimatorBackground() bkg_value = bkg_estimator(image_data) The median/mean parameter values can be adjusted as keyword arguments to the estimator object if desired:: tweaked_bkg_estimator = ModeEstimatorBackground(median_factor=3.2, mean_factor=1.8) new_bkg_value = tweaked_bkg_estimator(image_data) The estimator will also accept a sigma clipping object that automatically does sigma clipping before the background is subtracted, like so:: from astropy.stats import SigmaClip clipped_bkg_estimator = ModeEstimatorBackground(sigma_clip=SigmaClip(sigma=3.)) clipped_bkg_value = clipped_bkg_estimator(image_data) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/block_diagram.png0000644000175100001770000163221714637570305021300 0ustar00runnerdocker‰PNG  IHDR°ÔŌgAMAą üa cHRMz&€„ú€ču0ę`:˜pœēQ< pHYs  šœYiTXtXML:com.adobe.xmp 1 LÂ'Y@IDATxtŊbŪ8˛k;I÷y˙'žÎÅZ@Irīš´%‘U(Ô)JŸã$ŋūúũ˙ūü‡ö럜čūöúëWFœūųõŸ?ūüįׯÂĸü´Čb“ܟđ ’J.‘ũúĪ?Ēø ˇtŒĢŖtPđũk–đ ųo”áA‚^)•Ô^iaŧØ7ĮĸŅÅ>ú_ä÷áĒij[ĐŠAéuAFŋ‘L/dāLĢ ë*Ŋčͧ”WSk, €­7F˛§ļAüųõÍ ãē V4âũEßü‚1úhŊ!ހrųM|ņ˙ƒØ ú˛N×F\ā%’âwō+–´ôķ-ĪD” Ä‚ œÂõįŋâ›BĀ€ŌĖ! Ŧ­2ÉĻz”ʉ‹Ö<b%ŗđŠĢ9ŪúÁ–Öߏcē´Ųmal"ë:Ē”›ƒ\ŗ!Bj„ĘõÍ5­áˇxAÁĻ• ÛŠsk†œž-DÎuÖĩŒ%a€wYãÖƟ?ܗeū@m~‰;ūÍë?˙ cîPS-–ĩkƒ‹$kbĖÕTūŸũâŸq% •M>s"wé˜÷´?Ã˛`4Ū ˜NH4aEąÆŅfLžųĸ˙ÖŊ¨_‰ãĪīæįzJ°bS˜x—°~ ŨÖSÆŠDm´#VZôŸÜ+qjčBī|xë‚8‰Lâū‚æáŨG‚ĩ<ÁËeÆ1ŒPü2eH ˆkx‰ĩë„Xr´&Œ……ƒė1.c:kJƒ5ę¸؛¨ ŌˆácÔyF#¸Öž>qC:˙ō“ë-ÄäÄAiŨ/‰čĸjŋ{{dámk1!Ã|0ĨãģWĄ/oÎôy枍*"î /ô>ëĀŲ‡ž˜ũ•>÷ŦV͌˙’ JŨ:ÔŽAۜD\P:ą+×ßr0î-žĖ÷Öfį˙šK¤bė‰ >ˆ…ø˙›Ž‚¯Ąk'ņjÜ$øÎŸu_Î7ôđÎkI}ZGį‹Ų¸r”ĢkāĶOn­x0ÆW+ÎFk]ęGaŋš\}W0÷>ō„a 0HØzfØš#žã; Ģ>ŧŅ´Ĩ×īÔ vß>Hæ+vÚô“Žs3[bNŸēԝ‚ sĩĻkÎĩōŸ>KÛ‹ĪWāŊ[qŅgj×AcĻ>ÄT_ŽiĖãƒL]v–”uƒƒSGÚu ›l†û¨F˜Ãgvä›qęV*JŨš_ČąUxįáæ¤>ĸ‡â3ŋ%ˆÕŨšÂՊyĪāųŠīã{Ū§ã‡-à R6œ?M¤AHšĶ›”Ų‰5ič˙GMbĩsLä|ĢĻ€ô3°W5˛>°ĸë 1Ҏ…5}­`ĖwSĸëôŧ¯Fƒ/Žœąõ&r”~ÆyĢ1?ā'E4VMrŠŠČAÜú8ūĩ‹7ââV~b@2‡FŨg "FJ(ų’ß“‡ xÁĸīŲžLķh‡ žfé4ÖNëÅŲŧŽ!ÁÕ.6ÖŪM!X\Ņ4†ôŦyÔh\?ōú°' Låį,Rš’#)ĩUÃMæōx”%„G I’ą*ŧ$wõ‹Ũ~}“M“ᤠwjLå/ulnÍĖÚn3ũ1gÆĨÜãČúōeĀ8æ„åô€‹G͇oėâëØxÄu\"A‹ëŧ´–Ė•Īč6mÁ5ZÂ{í+Ģŋčđۋ¨cĸŦRՃs„MŗĀŒēŸ›ēÔ§‹0:4r:ųãP#ɖ¯‚›Od[–ķÅ{…qåūÔ#÷/msE.ØøđåĢ.ØČüŌcˇEebëōå”Įį§ĩRāËØ8 ` üāÂYÅvŧvá¤.ÃSs49ũb¯2_ĸĨåœ1×įŪgxmë°ĩÂ0x_piÜëa3úÅÃuzmÜĸqėÁ)ĩͅ¨Š˛Uxæ90b6/°C Y:Ŧqë=Lú¨âÉ/ޚ*-—Æp`Į•nŧŪË?2_ŽÕŒ7Á ~t‘•?~ŖßҐ'×IVr†—'ļ€Í'đ‘YzĘZ4OQÁū]­Ąë ŊŲ´đ­uŗ”uØĒøœ€§O!ƒ‹úšpĘ>ž+Ÿķúđ'֟Mʉę)ö}\™¤É‹НqĄđøcŠ/{>CŌ×ÁīXfĨˇH‘Á]U?|`ÞūgŒėƒxƆ´ķšū‚ĮÚC ,čŦŌ‡ÄÛÜ_õ¤Ĩī…ëéŪKuō‡ûbôÆq+ĄkIĨAQŗč<”bu¯å̏Q’qzÎaäŠAŸpÍįnŊ1ėũ5|+’A֑ķˇ5à 7xãԘ(8đĖūM™3v¨Ēä_Ąž8](ąY ÖL䝗hŪ—¸iŌĖ+]íMf}ĩœˆ—|á9›*Ÿü´F×}~dŊDzk zŖČŽō!ĖĩcmĸR¤ë|kĄĩâÚ§ũ ŋûTČæ ņcö6jmE""ûˇūۏȝssˆۚÆøĻÁĮŅĖ8ŪW|^DMĖbļÖy†8ģr™×;u%ĀËĮaœœ0, -8Ã'zÂņ¸ ™Ų'X7ΨčQ’ĸ87É"¯%×܆ô āį°ØôÃeaË w{œYŽprpŪÍĘ8ßbĢbđ4ä|…÷Á‰"s3xīÛÜāo|‚Â×htOĖíä\^%@įy¯5Ą¨ž•n}ĀüϘĒ2⚡ÚRøËWúĩ=fF´,‡M•z„ĐRlžĩŊųŒ|˜m5ČÂĸõ†­_ ą‰QƄŊČcXü$æĻëc˜ŧŦŊ…⩟čnaĮˆ/€OMāh3ˇ \ØÖņ0øæÆ!Zģ‹Oâœp:Įoø‘Ŋv]c‹3Ö_ŖĮ ŧ1ĪŽ78˜Ę];°,ž~ÅŌyiœägúØCq*ėÍųŧždãiīøõųj´vČéä‘ dÜüĸ[6铅MÉņæ_ œr}Æč1ēĩ ´DtÂ{­[čRŖÕũ°]Tß9äI/ ^XÚĐOqõtLLķb‹ūî3‚ēšcŲöe,¸ŧŨũ‡ŒmĻĮ.’‹ī h‘4Šœņ#Å卜5šÖ¨14NühqJlĮuC ŽRnlį…ü•ĩ"ĐJįz\Šĸ^Ķ]k}î~Õĸ“ˇü“U„FÉũY’ Č8‚šķÕØŽŨ9t@čxRbx˜ûS=l#æ!?§%/u™ķĖ01ä‰0{3 ĸ`Ū=Oˆ\F÷¸ĨŗuR.įéäOŒ]đvįgÔWŗ›ÃÎ?ŅŒņ<ë*6õÔ@æ5Ō[Ķ‹ …÷DČíŧÔ[˜œáãÛ9ø`Œ\ŋā|[m.LT́š‡ķŸJâ‡qÚųͿŠÚΟdކ/žJ!ĻÛ7Ą.˙ƒčŗâŧ ėÚbãk“2ÜÚc^|V Âčō€ĒûŊ6S0ĪŠ+ũ…–0„Ň!QOœŊē>čú‚:Y•Ã&ŪĶYŒ26~H~Ö^˜ģø#Įnd>ŠEžņėQŋŦŲ"1~ č3Ŧ(—2ˆ3ōŠLhuŧo¸?.Š€†ÖŊ+tōawä1Z{Ūûpz†áčOã'X<äŌšAŪĄé;ĪqŸĐ×Āã^,ûÄx^–æu÷Ö BÔēVŦAq÷§˛ŽŽŠ`Šm.˜ŽˆVöî3:bpŋŅĮ/Ą¯n¸æ@†Yįų€öë¯ŋūvš •öa ‚4ø˜$A•°Ŧ€tąg$dP’ŧŸ¤°C#…ØË" ƒX^0)x8á*Sôđ|. 9ҧ=>pã΋…ÔL9ځ¨ŋģVY8ģáb˙PŲđ]^}(Ÿ2ūŸ  IRP{/Žū>]ĩ™ķŲĸÎMĒŋœLMôi˃HÛS…ŽĄā/€b o„ %ŗS%H7ÆxbŖû¯ĘĀõXėlW/zČ%ø6˜Î#bÖ š—•`Õ,›ÚØđkčÔ¯gN +õāĻĖĒQžqŠĪšUŲ5QļžąmĜ#ã…ŦŨŅ—ĨžĀŠŅjĘĀ|c26°BĨWãŒ0F˜ë§>FnĐ[wŪØ5†ŲqAøzxúŊ˙:ü'ķęÅž6ŽS•{Ē›-ƒoeŽĨ IëŪõ°ą—ķ!yr ÷7õņ×MHūÖyúÂđÕ{ ›yÆŠ)_čąh^ô>säkČņÔŦ–ĪUˆōũ6¸`ëõ~zĨ¯põŪ`Ā_LņŅ%üū0 yũ“Ķ‹‹;7fųØ˙ˆpžq’3ē6|Gˇ5/vēF“:ņkT€ū˜ÚHL`DÛ}ņFøÆÁ ×1Į­ã°šø§@ÍųtšCŌØ{?hÕ÷Ô|sÖĩŖ“ƒüPDŋcôĮÛ5ņO°•1Æ?6œŪXZÕáāáaÆõ 8ßsÆT0‡D@÷:đÍŨBÜã¯:ŗˆŧ_@Ās\KDw|_ßy.ōlĒ‘fąˇ ¨d­SŸ§¤L†ôųqÆ2Īņ=C}žúâ  û#Hđ×°ģ[ĩæ#?@Žđd6l}&ā398EÄÚÅöYÚš7ÂČņ×û#ŖŽÁÕ4ĪN~]¯˜ØĻY,á]ŽŨ7(ķƒ5Ã7RĀ~×Ũ×'ÜøįĐpũ\^ƒh™ÕâÜ'˙ďūÛbásŊs‰=Īī›@­Ũãã˙ÚžņĀy•O¤ÉŊžĪ?רC‘n™\…#¯bꂞŦx,MWøí“ĮÃXu.ˆkŽÅÆؘdžš ūܯĪÂ{ŲĨ•ž˛ú„-žSŊ"ye˜b×ũâÖ’CŖëØ8Q˛ˇ?kqælÚīZŽ-ĖícúõHŲß\OÎ<ĻÄ!5ĄöŌ¨øčƒ[ķųČ:¯€Dû'õ{K ęč3DŌÆ{NcŽ<ũĖEg4(ČhÎ#PßFŠ:Æfˆ‹áŦqĀb‘â:í`œG G@]ĮQ;Ut#~4vā;ˆîČī]ĒŠŅũ{F!čbáÜĀāäë‰Ø.ÎreĢ#ëëŗüīBÍÄļc°sKX2U%Æūd¨?=ĖĩęÄļ#Œˆ5?ŗ Bq‰ĪĄĢœ4ŠŽk‚ņ…ÆÛI˙!6î<4y^āįņuôIūŅĨĒø„㸠}ļøa ŌīËÖi{­mW’7dâũã‡(ƒ5ŗ/øķõĮ_V ŦžHJŽËHôXÆÄB~¨ŅŅöōŋ1ŌŽ rŠM'0¨‚æâLīx¤™ēĩš%cëĄ!wN-QMĸ ޜ#4˙“_x(o]š×ÅCö…¸æœŅI{P=MH|3ĪzXĖĶūđũžåh/ø›îÔEäŠ;x *JNõž‘…ŧšÆ ŋ[ ÷k‡Tģœû“$KŦ΀Bî7ģôäâēPÅˎGäΝ‰RoũC‰WâčÜ%{%Įr!:¯Mlz8ÆiË%pāRúÕäœ5ā™›û¯IėëķâÂēG˙~ˆÆ9Ŋúō#iyŠWÆÁ­ūgY>ŗąˆú–f_ŠÕÕsųˇž?īŗŗƒ}Ą­ą’ÄķaEÆDLk}č5{ōVˇDZëĄQJųd×*Ë ˜véĖd3 \Õ)¸_A¤Í^ēÖjúä!f8„‡Ÿ‚‹ĩؚļÜÂ8FÎŦ´ŒũF>c!ņ!É1Gč8'Éj W[õÚ(¨1į†'iûĄ@—Ŗ?”ųŖ@”—8éŪ5ŨWī%\C|ũUšsnÛᇠŠųW{1×ĮˆŽ0fÅržÕSo>ę5Õbx‰6XinŨ !0„p€k'o‚[É/Ÿks‹÷ čôëŸAđÎÕ#ŖqŧēģŸZD¨ |7ã{nŊŧe&xį*}זņØk8%„4yqMô,‚ËĮ­âKv¯EēA]āĘĸęÔC:| röeižš36ŽĨY3‚Ϟۚéė˜Îŗã_×āEWŖh\ĶGØž™Es ÖOŋg˙^ՁĨbäæ .ŽBéwwK‡%fCˆ(˜ËJųĄHe*4ÎũŊÍÃķƒ–žįāģļwŋ¸Øŗ9~ũõ÷˙ŗ$ųą`Rˇ$BÄĀæT€di<ʰ#Ų4Æ_}í‘ū“/’ë„5€ŗĀ(ö’å4Ĩ\¨ōÕ?ÂÂĮų!ÖoË(ßÚPˇ)¯ŽĸM Ū!ī‚ƒ }LųīWp)‹Č?"Ž.ā^GŊņŗPÂŨ~¸ČßÄp vŸúūüW¯ŊĪô¨Wë!›+ņqø—Č"îĪŊá6KÆ"n~ŧB”Nũ:?ä EšIbA?šÖ?løBd‹ô3R—ZšĀ2 Ž|uŨž\öš}uŋŋ÷Į´ˆÛ.ËcįčÉû‚⚡Ŧ,õ“=wcƒ,öGUbË×ũtĢķ…ÍŨ™äđö‹Ŗ–äīæŖcûžÚ"üžoŽÅU†}u]×VHfŸ™ËJ1ÛyJ'-ã89ĢRgD<˜Ē ŦE)^ĸhäũØ%–Æ|s6†G~qq­=đž`­ņr‘7ņĪD,ąāĘǏ°ņØ%˜ëõ̎ĪËeü{oĩũīß|XĀ3ņs1šųŧÜę•Ũũģ¯`ļĐ´į”˜åÄÜļXsaox^g‰ĨE JëîžđØF›Zé&°'^į¸&ĩO믍”ˇžŧKŦ#ø[o\u߸s.Š!-—JœŸšÆÜēSŋĢ îĮ!qq ĒūG—Ë”û‹¯pC{ÖÔ§ëo÷ųbĒ=^‚lnžĪד¤ŠÃ>\ēcԃlôKīŲ›Ėė˜3?MN“—SãeAŠ:§úAM›[Κ ‹ĻŠÔ@Â`×$ŠÎ_ûčŊ}¸\ã̀Ø9ˇÁCėüÅ]î-tÄļÆūÎÚ ;ģ|qįãÕą|ôĖ\ĪHÂëũ䲍¸Ŗģ/aWÛŽãÖŧëk4?ÖūœŸčú, D G-æépī6f„Ė-WâFs¨ål;rĪö%.Ęūū‹Ę +sÉXˆæ$˙ž˙Ėģ5Ž~û—UÉoLĮ=‹ũu ō4’rŌížAļ<#™"oėîÍAđU˛ęˆĶA†Ø×ø"YÁX“H¨€5Ÿ—<īIŖãBÎ`YĮÆĻŦņiį:?˙#ô‚ĪXâvãÎ-sŊ<û`Öĸ"ZīM:E}ZãĖÍ˙Š@Îܯš9 ar-á+Rūâ5yÅĮkߚYkãD >pđa[›\ļ–„ä´ĒGÁŸzqnŨbc øcŽņxņĖÖįMí9˙â_g‚1 Of^X qĪĸ‡ŧū´Š´ú42c)^ZƒåŨˇ ą\÷@ëšũ|`ؐĮs‹Œrö 3Џ—ˉ Qĸr€á_/ڏ#(ņ[ģNA”Ę"§Kü› â:‘hÚŲeaęéŲhĻCH`Čá"Čû›Ú=ŋĶ×$FĪĻ œčąM<ō#¤…$€šĀ‹ŧ5oŇIž vú•sĘ-¸´õWƒ”.]6BĢÉÚĐ?ú´¸Ģoâƒģip<Ōu"-NĀq=<,d8‡ÃU8_Å"gųâ˙6kQÖĨ<ü@ßەϤōÕ!ÜmõšƒQŽ|˯ČÁ ĻwņĸjAb GŽnöŒ9ž9Ôb[—6ƒÔ¯“4]ķ‚ļîáI4Ā\NDdŨáú%둇ōūĘ px‘āž’Q6ę!Ī37Ėe8pESŲnũ Ma]LQސâ*ŨŠ=`ėĮIģ1˛†ŽMųÁá™ëŧėš>Ā”r ĪEmNÍIÖ õ ×nî†sAĐŠG7úØ|Ã;|F OÆŦwC.ŧuÎ-ZâiGúė[đœŖô/đ=8āŦą§Ī IŽ #‚5æžnuĒĩIoØæ茗3ãķu?ÔĀÃŧCÉ) 3ēˇvX!¨˙\k㍛A0]ŗ?˙šsÃĩ'kÆ֟„˙˛5i=ĐŲM|Ö¨b9ë1œˇ†1E†§Ės ۃ Œšœė}Ė4ØĘĀĻi{Ũ›ÆøŠįÎåõÕø'ū0p‡ĒĢ{š%¨­.hmÎ/÷Ē10/åŠöP5ĄhYąĀ‚ŗCŧ­C}RՑäģz´ŒoũvčŗN-‹ÉyLåÁ4j+_Đ÷U$ļĘŧųT^vúiģJƒëĨ 8ēūL÷8ØXįVÁíw°Ŋ­ŅáęōŌln1Ũß8@ppŨÜĄBTšf€_īS+(Zš–;…T‘¸ųKčđéĄébąņĨÖØ˛į}xdĖe2/L!Ō'9īüÎSxā´ØDY#5.Öw}#ŸeŒxOėĒe c¤Ā~zęäWwļÅĄ¯ ō5Ėc`-´) Úæ\pß\ŒÄNí^ž˛ƒ.öôŦˆXÆÎ)yĮgŗšoĖņNK’‘˙‚ĸuųĤ<§pąOŊs2?úŒ>ėÚ3N'"ãɞŒöâ„ë4Y>6Ųjė‹#/xųâĶ÷o  1HĨÕwmÃuSUíÃë-ä=Ā´ÍŠiÄ—R ÉûADz-u“ô(ĸwÂč>ĀôačĄ #L!ד!ĪQ׎ÔkԏAŖŒžE,¨Áü¯ō€h Ū …?ų7úÄáÆíC–ú#ËÁ¯į„ŋ‘äė†úJtNÎōr*’Ģ 2"kûŧT”či\•˛hÍŖōI3 63Z˙•GƒŊ¸JąrÃdXŅôŨ4đ‰oU9ņŽF*]ôü‘å= eMĄ„aÃ]ü‰‡4ØîQs”:[s9"ŖÉ Z ĪΙķ)@hc~đĩ`ČKĩŧôÅqÚ ˇú<˙á] K)ÚØ—–Č5Žą}g ņõą—@Ą­9f æzē)/˛ūU9Ę:0qk@pt€w˜<#ëˇ2p0•­(à æ<†˛Ô˙c1ü^Ŗŗ0•G¯g°쪜üSˇČĪF‘úPȚ:]}hԟ=ŦžÔĖ~,w›Ë āē˜bëšķōđá'ˆ*:īҁzæ??šėJjĻ=ëg~˜3–î8ˆĄ{uíå|ķ™AĖ_‹`Zá‘3jéđ4Ē]g˛õ¸ÖG‘XŦ9bŌæ {ˇ÷˛Aaƒ-ņøZ?—ĖSēÚwüî<˜0ÄĖŋ÷%lĨ­ÁcŨˆØú(IŨDFė៘Ÿæ`<ÆE ßËq´\ųAUs8ĸd¸ ņ™„xˆzPāÕįÄøęęžB0Ķę§{Ĩ?Ö#¨'ˆÔP{™aņ ĢĮŗ  ãEPģœáãŊâ âTŅ$ˆÛĶĄûŪ÷T‚xE}“GˇÂ5û! ƒÃĶęæ"Šhī:hÍī°šÖž5Ë>Ō,LZįuM†ú'ļY\ ˆ…ķĶ ¨mũÉ9cŒ2īr~lŠĪkŲ2€؃ŧh4j-uhœ EDbtø•ãMÂÉÍú¨‘œƒED4 æØģ#¸ûPƒ†-Ã{ TæēįŪ oye Žq9ęmƒŖøŠYûÚTrįp…´Ŗ˜§sɜ$–oÆ —˙ŗV < š‚‘ãáŨŠ1&i ’SžÃGBkÎaĀ>ķå?šŦųąrÛp3ŽĮŸf˜ÚāķO˙ūû¯Ô6#ULZ^Õd†ßeʍ@šĪm[ˇTĮ&ƒaČŅD°LŸ$éD`UūÖ NŽøÁ•B$ ĶĘ)~ÂgTU͜đ›ËõÁĸÂÖ¯é ķ­3W|ŨOHu%+ŠlʁōBĮ !žšŽĄl °Ļ‰@]-9;TŲøČĩ9†‹Zųqíë `î(×ĮCŌ—0ĸĢIė#{>ÍģhBa+úžxß“WoDXôô¯7ø:]ü§K÷Ą(ŨŸ-úXė \¸­a.ûK.č%ÁJbąũ\‚įËøpf‹ķpú .ŸŸĢ %ČĖ%~›#]đ`ąÚš‘œĒčd' ģv"Čã_?yvc2?H¨ck2ūúņųŽ‹{yđBęŸĘ`¨‹l#‹S '€ËŋøOŒąBEHÆ ƒ˛—|įúõ‚~ņC='ëž üĖ)9†g|3Īĸ—kЉDŅu~\¯0%g/>ĀĘš­^*('~iފ`‰k2/Ø;ræķxrŊ?"ĸ M*W jÜqFũŽ2ōÜĐn°¨ģ'E3mØ*āXž¸7—ņËgŽŠø1ŲŔ^˛kô™ˇ´pí=ÁË5ĩéČb~ ãÆĖųCã\žķA-įGÕaĘõ#DûaŠ×_:ôY'šĒˆŦ<–č(ī^,‚ÅОdÁģJĖ+Š4ˆēÖÁ4ėzīķŧšXĢŗO­Yle– +L7WÄÍßG뇐>“ĸ7ˇDãßthĩmŋįBs=ôÍ%ßÆÖ—ā§æL’æ O1Īd¸šDuAĐ?ÜÍfüÚ÷ŨÜŖŋá€áōÔjü.§čyecė˙ã2Æ`Qđf -\(é=u`ũЊŋ:ûŪÉwxt]ą}×e™Ņ›ßbĖ„ëķ5GA$Ä,¸ķ–.Ą4 uŌ,õķÕT#bE–1ä‹ob÷R|_Ɲ'dÕwøW¨`č^'ˆ æúāšeôøŅĄ' ę@Ÿu›Ëę­.rUƄ.Ü8'\ e DŪčë Pl­ü´āøbŪÍ>ŽŸqp(ĪVēœžŋiįž ęøĩÎpn­Õd^ēXņ@ĻķÁy˙™/sôŪqč{^Ŧƒ1öhëĢsH†ąĖ;zqĩT‘¨âēÖŊމôīGFAŊÎ=œ<ÛÎáā’Vū‰Õ{Œ<8āl×ú/7c’wũϟĢpņĶZũųĪß}ÖÁ„ĸ’‡ #"n&§Å+IKĶI´6ĖÅč‡ßú1×ā ¸ nĘ$Aˇ"-9 áÖģ…Ĩâ*äÆ>L'ÜųŽRã5úžôŨ(ē™Čdƒˆ9„ĶŧRõÍûâB¨ŋčú2 c0pĐ"oęãÚĻāæ:Ėię87Ÿ‚ņ.¨’=*ɡ!ÎĖqķfxūÅ!OÄQtS@¸ "|o2Œę„ķK'íė^-ö!´8‘Z<‘ú&ԍÑãŠÁĶŖĩã >_]§ŦÅæ"÷—°ŽáŖŨƒR΁š#4yšŗžīDqÂį:“ŧ,u+¨öā|C; 8Ŧ˛?ëũĩģ0(ÎŊ`ž`ŠOzé/§ŌƒO“:‡pŌ÷ėœĒЀĶl‚Q”a-*īYM°w…Ž5Á]ČĢGzÛĀđJŠ:¯ŅKČÉŽĻˇV oTé/üjšÜF;‘e|ĻôÖEYPŽw!á‰%Aņ@7>ãŲ“TMĩ_•Iƒ4F„‡ŲAáé(’ :ģ͙üá)fv‹+‰ŧÂFlŨ[í›”Ž aȒ/hŖĻŖOņ˙-z|čÂ>uL§PÎąŽ™×VÅPôyĒrb ĪųbȟЕSƒĮ÷å|ĩ?ŋA^ģ ¤ãtŠ9uŽörÔDŒÛÅ"vq„Ā´š#ƒ/šÔÔ~†Wīģh¯u/YíÎŊáÜ[ú>ĸøį›Ķ^Ô3`ëh‚W&É뾀ÍĸųYü(îeâ?yœ-…dng*3Y×OcAČPŽŊč—¯qMŊú’Üĩ6š*!Y;å0—?ŸáŊ//ĀYŗ˙ęË$O|r}ōĸīFRëw×ÚŗúØÅ÷û+xŧāŠIėRJFé+‹MĸīĮ,vČT!¤#‚žØãétFĘŧ1WđÕļâ%ŧ9bÜš•U82ŦÎöí!Æõx–2|>û sã*ĘãÆĻɄÅ×=[,ö9/‚ʡ/îšS7š¨0>UžŦvabĢy÷Ɖĸ,•4L4]sœŸ$č#äÄÁ3~ūĶŗÔhœ_BCœbJĮÚD<˙Ė%M¤ë¸cĪŠÚē<üMÆ|—ēŨ_Ī|팞ī?Rí‡ÁūŸúkĶUŖģŗ!¯æŠŪ_ œ°w=ĻC,@9^mīÆ Å1ZŽÂ'ú&É@$éâÂ%w’N9}šÜŒĮwĄuņÔū€}œ!čņØ"š*Z°wąé.p“΀ąÜÁÁâX$J¸A> J…$OcŸcrJÄUŲ76ˆËtķî¸xûûŠEÃîbÔ,–Īž >qp:_Ÿž  ¸8>6ąo­Î2œÚAŽĐŅ/g—5MŊäF/”v4ĸ*8ƒ•“:Ķ—ŗqž@pƒŦÃÚ0ūüĨVĖú"',TĖG­¨:ũénŠ+%Ž÷…EņđÔgc “>äŽņ‚™]=ļ6mųa7æ`[ËXYÂy$×îKšŗsŸ+}Ũ:i•%?Ɉ-đo#[ôŊΊĢ0|´3Ė(A#{ëŲ"ę9ņŗúō\KlɏŸ˒SKūî­yˆÎ{āLņsŽÅ‚Go(( üYķė[5˙ÚäKS„`ĮI,īZĨ}š?‰ĩķîÛ2<ŧÂ<šLĩ“ÜŪ‡j}×ÎãoFddūÚBˆˆú˛€_ũŸXSÛüâŸxU—\“ ×Ėd­Á¸Í}<ËX} gągžrČ8րÁͷ܍Ĩ.gôĖ´=Ī(ũ“HŸuôášVŽĮ¨šp 1î˙9@,Ũ?síž.UpĖ5}öœđû,;øütŪyÜzw"'~ ;r‰ˆ*Ûa`>ÍYaĀAs`eL™a㭜üäŦġ•ŊžŠuŋZÕpōzßČk¨9Eš?Ŋę/Ō?û,6‰É_ŖĄÆGa5†ØčļÜĮŠĩPėuК|cCkũ+8h÷E4|ņũļãQŨj­iŲÁv-×ôîŊËŨqƒ-k>NâŸ˙˜É†O0RŽWZ°œ‘SįúQͤĶE×Ö}5ņ":0×`Šj.ž#x,åĒēîÉ×%å.˜HV-ÔŅģG…ƒ5KÄú…Ģū=+dŽQ>Ū7o ēį>{dđÆ&ąv?Lŋ ęáō^‡&8˜ŪĪ÷™ã"]"`ŽŧڏĐīâŦŨ§‘{ g€üô7%åîā`^˜ Ŗ{ļsËüļvUŽŽZ†ƒŽũąøwuĮŊĮn}Ÿ4ÜĶ]GŒŌ˙û¯ŋĖÆę$” ¸ 9Q1+h74^Ō 4rđŦ›ôī?đP§ Ü`jjBڑ(7×Z8˜`ė f3t*bŽ…é"(§â;•zTĻ/!МFüˆG§æŌ'Ā|ĶGųüą;ūú͝>ėЧ‰ãd'&8šxÕ)ŅX&qĩ+*84ß`Ė™+qQƒÖčņŖŅņSK0qFlЙļ]žđļråė˙\ÉųæÎļ–%xEčënų‚§G†KôU’u<._øŸÜËũV§KlĪ }Zķ"žÎƒrsrû0…Ķē1ėEŒ-íÆTcĩĮķQ6Ļ~ s|š.#"ūôM†ųb ŦŲ@vt2Ž˙jj¨Â ‚b{ōØij`qotŅŖ2ŽüØ=ũß@œkDôšæŸë<ļŪíë¤ yöeĀ|į}}fØĩŸpĨĢ sąPõ§.ūl6ąąIĐ|Ņ sū Ū\87.h9“#÷ }žRˇ>Čɉ>ÄțO_&æôŠiÕá9ė­ĮÖĻ̝ëȐwO‡§īŲ0> ņoDÚ8A¸_ JˇÉô‚Z€į×~Cũ’ĄYö‚‘DFė:æO(†(69>“$ū†pŨ!Lm"bĢ<ėd 7phüꓠ÷WØ` vų¨$†Žq¨ ‡—øąļúƒBĘ|įpĩöĩ™­rXÁą.ƒįzžt\D¨‚AjYæs ÔN{pĸbķŽ‘“ dÔC˜§â ㉅5‡Į˝šΈ0­Uĸ6\™ŌgĀĶöėėwW[÷ÎÕõ€ujŦˆJâĢŠúÕčG~Ė;Üá‹ÛÖ§1—šā8h—ËÆäo¨]ŨŽŗKīŅą'Q­Ã=†éį[†dEGŪp8&¨A Pváē\:™ąĨčũ|ĩŦylŽčēÂŖĩNÄ)yÆhÔ?ePŽ*'e—O ÆKžČÖ䉭æp!īķŲēøG 9ļ¯}ūeސNĸ1ągTž_ÍģŽ€"fŨaŲ{P`„ŗˇvŗÃ˙jyëûbû]§ĖKãJvøiįųG™ī›:‹ŸüKŽĢ_‰ËĮ.īž 9|ėņņ™žat˛FX‚s?`Ė^ˆžį~ȟ\|ā¨mO'Ŗ*yļŧī°ĖüŊgĪæZv†Ž6—c÷¯0zoÛ4ČĶęĩdA­Ģj<Ņ@΄ühA!ņ_É9Č/ĒK8⚷¸ËË$0176ž‘r´/šŖ[#Ҏ­>7€ņ ŦåØ_ŗ9šĸ€ÖûFÔ&îsŖˆ=ߥĨûzķ×(ÂųãŸôÜÆ[dôU}x.rƟ0ä ]¤÷ÂôÉŨÎ| °…āÉģņë2˛ŪŧŒŪ ~茡ĄĐå&hmĻ0žÄåGīÆÕ 8fŗ Ûâķ=~ë˙™â]RD܇pųcŦËŨ$´ũ .Ŧš ›‹ĩãwô.VB4LnŠÅë_y!įģÆ$>r8ž‰ĀœÍč+&æŪßJ&ˁΛããÖ9y7ģ˙“VŨUŧ°ž5įĻŗĘnĶ2XB–7œˇ>tđ,‡æVœž>ĩ÷AE¯7¸Đ­Ė¸ĀŌšC…8-°ûļ큌žÄĐ­ĩƒŊ7 Wv—Xô7—Ščâ:ë ZéræÂfũŠ‹1sa:!x6R=ŧŲ减N%œØž\o˙ęЈļÕy%œ/ŲãčG‹‚^Ũ§(3ĩ*íŸëpY!Íč_K/‰ĸXÆä„ū‹ĒđæŽŧ~6Ģ_ĖnA{ČņC.Ŋ7ëŖ&ø‚!ā<ähŲkŽ,$d †aåë‰7…‡§ë$,įDAũCŽ2úR’Q#ˆŌĪæÄpæįˇia€–ļŧo ôœ+ĢÅ#ërqzEáwãaņd¨ S;JP?ō{cÛ´p`ƒžupW^{U€œ+ĄV4Q˜“Iƒv=Į­´X"ĘŽ†Ė‡FĢ'ö‘“ ÚbWLĩß>Ar;2yĸy[-N–Qęā‹Ųŋ¨Z‘ØMŽ…5k<ĄĒuüō¸_ą9‘/­%ŅÚ|ČQ˛ģ~ėē–ŠĶėÎŦL/ōÜ"cûéÂŋųh@đSƒÎšĄÆæîĩôÆÉĨAôCī3ģsŒ”Õ3pîâˇËĒSĪÔŪ{ŽÎ‡æŗÕ]ų´v¯Œ\ĮfdvšŒ ISîŗĸũ*Û7ōXeWž_lŽįáŌ´GLäũ|HŌv™ķ°kļĒŠIddū ] VF‰MÁą _^0|–`ÁZ7´ŗĢŋsųnl¯‡Å´ũî Ąŗy¨*FĢŗJ0čbÁīŠ Įéũş<- t1¸Oi,p:ؐ"šE’.ÉûˆüSTGZˇ@×NŌŒ čãzoŧSû"¨"~†ŖĀ‚#Į õS 8ūO“ ¸>\úI ßŊI$ÛËwų"Q<Ķc‚?¤ĐÃGHlĸr×'ÂķCI'B9 lķ ŖtkÆâ¨Č¤äIŨ‘ÖÉiÕŨ ķŨZv~RA`_ÃŽ F3—N#¤œ¯[ČÆWʰģÚ§fqlÃU7#ú­Á7ëÆŠEEVcˆœH­9ÅŖ‡ŒŽąfÍe^ēŠ‡ĻõäĒķXaĮŸĀ¤ak¸õS. UĐ_0|Jˇ¯Aˍ‡kĸņEMsBsļ)W’ž\sŒŒ|evS­ĶÁ‰~aōYîSD×OôÜJûÃĖ´;­ ãyęzú"#ahlgÃēčŸ\ŋûÂûDHtŦĮáhŌgͨ˛LZKpmT“g–ƒŅåTéÆ RƒÖ8ķĀķ'- hƔ+$ČōMWí ōģ‡)ĪējpE§Ü{=WFcâ¯;$ŦCtŦ îôŧŨģ^Į‡Žĩ0­^Åé`÷`úŊon­é(|ą öÁo/¨7<ĻŠ$¸téŋ­+;ųpfáđŸ rīT~¸¯ $PŗŨūeDdîÑ}ãŌ{õSsĐ;¨(ō%7ß͝—GPmŊGü‰ZŠ~¨ĶrũîUŽKįũōšß•-{íâ{jrøMŊ:‰ĒŠ.sáū_M‡¸Ņ1bN!éüK|÷Ɇ%͎‘3íbЇÅPÔɋę rގ-žc-.×øt]ŨŠ*ŲņÕũ÷üēÉPÍ51įP… ų—ī—ڝú4ĻÆˇÂhãڛ}k Ļ8âō'Ŧ„æ=ú_ᇸĩÄÆžÚs#ēŽFÆë‹î÷á6^– õaĢ_=g=ę×_x 7ĨAŅLŧ´b°ŧ=˛$ßũ]íØáįĖy/pĪ$^îŠãĪ50Fū‰{üöšųY_øä~Ã~ °ePjNˇŽ"ŖVƒëģŒĩĖŠqĮ—}HĐĐ0z 5Pc=+×įâ÷•Øšƒâ ϰ Ĩ&TUōáĒÂŖOCöƯ‚œÅ'N7úŽĪēqPĪÁđ@ũrÅomo*…ˇY\.åėg͒Å^ŠáŖŸ¯>Ē…ã7âĮ†ƒ:€úōGcmĒo$ĪxšgĐcÛđ“‡TĐ­Ũt1%rÜÉ 61§˙ũ-čŦ@ˆŧ‡ŊˇF9ÁōŸ†‰ēs8ūúëocĄāÎÅHĪĀHQ”H‹ĻŦü9Į&x*¸hsÚ2äæŗč`úg•],ũĖĻ=ÖrĘũ¸› ąę¤`Ōø Ļڝsé6:ŗī2ĀfCĖĖ€B?¤ær‹ >üW ôÆKåëMûØ:Ũ ŦąŊöŧtû¯ÜŦ”0Ō1ņ'†ÜOLÄ'cs)ē^Ûįœ1ްå%S§āÃ7Đņ;lPoœxˆŒz‘ënj.‡cÂ'.´h•ā8Fb‚'ĸČhÁ”ŅĒ5ī@ÚÄÔTõZŧh,ÍËßaëĪ4 ķ˙V“Ú\LÅšl‰ŧ~ †īŽS¨NJē/éŅŠߨúëpŅ‹ĐĶ“ Ãú°gíĮ"itņ=1ŧa‘ÆŲˆ”´Įųâcθ—iÚd‘ģĢ>ũšŸ ž˜_Čr: rŲĢ…ôœâ›=cÆ‹Āˆ›’o|ōP‘6'Žš"K\}ˆę!'äņ‘K:pZ› ‘hīŋ(uņīÅņMD`ī÷Z;‘ō:ƒËJMqÍW„ũ=͌§ÅĪš >0P#𴝆5Öâįá,ųhA6ŠPNląĨB†ķ\°§u Fo§†đ3šHpö‘1žõ[Դ͙†ö+‘tā˝Π†@÷ÁbÉÖcĪ4îœ5Gū¯ļ˜}f,Ž—˙đøJ I ŸĖā/ˇ6?–5ĸ&'üŪŦS8ą^:ŽęšPož*"ÉņŠ]ģȞ(č#ĻĻ´œéîęB,Udãˇāâ,ö´gĶvAvŠč™dâaœcÜôč#æt)ˆpÍsōŠ,į‡-ø˙ŲĘG­KjZĮĶõD­ĐĒt~6'—×ãķp\ņĪ9ąĢß0럪éŲ+iÕ9DĶĨT™ûŠM­Xj˛ĩōŌ•ĮЎsJ\3ûeíū4eƒíŠĩšš7šđÕŧØāē>[ĶÚ _€ˇnį'ũ2|'5Âb5Õ#OjČY# ē¤(šx4ˏÅõ]OšŦNđ ~üaSÛī’Stm/<Ҥļ`ËCL‡ōĀEžĮÛîƒAM[Ėũ!M-3J>˜ĀÛõÕ´ÖGm0đÃũ8 NųOWg¯>F­eyĖā.Áæå˧r§‹mœˆĪkČ9.œ{UČōÍh…ЧZ(ōíiÂüXB kŪ Â‚ęĘ…ŸŠ;rˆî>ĐȤQ ž'8}7ō 5sáā­ Ö™ø#˙p‰tųë ‚iļ—38DŖiĐrœŨá#‰¸_ôeá܏aŨ HēÚKC˜IbŦœŋ<)AŨÆã d¤gŨ°ąÖ×ę4ũ(Ŗ[6ōC%ąsĶĩžõ×s´æđŒÂŌ~¯D_Xē6÷‚sJv1‘úáŽļūq9Ā“|ģ᠆ī‹b<ˇYœ^ęQÖüEũ˛ã ^V7ܙ1AØ5ŽĶ‰‹M^ųãČ1wHõ`NSäŌ‡ÖE0?ķŲēépöŦØĐ<ø9OxIŋî ąYaiÎßų׌‡ē€BŠüœ‘P–h‡SŽŽåŅĻŽ° ‡v@ršËųŧüĶx7dY]RN?¤(А¸pī:Ž^vũDE}Ļ*–Ę@åėŽę„ģėĶĨ–Ž7grÎMWĄƒ—#g'ŋüœ Bލ9„hÜzÕBĐũˇ¯_h],zV­Ɛ۝}mjŠ.֟ķy}y&(žcĸĐF‚úīËSúĒēĶã%õ¯Oũ­OâOįKĖ‹Ļ]K~ōI<áåƒdé#ÁWäŌ9ë—õXls‰í-ŧšĢ‡9šd­[Žp¸ ÖZĀWöˆķOjրŌxXŊ7EíÂTß]?]3ČÍŗrE*TĖIWØÍyTW7b‚;öŦõÖĄĻ͝ø#‡‡{ö!×°ąÉ…ŸãéĩĘŨWÆoíJcU5ĒũÁuü€˜¯ņ-¨ĸ1[’,’ÔĸŒã=sąœjU&†ûP’ÍZU_ãÖ PbĐüî_ėãį-ˆfžL# ŦŪ:?ĩ2æ‰B˛.ø×]|!H>lu“ūjÚXÅų‹Ū5C\pŇx‘ÄwķŅ;>"ķߍGšČ@ô…¸=ÄîOÖ\Ûģ4ėũa.~qųpžū‹Đdd?ķ“üĶh dė8ÅyâøÖ[šį—ųw<“ ܟcĶ—ŸčąšvŨŲÔ7øpų¨€U0÷Mp}Ÿ/åĀ>Zü§ws†Ôû^]Ÿočũį)c¨ũ OÚļßQŸ¯ÃF[¸oŖøG,ÁĨës šRb%OŖp6Ô_ĐN7¨e…g—QæŪįŨ"Fī4^‡Ģ~ŽGÁsē2Üķĩõč=y”zŽŸį—\o¯{Xč€ĘÁēų‘Ā7îBț}°0ōfnˆībĆ>|ëšôļū|?ĸߚÕ2}ŋZ˙(måN÷‰+~S{ŖÂ§Ī|lÂwsĻļąŧûąā#^-ZŊÂä‡;\đĶŦļH<ôMŧŅGÔ5I—€IãVŲÚ7ty:IW\×S,á ĶĢCŒ+Ī×kĩeÔ{īÆõ™(ĸaVD¸~~úÛô™'lŠCđéƒáh˙¯<Áĩrė;éë¨öÔÆ{˙3>jF[:W§ ‘<Ņ´hÕ×*į íŋfŽī×ëĄhŲž™Á ōĀŠcŅ# ŠyÖL”Ô6ájÔĮ -” ƒŦōĩ˜.nr€đĐ̘Œų՝įwOņۊĶé12GôÕܧņâšy´zX4͎:ÁåV›¸V¸.A‡ëęÃī7Wl88Öīƒ‰˜âļĖŽžĢÂŋ)‰:_áőĢԞ°ËØÄ‰•Q!‡B[nÍæskøĄĀĘX×/ÔĮ}që„Ņ1¸äŅņé¯U1\,ˆŦYģõûy Ŧa Hj=y ŠPNlĶĀb„X›Hw-xöËĢĄ•ßûf./mcøú3’Į…õĩ ŲhhĮįF´zĩJO š}ēnxøli—Đmßæq œHŌ°Ŗ7úp…Ŧ_V+úÖGĪŅk›˙ęž-#ƒmÜj#0~}˜ã Á@aŠčžŸpŲ'îiōsÕ3ŅŦßzˇ”‘U +q‡=ŠÍšzįņläNnÖŖÖØP ØZ'IsBFMxē ˜Xĸ_IŒú/ČĩÚRzÜŪW7Vb*vģ‡ƒ0Xc2 s”îĒcŲ ­!Œ@į˛V;įōû”w˜sĒs@XËĨLn< ‰Į—Ž×Ōs^zîž÷y—OÍë‰RWåŽ=˙ †šØX¤-'¨"ž#bcäOŸ™WUEvžšT_ŽâaĪŦ§į/†W7˛Ã ˛Ũ-+ōBWúÅX~bį[!Dî;ĮbfON5ī‚éî¤+ŒĶYÍ:˛YrķKeĸĐpw?"ĮŦč=N˙‡Ė=nk5ü%ĩLģ’–Y厧øvž‡}­WŋÁ'GNę ŌiÄoôEGƒL%§ļš%ÁëĘáāėru}b^T+üåąt7ž8|Èâ2iŋļéī&?SŧŽāŅ}ōĶãCr%Ū4´ÚYWĨXĄmv-UđvƆšØp–*‚KOeÄųŌŦ>_@˛#ÎÕĢyO„iČĶšu¯ íúlė_ÍJôŽŒ&§ūŽF}´t<Ŋû /3Ö(ž-”ÆœkbjMĸ¸vü(ŽBŒ÷æI-ĀׯûRÉå88/ 9ž›=Ö< !ņ'6š÷ãcŲąž˙úũ˙BP‚ ĄÅ­ēû2ß@dM߄åøWÃnūŖ)Ÿ›^°n,˜ôl‚Č– éf*sîfWdcŽŗŖ<ã >ųōÅ9Pmčâv ę'gY\áéoNdœ>Ą˛āØhü!ŠFų"k˕îåÅÍ,ŋQĤPĢR[ ;Æ.ZüŊƒaÁíī8Į.˙f|N9 "Öđž#Ž&ĸ'N^"’×Ĩ?ąÍ%ũĻŨč‰Î(á׎š´ĻõWúŪeĖ™Ø°"OüYL÷˛ãī>ī“Ž!€xsvîĢ}~˛įC8#@IDAT‘á“_^­#܅… ›tž˛׍Vˇ¨Ú âĶO<ˆŦ“~ũû䈕ķ5KŅ7&ŋĶĐ'ÖŽ;å‰}âL}đÃZ¤nzg]¤ĮÜ÷/Lwüå0T9ĩÉúp§,â4s30Ļũé8Ø|œßČĀËŠŒnųõ`ū‘ĨÚž&9‡Ą!‰t‘™gdƈŅâũFîOV‰Æ‡)ØŦ҇‹Nš1ÃGÍ'ŽėœųũãĘ́‘‹eø‘„ŌrŅŊ;Â2eÜ<˜gbJĖqxŋÃ9Ķ˙E7•VŸ˜ākí{OæM<ņé´q6’`MđdXēdë:5i0‰1w/uũ |6‹ųčÅ'FÔŦ÷ØįuA}°|p°‡]?=0Æņ˙hoŗĐAø¸Fæą §–S5 ·e­€ÅīâW‚m$‰Ŋ6Ä S0ņ‡įށˆDpĨ9öÅú,&Nâ'0°‹5ŠŲq"ĮîZ{—ķ# ]ŸīØÖâ‰ëYë}ŲĶa×pZĶķԘÁôæD?‡ĸÃFdtã;]čŒŧ$"0Ŗs–ˆąXŽ­¯rœ°ŪĘCęÂŧ¤%×bŸ“ÎۃŠJ¸.˙Ė_ęŽēÆL<Ú¤zæåØęsū‰īs{,, ģeæ 4Ō4Ė ‚CŖcTI¤‡ĩà 2ÁWЈ,Â1@ ކ'ũŊŋŖPūų;%Wįšãš$Žķ˙\Č÷øÄĸ Ī ›'u“ÄsúÍȐ€D'īōH3yĄÔAø&uq˜ũÉŲ¨ÃËđĩ5NĄíBÚZiPŊy/aun§ÎeíŸn#ÖŲŗÖĀíŧŽ N×ëŽŪŽĮ^mr)KGäxōŌúASĶTxÉķžGüDedŒÕa“øāMØ÷Ė ]k>}¨uūīw„×ˈkŧâI%1ķmLˆSû‘Īü™Ŗ÷Ė85„\‰rƑérk]Ūhžo‡É.bŅ¯á ›/gcĶiPujL`3ÆGĒÜ'o§qSÍYëģ ÂKî\iåSļę×ĻڞņņôÖ1)ÍJ=8Ŧ‰“–Ÿ.ķ"CŦŽwBŲ úJëŖ†âĩō‘uŨmsMį›Ęß| @#ģĄfX\ŧhīü^nė 7>R夨Az  Œ.įŦņK& žo==ųDē÷Ũô‚x¸ՙŨ€ {ôÃ9cĮ1Ŗ‚Ÿ¯ˇŊ1tÍSŸŽ™/ ü{īD#ŲąP“eŌ"}|ô3eĖ$}‚iā+ÁC*ÆSÔī|ĶN]ŗÃŊ/⍅˜CPYäĸx|æžËÅ=+ņƒ‘›ĶnŠMeWÃĻ{1Dģ‰hž€ĄGžīqTú=SŋÄĒ‘@ßž –øûō v‰(Ļ‹pū"0÷[ešŪühBŽpŦ=2î™Ú|U’*0ßM#j­ąVõA…š ënŽ'U•1wüâšLą”D D~Į*>í“ãĮŲ _×{”Xy‡DŧņVëy\<§Ŧģ÷)õAÁQŦ?ą_yͨ7k/°žč¤U‹ãīküéĻÍĐ V?ę4_đA¯Ã /|ūŸRú`5tfgė—l÷XX,xËíūdۗÛļ~sŸC§TĪ€¨':dŨ›¨+suąT?ûag‡Ū‘Q>jĒ4†ēK_QĪÚ9:æ†piE\°ĐŽûqXöĀØÃÕøzd Ãw9īåīũž+ņ¯üņd˙ņȓ Áë‡ĖÚ m7-S 6Ō`øTD0ã‘ąööŽ0Ōd!"Ō,“ū)ä9FÚˊ~O ĸ/Æĸȓhā\eøĩ‡ú#Ę1dDü~šûŪ8ÄA ęžĀē€đ-ųbK˙bƒ+¸ŗ“nÃ8k¯˙'Ī V[&Ĩ5Ážƒø¸¤šōaJ :äiDOkNo˙jDl܍žüäŸ2ÕjW6ĒÄ( šĐô;ũčë@žj‘ņĸ€ŲŲĖnŦ'ÕÕŋNūt vĐŪMyQÉIîŅ[įG9 Mä^L]˙r@Ū@jTÛ'GđĖ5ô‚„žĶˆڀË!)¸Ę+‚€UÄų]?ŽĢā&1ƒ¸Fũčį1‘ŸîöE ã*ʉ{[Áé^HĶ7bÉđ°¨l',ÎiĘZô~øÆG¤”5ŽOō‡ĨĩĘŊ#ˆŋšūĸMįŊ/’WžØ ø°ĐY€5ˆMūy\đ‘ēĻáôņÉ:/÷“û äZ€æŲ:ˇ†Äa”ēŠĖØši=ā¯~‘ã7íûnŒ 9GšėyôWBbč™üČe2gũ¨*˛ū¨Hŋ@ōu+FĒžrÆi4šø Í+9Ķțæŋƒūä2aU˙:K&ssæ…ņúū%oWČš__´5×ē-BbÁ”5kŋ8lĖ82—ŗúāž–ŦˇŸĢoņžØ,Ļ/7ā`Õ=ŗcd{™iũĀšōâĪ€*Čyåš112_֌Ŋå’ŨHYģå¯lĒØĻVÆmxÎãíŋŪ ųKÔMqÉ4~ZbĀ"Y`K×Ŧ¸â=(ƒMxjĻíY 嘂KLƒ‡›÷&üÚIŸŊ~xîŠÕ5üØ0įô“ÕÙcęԃŠ7‚ģ^ē‡{s$=%ö÷råp75N œŦ-ęJ}ĨôŠ;ŋĨ@ž•ˇ‚Ģņŋ~{Ī”‡˛jĄŖPĩ¨@ŋ¸°ed Ŗ‹§„xf‹ÍķŠP9§˛ŧ÷*ãÕTŧ‹GRl8ˆƒ^t:IIŠûz\´ŗÉãüĘ;/J˛vyhAŊ?m÷ęúÕ§õ„?đ&\äĻŲ† Ô( }†‰…ÂÆōZc,kädIņ•Ã:?1`\įÖ`yŪzģŦEm.ž2ãÉéö鋐Ŋ?üČÆnŽ0dĩ~å1āÄæ.āÕā›mônîÔ  ;ÚÖĘrĀGķ ˛n"ÂoĨg#OÄ~@‹/AøĨÅæ™3Ė!;Ŗ×CšÁ;OfŒŸe‚I Žcē_\ãĪÉ9ˆ~â ĶĪ‘ +‘a.ŋڜ&ˆĖȉgzM@håYMžŽ¸ ;[@ķk āįĩsÄírįsņōŖąöœŽGN'Sųb¯ûõÕWZŽpxŸØĩÜÔĩ§°œn03)rŠí%sœ]›ĸĩå^'—ÆC Īē 4/!ļ‘Õ5ōĄ5t@\59sâŌûPZ{ŠđN#ŽĩëČ[įËé]HyoÉķ%6ž@.Fs×é ĀņÄãfuđŖ.îÅ$ų"Nß}ÆÆōüdn5üÔĘt‘>ž§ČĢxŗfLg iPz×d¸au üëLš×ŪXņ”XuŦ{Іŗ.Ķܯˆ¯z´}öVī3S7aÖąË[?2`f˛Ô5 Ĩovŀ}~Ũŧ“ˆ(­<íküHKíáŊHZÛo7ĄĻä9ØÅi=~ØÃņųÎ8 ĸ1ë°ŽŌ=~øbŅLũÃöŽ×ÚîŸ˙ü] §ÕŽ7ĸ~›’=Ü܄e¯xĄĐ~s2gj Ô( €OÔlID.zč¸qž-Ō/#3𛄠˜(âá‚RUN7FŦûzęÃą7ŠaUz ôĶįĖÕA°EI—gÍ|Æ 0ÆwķmA܉îGÍ ŅÂļ{AŧĨt~Ŧé ĒģO qŨsp–MbąEÂEɋBôÍMŊvÁÎ qœë'ÃlœđIŽsÁĩևiŦÁ mLô;ļÅMFB7^C6–¨X7Ŧ.lZ—BXĶöS]KÔŰšēVV=:ŸÔ™Í ŗæŠŦå­yÖ_ëî„ÕąaÜ\—8ŋÉ4ū°m=Ō‘ŖkŠ<Á|ņŋŪø°f.ÖlġæšOy拨ĮI]ĶúĢôõW û— ŸTÆøE˜+; ^ûá§ņ,Šūäkœū“mláö Ŧ¯äiÍË}ŽÚéÅȁë,ˆÍOt❘ēBĸOttWĶD[ėāV?áÎ‚fŦ^åŪÍúģWŌWŖ"åSîˆÕÔYzÉ˙ŅĻĶų&̐œ_ia7ä"ĘŖI9›ķąžWĩĪzpĄ5ĐKÖ ¤č\Ķ›WbFŊ\žHZŸÃtŨ”+ū ÅxírZÄ]|ō"1GИÖ.ūĊ_ôgĪ<į#œQ§ÎIGœ#=?Ž"{UØ_ņMëšŧ¤ŌąŒtå„Ē9wŸFK\]{1rO‚Í6,ū/JÂn #ąĪžqšę+×øk U~ûŦiRh fāÜb÷/2| &8z <áŋ5Uu˖Ņ8”Ÿ–VUæ2ÍLr˛FĖB—/W_kÕ|Õ6JōĶNŽ(ĪĄ C"˜ĢAs_…/kĸˆÃÕ¸ĪČöīlãū’ēt[×QCOÃėõĶësFƒŒę—į‰Ų÷QT#žëįQ<¤ũ‘ ^“˙đëē7ųmāԆB8˙jq”Ö"Ļ2ÂWE[Ÿ:`údG1ô_IČøį 2ŲĮ—Ž¤ˆŠÂ#€3đ-rˇūQ‹•Alā\pœ,Ũ˙Ķūõ1RĮIˇ‹c ÷'itļXŲ8ŗL–~­™(ĒÄO)û“ÍÜhtČĪ:TĘy>äĢÔŽlĮ:Š /ČøÜĐÅ?š‹s‹ú6¤Ū6oG §}S% x#cNšĐå:ûÔø@æ!H އ'>l0ÆæßŸĻ#/qtz”ëōI2FĨ âDâŋŽ„TMXƒåÁ@ģŦ¸3:Pæ -öGnv‘ŗ~‹ícDŅĩ_¯TämđāƒĮÄۋ`R 'kŸ¸|q{˜,Ã?Ú@?5 DOžČh‰gNn-t."€1 ‚§*į.ķę9Eģē€Sr>u˙_Đ}U—ļ”áƊŊáäž#ųüāÃ[äĀúŒ>3Ė8Z`ÁzßąKtŋhT؁ 2Gų%ĘøpQČ_H°ˇĒș„äzÅ3%‘-1Ö0üj>rFØ>/‹ŋû€ĘœæxøVák Kō“,]üßHNŨq§°*ûŸĩmō”(}ce™)Œˆ>‚␰īÉ%yj.ãąL(œ€iŌ5I*Œž¯î‰đéę1ĶļÆí6¸õĮ†oķ@ŒOޜâ/Cn}ÚAš Ú)ĒÍš6rČÜY­œ3û­5ęaēģ/D…ûĐp;ZāqĖ—qė>5nĀßĻMėŸŽ˜hė9ˇŠ)ũ°<‹āˇæČ8 =Ž ##īZĖ18¤Ū߉–˜åŠüų€ar”KĖr’M_ԉQįÖz9Žo9ãI0ŌĄķK5ëÉī`‘}ŸA ƒqy˜ãőM’pķ+ŌĖ!Ēčˆũą1ČŠspĖ1̚Ãeŧ`čc@‰ÁØ[ŋŽ9ÁÅ{Qløál“Æē|Æĩl€`–Öõ~ũá‡np-Č ,üÁaßũūĪßą aˇčhPÖEo^´sKĐgŅĄ@vyC‡Žíˇ×âZ’`( øm6ÄÆ›øąŋĸCKaZ|¨ 9ūąšDĄãz/$6NLų83Q2ļJS`]hë-R˛_Ōqē6uoh„Fį _…ĸÎŖ6åi‹OŌÚaOsLČQ{¨ôíö%ô^ÆĖ3ęÎo¸L&Á_‹eÅ6đžG‹+×x5Œ'‚v‚Žœ R§‹XI68ĮÚEoa›MKGœ%ŋHƒb$†;ų•ĸŽ]s˜žF5ôãw¸L&ĐėídQžLˆ“ÖēĨ“Ąæ•z~ŅøĀ0¤čØÍ5t×͍ŋ‹įösÖs~­Æf ī5W.^—hÇš`)˃puĸ2zŠžéĮfPķlhrÁNŊsá¤ūĀ#Ø* 0{^ŧ"ą1?.Ëqzăh[gQ ‹BŸéō‚‰,ļŦgÄŊĮÁ%F}”đÅ%~“,ķˇK8nܝ¯Ö§|öŋV÷÷ZŒŲhúĀ€áöĐXķĢ]ãˆÖā@o ŗŊō–kâĶæėŗ—ĀũƒÅ‚ØņĶL*‡ŪNŽŧ(0Ú}™ĢÜÂU¤į`&Ė?öÔ#ÍįÖK,Šę¨ŪŸ ëŅ}ë÷Ā'ާ6.Ą&ŸĄŠŌ s>žŸę4cHŒ }œŽŅâ°Įũ–uŌ}nÜ@iį*)Āûî=‡‰L7Š“†˜^‚ÎO‡}iL>.i°ˇ]Ã a8ģ>:—ŝÕŲSlQ âÔuāũ@œ‘č1؀>đôoĐû 4 c~}’…EęąĀd9ĒÕäb"Ņį{ąVšŗ÷bÖL6R"Ö¨{ŲuŒ‚Ņ öČח E…ŗFŪuū„MˆîÖU.P<ąĸL¸Žã Z“ĢĮÅTYbg=´…¸ų^˛L‰äđÛöžÛ &à dpŒI’tÚí úãkĀĀa`nĪōƒģ}Šúú°‡D–${ŒōúÛˇH8ˆđWN{í9eâß§7ˆģœé&X>‰™L>ŪúÂ Ī‰§Hø!¯‹…GųСßŪ'”P …­ ¨tV2ē[đ‘ęWGōÂų/ŪßD%_Įņ‘Ĩā„Ož.Æ!]>įvШ‡ƒŠŠjØú°G$ē6øöwŲ’cŋ2é/ŊŒ/˙ØÃĄ;=cÅÍôÆLˇ¯™ Tū :,.ĨÚģp#‘Uēg$ېÅ_b˛V÷âÆ6õ^ņ>˜‹=vø]#øĖ*Š›‘YĻØâ=FČTXķęs.yt[!!ˇ[/eÅŽų÷Ã4LņZ:i­ÛųDžÚJûFwįgîSīQ*ŌÚ@Ū­$<-?bãë|OŊ÷@ĀÄu÷ƒķ{öŅųbé8}˜(ÂÄpƒGū$6âEĶZ霨h‰›7<!ĸ‹ŒGׁ‚ôÚ6‚s9vü•ˇį§ĸŗcĖÁķGƒģĘŠ]ũ ÛēŸÛī<ģĪ(ĪÉ8bEūĐ5,ãÂĩm‹~|ĐåøŅNpûoÆÍ%X9K÷ŲļXqLtËBė r įPúŸ )áci€Ž+ã)ŽÚ‹ļÆ]ßäVĘę<ĮwëPoũ‰‘†MuK@™ĒNLg-Œáģ˙#ĄŧGl]ëļ”ā8Ü=āoŽ]'l\І0û!Āčõč ‹;*xȕ€lî•@0v.Ō+\ė4(gPt㔞1ū¸ .ŧč ƒ´Å§Ø1ÜÅrŌ/؋é(BͰŋ12͌íŲßÕØā|ņ™Øô[ƒōp'ĪôQ)¯2cI"ÄÆ0r –žĻ6ëÚG“FŧüŌ—˛bš+^˛Ėésīi˜akŠiŗūÜGĐa~‰cŋœ :'Zr?Čq1ĸ(ÄĖ VœŊ?ɯ\RŒAFÔø3´áƒ†ž>6Ŋ–¤á$ĐŠZ†ōÅADĮ8‘kã]đ™îS7 ˆÔ"ļ¯î ˆ9šņĢ";ÚäÆÄŗģģˇĒSųҍ*sz[ë[+Gųu‡īZh5čŅĘˆŅ ļ°ŌäjÂ˙€N[5ŌK`7¸ÁÚ.čĖuIysü‰klėô°9{ē‘eX včsôÆÉ52žīȝ [uãŲÚKŒV ™œåp.#ķ‡rķ÷įX˙š¤y‰ ­Įoä“!wŽ˙ūũ>Ķâđcāäk3!˜G6fWbŪv^$ŽŨŽÎT†Ų`oēŖc(bKbõ1IE.ÖFcüâ‡# YÆšß$šMĄŠZB:[㌯Bså&aЃx˙?ēŪíWÃí*īûŧŊ÷ö>ú„mđŲMĨJA(H ‰ Ĩ šNá!Ĩ˙ø*ŊHE*5B\ $dž ¤´8HHŠ+„`m0ŠĪ1>nöĄĪī÷Œ1ßīÛvæZīûÎ9Æ3žq˜ķ=|ßúÖZz:ōē˜ô’.‹n&.ōJ*ž¸ˇ.Īž‹M^éĘ žĘēœÛ"4øđā`r=‘@6pô§œÔz0b!ë¸yį†šõ>ļ`ŽUāÅW‚5ņŽ,:zÃMP20xúŽöÎyĘą ËgeˆåVƒž‡‹_58ņ;ū,֐ˆãéˇ×øŗ˜‘ÃÖ&&<\8kģ|‚ŗĶß:•Črœŧ =[|ä jb'gjŲLĸA¯˙>|oŽã¯6ØĘCĖS7/L•ī; ÷`sC0sŦĪøōEä›´]œ—ëoãčĨ8˜píœnÎü*Äö¯ ™mNį{d‚IåxRĩāÛ;]ņÁ'ZÆ]CÅī~§×^Øh}ÁGí7$\ģ&mXāôÛšÂĒWj xãĄ5t×MģžŽ(Ā]į˛å‰“ÆģkŗãīKƒÚFg”x$LÄÍ-+;ã^cŌ1öŒŊ1` d}bƒ=›õLFĸ˖Rü^7jtÚŽ_fĨļāųv3ž´z=?úâÆé—§Ü ?8 n8C${”Į?”úKˆuZˇîg@\Ës!é…īØf¨­Y¨Ą ū"Ɂ­Ũäü×_•œ˜ųŠÜēČŲhę[ZüH˘ü‘QÛŊVmP-W_ødĄōtNÖ°æē´–đãp ÉqŽ@ˆk ETvúpË3r}ąžédË7ô|9#g#&CKėɗ•aÜ3įÄ8h;Ŧ ĖbGŊ-ÜpÉ7ëėœT`øßs¯ĩ¤æQ˜_í+y„å§ŸF,v¨q:4<ŽB˜2æŧ˜ÁEÜšJHČ7.zF­zPLCwX‹ŦĻ“5äf–0OŒfFąi^ø}äg~ ¤ęîąhí4Ŧ ô1.šā8)ÖÃ`Ī‹-=„Ú˜UÅĻckIn†2Î5Å^ũf܋RrŒImEŨ;Iõ#CvÔ¤Ķ[ÜĖõōËeĀ 5ąä´ņVĸŠ9ŲŅīCôU$ä^˜"â?m˛x¤„ˆī;B­Ø)Ī*ÉSwķbĻđUž{I~k$OđD`Lw` “áTt'‰fMrŊāđĄęnʕļ6â+túDۂ‹“0Ā44Õrė ×ô’üu<ĸ´ĸ8ĐĀs+{b`Ü/ņėŌŽÛĪ`1MĶncw0sļ  hįķä†ĖāRū!ųmŽÕ—ÉũåøN¸]”´pĨë9’.Ėō#fۈģ:Ž]g]Ŋ€ÔäE°Ģ(ĩŗöKeŧáÛq:ž āqn[%ĮíĒzžVĩ5 ¸Ė@}4wöͧžŧǁ‡xŗŊÁdmđā—Ŋ¸ģŖœá 'K§YvúÅŽáĨ‘SÍN]Á˛íŧÉĨō6ŋWôPoâŌ×€Ų-Í˙g s<ĒērŖW›sū'F$„Ēķ’Ä>5Š%lRÁq˙KëMlôõ5ô/ šh}¸ŋΉæy‡2¯puÁÃ7ņōĻÚÄŨ:ââōŋĻ”Ŧoaî&sķ3ŲEũ˙.zė í3NkŽŪ'Zb;ʘÁÁ ŠŲ8¤Mš]čā8Ėé3d}CĻĘHȍāzŽF0˛Zõǘģõnŧĸīhj˛|õ!Ę:lÔ÷ŒV]ŽGĸ挭É'ūō°§‘S‰ÆŽĐ9n~Ä{Łå4ęɜâˆoŒņUBHÛļĮœ{>ņĸ`ŲcßĒėYm×ÔĢŽÆW9Ûa^W“/ëXgæuB$Ėáļ;õ^Æa#›œˆ†n6V"K!sŌg†ZWēņT_MЈGڧų%ā§J;Œ(:ø€ŧhņã7Tųāë›đaŋ‚ZŪ%Ÿōô‰Ŗ4ßŊH˙\“˜L™č7đ§9Ņ偆äŊi—ą'/åËÖ3;ŨØg˙:­cR6“=Ea€BeDāŌw8:4OĖ(vųô„CÄÜ:v9% }öqH¸ęë:Ą­B„Íģq”vNbķGÁ<“˙ ũą5ŋØpq ސcb‹ĪčĨp/āN!Æ!vžÄ;iüŌm?î…üū}ɉ¯îô!ÁëÜËÄÎ@ΈŽ wŠî5ŠŖq‡Ț:õ5;kå>'Ë0…÷'=ɍ’´‘X,ĀĖT.tÍ ú1R†Msi čĒ\Č⠘‡úĩÄšp/˛4DŽu8Iŧŧ(Čą71ĖĐyCF>ŦFĸzBˇ­&q˜,˜›Ú˜ˆu,īŧ5Ü@–!&Ö@Ē÷HjÔēĩF1(}7N‘c8Nų°W)xô?dAũíkÁNEP؊øŽõ§8598Ā03ņ˙Æ7æ#@Ģ%ė°lré¤ÅČâF'†d§…¨~"!øđ¸vpb‹ Ļ­īķq ęĒk‡ løÕķ(–Z‡€n}īūčĒÄŦ*ÃŪ<6 Ėd(yĩÔüĖ>RôæÃ9H<Š#Ä!ČøkĄ@,öG?_Å4Éæ\{÷S‘†0ų׉ Ō°Hŗ˜{ĖÁēp ŋįôŅ= `ט/#ÆÁ#ī}1´ĨĘą‚‚$ŧ†į87#-‡v3žâjÍäĒ›Ø_­œ;.ÉP­0Į5Dƒˇ^ėfÆĪ‘îôz„-b+R ũ€zC[ƒ+ŋ!2į‘zßˇŌŽßQ c]q Ų~ŽÖ rų†K=ũ´S„‘GŲĩ˛>‰~gƒšFIš"įÂÂÍéÜGÎZ!䴝—ŽN ÄŧĀ4™†GĨyÕCįI‹Qæ'X†KUĮ,HÚ b• U™RˆŽ|NĀę7ÎkūĘŅ_¤Įã(8ļ<ĖĶîb—ÃhĢÚŊ5‡Ž|ĄŲ8 ¯ˆ{ßĩS1‡°5`–ˆˇö%ŋË!* 7.õė˜Į‰Uļûų.ĶËXAöziƉįJ7c $mXĖKšŲxØaĶ,rŒ%“]Ô˙>*Û=&ũ1}`ČĪTIC×AGġīã6ē †›Đ4^ÖtŖô^CģFå¯aí0žš20Į÷Ĩ÷Á[ĶÄDŽw~Ę` õ%ė‚\š Jâܘ5MŪšÎŸ˜ë‹ü°qí/ąļė°Ge‡Gn‘ĩžŊ&\7,Y[õ[ę0ąˆgs6SŪô@ī!Xũę(ũä˜XxQ2ABL˜’EĘ"ėšiHq5.ÜĢy@ŧ`Ģ žŽ­›špųLX v´×%ÅX žŊq b‡Ĩûī~9$¸:€iõŅūš™Pv͍ÁÃ0ąeŒ=KØhtĢ0Ī|“ŗdų<įäGm˜trg¯ĩhžÆC}¸ p,QX›Ydæ¨ŋjˆÍx8wÍ9Ä!Œä2§ŌŠ@5M’,‚pđ›2|ÁD”9ŸØ0áDīP^‚DŦh­ÖË~ÆU•‹ē°‘ŋ _†‘ÉŨ[Ų[ ãÜ.ŪW87˛’â›­ĩĀĻ}:õkpÖáđžLQØôŌđáDGãmcŽĄ*ĮĜÍ<ÃÉČŽuĄÎÄ×x„3æåhŽ3>7 ÆÔōPĨųŌöi„ *0'nŧ^ÍŅ 8Ī2Ų†ģĄ~iĸQ"ąĶÜų‰õö"æƒk,ÛÁŌ/’yą ĐuĮœ]$DŽĢk} Müc„ŪyhiyČꃖ7ŪČ R^ķ˛D–čœÉC+ĩ:ô­ ņT€ŧ ëüÃ1Tv;Ŋ9Ļg`‘9!õ‹Öū4 ŋ—ŧ7ę÷aĻ9eT†ÎŧĩņØKD°ū‘Ņūņ¯'â߸ĸoŸčļÚÆÛš#_>úŸœ.Cķ Ŧ%°ŗ.ėcWW}8 ß/rØųí žļģ÷Üņ:G-øJ‹Áu+@ˆÃ"&FxuÄ#ę=J‚;iDî%A%€‰+&`ļŽíņ†ÂÆØÖ¤õStCR"ųĖgLŠ#ļ­G ęķáņN…l Ģ7„ˆŒQ12į eę˜!Ö3’l™4Ú>mš9f3ĻÆVŋ#—›,zᤤW‹œ€Ū{ČšJLÎûhīgØÅÄšąz†ÎI:n<_\g˛_Œøde7Cį$=ƒ,o QZ‰úSõč'ž=oŦÖÉŅŲl;âކíų¤Ė#čæŗwoĖņ×ũÖĐz_gí\^šĮ5îÅ ÅļeŊŽđëN6bņĄķ@āÜZ3/n9L=JFŠ‚iŸ1¨urš´ŒMÄxĄ2ė`ŽųĘyŖH,đšLGŌčrÔļëĐXˆ(XæŽo~‰Ōŧķ9dä§mvt­ ēŽ+Ėđ4t´ÁWæ×ÉāI"Ėr놓;NāË*ĢÆųŒĻ›õ•ĄÅXŸ…4AbîyÜzÕyä§ŅOrΧ-+ũ’tOÁĐa īr2D™B2l›ž†¸pŒøBÕ0vŊΔ –…p•ĩŸ'%ė{RŖČ&'¨ŗ^¸€_$ éŒī9藇=9D8]˨ˊ÷FŠéßĮĒmŠ—úî[lķîĨvõ jĸށŋuwqURWšébCøŅ”#ãtč—?ubL.ĘēÃŪZĻyQŊHÎ\SsqÃ06ØWÜjh­äšėnVbČfCƒ[ČŠĸ9ęÂ6+ccĮ֓ÓÛ5Л–˙՗“Į…]$kBöÔŨÅíÚAccÁK=öá”ĀŽqĶßõ†.ÍdsTŠDŲZ É 0¤Äš‚5žúŠũļ+éqmÖš†ŅaōKžķī\6üQÛÔŦĀGĘ3‚ä{ųĀÚâP\*(uíŦgäjYũÖˇˆÄFL\T;×3gËģu ÁŠüÅų×ø+axdvØtT` ŒŠë-ˆËíš‚s[cë ‚Ånuaí"Ž‚kN€,nˆå"oš3§ƒrÉąÜÄ\>lhu ‘Ŗ‘Éhŋa¤ģ܊ŗ[ †É!Ã>čVĶÚ°ŸÚ‚ŅG}bƒ~•3*ž(Ûāl_”iäZ×w™8?ąl& ­Q'¸ķ€° ×p>ĘPCōēé\bņZ,и˜y§ūéŅÎŊ„ĸDN¯ų9đŖŠé9ˇõރAö^ūĘō¨čH…[니1_ūíđŒø˙50¨ŗ6`2ŽŠP'"ˇĘ ļŽÎq†2[›oęk ũŠ"Xôąs!ėšœu¤ß‡0"ō܃CČĖŌ`c&÷nĩnΆ9q2?ØY d‡(ũɃ5dĀĸ¨ø T ugéÃĩĪûĶW_”ÚQÃt°ņͧƒû*øÉ…ŧ{OėžĖeĀdĩŸëŒã°ˇ6Å!įąįū÷Ŗ¯PĨQģ6pc7qF mƒfĀ?k%ÁkÚØM*ĘÕ/§)-ėpԐĶ#‡Šú§.9G†Z‹™m1ČÆ7įƒör>K|Õ˛~Ši<žáF~Đä@B—ķ cč†-ą!JŗžíŠ ~D¸>å98øį™ĀÉĮ08mŦ€}}%YéĶ'jž•Ô+$ˆĀK`‹ë\ĒvMOb×vŽúÁÎÅ89$Q™KRjMęŖxúiS“ G,:=¯w.'ƒ8EĨž“cbu*Ž.!LŠ^FÆĩ“ˇfüYÔãGŦ;´ (=cZyĩ{9ZPeēZôbœä\`ŲQĢętvŅ0ÜÜ vUpĨa[ Î-åāîšđž¯Á֘…Ô¯d<[[ßQgbWNah>އp9Õ9öÂ3ž#å īŨÚŋ¸P ÉÂvņć„åæŠ:~ī!¨mÄĪĸë¸Ō.XúõšžËÛDvˇ0Āņ…ąōW..ōEĶĨ‘ë'Rß]ôPŋ.â‚-Gž5 $ûËw؃āmšŗx‡(bÖ¸m0ƒĐ^bŠLbâŽė¤™9ZEiט>֗<ĢC˜¤ŗ $n§ˇF{ÁĖVŦj¸GÚ5;ƒ=ø ’AĀ ŌĀ0[_,­AƊŗķấ~kĄļ|Ôbc ƒĘîä°øPž jģĪ:÷ÜMĒ‘ėŊž”p•~ˆUj#ėĩ-˛}C˛öķđÖ4 lŲäāÁ i÷WŒĮÚ ņЇ& p€p|ĒËøaĖĸũ˛eVõŅĪŽ{ üaæN{~ôЂ0y`Áez}ā_ūg54āŲx&ÚÂûãŒ:*€=8d>kŅx.›0M6ëūūÔ[:Á<,”yĄeÖEŌ'Ög­ĻC,Čy€"įl=ĐQgōĮ 1;ĢëvSįŽæ8ģŠ × †ārŨĄæžŖ%oHĸۇÃĻTbÂ_{”ƈ8'0şQɆ}EÉŅų5Áô!aĢÂãōĸđZ.üžų€0­5_Ô+[ųĸöõƒĻą—õ‰;ĸ§&mõCŋYŠ6Ā*\ŌAÃjņ1âđ/Iq*Š}|2ŋqC–PžûĄH8’a6æ{į øĮ6ų†Lú(ûGGvÕäč‰-ĩ¤^§đ‰3Î7Ĩ(Ty}šÕ3Npé´(ņEЉ#‡ô:jüpî6Ļxšdáp´1-)ëš~ØašÖV.2•åÛŽĮ™gxΖ.-ĸ^_é0‘Y; ĸgŋļeÜwĖP¤N‰Ã#ģÖtûÃŖœų›1=ē4Á 2'ģFUdWį3ēˇÅhĮéû=õ oíÖ~gMh’5F Īũ6XM×Ģâ:—EGrZŸo9+Ã;ōēĒß´álܑ‰ÅwĒ륞;{@0žfDß8F.0ĮĮæsˆõÛĀ6mNî}÷ߚíÚ#¸´BęŖį%$\w9V 8^ ¯*Ú&Ō…kČ āÄ['=ЎŋGŠQ' oëqú‡'ãøF=Ķ<lLČŠ!‹}ÍžØs‚ņKâ€E]go*]Đdn|r.fúSl´p”´žąáB¨ˇ"Ę5ŧ°-j8„ĐGžŠqLü”.{,ÉėŖĄĪFÍ9R÷ôéŌ×oŒéįxfĻNNģŗ$Ņ%2ÆãũĒcDfH‚ph,âmQ¸TGÕĒ17ö.Rō…ĻGhĖ]“Aú­I:ēq§ˇÆqrĻŽ¯Ŋū¯!‰Â/„ā—Dú@E§ī2woi0xÃË>5.Æ1ÉÆ›tš7üW‹˛ÁŽˆ DŌ0ĒĒqú‰oûØ`ęė-Göŧ]¨ËVrĪÖZ€Ũ|SŋÔ¸ua´uöĻŠ-2l͗Žĩ'ŪT}æĩ‡ÆÖZf0ŨīzwX8 %ˇfåīËžĀ#˙Ú],3÷Ô%&Šč×ūMÔ0Ûčų§B§ôcʍ5Ėn_¸LuÂMÍ`ĸÅũûI#ÜŖĘĩ s‘ˇ~îŠŗ‹9ŖŖ4 9Ė­„rAzd€qDc~° —sU?G-&ķLLœiöԂŠ5öąt^i´‡")ŊmãČā>ūąĄÆhōēw&žŧVGŒŊ. a#VoĐÁÔĶđ%ĄūĨ ƒ]äÎî.žA]úZDÜ6=nÂŗÄ'0ĖsūđŸļY§ãEwķ<ĸ Ŗr@ž=ßģŽ0‡žx4¨(‘Ĩī1|WTĻŪ{Í´BQöІķ!SÖ¯éúÂŖ|*f‡đįCC 6ßŊ=֏Ĩ=k;@ķcá҃k īõÅ~„ĘŠaąs„Û{ZkĖúR \|%’i0퉈˛l3wCŦ/uB.ŽņĢõä Ô׎ĀÚ­šÁsZøâ“ÔŊĻZœfTÚô ŗ;×ņņáķWôüīáŖ/ŽšzŽÃËyŅ‹Ūáąjđ†gũ;Ā\„ĀÎnø;?t’‡cwĶŌgâfP•j°ĐËÔ˙sĸ•ãcT ŊûkPÆ'–ōvæ|†˛2žŽõ…|¸üŨL0ĖQ|k2ö{Νü#Ÿá …ŗņõ:Μâk)̃ú1n#t¯é -,ü‚<6ZFˇFÄ3<Ö ÄĈŧæÁ¤+,ZėÄÔÖŒYMدq?}ũr‚“Ŧ5î‚ÕFGvROåŨžĮkG0W—\4$Ņž‡äjyâTbŸØkđ‰á=.L9,\vĨ#] rd‚Y”ŅžœČ7ęr1F1Ē‘åp.āĆõô-ãN@ŗV —q×ũķQ+fąņĩĄ„”S­F0ĐZƒučžSĒŽIˇģđO¨éwÎVYgîĨœHĸûĩhޏŪu›ÁÔ\>õō¯ Æđ5ūÜ!aØnš FCãxę€ 7_ŠlL‚ŠėüÉρSˇ6ÔN&FĘę+2‹°žsIJ¨ŦÕĸ‘ŲĪ/~sąā!ē€ĮÃÎĩ˜Øî|ËŗōÅāldÔڋTEīēŧxVĢ?ÃŨøā .5ōZ3´uԀ›åŅŅČ2ę;Į5ؘvŽv™'ÍĮ|ébׅ ,/ŽíËû¸PÖčH"÷{ŽT˛įđæ[ ÖK{äæs’Ãr4wŲđQqģž4Ō/‰q-3ÄâĒĒŅy€7ąn_7Z1ĸŽ‚@Œpqû¨q0[rb{ÕķZ+‹ 4Ō™q¸āE>ĢäŽoĪs‘ã;â/ˇÛ@IDATŠ_)&Ž@Œa6Ėë„k7áĸ‘Ą+Œ=sÖhû1– Õö譌PÖŽü•šUĒÚ,ņŲJ*žv.[ ŽŦ/gBjŨQĨgØ`éÜųCætDŧk LQÕģ§Žō0Š–6›gœ˜§–`Ëā^ÜØŠ—ąģåh oÆęĘ3´ÆhĨã˙ŦGėđÁ…ÎØí;Đëĸ' ŧüÉđĮēŠ gkĩg’t"‘ŧœŖtįMū§Z`]ûÁĩÉŋƒ`â= :;ëgÂsŲ8ÁUī˙d8kY÷mŠÛëˇĢf¤á|ˇƒe>ąĄĨīuYąÅO\¨ÉœëÆ'Xyjwø- Ę<ģģ†ÎBÛk9˜¤—%ˆnįūîú#(gÚԗKĻ(†YsIņƒŅ]n—ˇ™/ŧ‘Ёe˜<e$öīäv™#ļ4ũDˆ\ŨYM–‰kyô×úZĖĢdĨH—â›<äj‹\@ãĄËÛŊæ ^k^íÆTGÅāûŧAĩĪT{ Đ¯N6Č9J š[ę7ŋBÆ[œ A„ €K"%Ũûcˇj#DđyĨuNX+„Á“ÃIŖå6ŨMášG}ŗ,\ÆÁ{c€ꡇ}ŋÄ ŌKė`áÖdē-úąÜ`]¤Á9Ģ8ÂūŒSȍ=|=čá+žŖa|ąë jã_"ƒÁūQ&ÅŲ=ž¨em~\„Ų†ßcŧÎVÂ“ÂÆË<šHsŽ'!všRw:m¤Hôģ?ōø7OЉbÚũ$™2üøMéÖ]:ü(•::S7/Úá ų ‰VzdÄcøģjŊŅļ֍Yüož'#âvēĶę!6™ģģĮ§“WĪŧmdaؘGļąôßūø"~ˇHâcĪ=eĻļĖāē† Íđwvæb|Ėą4.×JâržĒН‰ŧ[­ALZ;/NWj­ulZâh\M!™2'ˇõÄ™NŗMkfĖ…¨bĻî[Í*mėSëäb ûā€ÔŌ/Kcģ|GŸAë´7+VøÄgö6捓Ž-]sĒĖŗŅnübKÍɏŖqüEįē~gVlz=™‹6āÎ5×Ô­qãĸŧË>Fōˇ(H˜#}í\÷„Ũ2•‡Q6_d cœ–ĩĩzâģÔ3k’ë+æÚ`Į<Ҙ‹öāšj"ÆņlS2ũŨōS[ÍbŽ€âá°D€Čƒ˜Oũ0Ē!é;ņŅs7kF“ŦMĪŅČĩ#‡ļZŗG2k˜¸Cԟȧđä8~]‘Õ[zmåf=”Ÿšéf,Qn10ČfŽĶĒųXúGÅŪwe 5‘‘kÃb‘O\ۇ‡zā‹ŲĻxĻ=¯ëylē͞ķH”&.ĒņÖuˇķEũģ!ĀB‘ã†îÆÕĪXGb­P¤ zSĖĶų„g#ęšQ€>YŸž':*ˇvøž€›ģ~öJ„õD˜ãÔYžÆÕØēVģΨøã5ĸöÄÃŋ$íšūū‚E~æOÄÁčãˆ#!FzâáŲ6 ŗ`;Ņ3öŧÜsSA Ĩ`WsËÜā”ųĸ˛O-Z“ōca9“t×ĖÎqiŅbŊuØly—ŗ^†)Œæ§õæë:ΰ“JåCsŽ• 1ÂAđõÔt_lŋQÁ–Ÿâ-wū‚%ĻĩZ?Ôãž!_]]aá:&3ÃdÅO(zuŪĸEįĒãė4iĪuĘPĐĀ\ü˜išãæļΜ’Ani‚ŌIũy=Jūoxã“ų?‡Š…ÎE“ Æ)†U='}…˜ÔFĄ"î|w.‚ŠĀžB-ÂpƒˇÕˇ¤Ë'*vW(QZ1t9éĪ;€€sđ?ÃõˇŽãø2øĸqápŧPU06¸-wĄæ;t0ĩĸȅ@T駎,+į4ãfĮ8}ņu‡O/RžöDnŽÃåIúÎLDÖ ˛6ų!ėŊnú°‡fđf‘x‰’w÷;Swđ“Û=ąę.Ĩ§MōڍŒyÂGÛÕQƒšŽU # ”­ļ=ŽĨvV?f`ËÍEŋë ä(čgs­kÜqöbæøq‡%öXCđ 4hÅ1ęĖŲĢYyj7j×%\Kģį/ū13Īß]/ŗ>12H­u-"$c!ĻDd¤ŪÁƒ+ÅD_rē+]ĸ Ž‘\=kXęĶ*õæ6ĒHKĮˆb/ĮņÅ ēöØ<64‰ ׊Ņ(ÁĮÁŽĨ`EgL,‘aqčĘĻ2Š^ëdÔaäá_XÃĩ^sfÃĖCo ŖŊxņÛÛ úđc恞÷°­’!0ŖØđ&s¯={ĄŪķâXß9č4^Ęw^°Äaa Æ˙˜j°še`žƒ6”â8-CÄ-“=F#$?@đOGôâĸP˘‡Dę’>o–å`ŽŲMˆŦ P”h#ÛH­˙]^Ō$væ ēīnŽ[ĨÁžAac@Ķí9ĸP°t°Šģ:Á”ę’/ú’¤ČĮtiĖ‘+Myô͚'Ļĩ—wōÅÃhęĖ84'´aßÂwG 4¯Y_‡žķ2įļaˇÖ Zņĩal´Ī8kųΏņë˜5gXMÕc}öŪ&|Ņ­éIHHĢq?ÉU›ŗ CKõu؃ŲĘŅ›ëh㇙ë@äļØŪ5-Ąŗæ†m|ēXĪ4ĸiõ‡%šj]4ôc‡ÅzĢ:Ø|ƒ„sdGža s ŗmøŠ Ũč5ÛA1>ĮN͎__ G/1äĸ`/V„27ģņax\€Z”ˇŗnlc€JlĮ&G—ŠÁ1Īx|“ ņęŠU:휨“ žp=å jĀZ”ˇĄ­œÅŅxt‚E Â1 ށHFķė ŦRã^96ÔĪwšYךąÍūyŅEĸ¯Kn•61¤dĮÍž íc|ĩ,’ĮXUē€čIƒĘ^ņĐ0éYžYŒáėŋ4hčĶÔō>>ËmNYOōÄ ˀđč6Iø‰jĖƒÄ€ŋŽLēÁáŸ9 +bØ B8?´TáŦq§[ĻËËZlŽTˆ"˜ 1ę sŒĢŖžõ}ŠŽMėz‡+Í2:ßà û€†;>—FÛ¨Ĩ1 éräHt´b“Z•oÄŦ\KÕ‚1VŨjß}YīåšˇH ŽėfÅçŋė5‰ÎĨ'|`CFÄlõÕzĩi[HGÆ Wšƒ‘ssŦ?0˜`rä ”v;×"čŌŽOcēVI#ÁžļžX_Õđ#YcWÔžš?>5;ûĩž×ė…2¸Ápāŧ,#_ËF‚ ŗ:Zé Iyvm6‰I4­vĮššđĄÕVŲØ`2äJ744iaŠhYĶá{îEÕWR’Aä"¸ôáÖ }§aâ‰|ų÷‡žčĻ!)_ö6âdr˃ˆŌĩww. ã%sČÜtŽ&Ŋf1ĻÁģvŽ”ĩÖčÃSuąz '÷—é‹"_y &ļķ QsŲfė›@ėL1ˆ|ąq|f,nW‹dælšŲMøÆúŪ0ŖãȌF_ŽË06û˜ķ öDL$Ō¨ Y‘7Ōė¨5ĮûmüÔ Ô 9Mĸb4[#…ģ:ØÚžCîųĨÜÍ+#ë` ZĖcBq¯<§.ƒÅŠË@ãkltpõšÉYvjÁ5‘&Vם%ƒ$;ߍÎi ‚ņ¸œđîVVĨũĸtë? ĮËI ˜QÖÁ=ęœ ŠųØô'ĩˇĻŖ‚ÆĢ;œÃ[n Ônĩ;‡ĩhE6Ę16!bŲ~uŠÎNķ55‡m×KŸUŲÆŧĐËnEï Ą÷ĖD N,@䔝ø"'Tk5|^ķ'îNëØÛrA3|ČĶŦĨ˛øž˙O˜ģŊņH=AОˆŠ)@xÁ¯*nf´ }ЍĖčÃ.b‚üHQ]h¨›Qtæ¤}ŖŅplˇĪ‘wAcÃ+[â{[‹ā$˙Z~äÆ4oą›ļÄɅ‡~xš™ ŦąFFė;Ié‚Ûp‰„ÄЉ]Ã;ģĩDŒtØ;'1#šė` gƃ}dv#_5ŨQÃ^ Ī.Tm5A)Æi!ëŌčbÉp Öĩ1ũŸ’(]}ęo=¸øĐČŊ€I7Ū3ö„VÕh47† nAļ–D†Ũä 8ŅÎÜĩ•c—Üüų~â ĸČĘ(/Ē›ÃßV$/Ā0Úĩ~įeßÉRD4ą]šŗzÄŗķĒ/č¨9ØĘ(Õ8kČ™ÛĄv!Œ^ŧũ5€tâđČʉ.ßđāͯ™›•¸æ hmá+gcB>7=?1Ĩ]:ĶîãEôúą‹!¤°÷waꇸ}Ą&Oô9EzÃ+öz™:—^Š›y3‡)×!בMޤŦëŖ+!#]“q„ƒÜv0Šū¨/~Á5úĖ…¨uWÆuZ/9‡Qé D’|āôlŒžĢ6t˜Ė5øáz8ī:ĖõAgËCd>ūG/„v#7îĩ# ƒ6účžn Ë2JŠÚÜ<ß2ÎČû — k‡ [ŒÛzMJĐHWĪąqëĶpŲÉqúÂ# ¨šUĶÚ0v¨ļPD‡:ŋĐä4;ąŦ—eœņøâ@Ė^ļĖWˆ 5hķĨ?˜a›qí”ĨZ‹-ģž0ĨČ'>ŲÂMĪú ŧLS:đ*§žC֐˜Ŋ9?d‘)F4ú4 ā 6\áĐš~FVĶą…Øß;•ĸĩÃŖk$j#Ÿõ]pd“{ÆŠ1ōk b|€COƒŒ˜°¨N5<‘{šŅÍ×ĸĘM,‘ęãŒâV^”!n‹Ũ t 'vŽĢaąGæ$¤O0ļÖú^ļŽąieŧ°ĩEÜnö\iņ/ bgāô”C…BđĒXë1ŌŨÔāđJ.ĻĄã`üFĐŋųOâ¨N:ųā F1íšūė‡QĻCk@ũØ"q1ž\MŦ˜ž_õÜk}Ÿo¨Ģx¯%ŅOŊGá,œ9LIš_î×^ƒÔō,ÂųPæ`"‚,võ@œČ҃āK;øé×h¸ ŽŒƒ ”ø˜äO~eۜéĮG|Eß%ė0{ߟ)ĨqF"€i´Y‰(¯JÕŖ]zG†c6 “sDžâbŲcG°ĩG_äÔÂ"C¯âŋûXáĶ Î…”>܆Ą=ą„#r]œ~Į× 0x×íœéōāŖØú”mu&z-öŖƒb[sLbCŋJb].t´(ĘI8'ļúˆz8€íÖ畇ZŽË\Ŧę ĖGĮČÃGÂ^¨Uuĸ}×IĩģåĂ™cŽ•3y›ĶÔüD*¨|ˇ[×RĀG+Ī‚čpÆ;ĻģH‚] OYN…ėšqc—ĄôĻĖÚFčkđ5ĸ×Ŋ“ã ˙[1¤äZ¸‡p3dú¸âÜ,WÕp’Ī.ƒ}čÛüúÎ^øh(ŖëŊÜ#šK!Ņ:—h“1QU‚´ōî#'Ą4ę2]FÍŅēÕ†fG×ZĶÁWĢ$]ķŅbė%§=rŽõ vh¨YãÁWl(ßaĶ]žĄÁjŽÂĐhšF=1­|ĢĖqėŧ*j1ËkÃųWĮš ,  ŽØ;Œ ¸6ËJ÷ČZoE î9ÜŊŽã5đŒŒ‘ņe߯w|"¤­ Wc§*|ĨÍ‚Ëu}}q­K”ãŖ+U+[Ž]5õ5€-ũ|ãšÚ›ģ´āĀ0§Å0>ÍķLĸã0Íč3>>Õé@P´ŗ‚īō–˙ĄH\ČŖõ@'”Š2˙"âņāŽ\’GĢW›0œ9†#6züÔwҘvMīąf<Œ?yœ¸—§žē'6žcálŪāŠ{¸h!ß>ëq̟ÎÄ pE0¤W$ŪOn.•e´@u‰ĮoōD!yöú—1:ŧ¸2’†Ük S(§ Ÿ˜ˆm5KܜöäoÜä)v9 aWOåBUDđyO ¸ˆ(‚-×ÖŊø *vŠWKjœqT­€÷/Ú9@ĮšcáVē14äm›ŖŪøPŗËap’'xCÆĒƒŸ€ä’ļv,\ß=QÄØ•­†ąˆT™XxĶ÷ä• í}cކŨđ9ŠŽoÜO0 Ųhõ]~cP0xQ ‡Žkihģ Ĩ>jĸ[–LĮî Ĩ:°ŲLMᲛøˆõY|Ø)Āĸ͓1šrČí‡N6ũ%.ÄƖ>?Ę—95í }1č;žŧ2bÛZ›€ui)‚Íwãn, HĶg€`Ÿāæ{*lr#5d€ÜŲaŅ\,bÛ5Q™ęė`äĸíĢt/0Ø7ū2u Ū‡;ęąīxDæ˙bˆŸëŊ1™‰ô…‡\Ųá9÷äLˆyäˆ×ž3—ĪÆš1Hss,=Œiž{Á\ 0J_čq¯xŌ&~|÷ߘŗs“27^L”Žx#EĖ:†~Úõ ë  šė §í{ëčZ[tŊ€E†)IQZ.Z5Ę"ļÎ4ūëMņ讛…•õÅ[šĮ†&žĶ"Sœ$=SPdĶkŨ.ō­A¤ũxdĀØŒĐˍŧųbmACÛsTāķnįļĐŅ™›ũ^Āē–3÷ícä˙p’Ž€Ŋë™ēƒM3ÆËŽÂˆ#›&CüžeHŗí9V$, ×Ĩy`[dK*)(°ÎÜt~bŗĀčכ×åc„׉…ŧ‘ĸÖb õČŽ5eãÕ;˙Ŋ5Õ9Á؋<œÎNrôO‰ÆsĶ/|Ĩ˜1ÔqtD žz8ųâH;5Ųu]b ÂdBˇf‡ÖX5Ž ŌŧSPOãYâ%`!écÃēÉwĪ>–ĖöÁÃdŠī0čŒŊ׈KžYc)Ču×ĩrm~S!B7žá¨k=A„Ä}!ęØŸ9Âgr¯ 9šT-ŒX7pĩëė,hķ7ū0ųe†?s, Â&ĻuBœ‘g(3uåAá1î5rŦĪ킰=ŸvVāVwĉžŨfŽŽzUĖ&qÁ¯Ÿę°ļæ+õ}UHđ ‚:5=ÉkTĮȔ¯.įT#@ãŠį†~ė ¨ ëtūlī82ž>×GPÖ3šņũŽM}îũ Įhügb .Œ 4î ‡Ų÷ŧŦĪÎÃ:Ꮟv:‡]Û(Ŋv Äķ+0ųŠį4¯ûžf×õ™$žāœC­í$^ŽŲÕŽdŧ Ë'@;MĐgsím?Š}>āˆ9į‘į¸Žđ“|}Ķ6`Cš†ļ'Ÿ|21d„ččëTlĄ} ŖŨ„ឋ9M–éāĮŪŗO߄‘Ž VŨĐõ"ŽzoĻLLäE;ôxW1„“6)Ãw¯É[kŖ-O y¨ ĸŒ­iø¨ÛęŖ=†ø+ÅEsš"Ȑ°ËæÅ.GcŦM!˜äAq6ūNĘĖiĸˇđаĮ~n¸Úác} \}AV5ļ4äl͛>4“VĖžpŅOÖzÁn&†J>hįž6Ō×Ü=9ž6ŽˆĐ\|Á#S;âĀķUUpÄ∨WcKĢsnļßHÉ)‰’‹Ąd —ŌQ‰.ŧ{îšëŦŋģ.i ¨1™(ė0×&Õ`xĶ–xéŗ:ŽÆŠ}›=jrDtāʑ:ę kŖ"tHĀĩ’ā2ÂwŋÕßiEK5ËSÂAs¯ĐåđÕĘ=@KÁčÄŀH`Ų-y+í>]GՃ]YØĩ/˙^Ģe\\˛FčPî <ŌĩÖÄDėˆZfƕ]9Ek ¯—7OéŒĒŊ:Ų=6´Ä{8ˆ=ōŒ×ĸŋx˨>zūaGŊXķČ÷Ŗzķ×VFŠxŽgā9ĨM¸ÉĄkX\Gšû@´YĪ>oũU€ë\8\Ŧv8\évô!ãC> Ė&ÄEHĮŒ9ru•*D|𓨴>_rÄ<;°vŧFĻ_žT×8vŊÆ&`ždd‡ĶlÆ­1ąmŦËŊ–ßÚnLAœø$bxvŽq‚›8Hwīŗôˇuāę‡ö5P0÷r<ņ´Swž­Õ†ÛÜĸ@ĮõÖûĀąŧÖkÃæĄzėÍ!¯ãŒAcÛ{‹1QØ+˛tYī­o-ĮĮ},ĩÅŋKâžqYÃĘŒW† sĶ"}.$ë5ƒal$[`ˇÁ‡>v[;oœ\PĘÃlębiÕDfPą5ag*ŨØÁgN9Āąã&MžœO^4¤Ÿ kéŲe#¯GdĖ7>ÁĻåŨäÁÉLžjŨXę Mk1&Ua5Ēb›8{9ĮÚã•x‘5‚X °^@;ŌSq9ŖëÅ _äęĩ6ÔŦ/9ÁqžÂŊs ˜ļĮËįĘîã-–\ÛĢåŽ#DN­Cƒ*Ú ģâąCšm\Ĩ›Yd4ÖĪ6lW~į0ę–52;ØčMž!žü ”qíĶ]ߝĘpšl“ĮÅ]ÖœIF–kœ.ņ=b;æ˛ÕÛ‡ûˆZGtÉŗĒôPĀĀC[*‡ĢÎtquĮXÃāĀGÉaLƒķGũŦ-Ø­åāŦ/ũZr$ ,đŅZŒū F/‡Û ŠÁŌuėĐUŅ´sˇëŗvF•n#=Ēøt•KŖk¤Š~ĮĪL€K6ķSáQtˆ­Ūę‹ķ´_7$qžĻät´[srĘ×Ä8¤wÆwü]Ėå͝F2NeZŗđų‹žÄHߨæŒąō|¨špĨO\D€Õ51äā<Ūå,´ÔLO;æ¤ũ7šĐí<,b˛<}ĻJƒ‘“Ņ"z˙‘K1ģdį>Đ3W$2đj!Cäb„ŽUę aO÷ZWÄ ]ōë oųēÔl‡xŖŪdąÛĖfŒš¨(ŌCÛqũÖë`\=ÄĄ:Šäž‘Ô7ûá3€ĸډgã`x‘›8Ã4ōc25F…ŋiWō@<ņ4ēėi8V6úĸŧ։#ô]kåŸũ™˙Œ`7ęĪ—Ãq‡ĩ†˛5ėš‚byͧžqÍ`ÚK`ĩˆ,kß{.’ŊfÆūpÆBē댌8ČäÛ8ÃūÆ7ÎOTLt!f¸S¤ÃjDMgØ´_Đŧ\Üŋšj•1+QŽ{ÁEvß\í x•Fķ†ŽŨâ+ßøveŒÜø"C76NĐlLŽ ™["„´SėĨC1­uę~ęREíōļõķĄĮZdĖ„š˜đā Õ\ƒŗæ‰…^Å:FĄ!R6õ0Den‘žœ‡ [j: ÜēÁmb‰¸qđ‘›üĪÔW_֊˙RČEt]k žrZüË`GÛīÚŨ‡r”Ôƒ|ų ƒ˜][=ŠO ú;†é&†iŪÃØv$Ā›'fŲ_cÂJP6Ņ8˜<[hyēCNÛXáËZ×}tųîI‰R8éĩŸŽŖÆ_.ū<€Ŗ¨ėsņĀđÕWōNf,XĖ Ąužē—( fx ‘)ÄČļ‘qvŪ~‚š'Ÿh´…ųĪ€9⤎(û`(Ûp0’Bėéč%ļ\#Ī÷9W´Đ÷H< 4b¤%‡pœ˙Ŗq WŸÅÜīą¯(Ŋ3+R“[Ĩĩ*Ūõ™XZ ,Ė4åÃ&ĩË˜ĒžúJ˙Ρ˙áS;đÔ§::š9]Ŧ˛OnÔ*xcÃf[Á­­â;Ũbžį‘اūŅŋšé3—=ˇņšÆÄÚĒØ^,ÍôƒhoüOØŦĖÎņ}ėĨ^DŽãŖäŠåÕčWև°ôƒķ% $jÖ ëC\ŋ⋨hXÛˇĖ‘ĶšC{ų°Īŧ°ļ­ņB&ëúãô`]p|sŽŽWzĻ Öuʑ Í4AôWž<"ĶDČ`‘ c-ЈƒĻ4ĮfŗÔ\멋•íŊœį3!õ×{ærH8œÕŸ}ęj˙uĐŽ%ę˙SÖgb_ÃČxÁpQ ŋæ–\øō•øAë?>ü%aâė<ĐkÃ?Ŋž[} üë1ëbBōųAųęRAŽišVņ§ÔĐŧĐÁBl`īü4>=ÛRųSĪøōa¯|›Ëåįo9.wˆč˛ŖŽV‡<­FOACˆ›u”´"ˆHũ‚”šûßZË3xûŸN8œ_ā™OB”)ĨŠ1.é–qâG<ˆkåžĪqT6š)ÎĐVÕqũ FžČ_ã%‹¤õĪŧāŸõáy,Ak:ÁiO ÆØ•TöԎã(!Û~oHō7Atwzl5ãĮ}‹ÂxF–$yŽŖy/1öR„ŊōŠîÅ-ępo]s>á+ëĄį|ã§÷˙Æg}Ą›ëĶuH=ßČ”ĻpĮuˆĸ“hú m‘M<FŸ…¸c\˛í›^”–d8ąņĸĀÄČąXĀ$ˇlœ0č’x ē”ÍĪ´ë \s×~ƒĒ~"ŅĮyh Up„ßąŲ§W۞X6ŒZx{‡Šˇî ŒųN9vcŸ1Ë"Š%ËäE§ ûü72 ˆ-1Ō'›Lé!Or_yôČ 3$.đ^|čČÃˋ/žx{áÍ/X˯|å+ˇ¯ũëˇ7æÄĢ{*„Ķ:nm2´=´B":Z;øĮˇšø× LĢ_đúˆ2ø2#cÎX‹éo=ã¨;úÉØŸQliÕ ęX{P47—tDŽ‚Æą}"(§‘¨eĖŖ_Ė×ÚÅ™ˇîE•oé7žÁÃķj^4?ųÔSˇīûžīģå/wŨžķíīÜžđÅ/+ų€1Ē<Ô=ũĻ7Ũžî9ĮßøÆ7nßūöˇûbÁĈŠŧ,mޘ%!|ûãÔõP뤑‘ĩ4ä†Ō–ÁĖŠu”<­\/bŪHƒõĻĻ ÕŲąŧ‘Îd ?įĄs0 Ķ¯Ÿ]Cׯ8—Ü]Ŧt;įčišč& /ĸ€pcodqŖ–ĨhÜXLŌKÔ!{%ķõļˇŧũöü‹Ī§ö\Ŗšũ—žôˇˇ¯|åˡ|ô20WBč[ WļĄOü4]įF\pZ ĩgëu¸ķ0ãČÅY;P]O _xņ…ÛS‰ãå—ŋsûú×žĒŽz,˜‹ũ HįĨDx„;ē9´@+GŊ^Ån Đ-ĒŨīœ ąĮnÁŠ_DFOūö‘§T}âĖąb„ĸ6í!! ÔŦÜŗŌA"Ŗ€ ‘!ĸŒctX&™2-Bte<æņŠšĀ$B¯/đP#Ėēāđ €Ėe] S  4ũË{ÖÁQ(háŗØĐt™†ä:ˆ™x d žŽ„OĻqÆ (}•e߆lōL¯ŖÁéŖZjp_ËŽ}Ž'ŖĮrŠk.Æzš { ëÉ.ĸ;7Ô#ųZjjŒŠˇĀąÉŪČąU‚ŖFdÎĶå`ühŠ auōĻ4eŗÎøÔ§U/JYžÆĐŸŒöūŠHØK74H;Wĸ˛79ļP°?6rÆo[!ė‘gg­áq’ĐĄ .Ž=ņĖē5/tKÍ$DęŖ+(ĩ!€KŲÖĶ*ä; fÜØ0Æ_ąRv€B~Đ´ÖĐn%ÆÍ˜œö ƒC\́[õŸÚ„5b4´ĄnÚ Mrt:•÷žČ" ž*§ö‰žGrĖld“g cOۑ߁”ėšŅˇ11á'›ßaƒ Į'Ÿx#ņFIĀW-ã, Ո \û ÎĻ,”+Ją/ÄĪ\ôʅŋyįŗŌGƒÃîÚQúM¯W…īŠ&*V‹Û4ŠdˇeZq¯ƒŅŒķž Lƛļ5К¸‘wûÂy€.æv‡ĢąÖĪÆAl“!\ųęÅ/žáSœ'd=ĄÖž|Œ` -ēą0&ø°ߗ(‚ŖŨ›.q?õÔ͎o}ë›U͞ËWžķíĢo8ĘIūõ­å'`LúæwŊ^­cŲPˇŒŗ^\Z>\Ž]TŅ‘ÎÆīDŦ/XŠõ…|\ĐāĐy“?ũc¸č——X؈ũ}…ÃC#Ÿ3†˙ęč‰ŋņC¸­÷r,_ĮŦ)íÂ}Ą!{$ČxãĄŌ(īîÉ'ŸōÅįPc͈ČCŨˇŋõíƒâa“߉ėyVVx‹'F§ÄŸ~ļōŋnĒĘ9 *pÔäÔ°.[C8#_tđaĄXjO<iیŠa'<ē֜}ÃÃ7\Ŧ;b`ndŒ_א´čî0įĄALüŸPļŖahį9äOú4P ‚ØxąöŪ÷žīöÅ/|ņöÍoŊ„øĄŊđ ˇ¯}íkžķZOŌ6bÖ /2Ė=˙M¸qƒ`}ÆŊqˇäPkôČ:*%ą×ųڙ\į°(Ļõ§ ĻžÚĨúÚ5>>A1Eဝ֙ĨˇXdŖȨ˛[ŧHä —a[⠃ōbĶ''.¨qŸ‡įLÖŽŠŊ—”˙,d)6‡Õ'8/Žzņ;Š`¸đČÁŽ“(y†.’ĨÚ=9’¤6 ČDΏõ2ÂFŧ í)0‹[yĢn<%¯ŒÚöĪdn’dĩH{U~ŲĩĀāę_ ŠŽW¨tę‘7&8°1’tŊÄ]˛™Ā++ÉFŸƒvČX ,,RZ¸Y$¸p>8â-dȁq¨ƒ‹Įqî/ÄÁŠANhkCš‘ŖÉây’w™ŋåø­o}ëíŲgŸŊ}ú͟éĮtnũuIÃ5'đ4ūũíĘčɤ_i‚‹:Tš¸ķ ŲŦ„’k\ųÉ`۔1 á÷¨YúõÛ9m">8Ķ y˙U=ëMADXË`)÷A uu⨞§^ΚC$Ų°%ßžĀ†öF‰zėéqáCūjâî|įoÅ @ĀXLĪ-â¸åÚūØ9ÛģŊãīČ;É_ŊŊôŌKyėģÉpņpųÕ¯~Ĩ$ŗĶ›žŸ”O֙uš ë7ŦC×Ŧī~Ŧe)A'zr3/v÷XÕY‹°äm×5gq‹†€žÚÉĢ:1šNDJŨJâXœ Ž/QđP‘ų:0øÎđw *Bn ‘cĮ‡Ā↰} ÄüJ>ĸõū÷ŋ˙öŠO} 'ˇŸúПēũÄOüÄíÍo~ķío˙öˡ|ä#ˇ˙đ>r{ū…o/}ãĨ¸ÂãlķŽ°†ú"āä>Üæ†Ōx°ĢoD{ҧ¯ŠĒäo˛–2†Íô¸äĮß˯ôã}ˆ™‡§žz2ņŋ\¯+ŽũÔúĒĮĖŨgF"‡čđĨ3vÔsTč2vŗĶ,k.XkŽM‚ô8ˇ‰pdˆëŦ°ģ}_ä >Á]q] ė‰'TĒ ëŲëzãlT y8_äZÛŊ–flŒÁÃ?Ŋ†ČT’(z_mJ׋ŗŽīëŧĢo<˜s —~ōS~k‘/čâ<I]ŅÍĩĘŧ‰‹s#1q‡ĸųA9#:ŲæÚK—éĸu2žĪįNo@1€ÂF‡X§īÚ`P%"¸°ëbô{MĩÔí @éÁ›KE]Æ,wãœ'ūYØpSĶ{Īå‰D]FÄļ5Á Cvá.={ļÖ'š3ļ vÚŽÖ¤ŗę=%öÔVũŅC^îĩ„ˇuåJJu#$Tg•8>=Ä^¤ÆÁ/J§ąVũXTdžįÍĖL¨ ,Íß?s+ąF†~´ķ،ኴvŠĶĸLŅ›9ԃŊÖíØBNÃØLX=äČ9E yĄë<Á6¸;ß]ír?>¯/ŧi۟ĶG™ĢČŗ<Kbk”™šČ7dą‰K—†œkdä(~t×9+[JĄ…æuÄ ×B‡RflÎÁJ€ MlÃn4€Ģ*hč"ē.zØ5X1!Ií)[’\W°˛¸Ņ(?ėZôĸ$íQį:›FéME.Ă )páįĘˉ/fāŽXOᐏ\`ɝņxdbĻfŅ6žv똭 ÔÕÂõ,k1x:%X:§˙kEy wO@ÆLO&Ø2ČCAŪŊüĀ?āÃ˙¯ũÚ¯Ũ>ķ™ĪÜ>úŅŪ~ögVĖ{ßķ^߉ģš;‚rĀÃí5/ÅĒHwls¸_Ŧ75ÚmōÄĮēÄ7ĐIšDÍÆĐGüJ@ŧ˜ųøÛûŪ˙žÛÛßūļŧCۇ kēœĀĄŲqœš†u%megžN(ķulM īžC$ąŋz{×;ßáÃ"Gƒ\& _ÍÍøyØįá˙Ÿ˙ķ˙ųögög™“Oß~ķ73˙_‘œŪâÍ˙ûßõ.ūų—Ų9ûƒ?øƒÛ˙ō/ūE~’ķŌíûŋ˙]ˇXĐx¨Ų‡‹îÂŪ@Чlģ.W?ķ-ŲĘ{´ŽØd¸į6ƒēAZnötīëŽ7<¨ŗŋ=Āp>Ž+Œ gōÚUįÕåPÔ5Á>6§öÕ˛z^_Œg…ÍqķEē<üđƒôá˙ü'˙äöûŋ˙ûˇ_˙õ_ŋũę¯ūęíņoūđ¯Ūūé?ũŸtœŋ:ŋ_CFĻyM\ČČ„ØøI/ֈ*ßŊFĄßf3OÃ8ÂqÆŦĨįžÎĩÄÃ˙o˙öoßūø˙øö[ŋõ[ˇ|āũˇī|įÛˇˇŊ-įŸ‡Ū álŸxB įz'&]{Œ”u5kÉķßÚ'ą…ęū(ÄPėÉ­ė­hōwĪ`š„ÆPU™˛Ņ¤‡áø´^ŧ˜ĖĢajÚ9V IL›!m;ޤ V#šöĄĢ9Ž“‰lZˇ?ėÁD;ú;*ĀŲöĘ@ķFBŽgĖëģßûžlīž=÷Ü3žëũL{˛•§lÃ7›ķį‘aíŒŪEĐrbģ†9Rb¸ēV–´ĩÄÕņKĮA= ›Đ#ŋđ °oŠÔ7ûÔt|`­­Žˆ„Ü|3‡G_,doŽØ īČëåĶyÁûîxw~Z÷Ūۋo~Ņ:ęÃĩ3ÜĐ'öžsXGĀ|‡ieôĨF:IëŽB´Õĩöiļb9bcdđBīZíz5?íõ’ŪÁA;ļpŖ‹ĐųČÄ\oˆĨNÖä"a˜Å€œ†MíZßs@]ˆöõĒÁ‘ˇƀąM]š¨"äo;×éØ|čM_ęÍ\]pŪ9’[uÜ×Öä Ąv˛Ģ%˛n;—ČųОũũ !qx=ÖĻLÄnËtŅīšÛųãYcA°ĮßÄ ÔÆĀērCĪÆa´õƒĐtžCÎĄûø×‡tė€ëŗŪHōÚíÉņSBrŪLĶ)öC˛zSŒa/zü80FÔ(3ŽĶšAâ,jėˆŊīEî§:ÉPˆčRļČ üz׎žēÆxŧkØR֍YÎbģŸ ?¯Ōõ÷që#˛r6ōpōđŽ-ĒS— 7eĻ(ž\}WÁÅ|9Į^’Ģßŗ~ÆT ĄZ/Š<ĶĶžĐôũ'‹Ãž~p[kYņw-bclõų|ų&õw˙î“Įī÷Á†wšiĪ<ûĻ’X cG<‘ÖkeúFļQÉ{ō+{Ū™(æÚ÷GcÃE¨8`–vęÎ œ 0œ9ž’9{úé~”é͟ūO mĪäŖL/'s4\%‡ÁÆPÆÛųtøÁ…vėØ8аiéØ×ēĀĄŒD{‚ģųnëg?÷ŲÂg˙ôĶĪøyl~/F.€/'™_|ķí3Ÿũėíüƒ˙îöÃ?üÃĸŸ~úŠ“ËwōąŦgžyfXnˇŸüɟŧũ؏ũXb}ÍPOŋ)x/lWüüÉzÅ_+Øß\ Ģļû|™įĩÍ:gūÍdķ?6;Ņžˆ8›āÖ/׌Œēę°ĪHĮSwkŊˆë¤>ÛO|‘eĢ&pŪeŠœŒ\,’–ŋë4üĖ3ļųš3ŧūSpNķž3Ė—Q‰Ã”ĮĶz,†wÎß˙ž÷Ũūę¯ūęöĶ?ũ͎˙ãßūÛۇ>ô!”§ī_˙u2đĘË÷īžS[üáē4^‡Œ2d-Ø!fãĘ8­!öä (‡fU}™+ųžé™§o_ũZuüd‚ÆoûÛũÉŌgžyļn&úcōQ:į Ēdø ?õŠÖœČĪģl æĀ0ļũ\j‚‡›w˛)ƒ­s,ŊXĘ@†"ĩ`d$ Ęî&šV„;Įx17Мt 9a–Q:ĩĀ7˛l‚Đõ]ÁžsEČÎ"A‚v­@P.Ŋ–žHąSЁ×úŒkÃ>ũ`jÛØ_}™‡˙g}á˙“ŋįō;@/å÷ü­Ŋ jØ—>Ž5éįáŧi<ÄŦ1[­Į_zo;×āÉwCމyMWœ"mŖ—9‰ņúFN-ˆĄ>‡H'(yĖidÔ,ZīĮH°íæŗKúoĘuķĨožtûOw÷…įž{ÁŸ¨J5Ņ—CŦ‡0gs-!J3ĩ§YQsbpģ›7šÂp4î`¯{ Œ4rČŪ„†Ë8Œ(š9Rāi>'e`ö0˛ŨÍ cjÜķkéÕgz““˛ Ŋ9lkō0„x;‚rÅ˙ĒE­oU<¤v0Uˇæƒ>ķo|ÍŪs.ôÉŌΗ‰#‡âY­?…LßŠŅ¨q1šÍ'琎&ë 4øå÷QĮĒnąšŸXČĩĮĘĖs&ΜSžāņM^zQž^JļŖqâ>ļØ3DiķF§;Ž\Åkō†Û“3J‹Ą=ˆĻ&O`‡ʏŪÍÁŠIåΚŋŒkÉ(œ9\ôI6c˜ūęG^3Ų"™›‹6æōpdb6NúØrŌ¯sē Äąs˜qäxčÅM’ØF˛˜"ËSuô|M\J$vôŖ——ģ7ŒōŗiÅĐÛ8ûb§õ…6Ī(„ĻÃBVÕ\wë;´Ē vX–ĢžOŽuāžī$Ŋõ­oģ}ⓟPöÁü$€öÉO~ōöūÎīØįmqßü\É+>2ÜõĶr€lę&Xõ&Œ]mŅWÎŪ^öXƒ`OGįéĖX!Ŗ‰ ä\ ŸÉÃ˙Kų=†wžķˇô?ü#ŠųsŸ˙Üíw’ 'Æķš›sÆh(IÄw,ņIŨ'NÄ)iˆÅŋ“„Č“9qTĘõˆ_>Ë˙­o}ëöĪ~æŸŨŪųŽwæŖ<_šũū˙ķ‘Ûg?÷™Ûŗų¸ÎˇōNėuâfŨ3Ÿi?øƒ?č‘ŨŸūéŸÚĪģߓ‡ÎOŪŪ•w˙?ķŲĪT–w´hČ÷w×>7雹Ŧ#ŖŠœķˇs1Ņ/îzķ­Žcd11÷Ūū:ˇí\`-ÆĘ(ŠFư5Á×i!1=oîØÕļ%f>‹Ŋ,VB=aéŧėaŨ•Ĩ<š+h.\GĖÁ›n—ŨZā8Âĸ¯‰•Õ7° Wíįí9o>õ7ãøÃūđíCúPÖÖkˇūŊũŪīũž/ĸ9Oūũ˙ũīÅ|ņK˙ŲßpΡ…FR^¯_yî#nÁé5Ž„JM;›Tĩį›7ˆŒ™!ĩ6vaˇo~ŗŋãÑŋ.EëģpgŒu=0†šxĻ^\SŠŦjgi×ųĩ”%Ūų†ķHJÎą4ŧÕ-, ŠÚƒúlT‰ :A+Ÿx@‘HWRøqrãŗžį¸\~ÖXb‰ŧÉËŧt8ŠŅ‰ z×"|ōŨ^€ÆÅˆōՎž7ŋžs’Škm%Ö¯Ĩ^;ųæŨ×ŋūÛūčŪ~č‡~HđŸüɟܨž{öykīĶ—Ķ˜ž 1mūŊžqk=ęÛü +5qy I?'ŦiåÅWKŋį ãđsđEC+Ôk(ÂÔ ķt=G"i‘ļĄ‹L¸]‘7~ęG^‚X~õödŪ<áá˙īü˙:o”ü¨o}üãŋũáūaúų=7?×Ŋöâ˜Į5baAcđüÃ#­ķÚ(đ—Q¯ŗŽ‹M’XÜ )?ú֝ß'ëųdŽr§ūĮlŦG4ŠRZߌÄk.GU.¸ôųp4hčŌÚoĘéŸčâįœ8 ËÅXJÎc?Z Žsä\O2>øãžׄ %5Đ)3NĮ?ė  +%sš5ÔxÅn×â!pŅCNNĩ›Î✎ôņÚhŖƒØŗŠqGíú ÂmC™Ĩ0N÷†'ũšéĒ8‹ļ.rŖ9A&(NŸ Ä („XöxÛļÄ#ŖX龖;YßíZ+ø(`ƊÂN.= —u=p× eBOô'ö a-}ŅØV–l§xåw˛z!Cŗ>ĶK×p-GĸUe*éYˇąé ‘úĢáôņŧ\u~ö§îHt–cĀäĶ‹;ŠõŸđŊv{áųįo_ĘÃÉĪüĖĪøqŸ˙üįoĪ4ŋãīŧ}ųË_ÉE-^đ;Ø×CæA%Ļm2Û^4ÅE2b`ĶM‡Qļ;*âŧĮ❀€ĖO cá#īĪnŸøÄ'n˙ũ?ü‡ˇķŋ˙ßíüw˙×ŋķĀ{ŪķîÛg?ûšØ§Múēķg>.ŖGN õ2ž2âēėsU.gÎĶÚ]]ÄĪ[ßōæÛ—ūöKxŊũĢ˙õ_Ũ~äG~äöGôGŪĀ‘ņ—Y^úÂį}qÂG6øėø_ūå_ ĘĮ4>ā‘ūįŗīŸ īSų A´üĶ˙؏ Ņ˙üįŋpûØĮ>ގôAĀX¸ąĨnäœqķ™œ‘ĒkS˛ä&6ĒØđ2;:įîĒE“-§Ô`âĮivÛFĐĩûĘ9–ī’dėâF5:ŠąžĐ”Ô,16f|‘\@œ•ĨŨHBĐτw`Īą•÷ōײ^ŧũįĖë/ũŌ/Ũ~üĮ\íü˙ŖŸ˙wpˇ{öŲįnßxé›sAޝĒ×[GûđŦ7×uK¤1)ۉ-]§D7îá˛^éĮHôžƒÁųįI9ŪÉ–š™–Ÿ‡ĻŪŧöēú§.ĸ9SõT*ŊׯũÄ֟!‰Ÿ!QO˙á3ã ĨvÆ‚Ņ§Í˜yT|§§^+gĪÚ댎hÄČV[4ŽōØĘ}ëœô—æmĐhĻa™Z%>îīhZzäÁ8|Į îûY•Á”KŸhg‚;†ą5uė6T8ķÔËüĨ‹¯ú%.09gKԏ¸ž;oēüͧūƏãũƝüĘí-oyËí7~ã7n?÷s?—{Ü÷ŨžøÅ/æ§Ē}AÜuÂ|Ã:ųzœT’ˇoú…_ŋø Ō:ĻŗņĢÍĀkëØ[  %wģp•Ŧru bÆzĨŸēķ‘Ŋô|VАë!PÖyž2ˆā¤ÛëąĀË6Ŋ6jš346æ>ļšÕļŅe/÷ƈ´šō!ĮlåÅ‹ĀŽæüsuv=6°"ã‚zĸ_t…<ø+`ôdėŧrĶž×9€ ųJ<ᝆŲy؝ų1–Øō͚l.đŨÖ@Ž+vôų¤YĖS{Āრ>{‘uV‘"aÔš¤6´=ę‹uĨÎÚ<‰ Bš°# |—>ŧųÎI°n8–H”á´—ŊČæõ†ã)âĶxKRøFĄ×öÁŽ[o(#{P c‘ŌX8œ˜˜vÖGT_aâ+1 ‹{væ6ņä$ëĀr_Ōēp¸ÁŌē¨Ā›5˜ŒŌ_jëILøNūcI<Fȸ qB^gŧĶ×?YÚXĸl ļ<AoĒUÁŨč"G<,šāÍÕŰČį_x^ãŋ˙÷âŧøøĮ?Ąė­šP~!ŲÄ?ah#&v‡‘ÛKŦ(ãPwvg"'ĻØ/͇„Øčv§ úA"O+Š"Zômû€ķßūŊŋį;äHŋü˙ŗvđ~UŪrĶIHBK ÷Ļ@hR„PĀÅ‚i˛l¸ģŠĸ Ŧ‹eEWĐË.MWÖeĨ*ŧ*RL@zPLrČMi$$ô˙ûûūΜį˙ŋ7ôãûNōŋĪ<3gΜ9sfæĖĖ™yt#nčĐa2ˇY(E;į(„ō(ø­ŧ&NÁĮuNJVĻîÄE$˛#ĀÉ?ŠęĨ[Ęæ–ļ5epŌÛOĒvÚi'ÅUõJėØ1ÛUkŲļīsāÚ°1bŗĨŨu×ųPv nšågNģ\WBI›īEŽĒ8Đ&DbŽ‚ރÁËWčĘPųŖöƒVŪTÄ(ŗĢ_2Ä{ʲ H[§Œ¤Q€˙AØ>REĒIÁEĩ†pūČE2.ŌcíA0‡ĀŗI‚Š)Ķü@IDATÛx‘d!BQ§IŅ~bēā+tšN,運ÂČC9loKNŧ|ŗNé枊íY °ŸŠBē…ĪKa`‹Â ƒt@žIîčŖvûĀdîG?ú‘ÐŋWŽ´rļtɲjũ珖Ցæ+>ú‰  ĻŦ3č‰üZûd“HČŀ%2+A—AĒ~V¨Å2sõ /™ČĨ‡ŸāÂ_ęŧ ž¤ (?]qIŌą—6ņꨤƒNi ūģ`΂áÍũ•6¯Âd•Nžûđ}ô1˛÷Џ=æG °–)äЅŖ1h)¯Š|‹Ÿ‡ëÚĐüĄĀ¤§üĀe=Đpā^Ũę駟6đČQ#ĢĨ˖ˆ‡ÜęC§ÜGtn¨ô—ŊĒ&_ūō—П ^ĢV­R}’rÖ¤I“ęŨ€gžyÆpũtØmƒx üîć'8ōôę8 dãŽŋ´Ι |Ŋ 3˜ĖuttT-”9СŋũmûŲiÂ<‹ī8øĻpEļŠ˙ e¯ū$ 3EĻȋk`ƒ6d„záüGļĢāŠX”Ũ­Ké-ĪdÂĀ“zsQ¸#˜`d i™Vhhķ›Ëí)x÷Iyn´éD$é Ŧøį6Jy/åv å7.Ā•–¸ĖųsŲõ׸ˇqōŨŦ—“fY—•W–ĮˆÁ/ž¨_ØŌˆŗ}ĩ‹ 7mŪ`yr9ĖQoš T¸7ģ;ŪæøPĒY(B~—Õד60@;ƒpŪÂDßDžČRČdäĶV+_¤ƒá÷äų ǞîŖßŒté'`GI#>]ņcžüȊūEi)Wd´gĀkš*æ<–+ĘNFņ—ĶšĀåë2(Mp“<€ˇ~P ĀĢŪ=ˆeų7¨Â‘ ú1ÚĢĶę/“ĄúúHĘŌ"ëĻŪ#ĮØÆxĮ‚VŽ{đŠp+IŌZĶAP”E‰ .č2/A(âL3<€˙Ĩž,Ž ˜Lšāc†(oҟ‹,?ˆŗ^É T}$n— HYäKļô™†›âč_)'åÁŸŸ]nS$~…tEG­‘ūŊŌ9ŠŲ ēã~Č/õÎmÉøĸĖ u]'b5M)kĀĩ)?H0öĀŅPÂ]¯DĖgæĒ.aNR’;2˙8ß:V(Ą‡Há• ĮģčP^ČtB?ĮꭔIuN!öÃ+ÁB‹ķ1Žŧ”†pØĪ“0„ķ*đG$7@ņP‰ŠpšX3 ^šŋĒųV'Āüë2 mQyb˛ī ˜Í\ LĄCI"7:+ Á!û‰qæzĻ€ĮW•ĘuD|8<°ķf€ >PˆČĪ%'AiøÁ´_ø FĄõ*ÎHmS!#ÎÜķéúC%%l‰Ģáyį'ˆR!5ã Ō?ōi:uk„+Ŧû =TxBá Ĩ-5.Ãe#„th¯;@÷ō9Lål͊ņújÍÚ¸G<:ĀJĢžŖeŋ?Ō™1ˆ€ß+ÂģZ§ū–čĀ)´Ķ(ÉßüUœe‘ÕũP™&Ŧ|1ĖS&Nė0ŽõūƒŲ˛9D0¤`ΧāÔÃļė˜ ĄHÅĩT$S„~š€ˆ,)6DM™˛Ūnėv^%‡wuįEG%ēYe}aņR)ŗëT)É _aĸyĒÎ ĨpÛŅŖ*ėũq\ˈcåü ™ÛāPĻXÚȄĮˆ#ņētj#ÎuÁÅĪSyē: ŌĮCŧŲÅü§ !/Ô#,CšŠĒɓ§¸ŽPāfĪ., DÔ˙)?žč´9ä;¸šĐ>Á“ģõēžua×BÕëj ^}ĢūúŲĻŌ˜zûãĶqNĀš”*}õĪxTh/(l(žãvg^Āo~ Vœ`pāĖ×ŋŌ3ƒ~”člû*aĐ ŌB?üÁOˆ]áa„D27LĘ8pđnŠ^Ryāh¸õš7nŧ8#k”ƒ:á‡ŧ,]ļL°ZSYQŗqģSĻmĀŲų"ˇT‘Ģ-6i!“ŦZÖČ  ņk„âūĘē—cBf,BDSņüW'Čäl¨Ėæø(ÛĄ‡fœ”wžÎÍ⏙iŖ””š¤Q×N§´YŋōÖ>Úü°0úĩ†•E&Ûoˇ}QXb"”uoVŦxQ•–+ڇë z] ŽĨ:¯W÷ ЕütįĻwÂā;üŋĶÎÕ Õ´l§ž0IfhCËdÆļšo ¤¨”…ÎĸŪō7rķWŽ3fôØjÄȞõ@>€ˇDĻm܀…|úŒ•°0ū¨Ē!Ú.yʋÎŨhB;hĐ߆…C3&v(/”‡t+ÕŋåŽZ,˜€,hšQëqׅp¨ķ´Ü œr’ĘΝēB”†d, ômë¯Cã;—vˇIũ‘äž~Ž2 ˆvĮ¤˛ąIûÁ}ųVS"|ȡi'Ļϧé'Ž:靺P,~ x$ˇ8Žķ+ĩËHœûBI 2Fģ$ “ĪĨęGŨö-Kô‘Œ¸6ËbÔ+Ō< Jå¯IŨōƒ´ęŲ#ĩe8ŌFŲü×éŊëj[clŽãöʕȊĖ~Šs,L˛Y đ‚…r€ôŸ~úßW<Ŗ,ŽQúāIĐF“úZúÚ­ŌCWëäĖcĢ‚J ČP #O$ī›Ë5ÎŧEŊ‘F1…QĐB’ú Äg“ĀĒj;]ČÁn.‹\)ŧTmŦMô– „ĩԋĘČĖ íč/4YÚ,äƒ|õUųYƒ_Ȳ’( ‹0é‡R“¨hĘä"šô/–ßAēPC}ũOČo,âŦ•~bųUžŒ1‘Mę"”ë@ŧ*’ėp>ūŒ+ŪØũûŦŽø`ęZ#Î'|Õ?ÍōÖš­ōVęĖõg”+<gI­’YŽ =€ŌßboÆōÄé)>&TL2ÁMœōôŗĀĘĄŽËPžá ™8"ęOxKļ~+°äĨ–Ė (T @ØņÔOd&¨Ŗ@Ļ_ .ZdVÂŊJĄäŧæ ;WBEi r"ČD Ļ Iț˙^YL,%Ō¤B0ŦX*Æ'PŊ7ņ !ÆeĖڈSNŧëieÕĩ­ˇ’=šO0Í  "…đCŽpŽāwŦĸË"\Q`É% ^ë-“û-ĘuÆÆ)ÎÛÄt|ė åŸ[z8¸ûĖ3’ЌO¸|˛ĸŋL ĶJ™"0[ˇrāŌ"Ėĸ^åÛFƒîķĪ?īÃQāÅ˙ķŸßj˙(­PŖtvÎëô{o&MšT-\¸Đ“č†/%ƒxēŧ…´á7ĸŒ/EO Î)ʸ÷†8uu-0_ŒT<‡ŸŲ a7ĪÍ9(pģOŨŨ×ŧ‹éļÛoS\L¤XEōWrE'0_E?aŧ•l>ÔD9ŧĶŌJˆåĸ.-O”A Å6új2‡q)÷J™ųŸÍĨԆ†ĄģßeûũrĸÎ ü}āœ×0r`íZŨĪ?eaÅEƒQ2įÍVæ#qĢ]āöDF<Â‘?n…dåĮ×ũØ~Pˇ6 ĘÍž]¨ŊŊ]üoÔĻBîå˛?¸–USäĶõj™Åu kĖÚ¯ÂÜ]ßz%B3e›ĸ&`ä‰_:ΗpđŽŖŖCIÚĒÎΚĩÕs[}y;™MÍˇŠ“7ĩ7÷IÆOÛ$[ - †¤o”"ČĄpÚĖėŲļĸēâĀDwĮ!ÛWķÅwpĄLáaå“IufsÄDļpĪ=÷œŸLĖ9ÜŊ~ƒlūŊbqjį<ā›x’ƒ ”Ξ"{´ – í%;¯¨ŦķIØĢãã”)ģēLL-{ÆD{^÷UäŌ›k ~-oēĄmYÉW}´ˇˇ;ņü2Áé a#u(rĶÕĩH}Á+ĸEã +%r´™đÁNV:c%—v‹Â2G;ŽË–/5lo蛯é O×Â.ĩ‡u.§š˜\đ’zRķQíyjoīđŽã_ËM™2Å nģČ:3^9× ËÔŌ,‡ŖJ°W{ ˆÛÂUD§@Y7nŧV°ûIŽæõĒŽžEmû:LÉ@?pŠ\ƒuŨ§wHŒ¸pÔŽúßF\dvŖđ„œ‰fƒ’KZÆ]vYwܑ+D‡úĖQΈ#ÃtRå9r„ûōāĢÖ,,ŅßÁ Ī×kÖ3 đĨ”Ō?qÕ,íô…%KĒUŒGĸĶíÖHôĮ<ÖCiãøú97UąPÂöjŽ3Rô)ôÜžĻœ ¯ęOxĀ…°čÃG.q„ĨK?õÂ5ĒL,6­bÂ%ƒžööv/FŦĶų…’=7j@¸žũ„Ũä‹?Œō›ĩ(ÄÂWŽÂķ5úęv—ÆøAzųŧ›}ÔËĻ /Yųzĸz™[-Ҙ’n;bqÅÛ}ŠĘK_ØNœ8ÉģÆišiZŸđ‹‰Čüy픺j‰6*ÚCĒÁ‰‹žÜ„Ē­2—ü˛Ģü×Č/‹v]]]Îla≏rĢÔÂIŅĶīE%‡Ã Gų M™qA›1™f`Ļ €Tø ëūGīŽSÁ*o¸.Û2!‘<‰˙Ē–—čipō(ųø]ų;/ņŗGņ gĘĪø‚Ī–˙B?ËCŸH* šš˜āô O]ŽBŪsq4ō‡¸MK­ˆë—ôšą’2‰Ė’9@ČOZyM€^ĸ//`Į‹3ĻÕM$L8=ëBxū/9™$D† q ¸æ€ ŧîl‰2Äi‚kæA qŪ~rf)ė…Á‘ÜéŒG0%7c­#ŠĮ<.éOŗĄ2#č=*:Jšfhâŋ˙((Ų„b:PõFßųčX} ŖŖŽqŪŧN+šÄŧķÔwV{îĩ§^>•q×\sÕ9NŧŖnáJĖ03!Ŗfa‡HÉÄMŸ>ÍĢ-ø;;;yT{ėą§LUž˛˙¤“NĒöŪ{ījW)/ŋōr5gîœjŅÂEļyĻ“ÚN‡ŦØąxI¤ōė_e@>ÍEŠI–Ęš‘m†đM8s…#Ũ{ŪsZÅā7yō$wād@~衇Ĩ,ßo°ŽŽ+Ž ĸŖGAĄ#deŊū¸ĪžûÔ6ņÆŅiōÍĐŲ—t.€¯ĶYey‰PVĻ„bÅڕcV™`ļBų; üŌ1hA ;3/Ž\á_ÆĨĸ΄‹[bpYG”‡ũ?gXMa%:Ũ0…y"-bm^zōÉ'{nÁ‚>lÁž,’C¨ Ķ!VĶ’ûīŋŋ¯e@āĮę#|€ļo¸ĄĻ‹ˆ°87Aɕšj;sXâyŋ:_˔Âđē˛%Ė37õS:ŲĒ,˛BY‡ę>s”“¤ 9xãß(ųÛÃ4ŖX/Z´Č4ũâŋđDšŲ ĀL*” !+y@2‚YJS{{ģ.vĒpgœq†īî'øČ ū§žzZõōÃ0pŗ2ŧaSŦ~3Ȏ”RÄä —J /~˙æ7Q§üSėÂ>Lįk6ÉĢÉ#Pú¨›ĸ<ÄŖ,Ž=FãAĩ\‹}öŲĮ7Bqx‘zæ@#íįVŨnŤĮD9^&lQ …)~ßúORĪ⚠T›%˙Ã$GŖõ øƒÛ˙×ûāú8Ũ=ŌLģ@Ų`âßzë­ŪMäâ€AZ%dBLŨĐ„,€EĩŖėh_Lļ–ieåwæ™gĒ˜XutLtģ&_dīÉ'ŸŦîēë.÷L6ĢĪba$pĮĒpāEYŲT ÖĒ?7m͟?Īx“–'ú•įž}ÎJöO~ō“z7Žī9P”4;*) gT‡M–u†Rä^Î}D†&S&´ ī|B}XüĐC•LvhR°“åiŪ<Ęš¨ēöÚkŦđ­ķį?'“Â؅*›õÕR$ųŽ.đĶ÷ã¸\ ŨˆmPâWzqƒEęŠÕeģȰĶN;M‚ĖŸũėf7LD‘ų”{`Ŗ˙,2­ Į4ˏĘ $L˜ˇv‰”~~é<šĐ.C?õqãŽ0¸īâŦž*ų•‹_´WäöēQåčTŸN{ũũī_ķ‚ū`Ĩpž¤Ũ0ęŲ$˙ák^žŅÁh‹&17ĪaeLŧÍė´¨Āw1đ#ƒ­Žë¤1Ņ AA ą@NŦ„j€™‹7ôŠs[3Fh˛ËŽ'J âÂâˇŋũuõ˛cŠ—ŸŠ^†x žĶŽXp`,ʅŽ$fažŅ_Đō{đÁÍ3ʏWęâUę›ęū“^I´…ËäIĨĶ/ëƒt\vpčtÉoGģؐ úmÚ):ã6ų}Vrâë DĘ:QŒ§PŧŖÁ/V(Đ$(6&‘Ž-4Ĩ:5ŽđJëvZđF1˛MWäâ/ų ĘųĘš}úĶ CX]Ķ-p„ËA°ōM–|Ĩ|ú™č=ĶP K$¤ÂĢcÉ:ã@\é)į™x"Tr"M@’s`"ЉãvĐg8)O wC NCÛß )—ōķž?Ŋˇõk¨ãQ|˙×Ū7đņ+am¤4ĸâ'-?ā‰Ë'ūČ×°¯ļōĐĶŦpđ$_đök+ųÔų–ü§YCf'%āø•ō>č1.ĶAZh/tķŪß|€üρ´ŽĶŗŸđ)Ž_¤ zœwÁŠ•u§üå7ÍāÎ_¤5/ûé#č<5ÎRNøĸšjhôķŖũhCWģ5tĢKãŌK/ÍoŒ=ēņī|§Ą ĄNFrŪtÚmHáh¨Sküā?¨ĶŒ7Îū~ũÚúŠmömLœy}ũë_—ޤîXNët˙ôO˙ÔĐjuC+ļÍLä̀ԐyPCŠjüëŋūĢáØØfø6ö?(;Ü{īŊ ĮŧņÃūĐđRÔũ”‚kú)ŗŽrtu Ut#_YŋÔg‘ååœ6mšë‚ôԗë¯ŊŨO}œĢņĐC5îģī>ŅöxC+<Ļ‹2͚uoCWĐ5ôÁĩÆ/ųKËiwŪy§UŪĐuž ]ũŲ¸ūúëkē¤0GEFtF ĄAĶxŗŪvŲe‚xõ N)d.~ ō_ũęWæĢ&?5ŸđHųlhĨĒĄ‰@ãú˙šžąįž{:/ęŒŪĨ;/hk´ÚtáųĶ"ےkˇá>ũƒmŒ9Ęøūëŋ~ܐĸeŧå-oq´iØĐ‡Ē]]]–¯$.eAJˇyöáØi4Ąi :ÜūčS˛ÍõˇlĶnĨhÕø¯ŧō*ķS;Ũd|t€ˇ!Ű1sæĖēˇ;ļĄoJ8ũöÛīāį™gžayzđÁ‡ü\ŧø“ /}ôQ‡=đƒŽwęŸrmŋŨnîWú¤ҧ ‡Ā™Ī;Ņv̆>úfܚL5nšå–†vT~ĐAšaŒ‘2įēÍūfęÔŠ†Ąūįū‡q~đƒtØđĄÛHVÔĮxŦD–áIi Ķšĸ†" KšĢ¯žF8ūŗ‘í}ÔČmŖo(<ÔĸžÖÚí…˙ĐüũīßõĶÛØ ‹Rj=6č&$įƒ,Œ=Ö~íŒ5˛˙ĘWžâ~–qāÉ?<é~ž‘ž16Ā3øB‘wöŗŒmôĢ_ųęWÅŗč´˜âūÅ2ĄvFŲSĸÚí0Žƒ9¤ņ-É6é/¸āŗĶm` 64‰ō;í•:Géû4‘q8õĸ?zėĪzŅ­EŽ7n|cā ÷Zû‹üãĩŧįX“ōA;Ō¤Šņë_˙ēA{όZ°kl§ąĪūSįîTŽj˙¯ō}ąuŦZVRĘ6†Ū§~Ÿ`Ŧ+Šũ‘-SĐC˜âëņưčsĨ="%ŦN'™°.‰|gʨÂé‹lč‰%Îũ”âk™ŽļŒ|…Ū ŊÄ'LČ@Ā7qB'ũˆåŖ<›8#<ûäÎ~ŌzÚ(å__įôFš(ŋaßĖŗ™ôAsāÖė•‚&ņ<ųeXëģ s-ā†. \¯néĨ4Iqfƒ %Š4ü€GųŽ<Ėôâ¯qĒã@ą RûķTē`yž %ņ‚ AA9#ž(ø—ž\ŖŒb–ũÁĀf™•gVPŦ O9ŒÜÂa^Öú7@ÆG^ĐR 3đŨŌF…žÂKā‹°ÛnŅņ^uÕUŲ~ßēü[n¨_ûÚ×ZŦÃ0”@hä=‚V6šhėĐ7hĐĐFū7ßü3'ĮŲgŸeø;īŧŗ:.ōA1ou `ŠXŖPk•¤Ž ķĻ”k—Ō™Đá °Ëŧ¤ &4äŅsĀF9ûėgŖĨĶŖŖÕj•éŸlÖģáęŠHd$Ę6đÉcílX ~ÖŦYŽĶ–°;q­¨nSÖŖ^y'ŊV~ũÔĄĪDŨ žˆÛ}÷PŦTūGįO:”}ôË~”—ŦO”@âwAą×ŋėūũd°ÆQ7ŸúÔ§6iŌ$?ĶŽQí‡>”žV—˛Ķs" J…>åôÁkڜø!YGŪŖMĐ)"ßŅņĤ ešvĨŸ:īQŖšt0iÄiEžĻM+û.ƒ#ô‡úGΐ…žŽpä‡ō!ĶÚ=ĸĢēĸ ē/ŠAg„ †—­“BđŸ2Ŗ\ô”5Â/U;GGL ˛^nŧņƞälĨ'@N´g\Ų÷„LÁÃčGTČĢu0חzˇjgÉęŦ§ĶĘoãMĮg<ŲÎúˇ1čŖ @LvÉŖ9čÔ ú™Ė—Å ”h­Kj\Č ­N+ æuĶS™Eņ@‘!/}¸Ž!sã¤Îū-û!`fÎœŲŠÚŧĖ> 7Üúž‡q“6&´QÉĸ3¤ÉV(˙LŠzŌ˛{Nbdjĸ č{Œ{ŧ”v-Ā™šÎēęĢE˘eM~'´ƝŦSW¤ü—ūމ“˜VGۆ”ĩ§˛F_—)íę4dJROâč+Zũ˙zēėSRööØ}Oú?ü;ũŊ§7´ÂŨ3Š$ˆ×j¯ážôĨ/Õ0?ũéO†Ėč|Eôîr Ĩ ö×0¤ąĶŽ1ą$M:p›ļí5Úr×^N¨‡žåŒÅ‰5ŊŽ đQ_ß6^pĶ× kđŽwú–žŽgũ¯Û‹Y¤e’€ƒOŲ¯į˜IŋįqŸōã/í¸ŊôgŸ}v-ƒ,ĸoŦ&§LÆÅœ]]]΃ņ˜wŋû=u;uDųC9ˆGŽŋ[Ûã˛Ôę^­ŋ ]\wŨuÆeš´ÂĶú:“ô4!׋Q˙oō+ZŨ>› .ų ĶŅŽÜŽÜĒũ¸-ŨĖaČc‹h*īÍ×ėë ŒpšP]Ā+ĮĨm‡ë(ęÉí—÷zŧZL“èËÖ †0č)Oį Ξ´D|”‡ž!û›e†ÎėcŠqŊSī&ōŠ2„NÜ ‹2 ôædÉx˜Ë ^ã6NėäęéTĶ]"ũžČ”ÎØ Š P|Úãz‹42*Čõ[Gƒdû­ęԓ´å‰c›íö˙ķ*)ŲÕ駟î0l#¸˙ęÁ‡´ļŸ 8âđ#ĒÃ?Ė~ųōį-ˇŪĸ/ĘNĢ^”i Ļ kÖ`“ÎöčPmĨ.4>­ųŠ•bÜĄzyÍËę:ą‹3Ē<ˇ§OŸnü˜gi2ā˛>'3œ j'd}ĖĩSč¯\Sīo~ķ›%/7UoûÛÕW”ųŸÎÄč)8ĮČėķ¨/~ņ‹ÕšįžkĶh„î™3gš}`‹Œr˜qšĖáŗ”Y›P"ëäžOŧŽMßt†ŖÕy\Ņ‚dÃ[ßĻCŊō˙mƂ9´H™Õũüē?2OšŸx⠛~@;íáāƒ2/0)C´ķjķ¨ī~/ž.ŊxŅķ&ĶAĖihīî'ˆ@ŽģtI@Øģ÷S_fB´m˛O…#íDŲ‹_ŦvAã04ļųČjš%|ļoāROpŲ °JŲō2ÄfdKšÖôŅ8ÜʘÂX‡ųČG>ō‘ęâ‹/ŽûxˆėĶgr1c m™z;\cįĒč—ŪõŽwš]œzęŠę79Ô;¨6ųÔŦû LW1¯;†;{öõ|3Ąŋø6ĀįŋB7Úâq¤.€<”ģYöˆAįŠ‚Ę Ŋc÷4uZ%QL˜×ȧų[ˇzŲsĪŊĒK.ų—ē_ׂĮ׋ÆW푙ŋC&ZĮSúL}čGųn ũ5åÛ{īŊ܎č¯gŪũîwģŸ{ÛÛŪ&SĶu2ËŨÆæeRDmF7~ü8Éīs’ßĶū ų-ų=x+ų]*ķžīI~ĩp%¤gÅÎG†Ŗ%Ÿ2Ė<2Ki[rčT“_ų)’—fˇ…“DNāõâ1ĘŧV”â2ãĨ>“yL“ŗ2L$Žwĸ Jy6ņj­Pą8žĸ͝!Ž4N’’w8ņĨō]O†ī‚<Ąq*ÆSƒGR"‹ŗˇ–?đ+"”Y­ëDæ1ˆ™DĖfĘŦÉŗž2+IŋfąŊĐ>f=eÆQÃ6g(žŲxõŋĖŽ˜™xœÕzā'¯œŨĩ†1kĘŲu¤gG ļ_˜=œĖŒ7qåĖ-éhÁ陭āĘJ|ā"]ĀędŊgOą%;Ėڂ&h6-äkú3¯&ߚ+A‘6āĘ,ŦžõQŌ6ĶQÃۍ­YĒ*WJskXÂæ…o~ķ›ˆÃĢūÔŦļē¯j “4š"„?WHdÛëíAāY•eå'›^­HĮJķĢå‡ŲMēģīžÛyČîĐ+ˇ^ķ$œœÆe‡aŌ¤XŨ÷ Ūõœu+ lKé3î'ž>/\¸°q衇9LƒĒV3tË;wXmĨ%ũl‰ŗ%›ģlGãtøŦ6IÉELSH7NĻ]]ązôdŲE œŨ|Ąúí[M˜Œ‹?đOĘS¯4$-Ŧžæ*3;$ Īǟ:Ė™6C;WŅf%ĪđL?ŋ+šNĶ )ėõŠ'ģ1š2& ¤Ąˇį%—\Ōm^ĻÉ ;OŌ>ÍŨ4ÉŦųŖ´qųå—ŋ&ūéĶmH Šy•ĢdííŪōO“‚¤ >Ĩ“ŌŲ+îa2• üŪ Éļīv2E˙Ĩ+]k“˜ÖÕJĘ÷˙øŊâMžøĪ_´ųMŌÁŽq˜Gi"ãúŊmŦđŪ} vĒbE3x×ŪŪîôŸųĖg,{ā•-vãķŸ˙ÂkŌnú%VcqŦĻžxâ‰N“u&Nœč0ú#)Ĩ†eGSŠÛkâ˙ŦÚ&fF8)Ļ5_˛­Ž+˙:WP—‘v0’WŊ=1ƒ’ÂdŧüÁė8LŒā}X˜ŗ–~ŪīøŠ?ũ´Ę–ãRôåũŧ#ĨCįÆÃĘo:LīzŖĄ5ŒŽ\Æ\'ãĸ>cW&ÃZå/w›2ŽįP™ĮéĢáÆ1uęnyķ:M ã ;L8ę ”w—ŨL¯ jx‚ĶūËŋ\R×UšÅĀ ņ†ņKãeŽcgŲ]\īä.%õf–î#5“_ļáÃ;ŧŪĨ€ZŠûé¤Ģį“>$ųĨÉ^ŊK ~`#Z͜}ÖŲußvŨuÍ]ƒ„ŅĩČZĨē#L aæf‘=w˞žkYp_WÕ;gĘÔF ZN™ø0ĮÄ’'īāĖ>‡žųížáXÍŋéĻ›ë]ФoÄđây˙Ú4yIGÛūä'?Ų­ŧ™.Ÿė,ą”.wRáåAŪŲŲIü˙’_v>ØU€vëln?Šã… E;ß:Uļ3ÂHO¯ŠËßOģlÍ4Ĩ=ē ŒQŨ߯ÁáqnjO͚G3M—WŅKžÎŋĐĻr4ËB~`cLéäĩĸi’ā-<>•ęÆrlŲ‰‹ceSp|HmƄ‚OZÎ'Œ1Ęū4ĶŅĒeŨéĸl7iŌdũ&™¯ŲŲ怪ãˆĪ ‹iͧ;<ív'M % ÛW[ļ¤cR‡™.īœ%Č OˆtøŪ{ŋŽ1ącRˇ:ÕjŧŸ A;>ÆË úo|Ãi’/nS’éÜžÉ1ōrßl¯´ŋ˜iwÁ8~üã/õ˜´“´ōƒ'“'Oą¤\cn•&W_üĪĩŦ"§Z-t:äŗOi_Ēkpåāˆâũ1aČæ_đ Yc…ļVŅEŸ¯M‚ü’ŽŅšTáĮž\;ö'ä9[AŧĘ+­Ôč†+Û5c} ũ|*ŋŌæsRŽ\§9ƒtNŧ —ōgû§ļNš‰âyBsļ_&nįwžÃĶž:iļû`ĒáŌ¤at1NĢæuũ_~yœ1@ž§¨n˛Â;Ōęb€ēßâŦ@ē+¯ŧŌø;:˜@ĩ“˜8k÷˛–ƒ<ßÄBĄ ĨîÕ¨~h'izÁš§TörĸOāgGi˙*Ķ­„ÉļFyčé#]įJƒŧėķē}Lßžûė[+ž,€hgĶá´s­Ÿ ?ãuũŊˇßŨ§—>\á´Æ+ōĸŧôÖˇÆB 8ģõ’wú úVŌđƒō¤žLĩĶŨ8äiĪņ8Į՞ō;X&äßoŋũšō+ÜôwI~ŅSúĶ”X Ũ0ôˇÔ…Ĩāe.ōšĮ‹öú’ŌŌŪ ž&,icc9´.§0ÁzlĸSO~F>u_ Ŧû夅öžzŦōjՁ ^~ô复<ܡL„ÎņĨĪwY€'-?&<)_Ā×rĨükšĐI)‡Ō¤NâE%Ņ<+qÄ; ĮtĩŧŖvŸdÁM0‰šÄõķ5ŪYą3[ā͔îĖH\ÍIEx)“•~pNôxļVįĄp˜Daôŗ™!¤ æÆ*[øoašĘDãlV6L‹_Āeš|F9“ūš&ō%¯’Ÿ…É~ĘĩuÚ­*‚˛˜~`Ąú”—„ \4މŖ‘ĸØ |ĨĶŨĖ÷ŊīÃhģßđtpĖŦ9@īMÛM:ÛT(Pū´•n#°(Ū<[WˆÉO§ųN'’Jpf ĮĪ ĪSN9Ĩž ,XĐĨŽ´Ũá˜U?J(éāHˏĨ?oūx ÃRiĢ]qē´ã§CÃŪ Ĩ|Wup¤v\Lbžæ0WhēÍĢŖP­ikŪ°„Ŗą†ÜfcŗĖÕĘlėŠ`sŸŠĢh¤Ĩķˇ,¨^“ĪŦ^f=4Đq˛“ š Åaœ6pô†ƒj…EŒ0™ĐhbÁ€ĢF<[Wŋķ+ēÅM*gXõNĮŽéđ°+Ư-x+9øųåāríĩ?Ėd^-$.ęŽiŅM[ -"Ķ)û´~ųާ`ØŲHE$•æTŌr`?tQ˙ø‘g°2šãĐTŦ8Tî—ˇũŌņ ^tŌyhíõR&ģēbwdá…ĩ2’“äÁm§ČsNŽŽ?ūøzĐ&]ÚÂŖ8B‡îK÷î^ XņÎoô˜Ņ!kŲ™â•ōs[OۈVās…?wü6lØØāP>a ļ” ļ }pËīŦzO‘ōM+×éP ‹‰Pī M÷Íq†Ī•Xū•+W‹āŌuõ„>@ģÍēMY; (ŠO:¸4 ÛDŌÁWæ;ĩ Đ7¤bÅâ†Ė–Œ#'Öô›ÔgGĀ;_ė’-\ģ{ėJ{lLf9ķrÛzF#MĻâƒ2´‡Lõī߯ž\Đ~ĶåÄĨ‰ü=ŪešEÆÕi.‚Šp(ø\Œ`b“rž+ĀģHŠflŽ)QŸ9ą$]ēßŪq‡aY¸Ņ-@ö3á"=—<¤ûÚ×.uXN– ˜œÜąŌœ€LsŲeą#FžLē}đ[—8p€–vŨûāú‚—3Lš}¤ûJ¯˜öSßö×Lšã;ƒLxpЙģFYʒōŸ}DÆM›6Ŋ^ˆaRÃaXāe¨§x+y…ī„ĩĘį 7Üā0&úž…ëS–-cëĢM˜™TõiŅPēPā‚įšņj€!C×crģuŊ\fē˜ä ģČ=ĘåŅj!Ā8]Ûøō—žė4ôē]Č~āázeĻēeá?;céōōˆl'¯)ŋâ)mYøųõ*ŋÚÅ!ŽÔCt…Į ĩˇ)á =ˆ'ŋP€sLĖą¤ŠGĩčl9ö8]ļÅćn…_z•Æ&˙hĢ)̤qzę-āōiæi%Ü0āĘ1-đ'}^øĒĮžŒ6%Ķcfâī9F˛cŧ‰ąŗ™ŋp¸ME98¸ ͎÷B{ŧ˙H›´F82zfŌ¤÷¨/U‹íŸeÕ*¯ "ŧ¸č—üRĖ‹ė—āČŽ(­˜¨_Œ–ø ¨ꉋ4 '¯ˆO@ō•“ §“ų/ęx2„)OZ)ø•&l¸@€ÕĶĶ%^ųƒ ŋ_„+îEq”pÍü”ÎéĄē)[ār0‰ƒĀx[÷°ĀīŋŠ'xyįn\ķˆ(҃ũ­Vnm ˜“m‰ņã¸ßũĒĢŽ´-ųO<é+ š;XÆÁ’V:4mXė&q7üô†Úv™ûļÕÉ;\7›øMėzqęHũ䏔ķJ—ėâãŦwk`Ķ•h|D&lú58×öé¸ÂãøčWÚá6ęŽéd#ũŦŽ“RUŊõ­ou8ö̟ųĖöSFlU7ËĀ˛ķ1ąĖCŽ+7øē3€š P"§ĶÄÅOlFšCŸ˛§ŨŊ” Įi ¨¯ˆ'õNß>Ŋ#îô3OķũÔøķš: ˜ÕšÕk%ZēJNõ„„Š€Ž7}P9*­ōųlnq2Ķō;å͛9s Ą[üööŽš_œŨĀŠT]ëŖ/1~”AĢĎ;üˆÃk›aÎ"ā4¸Œdtõe|@ƒ‡ã°û”ĸfŋ?‘)œVƒüԊģėŽķúĀWl_ØN#Cę C&:;u ëâlœ×ņ%˙Ŗô4ąäMiwæV”ÁíC9sĨí0؜jj:¤TøI`Ķû‰ķ>á÷ŨD§ļĀMCƚVôŘ>ž.oųōmĮ 0gŌņ#Ü+ļuíkģpŪ§ËĻ[aœVؔ×"_g'ÅWŧ§Ŧš6ëãqĐĄÎŗnwķæĪw]J‰õĩ“6l2Žä+ō†{Į;Ūa[müRfyøėȚ—ōŒDH#\p-9'oTíqáœ‚&'xeĢūtĨ­|_ī7gÎ\Ë WfÛ ßC~–-[b{sŌ\{íĩžŠ?įZtķėŲįé|ÃčšoÄusîĖ b¸ŠˇĮ{ęúĀh#´1ũÁ }tĖŧSŊkbĻfÁëŽö™|ÔsĘIž­Yļty5kn ŦL—%RŌW„ĖR&ũ2: OíÖÉ6{‘mōšV÷Áī÷õŠ´īņ˛Sxd.h‡Gé´ķc¯JG õ3ē [˛OĩFxpØåCģV_e?>Øa|€,ÆŊ6ŗPN‘‡&*“ōufFädõhÉôŒ-8ŽJ†oĐK{‡įnęo˛q&(Ī{đĨn<”‚mŠQō> sãEģĶũ~ūņęŸ8Īm„|įĖ™í:\Ģķ›6Åx×GˇĖũ´Qątî+5Ž%%Yöš¤üĐnŦΝ| 'øĖ~Ũ–äkS¤äZhŖÚa\ĐÔWĒūāmæŪ{géŒÚL’ÚŽ}ƌcėįÃg:UˆęÎ3ƒųčēõxߌ L.ō”nŌDų3“,?![;ŖūˇŽ‹ņW‡nuívîh+uŽŒīÄ4t†f¸ž€ûÜį>W÷?ņxõŲĪ}ÖũĪ3ĪĖöĮŨyÎWHTŨDąFWtsžĮËŗO~ÃAopy!ûŠuõ*ŋ„ˇôÔ/ǿ]ļ,žKĶ”ßuĩü/[ļT:ĸō‹qÁâäT>Cž8 ™õdZĖ\ørfb‹ž~ĸ\Ôĸlj\\ŖöáҧěBœ„˜üÉg<ä¯6Ā™Qņ™@^CžSæKģ‡& ˛ ÁÁ4!é58Gë õ&|NÆI€hv<ÁĔpŋZW,ųš_Ŧ"˛O1OÁ­˙“ëōšwÉ_ŊHdHF —ˆ$>âĖHŒ)…Ųņ&1*r9Lb8@\@ŲSŌePĻ/]‡^[i(éŒĢ‰#˜.Ė” ጓĖüƟČ%…š†‘'Ȉęnk*žxpč‡ ŽÆM°ŌE%K” , ėĀ#.đ8­B:*“ Ā6ē3|ą”"œVLũäĘī‡u¨˛0‚GfŌG5Š1tēäĶjŗĶ>úØŖĐxAXR`xō.m9NĢK~2!]¤ũÛoŋeĄ˜{ …X ĖĶ.< Ú9čvuÅÄCÛÔRÜãā- ī˙j…öļÛnĢnׇšPLQø466ĶĻŽ…gđ`Čg^į<Ĩcc2“NĢiRĸ†iĸņŦƒÚÛÛũä0ķŦY1(p`ą…p}cAʈŠŽVVėO\€„Žfâ‹IšVļÄĶ8ÔŠB§å@oŪ͍âˆãpbúuŽÂatžüãŸė§ƒŲĸ=•~Íū‹|UUGG‡ķČĄA"×­_gÅėŲįēĻ•+?Áû+Į­ZĩZé@0ŒŽ>ŋM=ņr¤UUÃnN&"ū˛dią”~Ü5×\[Ũ}÷=V4ŗ^y2á“EaāI“I„ŅQęU†˜Ëi CÖüä 8Šd:˙Ū÷ûû*í,TŌŨųĩ,iČČôq{ËXĘ/“5”Ld!•`Ía`öÉS&WËī_.ūŧ&Ŧ-ŽÔåÃiÕßOōŋęĘĢŦœĘÃađ^“×ĀÁUWR*$ ´%Ú똹cü¨œˆ“(ųĪmé(ēYWķæuÖmƒ×Išųæ›ÜŽeŪæō/kR‹Ηj™ŧ/_˙Ģę4ÍĄÄWtqĀúõ|+-|^0}Ēûˇoúp¸Ėt,$\€ŧĢŧ–icâøoæĶŸō^”ÉN˛“žĮŨüLŧŠŋėcQ´šŖZÖŽ}ŲųãŖ\›t˜ƒĐôô‘2ĶQŸŠoŠčRÚ3‡ĸ™čww¯Vą­PFôeû!ö!]“ŠPõ´&œô/ͯũ*ÂãęĢã§ė!á1ŽĨ´› %Yk~ÎŦ۟†ī“Ė-¨´ãYMš4ÉąŒI?ũé˙ÚĪÄe?ž$œã9ɯ<m.'Ā]ũ˙HŪÁ÷ŪgĩJōL[¤ûKÎĨpĄ¨ÅŦÉÖT­ŧhú)côsđŖˇt­8s3}č M‘ķ ß|xV—RІȃ˛–l2ņÅq薅>Åääe)8´”a\*aøš9EĻ"•šōjĮ$"YVYY]{VĘ2ŋt˛˙ô„ ™;Hj Á( Н#ÕV’Å ō€“wŊ˛LŨ\xÁ…gPÂÍĘGÖ9¨áũ yfŊS^”!­øŽJ@IDATĢ ƒ“øsÅ9šŌVzõũī˙ úā? ‰FLĀP|P°@Ī—WQ´HŋI+ū÷ß˙{2ĩC^džã›1PÄX5ÆíąĮîū2&íķšČ⃃f~îW C\ ų3Ĩ– YŪä‘ +Đ÷Îē×xPY-fđîĀ!#v<õļV“QøĀ )7δ:äaųŠe*gÜēÔ×ŖĸÅĮņøHĶ7Ūā_ÂËNÂ(øø |€m…āĶ-ŌÄ7mÚ!uĐŦEH&WŊ´Fma{OØ)d ų|ßûŪį Ö×ŋ~Šqđ¸Ŗ´Z.yC[ĩRņ ėļäŽ pŦX2ŅfrŒcwčÔSO1O¸ņDį€,÷9A×Õ¤ú*ôö. }‹Ģĩãˇqã:ߜb$úÃmHôaærí dœŖ—âA_‹L†Œ7wŒ™LÆÁú¨ÜG?ú÷AííížAé§œâ]VPāā­ļø-”ƒYmŲ¸Ĩzčáæ.uËGŗh¯î‚Ž:ŪDō–áųŒ[€ØéÅqkŽĄū(ĘúZyŽāelQ´O}ĢŪą4Šy%o3KįŅúĮíS;p¸ĮĖđŽ~v¯īēë.MĻG”-tđÂû -xÍ.N?}ŒrĄûD™VVüŌé›#1Ąr?Ø$¤Wš„>ōPjû›đ‰ĪŧižÔžėĢ ­é¯Ŗˇöukģ MžĶ˙r;Ž ŋĮŦRįô#Zú &Á¸Z˜øŨ=ŋŗŸņcģÛ-äS;Eb]ôWšÊ˙ŅGíú3;Øėôáî¸ãÉīGkųå6Ĩ“O>õ5äwŊw+ļliĶÍ[ŨåĻŊ¨GęÃŅÍĖwÆræņĨE5čēãŽßÚĪ„— =+Ųš˛=_Ęn—]Æëk‘sħ6›FīÉ'Ÿōę2ņÔë5×\Ŗ/ÖūFĢl?­dë/Į‡ãęŧ‰'Ų뇄íu­" 5“A”tLĸŨ! ų‹´3œ˛TĶžü‚ŧôÕnP(íí,+¤MtõâK/Vģíē›ŋŌ‰Ųqg%-xR&Ôg•ö!{ T!_šûÄŗiŖVŖ4Āá0 Đ!iËō{öŲgyåöŋ~øŖjņ ąĶf`ũ ŗ˛>Ūa3jŦž&;ČægË´ōˊ.ņĐĄCŦpfÜ8)ÅrLԚĘÔŅŠJ°&Đ/X˜ÄøAyhũš¤iĸ…Ķ؜ē 2‰žëîģž&zŧ^wûƒRÎ NŋŊíĪj1×:zåĶô$[“Ö=RĪÛĘ|ˆ‰´l•ĨˆlŠŋۚ:ũ\Lž\Į¨‹T–C­\yY:VÜ^ye`G;č'?ųo]Q| ËÍųâ‹ŋPxâßUė8üī˙Ū ¯0WËôLbļ1VũĖ+ЎŒōŨ+_d2Ō§ēė˛oj§éT¯ŌV0=|æOĪT÷üîO~‘û… ģ­Ÿ˛7÷sŊ&6ôÁ,ŧ¨I6ĢņRŔUŽi€Š?¤Nų$.ę%X6ēÕMPŲa›9sĻ•YĖÅp‡rpuŋvŧf͚å+buÖ Z¨>ĄÕ‘Žō°˛ ĪXĖąiÛyE]Gä̇+Uj˛67ã‘AËĄĀŠã_˙ę7NHÛyLØÖgú[ķč.3Ä8Ä Čēäˇb՚Ŧ‡ŸxBܸqãõöögŋÍnšwŨĀåūŸÛymņBĮKڝbÕ[e˙Mœ1›ņÄ …ŠÅ™ž–wŧJÕwø&hk8tčWÆÄ€Qú@ÔLŌ‹/ę ĨDǍŦú{‘r*ĸ˜`Ϙ.ûū#<˛î/˜0ŨŦ¯6ãæÍëLĐ×|Ļn Yį˜Ã¤—Ũs]–°•ü˛ëp˙ũ÷Z~CtąÂVōK?jųÕõĶčôEÔc–ą.)V°Pr`U{›Íiâ]YԋBü΃ļmŽÉđļž ĀČzÁī|ˆr !ÆíŠŧrL&F0&øp[Wąhh’! ^4^úüú~č´Îh¸(›Į@ÉŽ$+šÅŖdNEë @`•ÔĘĮéô<JNņæˇâų@j3NEĸŌ™Ņā’@ü‘i§†iÄdGfÆ %g9OL(°]!ĀTˀGĮ)ĮŸgl Ē`{—/‘i–Ķaā2 \ŧhe†‚.âÁWh'ôå“<›øä.Š W“č8E’@V^å¤JH.Ė™H“4¨'÷â\tž‘‚ˇÚŅXŌwomãįJöūđ¤ač$BPyÁ$ĘĄWWOŊĶqÜŸ÷¯c7ž+ô *ž§ė,QŽŲjĪÕ&ëĩ…rႚP&:ĻĄ2]X˛t YÔJ4ƒŠž´ë0`ųä:îĀô“UC>ģ¯h7€ÕUŠŽ:О˜˙Zâ1#aEĮ 0åŪ[EéÃ념eNą]ĻlŲ¸ĩž+[z}^ƒÃá‡^+ž<ōČÃVpYuu>ŽWg#V›Ãzán¯ŊdR&š"‰Í9'í‰}öØŠ¨/xnÃvĐVõķēŖ]U.'žŠ§9!cCR ×ÕĩĐĢđÔwX3ųÛ´)L=PØqķæųĒx(zŲ%Ā…âģ—īhįîĖFqģøâ‹mcËĒøo{§”•;eV1°ĒŊŊŖš??đbį TEÍ ŗD!RzXņpYĀš´U›UšC‡ FÁŽf•;MK;IŨ„üÖúG1úĪD"šĪŽB˙ū!ãČūavÖÕÕeeūį?ŋÕf1úЇL;rļŋ&ŧÜ5Ι Ėąyô‘ęšĢ¯Ö=ÕĪO‡ĘŊDŧe˖ĒcŌA™1×buėE};`‚&2LĻpČĸn˛…=BQlyv”[Đŗ_‹:ĘöŽ,å. õßu‹•ŋāw`‰ŋp*9ZáŌäŸo  é€ļå)LĘJŸÛŸ˜”*Ŋõé;0×b7rîÜ9uœnČŌÖ˙~6ŗ˜4iĸÛ0(īđ…_:äĐŗ–,ÜGŠŸ tYšĐ!ÄjbGGõąÜĻ?”Ÿ69mÚôęœsÎQũĖĩ]:˛‰Y"ĻU˗Ķļb"K=ÛX%mh"ļÆmvT0á翚Ģ=áf×Ũvõīä“ßaÛiĖũ¨ú#](Pītéžž•˛R€5R,e^C(CŠ;{! xˏĻw”QÆ&^•5Ģĩc!§K<áž1c†ß‘Láøžwũ˙ųĪŗÍgG0ãĘķ[LĒ6H¤.ÜŸĢˆ•EōBœ•Ÿ­ūˆI˜|ĢÁėAąüķėgЉ1´™"ƙ|kĻOy˘ %9Ķ*Uō×@ÍôéŖlԄ7~|LĻņįb LĐ æ`¸tVŽ"Ôũ ũ;a˛‘§o¤Mq‹ūÂmƒĘQš×rPÍ?“+ø‚Ŋ[Đl^~w¯n‰{y)UXd'(/ĻŽ¸ŒwŽĘØúËgörėÁ„9öXhūâ_ę@ˇyŦ¤­2ņÄ1ūą€¸ú¯ßø‡ЂĶlˇUÆOä7ûQôÆüĨŌįÍ\•Ãå*å l°žĄ^“¯ãDL2<vÜęŒŒgĘeöŋÁŋcœŽŨV¤W.ÂCVx/lĨAļČ5øJ~ ‹Đ”ŪôĢ  TīāŅŋГ[hih—$u9— ‚õKė%Ã:ŋ SņNOŦh39ā\ ˆô oMar&‚” Ōņ+"‚ W_I蘨xS­š3RH&4čˆ Æ9‚9ôJ%ģˆf’pP`Į7Ķ%ąM|ÂXh3™hēE ęJzJ~öBN𮌠´î˜:ŖtÍöŠ ’€Ŋák爈w%vÖäåpūčg >ãoA帘xĄŊcƒ™ĘĢę8R]/i? |ƒfįÆjēĀ­(Ÿu%Ûb ˏcßxl=0/•BÃ`‡Ã|‚ƉĢž”2\áj~K°ôæ˙ėđÂEhĒˆ-Yō‚ė6'×+Ą(Ņŋūõ¯œBV`ĻNŨŖŽG1ž5ë^ĮŖ¨á¨{ęĪÜĻLéĖ&ø‰ųCtŪđ$ŪP˛C™](Ū—ŧŠUUííÆĀ Įƒûîã>(é§ŨP?Lü´˜äP^tQÕ9~uŽ&i˜Ŋ1QĻžáŋ#<Â;‹LØf˙yļĪ‹0iEųGAÆ,îÁ}ē`Įē Ž5Rõļ¯m˜Ą~„ļĪGá0Éū SKLõÕß@ΐCyéã]‡ĘQūäjSúĄ„ß@MØ×ŲDUVä ĶG&ŨČ. õÍå üpđä͟ū´wėn— õūëG×í)2ŌÄN}nÃÆ~îŸđˇŠGōÚŠĨpąCFŸ“ĢÆ­ bÂÖ8( c2Å"P“:ΞŪūĐ?Ķ3Pzū’ŋԚ0=oW­Xˆ‚>\šRBũi¸f"7:ø¯˙c|bx0hCãf`Ā!uj-jɁļ×ŋĐJ™Ą]^Ā)QM× Ģ’ˆ˛Õ× ×ŽüŲ~ã!ÖøÃHT÷RŽ‘x@ŋ“ũ*8ūV—cüg‡zĨvĒic(đ!ŋßõeL8hËäĪ!m~8vĖÎ?˙|™CūąŌÍl•nr8; |P ŗŌč˙¨Ÿį"Æ.ˆYčz ž7~„sŧ^s"Ü𠿉_œŒzÉô¯H¨āBZ]īTJŨ7„$Ëųx@(ē@ÚÃËÃŽäeŋ')‚T{jæ 0OI]ú™<Á†ÔF<ōt$ĘâH‰x<“ä Ėd ŧxđĸОv.íF *!B 0ĸŠ(€8"Œ5Ū>˜ŽˇĻsb%txĀA– Ч“ƒ[˙ŒŊްđ‹|å1ŒŌQ‘8OFôJīɄ€H8âȉÜUáș t3HN ęį8Âõ#%‚×qÂcR1Ė`D (b…ˆú92Ģ1č0“M‘OAī4Š#584†Ë¯ōr(,kų ”:fV;œ•T­~ˆ\Š‚&\Ž&ō•ĐÜIXTž$‰Í)[Ũi{œŠ$‡vŌ†’Á/¨eĘ&J…ԁ_‡•u— Ā›Ž{S­DŗZÛIøąĮ{ė1õV%JßĖ™ŋĶ--œsX)\ŦШ$úĪ ”3 L8e„Í#ģiÁmŲæ*ÉGŒ.Åcq…‚’ ĢÂŦ㎚]XŨĖE;W-įK ÁÅ!¸+>ģ°ųT]‹÷Æ •ōąČ°y Ļîėw:ĘøbąŖLūbߙöüĩ<*ū‘õŖCeІΜqā›Á’ÕûT8sâ†Ōu§Vîq˜ ÷đĨš:Ūq}Õxņ"xsÅ*.+’(ũČõˆŌ…rīøĸUrV'ŋõ­oIiųŽëķųįĩãĸÆ@Ÿ­î<‹Ŧš+üĸúā^”)rŌÆŠG‚úđæÎãwQy^̃ĸÁEŖT`rũÁvģįíGØL˙Y+P8dƒ‰'寄ƒ&@ú •~ŸĶŲ–¯úËŌLĻhė֐pF€ßąĮĢ Ā­ÕYgĨ°—5yiķ'ĘČV|Č?“ŋŠõ$0‹+´a˛›´[ÆŨ¯¨lų$A"“:ę~­„;pĩs/Ŧ7˜,üf/ė‰ Ĩåļ ō“XH Gtûá‹Ī€ō˛ú•¯|ÅĘr&ß|EYĻŋĀ\ŽÅÚ˛}ՕWkQ`ĸžZ~ģ“d[cĨ—6ÚęHģėŧ¯’ ŋĶO?CöČGY!F6Q0¨Úmy?Dį ¸QŒķ6W\qE+ēÚĪGšqhŊōÄô†¯ŋ÷ô÷úKÂÔ=‹#LŠ/d€“ú*][ę//]ģ›5ŌŋŅŗhQœ5"ųšį~ČX.ēčB™JŊĄęčh¯8čŠ;đŨQ^Ú%˛N|ˉú"ė—´+}‹—?ė ā|öAu¤?nĨ&AŦ€¯^-Y”Ë]Į•ZÉCÄyV…ø~LrÜađ†Ü"[HDŗī#ϧKyEūBdŅ Šsō‡3¨į“ôd‹CūĶ5ņBāG‚š8{A86äg•–ÜãV¯P4Ų…a\dG Š–ĸefŨžĨ(¯Ū]‰wd ,ũU–<Ņ{ōJ‘(c7l%Ī‹ü3Ū\­ķˆP]CTãģGiNXĶōšTD¤éP§ÎbÃSO=åĀĩę;Å8›OĄü3Ļ­ōeí~ũUŸŋčŗūRüÄI“|öŽöÄ`”ä˜;Ū'ŧų-ēŧĢ:ûŦ÷YųggmĄô}+CyÚå‹ęĨnĄšpę5ę:…ÃĘž‹Íâ1} É'žËŅC6ęhÆLūLg§ķ8ĻW Zå.ôåaœŸYBŖé6Ŋ…ÖnõžaĻP‰ŠkÉŽ}‘™G9x'"ōЉâH8PĘĪøĐ>ūŠú„×[Ģsš‰<bqũČD&._­?%ƒ$3pDtÎĻ^gGzyëA+ÃU‘Ū°ÆHF‚B‡|ŅĐņAxā ŅÁ?Â30_­ÉL‡ˆRØPļJ‡ápĨ6šĨ<ŧđ_4—G9JE*ĸģ;gĀ~+ę\6åbûčtŧÚæÃŅHõĨDûsBĀ՝ØĶZ`$‰dÏōdãÍUĨŠZĩËŨ†T„PŽXiÅu´kĶ;ŧ{ŌÃŧ•I9Äuņü*˙’8 7ōāōĐʡĶāÖę37ą`tˆ> ‚ŒË}”ôE‹› ’ŦŠ1!ĀĨ2ķČ#ąË7JĐI§:2Eĸ‘ę2dV…—ëÜÃŽõÎ+|Ü`‚c6œĘĖŽ Ž E7oˇXí‰^ā$N_‚U'&()8&Øī‡ ~Ú@æĨãÅ1I„”Ļ ėˆVøĪíMmmĨü/Ö Ņ6IŌé:ÜyŖĶą"íų>ûŧÎIęĐɛô(!\KIé.uķjÁŨŒ¤Ž9t;1côG+ °”;ę|œėĄsE{_­ ŗƒKYĸ÷M›7hđŌjwôMŠ-mUx-u*:¸˜H0 1ŠeuĮ¤ōÖr¨lõë nßž˛ß—mø ™s0qėėėŦôͧÁVxƌŖmš†Ō\utLôdžę|ķ€įˆ:HĪî’Ę×_ƒßƍą:™‡´™œÎ;ĖŽYø‡$ÐđZĐVĐņ´ÛlĪÔ5“ĮœBhāŌŸ;áƒŨ„€8˛P Ņ^ƨm1I/Ļ,ėŒaĶNēŋÆąēĮjŨ1Į[}ī{ßĢw™P20ؚ93lÚsbÚÎ:ŋ”uNÛÂí6uwĩÁ.cđ'RBį°aģģŨüčG?ÔĒáqlQ†Ųõk—ōIōMŋÓÃΛ4YųÉõ×W“;:ŧ ŅŠ—ü‘øųđŖye=č˜Zaō8Uôpfģgv˜ ĐO_ĸ­ôM”JsŋHŋíŕ ÷oúKz&eô“ĪÉ^[_Õ5ž]ÚÛĢ=´ú='N˛ ´Đ_ĸP1)šā‚ĪTßĐíDÎfą‚ <˛ŋrՋ6eĸŧČNßÁ¨Fh§ƒ~XxāŨ@ņķ øŌĩ°Ë°üáæ'n„ŖīęĢ~Ã-ų˛Ėô&8ŨÀ÷øV‚ø§˛†,ˇĘ~d-Ō´Ģm#¯bm­Ã€ ŲŽū&2{mzĢ â蓨9Ģ3KđnŖ¸b†Đ JžņØę/ŧĒ!ŌÛ jë@čd&N8E,$i¸ÔW74°Tđ'f ”ĨģS F%/dkÁMAúâv÷$ã‡x|T‡AŲ† ĒK-VUC$“(ņsįΊ>˙ųĪרŲ`a‡~tō$ĩՎvīF0nŋY“€;īēCüĪŠxā~/Æ,Ņ"@šė…˛+TĨ¸Žcø§2ĸÁÁ¤‘g€ā ŋåDúĸcO<)ęqˆ÷:az,9† „Jå(°ā׋3ОgQAKÕ@?0äLl:Ŋ)1čb1.Ôn˛ 'ČfREžQâ€hÂFģq˙í?¤ūēC´ŅÁG*čvŲM“ÂyšqWeäm!k“Í“ ˜ OČ1_ČT?Ãyö ƒåĮ#ßã5ÂôWiƒ(`Jœ™DĢ ė˜ ņÎß`~Š‚€/ ĐšÆ„ŖPįäūíúoĄs~ņî%Ž{§ÁŽ Ėzú["HJŪyāÃ̞-ŅŠ §§SYˆ9pĸÄĀåāĪ :•;`■L ČŋđAá™}6$`7iåG_3ôa Aƒ†kpŲĶôąJœ7ŋŦZųR5rT(ũØCŗ†›ŖÃŖ8îČ_ŦXĶ/>“3BFGޞZėšā¸I#Wˇéôļ”ÃgÛHJ—J+š(R!]Ö>‡<ɓü˜€$OôaíŠėbpėí˙ûŋ¯ŗŸ-ߜ€Āˇœlä*9ŧÛ¤o ôī7ĐöčÜ´ø…0ŲĀT‡W¯qX5éŠ: "ņŗJžÛčo{Û[ęmWbˇ–ŧ,;T*f›m8ĀļÔ+ɘā–• ĢŌ ē”yáJęg\î€7' ÜքËÉ\̌´Nŧ–.YĻÜeNĻ-ÜTļ‘˜%Kk0¸joī°’üœxøÔSOúgÄúƒŌŅEŸ÷ALvPF°QfĀŽËļێ)æ:1°[&œ8k…šeČ>fMę›qÚÛÛÅÛāgōŅÉũ‡vŪ‚KhXŲšN‹M&L~ōĀöáĪ<ķĮjĖ蹨Vë G›ĨAVQV­\mŗ&vA˜ltiƒ2ÅÍ0üŌņ] ėÎ'MšhŲ)AáŊæšk*&ķæuJûi›ûE'ÉIgrr2ĘÛ Šܧ5• ŨũFô ^Ą,Š7â°ëļíļTÎ’‡Ņ’‘åŪ1Ņw$+ŅŖ!‡0˜‡ģSƒŲÎFĨ‰uĘQŖGąŨ|pŨĀ-šĄāe׊ī_āäSY8ЗCģŨŒ”HĀbŠ\2YâĻ&2,\ĐGŧ >Wæ;ãwޛϞīËôņ”„Š9§i•:Ø5Ā ‰įjÉũž~ĒęԎ}&y8ōÜC„Ow´›7f‹cZ˙˜KÕÛO>Éøé—÷fīĻÍÕŽßsÜ ŖV‰1Ÿ¤I× X7÷tīģ[ņū-ū å‡É9ũh+-]ÚĄ{^‹?,Æpļe“úĮ~z219õ]§š^ÚAĢ{ģd” Ņe÷o´”ß/õĢ4˜V°â^üQģ4ŗuƇŧĪ×Î}éM7ܨúZĢŧGŲŽēƒ)˛{āKüĒ7UXŒyȤ}VįĐúˇÕõ• 3­2ãĨ–ØĄ'åä€K˜ '0௠Uû@~h{!ô3ŠPV‘ųō9ÜĪ‚é)ķŌĨËĩK§q_ŧ$ú3vî瘝öÂØÅãũ ųĨãû ī˙ßû†:Æ4&Ɵ6mš¯ņĻ “=¸´eöˇEŸĢōPmúPY­Ž¤*)ŊĀę˧ņēCÉą„˛NŨŲéy`*Ī;äŌXD&BZڒëÅŦ3rŗsoŽ€G2Æápŧ€;ĘCx´eh‚?ō[wށĩD™pšĨžŦY°ĘŲBr‰ ęgđC&@ Ė´0VÄ)nį õ|dBœ™CĢ‚Ÿ3¯H…ãël(täjū“īā+”Ö  pfŧ|æ™3Î'Ō qJeô@;DčČč XJūdķ*8ÁŸđ.˙:Ņ@¸p9üQĄx`[č:,ō-áÆC°œÉ ¯ūŠķ&ƒ‡|äĄ\”ōė4œGâsúfyM üŽæŒöĊû…~ēÚyÂÎN…íųwūÖöą] ŠŗˆN#Ī`^2gN˜2 ™˛BcîĒėC‡÷*!ĄšSÍũ7„BÅ5˜y+ON.€Ú›7=É'ķ(0Č]|ô%äīØcßX+úyG: üŌĨË<˜iōäIVDP@ōV–Ø/ē2Ėį¸ãŽ—r+č(Ú(‘(fEם¸čA!Ō, ŪIčGá•4ōÅ6‡˛ģR“+æ<(ޏŧ~’wv[úöÕĒ7mNĻNÇĮ„3ˆä™>íîtLč¸í¤ŋVļ}WŋBY1d F‘˜ÛŲi8}bÚÅՒ(š\šn›á#ĩüR…]?+Ųúš¤ĪœÄŠ_œ]€žĶN{O…Í7.hVÚwߝņ}ÂļvAԍÚv„Í ŪõŽwËėgƒŌ6>ūą[ųg0ĮD™C‘5ÎōŦ]ŖûúÕäĸąŅ˙ŗú“E’ķŨtVeŖ4ø­IČ—ąûm_…2×|‚Ŋa„ßhgE¸įŗāEYÎēLŲBaˆCčÄD9Ė…‡ž¨ūŊcĪr.ʄœ#aō(ŲԊ<Ęí°Iww Hcŗ?3ŠĻŒ¯å˜@ų™ú×u +ˇi—ųÉ?ūøüĄ ĸ9gpÅßÅkĨc°Ę‚Ée‡ÉFÚ3úKžĘ‹ĸäę0x€WOđį­Đ Ė ßŪ{īUķ—[JpLÖÉ} +&SĻLvõ–W¯˛(˙įžûa}1õA ŋä’KÔ鎴â; ŋĖt*ĸܜgXüüRO\vÛuĒqĸTŖxāIV¤qą… Ä ĻWŧĨØĄRvúŽ\<ˆ=Ũl$ķ×ŗæÍFŧ§ĸBڐ5j(ú 7%úÅā˛Ņ+ķ|”écû˜mLÁŅŲŲiuüžčŅfĩ̉ ?zoŧņ&Ũ->Æļāė^°ÂĮ‡iĩąĢëy™M=_­Đ€ÆÍ$‡s˜RĨKcÕ3­PØ0ÍH…&obGbÍjŨԤDžgnË(čQÕBI„˙‡N‰g¯H~p>úˆ•oüL>^˙úũņĘF|¨2 čúÜŠBÄ |˜—¤üŧķ§úœ;i2ĮÁŨĻĨ­ļˁ;'­|PŨVYũįúL”*>@¸`A—¯Đ}~ņ›ąkĖį5ŠFĻr=Ž÷B'uÃä^ķ§ū‹xváčŗâ_GÁ ˙Ą/♠yŋ&NōŦņnlÁģŪ+ôëÄɓ~+Úņ_ õ/•å/ÄsĐŗYÆuōðGų Ų‡v&z/ãF+^Ldá <ęŽYđƒüÜ ãÆĢŽ;á8WŨ*™1ŲĀlŠ9ŪÄjŧô'7¤¯Œ’ōA¸Ú2‰ücŌŪߊųîģīŠ~il&¯Ÿ‰‡Ú+ãŽļ4nœnŧ[ą´LÄR•Qx#h#LSi]20F7ŲāŽ;ū›īq‹įDžˇĀáDޝîEé?œS7āV:éûz:t Bi™æ¯ ,¨O\NfđSGéēĶé†ŋ[C—t@­_ŋÎ(~øĄēŋā&VØqL\×Ŧ}I;Ņë=VŦÕÎ< ALl×ęų˛;ÖĒͲØČŽ3;ŧ|đņĘ+¯ĒÎ8ãL+˙đž]ķĩē ‹‹#¨>ėĩ÷Ū¯ķ¸ŧí¨ŅĒĢ—•~‰ėúŲļ؞喧‰]æË/ŋŧ^„cG–se¸˜Čĸgáš\h;¨ƒ:&˜ŋ…ÃD¸‚;‘\Œ#rđŠJž\5׏ĪĒ–\Ÿ—ū ˇz1$äįŸ",sց5މĨX•C&ŪéB6ã ŗV :2ÖePeXtTŅX×tSnÆU—‰pIe3%‘R×Į&Iƒ'rpvÅÛĖ›Č?<ēēÎP‡hĖ,Äd@aH 5„åžҤwc, ĘäJķ gPčÁ_~āDŌoJCå $\˛PH ˇķ 7F„ALj:˜FŪE Jƒ1J§˙É$žę KéđƤ'€iakÅ߂€LL ė ”˜rCĸ`žF4(L7ÉՉnÖJŠfÔķ:į9qÚĻķ‚ĸÁGRp( m}°Ū‚O%OÂÛôõS)bÂę!á›ßüfĩ—”Iˇņq¯ĄCôŅĄį—YņEI7cäō ]ˇø˜žnÜŪŪaÅ›~ _3ÄaūÃa>\Š•G‚Åä'e sTđ—/ß2AØĖ˛NĘÍ`Éd„C¸(f8ņåÎ|üũīp8ålHYæ’bEk„vĮĄ¨į Hļ)G.ÉCbÚWõ— ÷”)ģž]Œ4íą,)4v^T9ĮÕü`š¯éⰉNEpŠl§8āāäîfģ˜Pĩœ% üq¯>7ĮŦķ­MĀ1ĸ^p ÷ߟũąŠ+yUįJG>Ą}guöíZ1ß%v'Šũ{tTĸZüĖ{û rgdš_øJ¤Z‚åךŠ/ô)ÔŠãxJÍÉĐYgå ”[~v‹éšjŦˆą•Ä5tĸô2ĘČĐËÚķ~žkís@ŧyž—x_üá:įˇ÷Úk}ûęk¯ĩļą›nĒ#'{žéoč\~Õgd-Zx;Ī+éčB@Uâ*ŨŦ‡ôŪB;€6vÃæäÄÂÄôˇVvúög-ēÎA}G9­ڋŧIhsʕS÷tô‡oN:Š6ÁįhP7zūŠĶШŽvØtÖ?æ{'$éčšG•ÍoAxzUŲąNË2ߏK°ĩßøpYFjxhK?õž˙üŪá 'Ë(—æŋé?Ã˙sZ‡é´i!EĶię¯rüpŧ߯s—eēžÕQy~ŊŽÔôĶä˙˙buëú’$ąß#؜™ę—qR–Ž%ÆcŨíÄK? ĸ/Ou ›3ŨŠ*lĪ$ČĪßüi[:ŋøÍú‚7Nō°b ŊjõJ&ėŽÁoĮ˛Žö[ Ūëå{dWØŪūŒmŽv€nL!î~ÕŗÚŒļJ+6fėo.ŽxA•Oķ͝Š|I?$ũ*&Ūļ2,õŊx%đ2ÉĶų‚(ķxOå KJ Ō5‘ī”aųĨēr€Eéâä¨ōņ˜éThčôˇ:…^‹ĩ¨Ņé”úá”úkXŧT3A.ՙėŒ2pVéøŊ+LSH)u>vŋĪcÉ>RŽō ĻP†ZDe1!s$Y+ Ršiō`åh a**dQ( HTģ‹×äĒA ‘á;ĨO×_J1HŖg.˛oPKiû #DGό( Ŗ"ČLŲX‰ÄÉßJø,ÛącõÔ§îG'ž:ĘVÖvƞũėí™ūōÉfG*_㭄īŊoå°1…'>ņ YĶ}øá‡¯~õĢSčíØ|ücū&›nL!Ŋ+ë\ ØcĪ=xåˇqâ–,Yœģ;ûŨ8˜ĘÕLšLÔufmųÚƒYs+gO•ÃsĩuvĖtvtÎÄģVQg§ī÷˙ōĨŲ ļĻ‘Xî]ÖÄBĮĶB<…ÄJfŲ˛k‡ãŽûØøa2oj'ixÆv:t­Ãv°¯ĄÖÚ÷͈<>ĪŊ IŌŊ š:Ug:.Ŋãc˛øĀ™mõ°Aõë sŲąûãwg™ĐelĘ;†ŲĨš-ß%gœqšŪq™Å|–øÜ͌Ë>ûėÛdwÚi§U_ M^@õwƒÕ >”ĸë§?Ų1ö\~]5ŧ_YŗéĪ:đYãlēkķuY™ũĨûĒãPqõú}ŨėŊpÖĐ|S+UgHũBr_Ö˛īžS3Ā—_^éēå[ąü…Ͱägt<'ŨAß":—­ŧįŽäátXĐŅ’<3kᕠŽLĩĶl}ŖsFŲŠ­XqĶ°ÃŽ“äéՉKį¤åņ{ī[•ÍÖÎTÚĀ| Ÿn֝wŪyÃ_ąArCÖ-;ko§Cį:}Oå_g؎ÁIüë:ȰĶ×:xč[é|īā`ÉŲ°vØÔņĸyŌI˙ÜÛ9[ßtsP ëƒĘΎéä˙āƒĖ&įÉLÖ1ŠoŦģZēX¤RŦ|&üū°=:ŋ%`ēë,›Į×´íŸôÄĀiƒČœ˛īŌ•ÕŧaYo|“õļˇũÅøĖ%Eg0sŋŨv ”—ƒŨ/æø)g9ÖõĨ<úû Ų´sšĪČëΎMĘ~iä2%–×'īšW6ĩŽe¨u¨ĢŽ*Û˙Î˙ØąÅ!yĘģ›Ä }կˆt.šäŌĀÚqwBEgŪpiĨmĩf÷3IŌÄöĮæđvA7}éSMz‘×°•:Ĩŗî=Ęsą‚,C§Įũ˛ędŲúhÁé ŧ:;ų‡DāÛ–”|€ ĪÍÆâ•ŗ~î4Ąg†t” 0¤Ã‰ ^O¯Qŧ§`ĻÃ@$ØĢ ĪļĄ×čBĢ´!4D…÷üSqĄPru™īNŗ÷ŠÍoŠg´N|é2dįUGV‰ĀX™ŲīÂh#ĘđŨoˆ„ō`10Ą 0ļâãię(A /M•Čy‰įs€đë™+  >K„VÔÄw­›f ‡ŽĒŠĘØhŲtŅ âũy'Žx…R”B'yvÚ gO¸ˆeŸĸ!]3^ōØĨū*ŠJ‹ā>0):âʑ5āl吘|Eŗ/š(*CÖ<ģfũ{ßûˇ,°ŌLG9]Ģš›Œ/YšŖ(_Ëō˙Á’žéę+_ųęp—O&&&i ¯Ŗ"ömAuŌ÷¤“á1tēūņß2¸Ė'¯ËŌômĀĀfũy|ԉˇē‰í'rˇÃđ˙QGÕÕ+ĘĩŗáĮlöųĪ.3øÎ(Ķ‘GÉfäÍ2]vŲ53ŒV.fg§Ážė˛Ēt>ĀI¯ũëŌ šYũcŽyGøŠK֞Μ]؃P—;ôúžÖŪ™*—mÜAĮÕs´ëUyHŒ6đi—]jÖ}Ŋõį w˜ČQ“YC‰æn¤ĩŖįėėk^ķ^™ž5›6´Ķįųē-ŲdÚgČlu3ęÆLËUtžĘd'ΝĖŪ|ķ­ä‰Ú|íĩWį5n_&ãZûžļŧ/Ŗę3Đ{Ņ îēöt3ΏˇÃuē›˜ØasĐ099™S"\ķä'ī•åZ•īĒ\Ŗž.CŲ{¯ŊųÔûKĶ`›Ž}ã¸įˇûå\7bŪu÷4Ę;æ$gų\FđÃūˆÍ`'3ĶŗQ|–?O!R]_Žb=bœŗioas¨›œŨ_āœÚ]5€2ŲĐ8čŌüņŲüĢß˙;ŪņNŊ)7‚úˇÜoĐ;°Úč9ŧ=ķƒPæ­zSPįô›ßĖwn\ŊøâęÄä#O†úéĪ~ÆÉP?Ąŗ?A/nŖä9yĻsđŌ÷Ŗd RŸ LlÕuą3Ė,ī–ŖÜSéXW(ÃLˇmlËIRßÎĘĩw_Ęäî™zņEæ-€yē/Q;᜺ķfÕIīī{ßûōAĩ=XßmŨîā8ųZsyäö?ķ™ĪPį՛č)å[ŽŖ­qЉhŨžđ…áāįœ 0OŲ:‘ÍÉ/e÷ĸEWĻ~°Y°U‡Čˇ\áä@?™ËNëß˙ũûCķVŽåõ ̃Õ¸„Úæ–ëŧįąoĘo>¸<ōæ›oîcĸĖ<Žķp NŊčE‡„ö%ÔYģīū0ˇ¤ėhCÂÖ9.›;č93ÁņŌā÷:o, ŊīÕÍ0%NDœūh­QYIQæXpÃõ71Qą Gv}8捪RĮi‹ŋüo9|ösŸÍ˙ŨvŨ-ߥ¨%°Öķ,3ĨŦZĻ­į:čā,—˛ ķ-c˙’đļÛ-HYđÁš„tŅ ^đ‚Lîxbœ{Ô<ŠËˇ ļwÖU¯Uš8h’ūäääøáIķŅT=éÄ íũĶSŖil_›ÛgJŋŖŲŋ*¯ĶëŌęw•mí§•sæ­ŌBqíÜiŒō^õũ€ŅŖŧpbJHí€1ž˙†„_ŽĘ ŽDŊT¯āPö@ajÂēüōęyK2=_(m:ī’ō'rHä‘ŋ2 ŲĄŧJ\ˆŠ[ēˆ+t‰—ëĖBTHÃE*e:ÁHŦJ˜˜ZE4<įÄŊ^ĖĻ…Y1pʞ@SƒH'MŽqŊYPä2 ­0åqTeÆ1$™ČÖÅj8Ō/´ĻwčŠd&„ƒ†’›đúœ]”ž#J;ĢR‘Xķ‹(|2EáéĪ)&=ú ŖŒ:ޞäO1ËŪŽĖFÚųąP^vŲåéœÚqv'ũŅG;ŗņÎđ+÷ãŽ;n8ôĐCY>SN;o~ķŅŦ‘äô*Bgî}M_f›™i×ųj˙ŽĘĶÕĖ=§˙ā¯ÔVŽ’ÚÁģKnn¸í:A‡­,ČŨÎų‰'ž˙ĒUkŌ1vÃé…yj%Ō;Ųžĩũ‰O|šŪœY× =ėâæ"âķ1gœåøā?˜ĐN;î”5÷3éü{JFŸ•œœŠ8+¯ŗcŲŨŧšĻŖb§Ew-k™ŗŧ‚Ų&gLūâ/ŪÆ Č˙ėāŋrĪ{Ū3üųŸ˙y;Ī?īŗCK–,Ŧŗâ˗ßDŊ˛NSwŨu×tŌô÷ Ųh×]mĖŊ-ém˜ƒƒūÚŧ/-Y¸p‡aiÖ˛˛ôĸŊ혘œĐđWo˙ëáŸŋĩsŌ@YŪß÷žŋŖŅũĸŅuÔ‡wžķã›OåøøĮ?žNÂōũšņLđšPĄ@Xę2å[+7Ŗ?ũŠĪ˛4ā¨Ėúeíī°ÔʎĪĪ^uÄCH´7‹ãō‚īĪ˛ŧÚÎøEõGBú]ØcÂiŸ(¯ŪĢmC,˛Æ fŗl˛rI˙gž™NáÃ?|¸” ļ'´==D‘ęéŖųh&M|rM_Æ9ëšũŗ`_?üŅųߗŗÔzú…/üƒ Š_üâ?bũĀϏã˙ųķEņ;uŗŠnûíŧ3›Ũëüæ2Ĩ§K˜t}Ō)\|ŗæÄ‘îsŸû|žœîŪŋjīdÄk_ûZŊ&ūá—CyQ–Ü:™ã?ũ¤:a­üŨÊæC\úVĢ,OZs7¨õļûí˙ííÃˇžũĪŠ/Ŧķ|ãūŪ÷üíđĩ¯u$˙pĪQG5ŧũ¯Ū>ž¤vŪy?aYëgéΆÃōW0`Z'“…ļßc@qøáœãĸ]ÂųŪ÷ž'õ÷מöČô¯ģîÚ°3ûRæËøÚēõ‘Î%…iĮP+“ĄôŌīō9ũ(žS‡Ē7oę )8éĖŗõ÷šM.Î~›ÖĢ{ŋJ":†ĩfÍ\׊pY*Ģ VuęË_ō9ĄbONg߸č´^*a=õ*D>Ŗ<QėšT5ŧ¨ŒvOŅ 8#K wpSŽhFž`āŌūtƒ7ĩEæŌ/\Z<TB€\Cņ’Ų° cĩæ3?ŦSÉÁÕg—øđŸ´UšKH…g–ōɛ8Ņ",O‚éˆ4^skļ°J@a•Ņô7âK=ÆháĄÕ öš„æT4ˌ%'i´KRB?.zqO\äjÄ´ ŠėzÔwü 3Ž_šgĪ^Ÿ „UŲėDƒĢsīÃū0Î?ËkG ÷WžōåœėqúigäDáŦT÷Ū{¯ÁuŪģqĻ´ÎNņˇžõmÎø9OÎÖąųņNoaöĀĩĐnvÔMLNxcÆã>$r~ü™æl ļKV÷­iÁMšöWį,q?EĮĘũžûîa†s:Ĩt@w}đŠ›nĻs;‡ ũOŗ–đĀŸ•ŠĐˇ{1}…ZįnGáÁTbŽß~ÚĶž:VnT}?GÛ9 °ÃˇčĒ%Čž#FNn\ŗēf˜ˇå˜Áîüz¨3Ίhgˆ}{˛ÎŦõÚg CøžTlũÍÁ1ĮüuÖ˟}öŲYÖãāŽ#{ŗYؙgķu'œpBö=ø‘;žįŸAIq ČēđđĩÎĩņŊņq-Ĩ ƒ†Üd“Í؈u×đõo|- otÎĒô†biëP‹ī&PĪgžnŲëKPÜp G$ęú˛Ŧ›x;ãÆnׄģqëM|aUįĢk×ҟrĘŌ‘ģ…Ž´'Ač<ŠČoJk4?@IDAT(h÷žÁûG?úQfߌwŚ5Ģ2ŖīŗÎWĮvūu΂õåJŲsa&}Ü íŌz7q÷ĀĸE‹˛9×4u ûŌ—ūQfŦ\ūrņŗä͌éę@ØĨHæ‡ŪqpæßÎ˙Į>vÜ09šųĢ3ģgrnã¯ûä'?Åëęgf™”yY>Î>ël:פ|˜ž.Ûwß}rĒütæ7{î3qųŒEĶˁbŗ6ę{ú`7ŲēöÕ"ęKĢĒ‚sđO˜eĮŧa™š*˙~!ķúë¯&'?;˙§9‚͝z[î;ŋü†ĶO?ƒtŊ&j;.;īŧ3Ë㞎nû&¯C˜ūtė YøÆĀš ŽŖŗsŽ+ģĩ`t]°:_sÍŌtrŪņŽwdÖĶŽØųįŸOú~Ō DĒū™XؓË|#å~#;†–;7YģūßÍū˛šÎģqûŨí1dķÁ jÖôëÆ4#÷ް ~'ÁSę|đ˙đŅá%/~ ųōôŦ÷6ŊũšũŪ{īå"}šžõËč^ųĘW„~į&´ū9ĢŊGLžõ­oļ¤\zØĄmÆũEi#|ÃøĶŸū$HņįΝĮÛĘŊ2‰Po\ĸwˆûÂđŠO}jč$›ÆÉŖ–ŸØŪ­ũvčßöļŋĖņĩ›nē mņöj•Á=+9I˛‹x#ōo|“Ū’ˇŅN˛9šsōɧwmøķųũY&ã~'']ŦKŽ˙◆7yDęõ^ĪwûöĩÛ)ŸØ9á힇vɤcKu÷mįõŧé´<˙‚ŸQ_ŧ{ø oËũ‚uų§>õI>|÷ĒLā¸ĪÔôKÜ~‘ŪļlŸ}ž2žAV~Ûc{×|éˇœ¤IG›đwŋëoxųš,}œ˜˜`īãŧũ=t8īĮį K9FVXmëDĨ}‘žyĀāāÎĨ” .ĶmŗÍv´a7Đ&Ļ6$EŦL žįŦĮ õšåĀVOæ47ŧ&˙ā/č*ÍæĖÃÂzI?’gîR*ēÚŲĘ:};ōBÛį‘ģ“ÖÕåOÅNˆ˜’.§G…ctÉnlÁ°ŧD‘ƒÕÔÄĩ´l3Då’2áŋļī2QŌnŌ/é"ŋpāU>IÔ4:!×.ÆîĖŪĪhDÉ0D”‡AíŗŨ1P ŦŪĀk$ÅŌ•`А‚Í܉—Ÿōárūà [Nūã‡ŋôų•<D< V~iŗ¨žÆY:Üõ*w—OrÂI3á šžžŽ)”Ô-hÜN3¯™Œ ¯‚îâ4fHZRņŠ9õʇ¯įį2ōžū†šÎčë˙íˏ|ƃ}ôŖ˙@‡h‡thœyx›ĩōĘ]|ĨgÃ۝Ũ/~ņølTÉŲ÷.ƒųå/o ķÆGŒčôôĨüÜŧjĪ/tžOĮcîúu Ļ&ŠėJ­JíÉNGīĐēžŊoģęĒEaī’÷ŽÕôģrųō•ŲĮāēĖ8 •č>tŧüŲ‰ÖŪŌvļŋĶ÷-…ëŧ=öũTd˙6LLLfύԠėw/¯kįÍĢõ†v÷ĸAĐų5×ũöÛ/v‘žM[nĩy6šM°1léÕK†Ī~æŗÃģŪũŽTÎębåĖvˇ­vÕžē[$}ū _ ąø‹q9ŗΞč歊§ą†´ ’Ēķ˜čtîũvųĪŽŗ¯ŦL.Ü~ø%otvčtÎ˙üõĻ@ŋÍ<*gķČžûū^:ßÂ9íە™'ęōzvꝍžÃ7 Ž>účĖōôŲ1_Ũûķļļņ7}­ĻöŋĐũë^' ã­Yús=iļãŦöƒa\‹/Œo\Žēę €Íb+ĸû™5ˇ¨s¯ˆK~tÎāû6kˇŨvĨyU^-;¸ōį ˇËÕBâØY×Îī|įΌø$ÁkŽžļō°9tī4oŧņ— ˇĪÛĶÜY-ĶUŪž đËļŊaSo͎§•iū:Īī}Ī{ąé÷2{xõ5×f [{&ŦЅ\/o}Š™ųŪĶH|+efNŊčVøņWâNúoŊĶ'<؍žKĐ8Čõ+ˇ–‰wžķ,Ú1ƒ2O6ņUŧËÁjpáō´õÃŋ6:õßO:ø „šDĀ2!]Įy5SVā.ĐšĖ¨oŌîņæŠÍ6Û2ƒ ßžîõ¯ ߒų›žF–™3gQŽg0@y'6ߝÁĻÄnëāwžĮ¤l`Ķa_!iŖĖĨ™Čšų&ŪnĮĮÃŽá­âŸpbĖ?ĻLoĖžŖ?ü×0ąņÂöĻĖũĩĮ‰;ŧũų7ŋų͐ĩūëå§ō^áŦ\u÷0g5KÚfĪ{Õa,=Z™¯8;€wpîĪ™hķšōšīĖŋŨ9 õyfęŲŪtĶ-9Ač6ö"Üų…‘¯K9u–/–pÂloąe9“üŦ'¤ķĮ|xn¸Aę}ŋVí$–§ ŊœI5y;pđgy¨ĨĘž¯īaČÃɖ?ũĶ?KũúÖŋø¯õ,ĸ`YęŪÔ]ôZxŗëĀC—2IŦŽâ­CøŲNē˜NN‚-aŲoÕ_ˆMÆú‚e Ö.^ÍŠAÚËúnzj˜ƒõ#^˙†á’K/NŊißÁļÄ^‰éč8ˇŗ>=õGÃ[Ūō_†|āīc3ëT—ĘÃļD×ëĶéõÁ•W^Îō͞ÔG7´rĒŽmZúļՇŽ^vĻĮ~“ :MŅL`˙.¨-Øh”›zJgĨlWŗųˇįUaÚŽėH8hÖŋĘ ÉīŌ–AFė-›œ- X…Į*\õ7ÚŌ-|"m…a„đlZË#8ܕ!l e "^â‰ęįëˆÕgüå­Ŋ¤Sōí>É4Ō—vøĢCוģčá"~ú[3˙Ļ h ?æ×Ŧ4͐‰.c4xo×XQ֐æâ•ƒ 4>k ųčρŽŅ §‰Ié‚3-\Ô2^¤ķ1Χ4b‘„ qpĖŲX gŧ\z¸2–HąŸ§ ČpŸG úyęt’ú&ŦKČā/Ãå]p%ƒŲ]?Žáėۋ™MyõĢ_•ŠÄsû]?é ų—žôOtÄ6ü¨–„?_T+ĄĨK—拨Gq$Ëqž‘5énøq˛3P$ƒũjvëoģ`[ô{ÍģW˙ÎlīYgŸŊŲcE^ æĒĻ%76pÏøžŪt  oü ŌW\Naš5ŦæėæžČŽŊôsôžZsį]wĻâõû~ŒĢë`įS=lŦÜí€:ŗđQŽû“?ysÖ#Úáģöšë׊ŠX¤ēx—Ū¸áÖmštĮtéļąôŲĪ~.35ë¯?y8 ížUĖ–lšŽžuąr´ōRŽn[ĩwįä“OÎ,Ņ?ūã§é1ëÄ,Šč9įœËĢ͏ącgV¯Ę[ '֞išÜ=œüũĶŒ<+Œ2I۟ËU\ßzæ™g0s;üšŪZ›:đųú×ŋ–Žĩ3Žŗ´ŗįŌ›=X‡=špat>įœs˛ŦgCf›îž{%2Ų`{ööŨų¸Ņ=ÜŋúÕ¯fƒ°okēÍģŽŪ•ËüãĖŧ3Z~ė#ų0šĪ`€¸%rrđč×|YfÄ āļlˆ} ƒ,xísꊧÆFsx“å‰"–ßZhWO):úMGÎÛš÷ĢŊ§œrJ6ŧ^ĮúŋŊ0—ü`>˜ž¤íŌ/—ķ|úĶ˙˜Æß}æ…Ģ—^ Oš`+SûKŲ"Ÿē9ÜÎį)§œLvC ŌīļīyM;Ųéw†ĖÁŧ?ũéá G‘üæÉ!W_ŗ,ƒÅŪŲ37āŽ\y'a9]gė\7ŋ;¨ž‹cņzƒ^ļu‹%ŽģĒcRŸõY-ë‚Ô¤.™'''ø¸ØŠĖü—Í”æq;ęā[“ž?ĩ§ƒĐÔŸũėpįŗt֜!\›NBĘ.WˍoqtûīŋĘÚE]ĖFį“ȏ,SheßPiKëšOüfĘã&oÂw=ęë´ĶĶHZčL#ëĒÃ?œ7!WOg–ØSž”¯§ĪīîU Ø Đš ˙ŨKŋœåļ9lŨhūtpËß|ŗxÉ%qúÖwķvs.yÂŧØķD/[Nę¸9ūc>ņæ7m6ɛ*ķŅr&´œ˛ķi§×c)]Zë‰5;é7LŦ§ōņAāî=rŲęôúA>ÖÕʡ|š‡0œ™Áō'XŽčĀ×É3÷ X_ũīĶŨ‰~8øąžŪÁ/äæķVŅvB—A_ôPęUĄÕ”ŊÔkNaV5|ų%$5lĄëvŠ Ö.Xxči`cW[‚mĮƒAû~ xŠ2ōT“Đ՟•‡PU×ëŸrW4Ē˙Xņ]ĩėp )Ģ7ۋū„¯͆˜&ãd‹Bzëxøãozö"<>•ža¯™kĪVßĸÅ=•@ÃP@ Są\) ÁĀFθDwÚĀ—aÅCØÆDĪỐŌ;၊ĮčŖ’‰#S4åķå Ē!@'ž{&ŦŅ*ʒ)ybR-D럆'‚.ÂĮôCܞĐ&BfėBKPiļ6]œÎĸDwaTEƖƆ–ˇ‚ŒĀÜ˙feŌņ¸2›\ß͌´œ…kŖ÷ÜķÉŲT# 7ØlxåĄ4ėö¸]ĶÉąÃlƒ{9ÚˇŨí°pĮá::?.ĮȇVzb‘Ž÷ŗj}6ģzVđtˇŅ†›Đ¸ŗLĄˆŅ¨Ah—Ļ •Čü įŅQšĨEÖÍ#ĪėŧÚąĒŧc8ų3xZÉt&%ŋũŽ[ƒp衇eÆ[Ũ­ŦÜG⌁K Î:ëĖĀ8k @ž?n‘•%ÄLĨoåÍ6ڐŽæMÃÄĎĖzģvy2g|˙⠇ĶN=m8—c,]fe'Îãĩá O^2­Î<ķ,:ĖßfŗÎô^§gqŗ3š=ū~YoΙ™r–ÂeE~ŋaåĘģztî~16ëĪéā™gÍgÚĖSu\jd‡Îå_ē7Ņßmˇ]3ā4˙ŨxãōŦŨwæĮ͸Ũ-\¸zŪ’Š3ČŠäȡ! Đúsų@ ߘî6ÜpŖáÎې Âfqgĩļä8ŧeËŽayЏX;˙Ŧ4>Î ž@ããæA—ŲčæmÔ^{=™Ųîi\7H'ØFäĸ /æmÄIŲxãÍhˆggmĒ’8ŠG¯„ÕßüišÜf›­)KæĀÚå““Ék6‚‹/NžruéĨĩWd“M7æĐņYFce>–^U ĐĨāØ)vĀtˇAōũô5āŠn˛9Ķīˆ‰m2ãcjõNL\¯wŨ°8A>ô-UwžŅŊpr!ƒŋ‰ =Eėü;XÔmÁIM\ÎWŸũČ\äįŲôīL #k(SnítėL9 ßŖEŨëŗ9ƒäĢ› /|á!™õĩĐĶčĘ+HŖ‹/ĘĻ~é9PréÃË}eŪŧX?Uũ9ÅíwžĮžŦ¯-Sß|ŌˇŋÃĮŗ.ákđm3?åØR`;~īũ÷d‡eZˇĶŽģ0Ëü\N˜Ė›FˌĮI[ĻŽ?ū‹Ųf›ms:šyrē[wžšÂ<ųÃF“ũ~É~îúķ8n“aéŌÅAŲ›ãǟĘ9ō“““´{[Ĩl:QääÕŲgŸÃ`âœĀí°ÃNÃ˛kŽãģžvC^w MæOß%<\æä˛ŲMķV|ÛmļįTšŖōVÔÎëÅtžĪdųĪgœ˛cgÕĸãĀȝE¯;{7„×Ģ^õęagĘÂÄd•GÛ6—0~áķĮŗÄļÚgį/Yø~QokVaO^sąšez”ēB9d€3‹ũm P‡Q`tQå™Ē1œ•öíÍũŋR_øÃˇŊļgNY_,Ąž¸‚ Å/ų„Pq`㇯cųĄ“„é›Xɇ ĩG˜yęāĒqi ˆķįoBßä‡]xcŊívud°)K—.͒É^ ;19ÉļĢSˇ¤ŽyëĄ6Ŗ~•†9ÅßPEI_ ųÃØąŪlōU'=œ÷jÕ+ļ‹„CÄv:ĩ/úÚÉûvFRW–<BŽTŠĀ¸ ‚I°Ō§:ōEnZŒŽ˙hŋX‡ø.Dã=Ú¯čøAŦ­ļڂĮĨŲuØa‡Ĩķŋ˙Āđ×ŦKw#“ŗģo˛afŲåøëÜÎ;ī’N¸ŗkĪ`vÚŠdĀĶ䷓äėĀV[oÁ`cVfLü ”âtŪíiĢ˙jåŨš<ũÚČãÅ| ¸éĻuŧ䊛naū6đéü™ĖŌ`EŽÆcufzÕՎžŗŅđ$gũũje}-˛ŲjIčudķĖigvnäCdäėH¯föSŨĖ3+_éLLnËé9Ërū#áļ# ‹ŧÃ˛euڍK…Ū::ĘbÅbĨí‘k›oļ9ÜzhÃ'ŽÎJŪĶ™nJc:˜6zl07}7c­dvȁOi•×ÎĄ°Ų¤Ÿ'ė¸ļV}Ę }芧‘šTū.}rsėævÚi—čyíĩĩqnœŅNžE9œ9Ãĸ¯æ]ú"?ŊyÅ­ã ¸ Ō†ÍJž]X¸paôÜ vˇÚrANqPļlËy~s™ķëY;z÷Ę;ĶP%ҁD]ÛŲ•-i‹<æ ÷J˜nŊŅū5,2ŗšî:뒾7f€U¯ÁĄ…Ūĩ‰Jģ:Čķä§Y;Ŗ~7ŗūžĖc:*S¯ÆÆE‰"ŸåF9mh”zđĐãÕFZZ:€Û”=î/¸âō: ÉØGrîmŲ~bßëĸīĖ™ ÚR×5{ÄRĻ”“œ|ÄŪ3ŧī]s?õ…ĮéVãYō+Ĩ“ōá@y!Ž;šiE f‰˙FmšÁÂú5kVÂŌĨ­Ā#aü.ėąd ˜_ŤĮŋ2{}9§°ÍcāŖœ|Đ ŖßŖyũЏ'ũ,^l=ūČn™s8‚w›lÂŊŲsČųņmŠISĒ=ŦŧWÛ ō´õؒ%‹§áūĒwbb2oĻŗ„…zÕļ=íX¨VŽRđ‚lģ-×ļĖÃ)Ž=Ŗ–ĸfIbYrŦ§ü¨á6ÛnMũĩ”ŠûÃņ(ēũļ;)3lŽnpJw—âR´âÔĩjuNEÛ Ū°ŨĮ›;ŒMĪ%U‡6čÁ°>JeÁ><Žw3–>ŲO¸ü?Š/$e_áF>äåaž)IgTę­~ŠŅdĖŋŨ{­6Ũ‚Ã|á¸B˙GˇķÎģf´íŸK#û2Š A3ē…×Tģ•§nĻ!ˇōæš Ŗ^Ná:!ārÚNĩPÔ(bĨ ūiN@Ĩ~´_b?UŦú‡N§G :ÂzWŽpšŠ§€*ĐëüŠ÷Ēlyę˛4>ŌqI¸mMMœ—m ŦÛIšē.H÷ŲVЃņæ*o-—Đˆę&úņ`ŨĐ ™QЌ9N_ ā6¸­0˛JJÕŠ*Æķ—k„Į¯ė8ášT>5ŋX6b•pÕ ļĮƒ#’.ŒĒƒ”°„WáÍķÂV¡gŒĻĄŠdō’n#Ŧ瑯į—į@íā:8!^=¨ œUôäUĢîN?íôljōĩūŪpd–,Øz[FčÕąŨŠNŽËXî§#b%Ϙų°ŗŗšË9FÂÎÚB=ŋ&]øõ•”ûYĻQņú GŠ[r&-AŽĖj…ŒÆą‹TĒ‚~N–n­ĩXKŨōhH<ōŌ&΀ÚáŠN…ˆŲ˜īÔ)™ 1âãk]N.YÍIVÄÅ_z•z'+ Õäã†=î´ĪzœÖr3KŠāŌ;É.Jî°Ķ+Ÿ¸Æî>NÎŲpC:™ hÔ,ëŊ —¯ō¸´įzÖ.ĒŗŗdvŠ­Ŧíôų†%d*f°^Ų™GÜmĘŌ›ųŦ%ĩ3nŧ4Ŋ{6ģ3?yŖmķ“z˜.Égvü5`hĒģ˛×ÚLgtŌ´aОÍÖ€ķ˜ 7lN'î6'Ûhģ‘5i@ŪÂŊ:¯ClŽSv_§DHĖ 4vÃâ•wũēϏĘäëqéÆ!‡oĩļÛnģl&}éK˙hpé”3Ãįž{n6gŗ8¯ļīēûvÕš €7c}&ēÔúŪn{;Ü71¸ŧëŽÛZ^ĐFđQx\™§=`A匌ȠŗíōŦ͡ØY=r°Ō@úĘŽūϝ{ÖÜ우uŗl‹˜ŅŒÄfO`åĀT<Á™ĄSˆ&ŠØų*bLÖâå[vUvũ8.úĮē3ĄvļD:ĐtsŨfô™o,÷=ŸÕ€ÛMˇ’~76˛1×RáE¤ōŒüįgGfËÔĖë95ŠĀØNy +>ĨŌ†s™)tɜ"rIč#ŗKŨ<—ŨÍū.ĒN҃ÃŅoųSž/˛QŌ“T ­ĮōEķc–vy,Kú[MŨŠsLKËÂw9˜á ÎŪĪ€Ĩ•5 `>ļ|[fœ§CKYۚÉ—įxtĻåßüfŲ0īŪĘQšˇōļ,}Ú' |ƒ'Ėwøõh6™frCĖ˜ææÅ ķmĀ :ô[3Q5›Ų÷Ę{bĢî˛.uyä ×-§wÖŋÕ[ÖĄŌTˇÜŠ.Ū´3.>ˇŪ÷T—~ĻŊ¸Ÿ*wũõËą‰}ë\ ´ū&pbÁ/á:Á!ÕjŋŠ—o÷č¸vŪ\¯cÜ\ëDŽûš\åÛŅ .RÁ(ĢËCrE€ßÂYž);ŊN‹>ļ[ę¤Ņ e›ČäĨė[MžtŌ9<#e<Ębj˛IW\ūĸūŠ(>Õŧ”a (yŠNŦ~­ã˙ËI$Îģhå,ŋ%=Ã˙ļÛn֙9/ŸdÔußûŪ)Á˛“éląëũn cL,Īė—Ž¤›NŠ,rü)at`tU ›Ė )ŋéĶ%ŗ_0ķA ĻOū”œtįĶ֕yK˙¤V„!ghOøeWžü›žÆÅš!ąų  •,úÛøšĄk+‘ t_ĩڑuŨŖōÍāíEßPI€A~ŗ*CS€ƒžxķĄ[g– MU¤Bj/ mŌ×Mč&ŧ˜Č<“™ĒÛoŋ‹4¸UČTXF´Ąļ&cˇ“2z¤Ē:j+ķä!ķ%ų„%Č´‚†pëāgŅ8i"7ĻŲŠķ ŗCåšÍ)I¤Œ˜h)VēE.ljCm¨icžĐļ>WūÃâ+ƒņŒa6o"Vđ[ŧûČ' ÜKÛmŽî•,sD†4¸§Ķ­ ĐT*#=}‰Å$ ÛšëW?ŋ+ sso?5gņâ% ›Ī,Ԋ‹á7'K”–˛ŒM.6ā™1ŖQņ-ƒtz^ejų53ņÅ[žūpˆŠøęˇöŒŲ™_ĘĢéčG­ÍėŦØš°ąÁĨ/}ЃÉDmŽėZÔšōoy,Ŧm“ö@rDôÉ|ĪöŠ8ĶŖ‘E†ŧYиBl'2"?NiÜŧâėåŅļ–;aä9â×0맍č>´ōŌy„#áv`ē>PâAũW1(fô™ŧVĨS= nŖDyļú…ã%K—ä9ų˜ōä‰/÷1ĐĶy6šËæV­v`ūąCˆ ūĖagĮŎŒųKYî˙†Ā¤wã‰ŲķÖĪû#:LXƒmRɌ1æ“G„ū˙hy˛ž`i¤6H9 åã_^„ë\ ėDoÁLg÷ØdMgPņžĨŦúÄåƒōī&[÷ĩNwCęeō3Ÿ9Q`™¨2lūs/{ȄĩPā@-žĘ[I´ĘĸwiĨŊ`FW_"ˇnļ<Ŋęü='ė’/ 5kíŲŦģŋƒ´K=H MĐų^ƒî:íŖŒĘnŪĒöÂoëX×T™ŦާöV.xŗ¯™ۈi}Ģ]Lon)ËømČĀŠ| œ —:YÜÂÛØĒ/hĪlŖ­§(‹–×*įÔč! Ķ[Y%$)}õŒ—ú3č§îY :ļ \´ŒčO^ _8Ŧo-h3é¯+zåW)S”MŠW"#ûŋŠįYŖt} ERÃFøÜ1EÃ/ŨҘ`ÄßŌ2tà \Œ2-ļø…Ē´îÅÆ…ūQ IxRˇĒaą5Bšá-ēúą&‘_*~.nø…PŅĶ5ģ‡‡Ō_/~1É÷Ę:Ŋ­é dœũüĸ'/§!åšč.HnĘPČEX qīÚ(Ôg'¨F‚ąEō0@…Šâ ŲčhŒ†ßíЄ!”Bb`N *yj”Fr—"rOUXF\ ¯ĸ+OđĒ*ό"}ÉS†”Ÿ?p âLŖŌ0 °ĻGƒĸˆĀˇ9ĸ"™ b•HøŠ~‹Ĩ,ˇX ^qčK˛ŦF ×ĶŨyį­ĖsNJč€IĄríŗ:Ёa&N]}Ā–W~$ą °5k#ĨVčĐ+(öˇą“ ?ū‹2ÔUjZIįÁt°ÁĐ8驂ÆLĨh”ņUŠéWNÉVöGŌî1UAZI˜ÆVLž "NU`eÉTæĸč‚+]ĶKzEWŸ˙qĀTƒá,Šą÷zû'ß°ø X6A:TÉŦ\īŗņԌ0R餀Ĩ™/M3p%Y¸ëbîFāOaDˇŌ‰âĻžüU &í%o5ĢōSyŋW*$ĩŊ˛`|tÕąE“ŒrņpÉxö—īZ4 ōÍL;q&ŖĖžßé…BDđaķ‰ĄákZã[ X bLKĩP˙kņ0:˜:O6Ō–6؋]•°Y4âé|nãD÷‘gmʛ­°­F¸Ō€ØĘ›oŊŦ7´á ЌĐĐBßā$ęˇääĄ2 e $å ?Õđū8:-žAŊ‹åOzÂøļÃīqÆüΆŗÎũq6EēŦc:ŽJvōqí Áe3šĶĶČē“ŸDãÔŋĶ2Ū8ߨE`ü:ųÎīė)U?˙Ņ9õÅe]Yj… ]‡Õŧ5|âãwĪRŅËŽ¸"ûzzœt§LęôreĀĻBqÖÚĐ|MЉ2Īh á4ßņœŧĮ ˜žŗ+íÛSâ–U ‚-?Š°2!/6ĄG,3Y"ŧâˆZe§dRN+ŊĸJd#ĐI›/`9ĄK"*ß<ülŠtņÅüĮĪųđŸ'…1`oōÖŊ2.ŨģSŗ؊ī Ā&ųĨ\gõu`ā1”ËX“Y{äP;ėÆy¤tnåˆÛ;Ûƒ˙mŗ`AŅĨķīPq=Nׁ„õŋ8{sŠÕæā‡ū˛†ŨUģ&T>ˆBÔzĀL‚ü­?–¨ŧOO%ŧ2DōO"ä<&¯đh[^Žå;SL°”ĘįÂV]c8„×õūCp NÆäũχø„wVÂsíßĢįäå&gXā¯ēTzÖFA¸9Ÿ›-ēPa\°Q!x–`›,ÁQöđ*R Ŗø5žFHį]څ´üK{¤ūú36MP Å!uJš ŅĒ;Cc&L›°ĒÁ"C?ČĄ]đĘŗ¸Å_^qtrkF­ęΆ˛ÚĀ4VŠŪÆ1ģQ‘7DR‡fß@;8‹~Ō…}`rŒ&8´Fš­ŋœˆdbÍÁŽę9°íuŧÕ?“@ņ7Žō…°2Tˆ†įŗ4Á+¯öi‘Ĩ`ƒrhq­ %* O>1.LlsʖNˆ… ׸AŸúD `ĨiČÔ_Iahã!áģHÆ”ØøD—ˆ@!ÕÂÂŦŽđā3 Žb´ÄH!ˇÍĘ?Ķ„uųãŦ¨JŠīŊ„7&rD–wą§=Ōá,”Š{ē>‘ƒ(e)'Œļ­§*Pâ r ¯–×â7¯„K§ĢŦÄT+A1’°đ*Z=ČËYąéoŧ`ęõlË#ĄČERų‰h ty+a2˙Ũ°Ļ\FÆ ž=—MŠ„éáĸacēŽ.ĨO )7!AhaikĪržxøŲRŨK¸6ä”Nü‘ŖaĨ4*‹yF¸‘Q<$Øķ_đÜabbB/‚¯É}bb!yü.ŪÂʡIaųÆĨė 'õŠn™ĘįæŅ.z5ÖÚŅ4h „‘DĪī•ž!ÉÅŧŨ*Ud÷í[‰ FøZ†*´ŒŌÆTƒņe oôRiãįíœy1ƀĄō“zU(ô:SČH?W}6<%›°Ĩ÷ÎØh+¯˜ISxā"ĢĘ-}žFŅĀ4XĀ*[`ĨXē×Û qŜî¤+ Â¸Õ GdJĨhėQb;ÆŋŨŸRų!ŧ[o=ėŗ÷“3Ã~+į—;“îÚę[ˆÛarbxęžOIœoOŽzŌŸ@g~íáĮ?ųŲp‡›3Ûۃ‡Ęû 7]ģžÖđŽĒõô;č:ž¯…Đ5?k îßژ˙OęžtØ7Vđ•l-nįßÁ‚xûėŊĮ6nŸ ŖÎū{jÛjäü%û[o5ėĮ—ŗ7ŒÁ€˙Í6Ũ$T¯¸rQ6_:øx¨Œ‚}mkø)kš^NĖCūš#Í-īÉc°ų§ri˛ UūĖ•':zžą}ė•@ķš„ )ØNŖ—īĒ” .d#XŌ†tŠnōiĒQä˙Q×ĸ`ˆî–č•ļŗ “¸Hhœŋf̆Ž8‚ŽõŽŸõP:Š%`ĢÛq!ĸXa”ŊáBtĶ*‹ÆvúŊŽ4ĨToGy0R§–Ō[/Ō[h.ß eJŨŠU‰Uõ@Kcā“fĐ ŠākéšG/y úG.žĶŸ č†–ĀŖú†ŖÜáŌx7ŋ¤tE[Fa’ᤛœũˇŊƒh`úWļŦēW€’öúŠģéU{ÂsHĘHBĐá9*”8îxĒMÁ—z”ģ$åÉ_&@­ËåĄ;ĸ4ÅĪ%÷qâÚžōnŅ+HI„&ąENüNCž:e-aͧ:e‰`yf Pz5‚[Úŗ4dnø(Ō-(Ōa4”áüĖŠÂōƒ&ĸ—ĄL E×Ti,Íž„…§—+ [Ų€ ąĸ&ŋ ë¸-žĶ Mąí‹C!V‡<ņl|ËHÆJZYēË,‚xÉTõ†wn!ąV6Ķ&‚¨ē‡ŪĒāĘhtåĘ[†Í6YĀąaˇ1ûsûp)úÁNh}6Y6ό­RČÔKŲĻ90KGE>Ņą ÔG Ÿ´Š.™4ŠŽ1ā !}•VØéÁACⓊcÖ2Ę ąUqYxÃS˜"Ü -zᝄĻSG`C•Ķ8%ŠüBöō‘g;¨•z<Č!4M‡Ã¸ €?ļ1Œ˙˛Ŋá:˜5oč({ˇŗū \øü$äq %āSŽÕIš\ŧ•Üî1ɓ Sxi`„1.eÖÆzĒ•ÎÎͲe˲$ČYV]ffŲđíūŒ˛Ĩjđ'32‡퟈F;˜ .ĄāŠ6Áwē]Ô;hÉh!Ų°šĖ´áTq!Ö*ĘĐ:ÜJäQ ÜōUĨŋĪ•ÆæIĶۆĸ§Y*đЖwĶ-™d‡+o:@Ŗ–^(šČ%‹OÚŠ¤æÚô !YãĒa€F7[xˆ…ƒļAĄ5å-æĀ¸´LĀN ož§äĒr‡ äRl€—a=H€Į֙´Mhôņ(^ĖGžĸĩߟxöŗžÉÎ5Ã|>Ēįšæôœá;|ˆč’K/ĪG}ŲK9ĻtŗĖÜ?ķĪNüÖˇ‡{ØØüÔßÛ'ņûqÆüŠ|LĘÁ‚õYĶí‰0vØ÷ÚsaĪ=žD=Ŋ’ĶO8†ÍÎįūøĮų˖í‚§~úđ™īˇŧäE/vā;$ÚÄī›œuöšÃ™Lč˜_˙¸Ũ†žš?kä+ėû”ŊĩG@_›Í”¯älõ­9™Æ%B<ķÃ)?øŅđÃSOvįÛî_šÖ˛MŌĢ:â73ĒIZØŗŪ÷„ėwķˆ@É ÅlLĶ KŽŦĖSŲĀ|ĖŗšŲŦa^Ɵúú¯WŊÉ{-ߙĢRwVxķ7î–ĐŠ >ĖĶæÃÖüŽ…aãĒ<ی/žÁ×_`Ÿ¸ĸ)߂->˛Ņ•Ŋxđ9 V™éđ†WŨĻÄ-Ž!‡—8ŨÅ_m\‚ĪøÃOyŖyą#BÕh¤%@NĄŦMŦã℞ģēÃOQēS~!Ā1í 4ZIĄÃŗtŠô’Ž?\ŌAôËV>—R¨Ģ,_3ĸ’˛Čg„Ž:ĩ…ĩ­ôQ–’­@ŪÄП}b☸J÷)ˆ ũüĸ¤Ž4FW›+Ļŋ´$:íøyœR“>]ŅT.aT€i° ĩÔĶH`‚ož3ĖGä”,.ņ’´m„Īj;|k—‹iYĒ!ŠTy)õĶp]â“Ôy,†R8;øēžY3ëUøeT L:1ÁŦįŒĻTN§r-.ėÅixfŧÆ"<āVÉÄ]ĻĒ4U)Py“10gŦŪø@'•Oč- Äū+}Gĩfvy§Ō‘f$č‰0Č'ä­ WXDāRųÍL$Mų"o 'L%!Ĩ&ĪŨwųmqîŽ:ęh^īÍ׀_ÆĢß94ëåsæÎJ‰Ŗ*ĄyRv{ÄI‡šA&˛Ä„žY!đ@ć'včã/ęãßC]ŗ”¤ŖŖKé´[F13Ú!&%í+–ņ"ø,õâI0VqÕ6EĪ{$ J| <ÆWĨG¸öUčz‹-äYüę^üFžČÔÁĨkŌIKĖŧI~„~“øŒ:J†Jߒ;:[:gZįšÂ:ĪĖ($ĪČHžÜĩG0ņ…(Tšž]¤ŗú%\œĸūđ|ß­(D2`TGŽiĖDĮWŧŸ\š<üīöi eÛná)í¤Û!{f¨dMtéV§¯ŋÎS"ÖNüæˇ†įü>ÂĩĪđŽcŪEžžÍ›€ëXúÆlŊf“–¸Ú!D`fŽb¸…­—Q(pˆÄ#¸ōąÄ OšŌ*yōdH\Uø6Ō“ŒņøG0ĶŅō+aĸ§ yP›´´ņQ*sđ€K^•ąåÍr ކ_ČyŨî‚ß—yRW9šō´ü™Č-=øUžPæ6k+âŌ9 ŧü:ĩō‡ä埞˛3ˇ8íR4J?ũ%hĮÃR˜SKSÍAĀŖüŗ3Ŧ~Îä;;nŨ°¯ãÄá#˙븜?ū><÷ķ‹/dpā ā¸r8öũĖŌgūĪûéųhÕ/.ŧˆÁwĘøØJ:Î„ĢŸ§Ŋė¸ÃB:ô ųŠë/†ŋųÛcķ-į=÷āaņ’%ÃÛūúœ‰~Õđd ŋ¸äōáĪ;8đ˙ō¯˙{xßû˙įđĶķ/žÆū]õKî/xūķō•Ô˙¯ '|ǻõ;įœ÷Ķáå/}É0Ūç>ũ™á/˙Ēč>uß}†9ÚY\ß ˙’ãV]ļ䲠GËĻĶĶJ•”ĮĶķ]…UŽLũ>-­Mņ 3ŋ˜§+ŸhĐdm Ųōąž´ŗ– ˙ų˲Ō~A7Œ) Éd@z˜<ĀãQōSĨ%Ŧxž‚3OW^¯˛¤ ’HųP†‚Ž,Ž, ĒE<đ“gØWt.”‘Í)gŠ{“qÔHÄ,ÍŊv<î{Agl{ˆ¯vĀÚČ:!Vxö˛-#ƒė%•c“Ģ,“‰ĢAAՓĨĨRŒP™0)ÛĮŠíōJ_-¤­ ÆĢNŨ›€>ņgĢ*,ö; EõÕĪM_ÕË%w… ‰d>đąsØ7dU'đ^ĖSįBS:ĄÍÁõEDĢpáĩ!ōEpĄ~yĶŒō—L5‰"ņøOģƒėĪ?\ĨtŅ×Zt@HėĖÁē´ŋ`)OéPōûvÁg ›+`Ĩ~ŗmŖU+1ā”ÁC„„—IéķPœ—#ãXRĸB"t *fūĘ0ÄÅĶq+ާ˜Ę{%Ä%Áŗ‘_Ņž\$‚tW\4p& Eˇ(šf’ ŧNãj,*1]âHķ ŽtÔØ,÷hZhUáÉĖčŨJĒēĶĨļA_ŋúlš{)Y"aĐÄT␨ !:m[Ũ åą¯3‹#+WpfîŠ[ė4•›5s.øDvũ•A>QM”ЏˆŅ TuŌ{zDäkКĀsáGG@ƒĒôž0ēX\{ɡƸŽĀ\RüÚW=uʍ´clJē$šÚ 2…ō)XŅ´iŲĩqoļ–Ļ´ÕQčãŠĐ§ˆ*ÎØrŌ.hÂë3&ž›…Ã_Ö]ģĸ›Ä•ņ ú'zņšIcŅUXb‹RņMž Ļļ ^Ĩf ¸ēGTčTžíųĨįEx…9°ü‡mĐäןĩyIŲāQö`ãeåŽiŦ ¸Č›´“p/zûl€zvČĸQbŗĸ;3{özY§ū„8—9œÉīņĄÕQ !B՟[)Ũ@å=Ķ$‘ë+œ˛‹h<#Đ(ˇåRą"ąõjį!iŸ¤ ¸^Ú€Ģ”*ˇ&D1ˆM'^ŨSî$íāQoYę‡_H 'žļ%‹€TĖ\š5‰eÜD¨Ę— G"ÚÆ›´ā/=É.ēizh¸ļŽšGŠOx"ŧ.,¸qĘUū†ŅšŠCōĮHįō˨˛‡:Kˇhgy •›3ĘÕĻõ(ēĻ.kūīĘWŋoā+⟠ãŧÉÆ›d-ũ9?>o8`˙g ͟Į™íe&Ū¯…ßÎRO|ŌoRl8,œØ.oėČßÂR›Í6߃tčŠPÂēšēmĢ‹|„âŽP [B‘_IŦˆ’÷“ģā‘ed+.Â#ę˟•×0-c/Aøņj8”ą’•p1œˇf[¨”4âđįbcž‘]9}õK؆P;`eQŌ}ā<đâį@Øë|x@Æn?ŖF{7ÚŪ,kVßĮōŠØ<fké|ųãä™ŊŦA+ĀąA—‰{ũGēnGcĩO TúšÄœ!‚vQŽ@„ኚ|S0ŖÜ!ŌâxCixaJG<(y“B!LĨõ¨ŗÜŊ”×āA'e;ļ2Z7)Č×?]—+"T™S/E&´äg˜ųüÔŊg2ˇ–@IDATJdŲ”^Õ =WRŖô[C§Œ‚Ž7b5kĐäČ]Č.áFižkēD:m4âMá´L4ÎN§Ŗ:e ĸ÷(]]ŋËtœŨŋčĸ‹y4s؂e>vÜ7ŖĶîšķĢlžÄlü˘]Ũŋ&nûéųį˙üī˛Šv| ũĻ›V ›0Hp¯UuŦIsö§ÜÃ×םywÃīU‹Û°6ĶMų€!ņķÎ;/øs‰›Ë€.byÛ˙xø­Î=īgÓ8ąĮ= ĘĄ=×á €Â;ƒeF{ėžkNņY¸í‚XÂČŪ{홁ĀäÄÄđpŨŧė2 K/ģŒAĖ픠_Ôé?}öĖwŋĄAI×Ę3¤Š=š–ėĄšĖhĮfĻc‹Ģ$5O^øÉF4¸düæ“Ū1ŦļÁ0ō3.ŨĐ| jŒ–åđ fã!õÎ\z7‘§^våŅõ!¸å§!âņKūYāåN–ũOđĄ/5(ouR›XÅ7Ŧ‰Wš,یüÄZFuˆŦÕÂWUŲ*Ü|oŊí÷ļ  kyS†Â3.ŽöÃä‘qČWčx‹pi5å-Zō°ū0Æúĸ;‰…WBŅŖč QqƏüĶÄDWāé#hkÄ˛^ōŠxÖͰüé' ­‚Á›v‘ģ{ãÔ[>ĀTJ­I„ ˇ‘‘:’~YĶĩBāŪ™Ü“ŦŌU*ŊôqÂ/\d`ÛÜĶŖd0Ö8y ĄFʧŋB€į!jĩĐh.8 Eˆë‘§äǤīü˃ę'`é*™jĪKŽ~U†’CŌōlÄŖ°¸æķŠGoË9œˆĩĐH5W}f%"A‚š†ƒVˆ˜Ę>Ō÷Y¤ŠY!;Ō:¯ËOĨ´cæč‘Ąs ~ Š €š‹”ĪQVÍuö8ã/šc"„2NÍđ+@Ë_™WđS¨’6€vŒĶ¸=ŗA:•ÁÄ( —ŠČ~Ģ­ מâÁ‰Ų˛_._‘éä|īč/Oąt1C,d+N~ūÉGēÂWAī&)í|úĻd‰Üy&Hg†Ņžõ„Ėfžĸ¨œbF<ąTāõWwÜL)pŌ0ÔķĨģ\Éȑ-)P\Ō)Ö –i’ˆüŠ<ĩQQōVyŦ§ō O¤ 7ΨÄ~Ž5Rš+ŸÜņ&¯pãå ļåš *.-6p•O„ąŖLzTM6Š*W1VŽŦĨŧe0!:GIʼn§g*¯VĀ4Ä&…!æ+ÃB)•ēøūL3Ãø…žaĘn~0Ė8…Av[ĨĀČŖę ĶÅÆ}å=ĢÚb15¯:ëhSš [Kkę rČe3IB?4õÃģŗāąC*MĘ`B0J­@B&ü†’8lŨ|íÚô iŗ=ÎČĄŖ'EDą|{ĀrƒKŖAxl™€?U7gŪQŠvĶkšÍcü%CōOƒKŽHįŖ× B›ˇJŸē‹L(תּŊ^Ž›UšĘ‘]u j•éę„-‰ĀPàŸŽ†é­íuá,dRFōWPZ< Ō7HYkY RY†ÂG"’CD×éģ^}ŽIļ~ÕĸEŲwâf]?–¸ĶN;p’ÚŌaWîŗ×]wxīąļ§ÃũŒũž:<ë€gręĪĨėØ ūĨW_“€ åՖvžoį” wÚ1:ŨČŠ=nŌu  †nôUGĪîˇs'=Āítúoåmœ$tŅĨWĪ?ø9Ų;āÉ>Ō\ĖéæĖžÍ2ĸëķfBŲO;ũŒáéû=m¸ōĘ̆?xɑÃk_õâo<āéÃ5×.ļÛv›L ]Îö88°ŊhIöÕT3Oų‘ĪŧŲÆ¯ĢēÕ2Ōębŗē¤sƒĢ|Ôę #1/˜ōįckĮ,ŖÄ'_Y&BŗŊY0Ÿ„fÕŖųސŧÆ|&aķ54„%Ü[#˜pōŸō&ž|SV}¨LÕ_ĄByIŅŠĢ&ŊÔ ’ĩŽC÷БY‘Og¯ē[Ņ67ԝ‘%š!ŦҌ-ŅײX’gCĘÚ-”i āŋéb9Ķuē”u“1‘Ÿíc€;ī†LÉZõĒ2Š'õ´’øxŽĖa˸8Ũ)ŗa¸Đ’K{ŽŒŊ@š‚åjŽûÁ›d¯a0iYŋt}ĖKMũä¯<ÅsĘg~’QđĢtĮ¯ėæĩüīĒ?ŠĶK}e€’Wé(ö”nŌōšxˆZ \”§čĮ’áIˆtDL\ųŗ1ņY0BŠ[AZwŲd$!1âáfĘÚĘ~ƒŽė“|h=WžR>bå@8„rĶhAōI'›p˜ x¨Ü¨ģ’€(=õ u!’€É=qVÍØ(xđ¤m!n cXžXXÆ.ĘJ‰ {+ŸTOų ėKččÍ/6Ė‘+Ôz„ū6įi>ž¸ķįo9*Įy~ņ„¯ Oc“īĢ{å°'÷œøũĶō5ėUlūŊå4Û2 ÆM7ß üœØfmö[å îvø,Ø:o–ŗßįíˇÛ6uõ2–ōÜ îÄöÛĨCåU‹˛FkN!Ú}ם†Ģ|ŧü%ŋ?<~÷Į gžuöpvt)ŌS8ũįZ:÷ķxsđ 6üΟ?øŲ˙÷ Ûmˇ{öÍĻC_ņŌáÅl&ö#Kģ0ņ¨č̝Y–%I†iÛßĎĶĶÁN¤ŋœŠãŨ´ÔacsCōmŌØühĒ|˜ēÕ6#ųÄ0Āæš—!ËMĘŽy;xpž…†ßh§-‘™5äk;ƒU>ā!?âÂīõ G×e$ÆbĢ\Ž6#e€0ƒ”¸Ę<,§YŦ!ÚÛRi |zsá‘rë i*š‰ë”–~ˆék?Ÿųõ`žŒ õĻs‚Z¨vK(BikųczųÚ)vL(iD)õ(žyÔđŒ] ލS—ĒŖBØ´j¨‘1@:Ü­ƒ Sü™ĀĢ\“´ob$ÍēŪ•y:ū<ŨˇŸ•hÂLņ’°Ü ˇŽŪ~Ŧ§a=īŏ|q­oŖ õ'ĶÛXéšÖ=Ö{kSGžÂ›™ÔšĻ ȑMtāō×č•Ųpæ9¸ÔCe‡ęo2ÂÄx#ˇ„KH|H@ÉÃČĸĢÄ ī‡ßĖ Jâ ZŨëåŽåCÃ}ËAÄÄk ŊÁe”QėėäĩHEĻfSEąËüĻŽSč>ÉZ3oŽV jpågĸFmœįČ|äT€újéZ}ģ Ah)Ĩq47ú¤Â2ŦBŠ 6ØVNâ…s4ĸ&sFW9‘ĀŽ(N ž T×nĪ‘h Â.—Ėš]ˇĐl‰!­. ĖēÎ!a–S”lōВĘÉĨœaVˆĀUÃ]ŌdPa‚S:gŲMQ­ˆPPōâęO+Č!QŅÅŗå‘āÄ@DF !u—~VC,{ŨųÅßôķM¸ĸhaĘæ ’ōSXˆø¯ ŗ4å sÖãÁtęI§Ā'ØØx"ą0hˇÄ"ŧiQĀÂHC>ōmą’J‡¯%duæ¸äüČX¸–•¤_h¨Âį_¤Jƒ–ŧĩ˙ ôĒŧ‡„Ķđ“˙‚~:zJO[$¯H[Mz˜räĄx̊ŋ†īŨŧîdš͚ĶĀ MtĘ]ččŠĒôŠošé”É„XÆË˛*Ûë‹*› ę5ķ_”’įÍwĄUų%$=älš/^ OI̜3z&AAĩ@ŌČ ËTį_ŊL;* ¨š|eœ<ˆOÚρ3+‰SĸÕŠLÁM*ē~5¤‡Š“đ\cëķ¸Ī‰7Ũē,¯_vm˛_1Jf7Eë"_|„GūNŋxc‰†#Ŧrˆ/Ŧˇ”Ļ↝äÉŅŽâšZZ„ēĩÎ~–t4šģvĘ"Ŋų{ûėãeg͚5ŧûoĪ›u֙5|ãÄoqÜæÃ%—]Îô=‡ŖŪđúá°Wž|˜7oîp*§ę,f#ēŌ_Æ›eŧ1pÍžF•°0ųįö;īÎׁyŋíö;Ķi÷ Ģ‹—,áwĨã?›Ų|ãWręÕigœ=ö–7g€1oŪŧáô3ÎNg?œŲs† ~ūsö%ėŸ‚Vķ•ė{YĒtūdŌį?õDh(—6*9ÃĐÔn|šá*Ī s—ŧÎŌĄĢYhôĄmLiF˙JŽ@4¤ÃK\øĮ!äE„¸ÂīeŽūĮÜ5ŽĘšJÕí§<§nRĪNŗhyĩî/(ėĨŧ‚›fmļŅ/i/¸DuĨę0‚ŧâͰ_AüˆQßmĄeôã Wø&C`ĢŅVBF…T>‚É]Z&oņÜ +° \ˆĐˌüđ–ö-i0ÅÃ0CÔ¯ŧšˇnĻX<ôvēúĶBŖá‘šI €‹ØEšR&gŽŊ~Ës2iÆÁ %d=K¤\ ėK˛Ž|6˜v˜0C1,‚„øÚ_ƒ'K€č-Ö ´FŠŒfŽĘU‰”:ÕĄ1ÁĸĄäčJƒ?e(•M>M]é§q*ÔW˙†°yZ…H}šūe`‰v‘”vЋ—‰°ŌŠARķKˇĸOš|’”üøŽØ<•bôĩ%áĩCs*éŦ'ÁÚ¤d‘He†ØĸXD'ąR‰¨"`IĄäŽOâ{ë™ú÷x(ŊBn$W¸i+â§đ‹ąVęNež`J —ɺ͐—tMOe—ŋÎøâ^~å–ģņW“4yĩK clá]dlĪ‹ØĒƒn1FíŽ7O°0U}VŧĨ“:TČQšZ…˙áe¯|EuV™­n(ÆY<ĸsҒ̇?{ķ‘Yķ‘ãūŸáß^äķL?Äĩ喛ŗ˙ŽĖšīŊį“X×ŋv–ū,ģūÆa‹-6c“íĒČæˇæŗÎŪ€Šiá+—]ĩhq-7ĸŗî Ãŧę:˙™ø×fũķ,äp)’vąc.ŊŸü¤ŦÕwđqÅUĩwĀ8—=õ˙[ąÁwéŌ̇ËYōŗƒåZšrĮ{Îæ4Ŗ'&u.øų…Ãõ,;r#ą'ūŦ"~öėuŠōÍ+=Ą c åWg>ãÔS‡kŽ^Ä[‘°ŅjlO3‹y ŧ{žĢ‰2ƒŒ47×xZ])¸Uí Ŧ –š‡ĢŊ€Œ–2#ŗ@Š\tC€2—ļ°˜VzAŦš‚’g-—AaĻ@ ŸōmžT6ËBsŌS¨Ô † _7=J įčÕĄä7V؂ nîæĄĀ¨ĢŅEŦ“œâŖĪr%Xl+°˙‘Uųä* ü!ëáW:žÁEácg.NZ*cpKGíá~…đĐ>Ú °ĒlC‹“ßō˜ę І9ĸ5åŦ—Ŧ2¸kđ‡f3€ĩ¤b03„^ú†ƒ¯ŋˇÎSÖé8<>ÄW Th“Mœ¤‹ų(ųCŨ€UžĐ/p) įĩøŽß|,Ŧv¨4 DōˆZđZÔrđ,ģjåÕĸĒĻÅAėĘ-ŌM¸˛šåøĢAoĨ‹ÁŠ=AĶ% “–aM†Ø\[Ôt{‡üÕ?BJaH÷´}ÆŠGāķĐö˜økbâ3a+Ķ,ųĢ‰X ąh–ŋ Ę4^ Ļ1ãS\Ļ ˇ2đĀ&ēøķH<åÔZå!°`ņQޏ…uɍ§ ÅšQ%­ĻJeČĒĩ˧ėĄl"—ÜeŊĻĢÁŌ–xĨâ(†<ŖqPšZ°:‘›KĀ„$t‰0=$ŠPB+†tŨÍ-Ō“6 Wz-‹@1+ŌVXIwåƒ?‰–D° _˙E—0azl#‚Ü %H“ŋŗĨQMŽāķ,lĶ5é É^é†ņVļ*[•ĒéŖ]ET7Ä ĄFÕŅų \ė7pR6 Ĩ‰Ÿ0uŽ2ĨU=ŦŲ+65r/Ŧüd|áįi,š<ĩoåĀÉā <ŧI3ŧ@ņKĶpčį&y&ŋé4E˛e‚&Ki<-Pē”õĨXƒąĸU49ĸƒōG8 ą› JEšDQŋČÕf‚ˆ_‹EĮ‘ßŧҐ¸„Ø Z‚MđŅĶÁšiPü*ŋ‹§] m28>BŪËĀá-'uļ?YĶÜX2{—įđŦŠĪU8ãŊ•§–• …ŗÕEŦ~Ķ+ä­íŧΧķė†Ük¯Ŋv¸ųÖ;†S~xZ–ôlIį~SŽĩSíŌ7˙Ëŋ’úæ„û5Ũ˙—ŊûųpÍīķ ßŦĩfcĪ~Æ3vlĪ”:S*•6å€Rå ÍIi%ūĒĐ"8¨@Ē8BœpVú9q’PDJØŠ V(H= (1¸&‰dėď73íŲŦ5\×õšīßûŽå‰ŗ3ŌÄ]÷ûžĪsßßÍįģš7Īũ<ŋÍû6ßŖīĻÜīé˙ØG?ڟ߭súæ[oí-Eŧ=ȡš9ö­7ށü ˙ŋâô‡6Ė7?~=§7˙×˙ø˙ÎįžÅ€?͇ũĮ_o2ūĘOũôã?ųK˙éã3Ÿúdo˙“$ĩų÷ˆŋđ˙Ī=…˙üį>Ķæ_]søa6˙–ōiåäÛęīGq<ļ6ˇŽ Ą:Æ2äüˇO%9Ƨįør<5h}"Kģąsô7a7NīÜĐšœ¨•Æ[ĩck#UedÜ8!ŲÛ 8̤īųt6ĸFĩŅ_o ĖŊ Ë"@˛Îį‘°{{6‹Súk<ąt\έ¯ĮsåÍĸ ××ÍĐņĐ;p6œæ°xū16ĀjSzķV"ĨĢ{7øâįpÍw QŦ ˛…"JcģęĄÉ€#A:ūč]ŸøWLō3f˛”˛ũu¤3ĢÔôFU˙ā§Ģ/ĸrŽ /›Ō5āÁ\XWΊũv0i&ÁÁoƒX_Ãsq;q^žąŠĨŽĘB‚­aߘ<_;Š “ŸøŸ‹ĩSĻĻlŖī,úJŅ8™ITîõ͚ų†Ø^lŌ…´…}ĀâJāÍ}•ĪĒÆģŖŲtĄø´7А÷₾~K1âXõi7߄Ÿ‡ë_ NãyąxÆ $Î`W %÷ŨB•Øq¸mRˇ¯ŌMk´õåx’—]+âY†åqšŖ–ųu8OeŠ7ŗ‡|ũ‰‚pØx/Ŋq ϝ 8úķ”ÜúĸŧkæęĒ2MTŠŠÎÅWŸ]Ûö‰Jũ>Ūâ÷mž´‚ˇķümžŌ˙ôö3/ū؏ô @nâ}bîSyãwíæũ‹ŧßß(}Bī Å¯ßü6›î?÷ŋũ Ũ,ô Ewô­0˙ØGÛüÛŨxˇ2B6ƝOūØöGūōĪÂ<뇟¸Å›‡ĪđõŖ?ÄgÜøûa^LĨ‹ū›|†A?-Ū \Ũl–Īßį<^ĮˌøŒŖŪDŊš‡l¯ú¯¯ŋ4GŠøW匏”ëCšk¨DåÜ :&á&­ņë˜Ö Įí^‰q ÚrŽėšäšczx~NÎü;?]KüÕú°i@÷ú(R—ŒË[cļĪüȖ|6MÍųžâS< \ũ?v_h›kÚ4nĪKŸŽŦîüԃÖLÖ^1ôjų‘N=˙õĀēœ]yÅ0¨gžkĢ y°5 ‘čĨÛŦۃõâcŗŠü—ôb¤rõ­öŖ=ĩŸcdŧų#LU”ŲõCęŽŗÚFŊđą1bzēc|Z2Ū’öTP•UõÆåx_†ĘH<ĶĄ‰ įtˆst_?%é“ Q ܖJz3ƒŽ!\IfOŸKB }BĨ\;žŽË=d 4‘°‡M:ē›Đ˛ĸŗģŠ@AüyrÔįpMŽ ŸŠ'/&e樃ųLŧ ŗí)9•RM~ö§_.–I4/NŨę´OĖâŦ~덪ņ득?Nf}Ն‘98ĪtTgØņ5’1Wnú/e›/ĐĒ2ōolģI-SˆuĶ~]­üÉC&…)-TŽˆö? ī,•/æũđiÖ1PNĖŗ ļ‚°4ėÍp†Ô3×ūtgjÛž.D3ŋÕ%NČQ9;œ'îĮYđ%3ø‘F‹†Q€eLc€gCų~‘g+|ĢO{bh ]ĸûAqđ\2‘áĮ _å)ŖĪWÜ ũ%—,¸‚‡Ģhŗœ¨{ĻxiÔ6q+kŒīōaĢI ̎qŗŧī„õ˛EE ũļžŋq yę9Ŗŋ .Īō†­žô•#Rc‘Ų{åēŋ~“­‘žŨb*ŸQ›‘ß"˜ũäč[Ŋ°ŅŸ_JŗĐĶįēëøPŸö@€zūxæ¯âų4ļ¸Ę”ķ˛ˆ6ƒI Îl˙û8ŸZ1‰Šiü.]bÅI#VõIC´'rŌDR<[°ŖĶŦ\]ų š;cõ ü.fŌõCA!û´—¯ōûMĻp{ Žąhƒ~…ÚÂė­ßaT”¸E-Ę!ņj; ĮmëÉüuŗÛįđī9×.Č?晭3›ú7x‚˙öãĪŗy÷ũûžˇßˇũčžŖÃß;ŪfĶīÁĘrkØw‹ęˇ}Чķß̍ûē,_‡ĸúģÛŋķ6˙Û¤Ų/ČTīÄۇžEžÆÔÅwãų^~žËæģ4~ŸŒ•Öoæ˜7mĪBēN\?ĪđĻMpišé7S>udė{ĖÉ×~ĸ/75Īc&đZ'^)ƚIī:ëܲu’‘Ė&vų˜Ėø7ŅĶĪMpæ„ŌūÕŌ^2ŗ'įR–#ƒ)![įÂ*((+ OtëÎ-NüÍžeųÆĩĸšhޝšėJ§4ī_Ÿ.ÉJTd¤¯î ŽŋŗÕņZúĢRŽ醑7BčÖžlBĄÍ˙å}>KŅv×%#Ę.QáÃ\Ô9A–*Jqōú—D¸Ki@ŠtQŖŽTŋāoîíh”Ö`üō2qëûÆšyyíÚz>t9žčɤT6ž:%\s`Y‰ĩĩr9X˛ô‡ˇēģĸˆi_4j!ĶžÎ õĻ,ÆVŪ€ČGčEˆ‰pŪÍá0ĨmÍUwåģÚv/Áųz¯oÉŠôđ´~âCõ­],ÁØASx=NJ¨›˜ņDÁÄBpP [RO;Éy|7 Œmznį…Ģ‘CØSīäŽrœN H}ö¯°ÕŽ\&Š­ŗä+š—vŗƒÅ= Ȳ“@rBԉļ2˙ĻÂÄĀ,™ųíEŽļąŠ'û ėL!é4čGXtæ×Ōø%ŽĒÁ<+ˏwā…•˙ (b߀*cŋҝ–ĸwaHŪ§U „ß­Î|e?ŗĄxgjƑWÚŠsõã_ßÉ̚`ĻÜ „›S'~ú-žĖšÚĨī“§-Īķv(Ŗ' €ÜŖ0md}š^?ÁBīe,yibO9ĢMŅĶĮõüŅÆĖŦakŲs­Ä!ˆÜėž1ÂąE%áųš~|‹}FČ3ŽãCãÁMT~˛Ā!Ŧ–ã0UąŽ…ŗ'ōHéGōÆO]ÅÜģOIĖ.<°|zš!hŲs!)“Q‘ŋÛ ×Í!ĩ+)5Ža&á™r^Ø+jNģsũcļĩq‹|Ôtŧž:t†ãũæ{ŸIJ’Ãl‹’vë•-tPØČô}åĒŪib :ņƒ°Õ)?đĘõIdķ_ÉúĘM ‚Ž;h^‰tŨ’¸,ĩíˇķämę!“Ŧöø18ÚõMh2•Z|Å==i,ū¯& ēÎáå3ÄŖŽ›á”;HMMÖ<%šÃŌg#™”‹ā™{7…ÆĮę€ČړmÚ3,Ėī_ŅQūüNKõ%$ŋåUîų4‹ęŊ4â]&žBGõŊ‚īæ?m\ō{ÅéÔ;ŽČËš‘ôŌü]ĩßK÷w ˙ãđ[?ŊÆė€g<1“VŊ^)G7Æ]¯r§O{Ŗ~p^ ƌ;qāu „WˇyāĪëøx‹R˛˛ĶÑÍ;WžyŖ!Ÿ’0Ág )oõÄm¯āØ÷gwāȅ|âÛ͆bžå‹ä[wh/QGML×fÎĀėŖ=û.*áĘP˛ķˎ#ęc|dąÕĩ1YÚPÖ"OōŅÍuÖŌÅĢĖŊ&ā‡šIY|}ЗSR~nĩV֜Í_ŊD|ĘbÁí }ˆm0úīĢ ÛgĘ.o5 /]!Ņ€É§˛˙öęNŊˆhâbНÆƒ‡[?íyt„œ)8~ũöĀ6Áøō6}ĶõC”dõŒRĶúbbšĒ–Œqún€()!OÃęĸöš"­„íŧaōŪnBĖČSĐŨŠT3pˇĪō 9™gΤKsØ9 Põž‚$[¯ĩŨ[ŧd‚ĨOæeûA|a\ž5Å48˜ë.Ū—ĻĶ…΀ޖËPŽ ÍÛUN“]J‹0˛ĐäŽE=n +91áŋL,7= đsĢˇåCEÔ{QÖíëiy’ëäCĖųk‘ŋ*‘é˜Eļ“#X†ŽŲ0Æ38ëÄT:Į‹æ€~1ŌŨø,ƒ}˛eg#âĀ(X ęđNÆ5°_ĮĒÔĩ?/<šwuŦ§€=ŗWÆęxķĸÜ~Œg‹â ?Œ$8 ŲÂb.ˋOŌÄ*‹Ôf)„ÆĮÅN >ũ¨ëÆEĨ lŠU“ū<žˆ9 g9žų2&f)›õ-$ĶĢ?fL Ô¤7wúĸŒv9úW.Z•ĩ'ĸNN:ē.}†ÄēR‚(VĄæŗæ2ģ ‰É[ՖęX7—Ûģ˛ĩ‡Ģ Ė›-ŒÚ7Œ9FŸŲŗü•CũBĢ čMŌÆBØĐŖéFU+‹‰ú €S’[Ŧĩ}Ēũ1 Ų^ĸ7vlâõq–ĖŸ õŠy^^WFׯ|W;ÁįydP,u—ũTŠeĖ<ž•FlĢøč¯bRÖ"ķÄĻ{ĮōԑoŦžĒUBh*įƒ1¤ąšÔ:Í1ũÁÉÎŅŲ*ÆŖs ķMŒx´Ķ'/AI¯l” GOßō˙ŒĶ]Ŧr/õĮĸŒGüÕ//0Ŗ–ū'¯įŪđĘß$|ĪožI†ä˜Ō¤uËĶī°G¯ŸĻv÷k>ĖÕû֖æË‘ûũ>}āíyŽyûG*˙Ȋ˙HÖž•îīĢ@›+xYZÖ¯‘wTl>+˛ë{#Æ´ábXļq4)EļŽmrƒ"œķA–&!‰]īJįˇ} x›vķC~û‚5Ņ[;ŗÔãŖ Vh)/ ZkÍ'ļ6„āl“y5Îüēņ0[s]›¯ĸâ<>æ‡N‚ŅNI,q F<÷Ą{Ŗ´¯mÜà ũS^9dšëč–đÁÍŖė™ä‡?=ũpMÂŖ× ḋĒFõ“>#ÖRÜ˛´>X誚âĸī,l1-fZÂŲŌ&šíŋŽß §Ēlĩ´ÆļŸRøT>ö/āęŸĐz…‰Ŗkg"Šá“öǞh"īæ9So˙ž˙“fë”ĘG¯ũĄ¸„đvķ(†ãU?°˜ËØļncú–7]Ë đrFŽœ†(Û#j§ä.§Ōų[ŋøn‹$=„-G{UŅQßŅ7b7>ʆ‹ĘūŖ7ŋķĄŌ–ež)U¨åęUælōŽIã¤ĪĶöéļ(ÆYÂëDÕIJb´ƒĀ\P "7čN$“ĘŸĀp)nR“/áP"{–LYYÉŠ’¯ˇ/E<Ņ^OXšú $mķÍ“›eŗß‰>Ę,ĢÆA'§§’Âú+$1PWv“Ļ˛´õ) åhéžĒvfõW˛ą$Ž:"š‘ļņŨ *ŽänÎŧI[Žœƒ7@õ—ŠŖŽ/úŠü‚ÉūۃH?Ÿ—÷ŪOũBÅŊEkpī_6x|Ū´r,ËnJŅéÔ!ã[P ÖĪ+č”ųˇēGGĖĩE˙ļ/ŗ7}ōƒ.üũqƒn‹ƒMdü•˜ũúƚ6SŸ“ÎĒПØáŦ_7ÁÅÄ#Ođû–„$C.q- .úĄā{ŠŨ8ŸV‚ÄĩüĨį< Ŧ/3Éa~6ļĀošÅīŒęT^ŠŲĻ+ũ-6ØÄėÍln]Ú́yAS+ABk—PēäN*šZ0ėãŖ<}ø[|ųzæōûf ĨøÃ—áŦí1âĀ4<ũ…xKąyÎ@Mˆ“mũ ÄüˆmÃŋ•XĘæ,´ƒ÷nĐķY4ʓ9ToX õĀh:͚5ļ ËúĨš­=ô删w+Ō4M ˆ[Ŗ6ŋęŲüš„2dA÷Ô°ŸÂ!bˆ]ČgIyB¤1Bqä—öތ üĩŽ3ŋø}Ē}ĨÍvîW=fɜōö•mh´L&|IÚ5ũŦëŖžüžÍŲgÎØŅĪ"+IwķēŌ1)WŅÖß †ŖˇkŒ‹sįJ7š‡ē•ŪYëzá˜DĪáØ|p>3š>ėz-ãeí›9uøaL÷Åu mEh ę^Á [ūdšSUמö NásĐũkž{ + ­8Į‹ĖdˇX†d~2oL4Â9ĘbÍ!e/ŋļ“˙ĮSü‚ãĩ°g(Õ¤ų •8›ÃVāún›C†—,_Ü#įvr=ãIqĒæt×oj,ˇ^ÐkT‡>˛ĸŒŋú`]}sw÷’ Ņîɸ™5ËEPgëÛö¤æz¨,”Ĩ<>˜ŸĢģü+%MĮ“ĸmķŧŧL Ą•÷ØÕÖ(C3ˇ'ÉđpEīŠž– dēø ˇz*6RĸĨcÖÕe žœÖĨ!ß|ĒoŸIŗĄæØ˜ĨņûŒ"—!ŗcßjunÂ@ËģM/B×JcĄŗĸÞ%ĐņDQ×M˙îį´¤"ëÎgŋđÅ6¤Ŗ›ãtūĮŠ@ĻJHR߯QŪ<\’†ąŲ¤Üĩc’΀Je> äf/héŲņ&…ZI—ã`Øß;uúäŽĨō‘žËA‹n1.‰ĐÔ5oGÉC یŲėb?ø×`ņLŽ|v‹ Í Äģ‰ēŋŲļ˛F öÜÄkˇMƒŠŽHEûŖļļā–ec˛Ž5ų‰ÄĩīÄqđCWŋ@ôWøwbĐŌ^tōŲ&Tõ ŋäEŒkkã+8 Ī–Ņ'ƒ]4ųą/ņ#"ú'YĨŗ1žC˜2'æŌūņČʇ HĘYö61q͇v´§-Žˆ×(°4`ņģ’¸¸3Ūõ É|Μ¨ĮnÉÄ:QhkasQĢâĢ+Mw5ĘßĶOo°dJž˙ķvũžns˛Ĩxcˆ…MŅlhJCsųĸ*k}?Š(0ßk ŖxW<—ÆY@é­Ąō)CĘQ1(mŽ•īãLČŖk\1•÷­—[îžÚA<$Ž;Žt¯XœSāĸđÄéÖmī8ļNŽßɨߊ›ČÅu}VÆņļyϐŸ⍂-ą[ŒÜ$Ö)nĢđ8å‚Ü š‹‘L„1¤dų:ô8Õ ũČ1¨MtƒĩŗĖ„TÚKŊCĀoŽāDPK6reŪëVM€\o•čŽr‹Š8ę.r2-%#Í:ĀīWîå΃WúGÛKãgīJ gļ SŠē$c Ŋ7ŧ *Ģéėĸt%ģŪą>}Dá|Ŧr&ž´mJŠ(ŨˆŅļOą'ļ\Rũ“ŋŌ/™S1: ŨŸĮ¤ĘËġ‰ä‰e#Å~=2Ī `‚ø.vŗēqA~Mžų”ĻŋhRüRˉ9ްuŨ'''ūų+-dÎHĘsˇĒÍi';QŽ.OÎ= ظ'ūūg…ēĮĪi~Ú9(’5 jÚ¯ZsRz÷ŽŊãzš—’OÜ7øf†ˇũ¯‰â†Ĩäė§SœÆ 9ēvOžŽâĢû^YCÁļyôUąO ĶX9§må×'ZZ -3~la˛Ŋ˜įĶŠ'k=ÔÎZŋÉ‚Ņ ˇc¤CūķŅÛi_ĶQz,Ų}ßy´k{øËS’¨;  Ÿ$wÉĐ0Åųēžj6TožĀ[ēcr6ĒįANzĨĢžŅ)1'õô;m~[ŸTs\{&÷öåz ?–QcÔö5­8ŪüEĀ›yÕÃ2O$īŪ,§T›čŦĶģØÛ×JŪ*”ŖRbVOĒŪ(˛=Íõ_´ƒÃę/ûä Šy@UZÚžø`īĐ|ŋģ œĖâ ´ƒŸZŌÖÎCĸ$iZ^Í!ũ+‹§3¯_ފ~›ŧįęrŦB `äĸ,_¨ø{ЧÅ鿞ũ_âߨą´NŸîYƒOWEu=ˇ%ĸo÷Šnü‹#eŸļ^ŨöT=.WŅE´o†q=ÎbúEŊ ¸ë—īCPöÅĮģg]ÎfcIKf Æ‰ļôŨũ¯}m^#y¤Ø “‰ž.íÖdųČkšēúöÕqE9ËâWO,N‚yāOŋM-;ë#0úėJ’ŽAú›Ūš=­ÁŊpĐæžšĪP Sš•ņvŧVmĄqEˆ[Ŋž˜´3ēūCĮīÄôAĀâāŒĪKŊ\}PĀš ížÎ7´BÜÕB m\ĮföiG>Šƒ)œ Wų” ]qvQ™œNāyeŒčKę×ŧŊČôn{7)ŽģŲķĻNáŊR _ękÃbN¤ûJ°oyÆø3LŅÃ]~ÔzÆiίāMÜã)’Ëģãķ˜;š-lĢ“ŸŠRwĖZĩŽ<įwxˆ ü ĮƒãÜnęonvå‘UDßqã oo††ųžųēÔlđ5˜Jot,ŠÅšNąOãë§īäë—‹īŗ ‹ŦīSŖœ1ĪĮmŪ1ŽžŧåI|säŒmŊ nĢ]?iÛŲåĖOˇ*a­Á'Б÷Ŧ˙âØ§RÕGûœāIĩĄTš’uDbÅAŽØÖo)÷Ę~æú­6¨hK0m[ÕF„wëbÂĄ9/>ëĩūQæģÚķĪNŪ'e—mäuZ۔ÆDcÁ^d6Đrû 6”ëķDß?|Ÿf`cĶyG¯2ÖIKÎÍ;ī'Žž­ƒ…ÆĶæÉ>pŠŽãV}kœ*ļ¤nŧ6֚_{ČŊSZmĖ5ŸW6UCYØÍ-jęKđēßZĢ=Æâønrl ßší|–,u Åmķ[7ž9ŗuSŪæ Ž)a^TZ "fmį¸Į Dưšp+™ėŅ3nŨ•æ|ŊĨ=Ë&¯ų'{•*Ķy€Ā) iĖë šŪ*&˘@éú#šW0•Tšë s7g3¤’ŽKŠŲ¤ámŒ4ž×õrîü/ŠpÅ՟Ŗ rŌbÚ Ŗ­›ō—OŊÛĢĖĘRM§UÍ+‡T}NwSđĮgû(đsšßÆČ":"ŗļõĖŧ¯ģŋ’k_ÕĮæ:đWĩŽMs™ībQ‡Ûú}–ãŸĮ×c}ąĮâpŠāš…ÖmũVįėéØTvq¨§ũų­OۓH/›aÖgæÍhĶSázⰁCē~XŅčÜ Gt0ļáMŲpžÛļĘ/ūŠkí迀äæo}cŪ†"ožģwKŊM¸ÜÛv~5vô yüPŨ§IšS°ØĢ럘JĀM@ ´­ŽÕ_xĐsF„õGˌ{k˜æÆž,&+X ‡uĖ,—É`1 å/H9' ßJ9”>?†d.ĪjÎÆj5ĄM.'¯ßŽ Į|îüÍ%ŒC25ã NĪžÍōqCđü ßV;ZŗŊ–W_qšs‡šö”Ü"cdЇ¤†fhG’ŖOR՝r|čy#ßų~ÁÕˇ č¸kpæĀ|ČŧŖ‚†1{’\ېŠÁSĨˇ>Äwī•˙\ûÛü“­ˇÖBeß/ߡؚĖ?Xã?Ũ¯S}|čņ˙§Ā~gƇVˇ­öŋsŅn¯ë7Ȋ¯q ÕaãqO­mĄĶ‰Q¨ŧ˜ōãŒvFŦ’üxtūB}Æm<´ˇ—8ž*Ëąå¸Éa=}¯¯dN{^=ę÷Å:KnËä¯v^j{˛ąâš¯3/đCWZÃz…Ĩ‹XįØ<—s×SčËŊįcņ‰ĨW˛]_ÄríRŽŧ]ŋœŋ‘¯‹åTŽ‘ˆ>I ņ§ÃŌŊ–Sŗ§”JgēéOÎŨ\ōDw”–Žļģa ß­;á ;›33[ĘĒŖ­ÖČŧ<'YüčŽ õ¤‚ÅĸÕå;í3bĘWĪä[‹ŽjãÆēüÆÂå{­ŌۈĄ.ölĪ‘Wiʋn9OÁüSēÖų`(GN?Pˇ'{bĪÜė•Uc×#˛8ī[lGWQ֎õ—`iĻ…¸ręĨˆ=čĩ'/ Ÿ€Ø…āt€&ČīÕÍŌčĀ5¨œyMT“NëāΊ%¸⡌N–3ÎũŪ’*t› M@ŲąĶ ¤ú¨ęLĪī^.”+b{žw˙¸4M´ķ΁ëSʕu“čv>'|,R‘čĐļ‘õĸ•v‰Ö—ywԁ28sŋ’Į! Íi§ÔAŗ-¯h‰ī>ĩ—NéGbŽ@ĩáAĪ%įßĀĘ˙æ‡^lų;ŖOŽúÄĪn€ôœ$KŧkG3]Ę`ôŠjķ.Œã9Wn\[Ūx@_|”Ģ•]œ"f“GĮĸū+˙’f1ĨÆá(GÖoĘMĖÆzķH[Â;_HZéjî„^ģ^¤B̘ŖBAĪR˜!ÚšˇŪkĸ°š¤O˛Ā‹>ŸN}–zŖ…I’j.Ÿ4ŗ§Ī™ãŒĻqđcŽ´×z§čB&ļä-Wō>ø!ūšījû•_ų•Į'?õ†äQ8V˙I:™SģjcüũË?HŪ_ûÚW_˙Ú/?>ō‘Īō? k&ÁA&öÔŌņõBĢę0i $ ‰ŗ`įÔ†įŊ’įˆP‹ē{•îŠŅ ßëÆŪ$¸ĨĖVAMHéK¨­'ã}/đRFų„HÛ)å>%pXSÎŨĐĶÖÖ:TšŊN~d‡^#8ķYhœ3Éqq 15tŊW˜Đô‘´ U)ÅãSídĘ9ūĘÕĨ f‡û]먏Ŋ…iļ[ ÕĮX8AÍf}éTwĀ@†• ‡ <ßī´|ķë ĮŽô ųœÂúč(wO‹Õ÷éŧ9 Ô?Ā5Ō*¯æ÷"œúĸR´ûōXHē’ļ<$1šØSYœNŌĘ ´Xx1oĩ,ŋ˙úZ•ƒ2˛pč;HÍ]0o™]ŠOO›ļ+9;Æį~Ũ,ėđĻ>O?ˇ°šl×ëęR'ŋv3GizŪX„oĖʨíë—ö•ĩŦ;îtYßrĄoëgåöjĘķí,Ę#2„”Ē‹ŗ›v=—Ģ˜8pZ´Āq\—;|ąī\D5Į›ˇ)ŠJŲ[[ôŖĖúp틍R–ē`Qģą¯OŒM˙nŸ%žīڔˇ[8“Ëķ%ô žã×úW{ēįßĀA¨û¤ŧĪv_ßæDJtꕗÚõ&0ĻŽcq™4’ÆžŖĪ|ÔÖem —TåS?–íÎJ97áđīgD_úĩrrų‹~}“%ŸŊ.kœŽ,ũāg96­”“âĩž5YÜėŅļ4ÍŲQxúuv`˜‹ĸbœôy(HGä8FôíˇũLËG?÷×˙Öãķ?ü#ü7ŨīŸ[ūÉ&‡güŨíC~žŽü{ĪO÷āŧWîļ¯ü{ÛīĨˡčļ˙=XũÂ-Ëëtōîãß˙ŨŌ/­ĢwĪ/œÕ.ũž/˙ļ˙AĪę)[!(˙ ą¯Ú|‰˙ŦėģƒŊ6ŦãOÉâexŽGÅzôsŧrˑ¯>O⎐Fų˛ļ׃ëúÁט āKãO¯2ËĶÖa×M‰ ¯ü‚6\æ”ļĘŊąkhz#^ß$Éuh\x**י*įãŪĐŊyĻčĄ)ŋ §¨Ņ†ˇ>@îYŒ§đb$ƒëČŽqļ•QŦy͘V&áß(Zä˜ĶGķ—:ĒŌį"QˆS*(™bcG)sąq¤†%MĖÃAĪ=žšC,ž!ž0Ž>đæ[ŸZV^ įW‰ŗ)Ų]†ŗ*Öq‚ĒĩĐn“Ā 3`dŨGģŽtm cIc“eŅσöɧˆĀ:Î\:ūø2MFõ6ĮüÛäx–SRčŪås3ŲáÀģd“Aš“N4ãŽæi0ĩŗ~xšu…Ž\}aÃIĄĸŋÛmŊ´Vמã+y¸' Xûôšņ+gT ÎĐʍ’\ Ō f-›čØæs_Z¤7Ŋ4–EûąZ˛ĪÕģęŊj›Ĩ‡› Ž@™‹s|ëzŲ% ĮÃņ<įRîfĨ'&:˛Šúl˙‰•!ęōuÎI&ŨæęŅiNƖ?NZqj“ąZ~Đi –§ųŨEÉ RRØBe›díŨÃëéĩöĪ&Ô.sxĩQÕļęå-/Ļ8~vāš”‰0ßÎEđú 5KŗĢ”7 ĐôihäÅÉä93æg>KØ ÛäkĮö0Ÿ:œ›žS_ĮÉßØī=ų⇭ž'5‹õ6ÜI-P7wʖĪ^øīģ]”ß mfIVk$v ûZNUoĀq–D}ülÂۈp,)ÃØ8;ȅ‹o͈č×Ēö§“ĖĶQm*č1p‡#p–QpûûŽ1 ŗõP7ûŌËÃKÜŌ_Jč`õ˙ZÖ ĨŗĒœmøķíxƒ|ņ&¯>=A%žķüĨ]?ßņ\–e¸u|“ˇ’üÎ×_ø§~âņĪū˙K}äÃm*BĖØ‘ú=EŌĶô{x6˙~üĢråŪ{žüāŗn BqŗjÕöīåŖ˛×Žõ÷–īÅû^´÷ęũã´_ãW?qõ‚hŪäËžõ­o>ūÚĪüĖãoũÜĪ<>üáĪôôąŸųPĐDđqp’sĻĪÆšk¨lG4⭟6ĪZ–ėQØē#NœÎ5Ü&c¤kÉĒ`:÷kúîÁ9ˆŪęœÃˆ-7zīõžƒYžu˙\t˜4ķŦæĀŅkP"mŪöĒŧ>‰Ž1åqüŲOÚCåT¸†´ZdΚâü×¤ļsKũÍ[ãčšÁõĸë%XZAKa˜V(úrōQŗœ*ŠXŌēyĻæ°ä,_ķ˙ ō°ųHŽ'Íø7´Õ('Ū CÃv|Ã9ũš5"núb†¯]¯ĨųŖm­ø*‚į<šŖÉŨļ1¸/9žëâŌ‚`KĻÆŠøvßōë”?IšĶ?"'!doVj — hņ[?]_ģ2Ĩ€ũæqf]ãØ’Ĩv0B-œ{kĪ}ˇFÚÖúbĪ÷Ŗ™Oâ‡3o¨_ĐÃéŦ/Rn§fßũîä´ĢŧõÁOrŗˇn¨3D(šs,/Čd Įœ¤Ų">IÛžsЌdÛÉ*’/ÔQ4q‘Hܒ>L6ZČ>Ÿ#&Ė,¸aĪõQÁQ_¸Ŋ Mâęd˛­ŸĘCĮmÜRĀ%Ō{KÁßqsÆšÔ#ôōĄdô}/ųËU L~Â-åž‘ĒjZ9ĨÜŌ~ō\ ÂôŦuŖŗ8ŧ žiҐV^EÖ:‹ ­6`ęđÛ@DŦØĩ!('s͇{hčĢå)×F ‹ūíɯņjwÉOK5äæņ˛Ōv úɗ !ŠĩŸ›Ë…íã]¸‰IeÎîÃ@ëíģXâˆyPĻĘøb”KáđF:?bdũčų”´'<ķr´fŲŦJZ*ƒ?đŦ¸^Éĩž<+pĐɲō,Fz؎C!Œ šā¤bŖÜējÜÉĄé#ĨĐËøéŦeKôOũđđ‹!Ũ\ [iÎAũRßŅŠ:÷UÖ0Ĩƒ•÷ųđ:yظ´ŨÃŲĄf)÷Vŗ%æ¸ŗ ­drÆ?\?Å̤”<É´ë[]äĻ c0Č×úŲ~gáį ĩų.°ŸĒ`‰`wz^×Î#ۓLzBJÅđāú wāįWI.§ņTWŦ[O›?ށmf‡įÜnc^ë/QæëŅXųˆY˙ÁËE¨ĐAÛKņÖm;ƌæÅ¸đE‚ĀüėšųÉžęh/áĄĐāGy@0Įz†î,hë°¤„ƒŌo}ëëĪũđ>ū™Ÿü#üĮŪ!Åŋ|Ëi.ŋŋ‹ųüøČã˙öŸūÔãßûũ;/~ņ‹˙ņ˙äúøė§?Áŧ?cūû;„ßĶ;ãûÎw|ĩæņøīû˙[ķį_ú[íņá}æņļogĄ#Ŋcö—ãBi{ÛâŠX_‡ÎŠņ|‡-Į|]΁úÆŅÆĀķ‰cjāâ:dhīLuwėÕÎčŠmŧŪÍķÄgËëč.9´S9cŨyploŨAĢņž6‡wãÛ~yČ!ŋÜw^*~ä9umƒÖ+g>Übū,U``ž‡WŪ9ÚÜ0fD…2Tk\åũ9Æ0yįęõžļÎ:4ŲQ›?œ+Ų[œ›´2ĒAū4wė§ŖĶQ\oŠdŌ>žl7j`VįŲÕØ­Cëͯ(áœ"ÂC[°LBˇÔÃ8ųÚWI/ÛåáŅ…ŽNą&öɧ.Ø:ô­}PŠ_÷f|}xrûʧY"ÖÖâ^ =ž—–cėč‡}á?#ļ=u‰#üŽUÔĨZœuÚЇŧIŽšm֝xg$ä§:ØKÛōSÖgđúËéTVÄKŒĩØĩųŠéŠÖÚXæPŗhXņTh9á˜Áũã ęOūä1u‡rŦãŽúŌX<ÔņųĄÜEVSå–ČĖãSp,všúÉǤW<ëōā(kĀ ĩ™†w{ƒ\IœK,]ôˁ)1(ō­ę-ņđĸ×θŪ|”1ƒ‚6¯Ą”ģI‹!ûhØLNģ¤ģ"Ü´ēDAâgOY]œ•Ú$Jû-Ā‚û[ŠR Aņs{}aÁ{[_Q(ƒVéĢŨnĄWÎáÃ2GT6ÄR¨^Ŧãõ-Æ{KIē9ũôâ’œ˜’•ųąQ8?RĄŋÛ<2?´ŽVëZ0Â(XiÖÉ1&W{˜­ˆœBŗGg*c ]ΛæđØÅŨ9j?gÕ爿įl)Ŋą#ŠĄŪI+-šÆēöM¯>/2rÅčLÕô„Š4{ål› ŅõĀŌø(_Î?m ib+įú揔Οŋô‡•Ä;oü94Svˆ§,dGm¤īÃūÄã+_ū2ŋúøė}:ųĪ}îs}˜˜˛ÉÔ˃pę™ sđ“5áäoíJ¯}Ēņē.áļīy7ߟzTˆãm>ûŠO~ėņĩ/ũÕĮ—éˏ˙ĘŋôĮ?øÁ<ūō˙ë¯<~ü}ūņ­o?˙~ū ĒM‹˛¯ēü×ŧ×mëˇ\{ļ¯ŧõīEŋö.oōĶ~Ā÷+_ųĘãmnžöÕ¯s3đM6˙?ôøÎ;ß)Ž­?׆Ú8p í:Íx ËëįuŪ!ė4.Ė•ë|Ɖuį[n"^oMdž6V!Ék}9ãĩŧį‡ļîĐkÜ?“‘ĸž&ø)å˜ÛÆŗš=ũI!xũhÜÛ8ëë‘ė;ÖŅīZ”:ũ6ŪĨiņ¨€ŸÅFŊ÷ÂķöĒž}hh؃Ž5Ŧã=˙ŠŽ.” ôĄÕįÆĀ•ĒÁ#WNÔSž X~ŠŽgV(Æâ:{C΁5&‚\ēë#Ņ÷@ĐĘRüÚâŪúŦ•đg͝@„l=×Mė†ĮŽgŠsOČJįÚĢ~kÆf?9ÎôĨÅ^<ūøUŽĐ3¤Tōô‰(”,]“ĪÃK˛DÎ(ją|EČŸ2^+Ŧ ¨ãQfũ)˙ÄzûwŧÅ1|°U)UGՏüˇ˙šše¨jļt&æLxüJ_Ië™TÎ뚎Q7ŽgÎ ŸņK-DeŪ:XŌhûž3+ü°Ĩu›`íbfãt†Ŗ†ŖŗēciˆņęÛ#ÄĒZë–ĩ¸îëXüü ­/úZĮÅÛt…Bo›/ū|ÉŌ€úĮ1c—-(&ŊĸÎڒ]4ô3[AˆÎ kĒ"ö$—OüÂWo ÎÖ ĸęŪ;+fŲā|ϰyv‘›Ī‹ę”>\,§Mgĩ֕×ņŊˇo<ąŸ1d_IĩÔsųgŗiŋičL.|ņdrÆ#×Ŗo‹h‹‡$cáOęųO])4iãIyTE´•ŧf'âŠŨ|øĪ$gPÛ ÷„™ŗ¨lpԐįmWų¤_§î7-×ī Q‹ņYäéúnŦ3 ¯rYUĶ?íwņąN<ÛL›7äôŨņHšëæōŖ,ô6HXßžļx]]\)Đņ…Cú˛¨c$!dÕ§Ūīh(Ãĩ^vå_ü#rrĄ\9G á*ĪąęâLOcŗ;÷i¨ë˛vōKŨ1élúL[ūĐĮØdžũøU>ėāūÕ¯ü]΍ŋ¯Ë‡>øŠüû_ũ/˙׏?ũ§˙ëö'˙đã?ų˙/o}ũ돯ūƗŋ¯}˙sîÉŊų?>ôÁ=žŨWC˛oíĀόcøŒ¯ûmÜh9r”UZÂ1ŪūAY¸ ĮEŗų‰Ŗ#ųa{FöĖ…m6ŊÁp0 ęX^ãKķp*Īē>^ēšc$—<GH}Eĸé•Oú)I['Öü;ękŸf_ŨÜ:Ž‚oɛ—âš ×ŊĩŪē°ÄŲ÷ J´Ŗ “Ģb2åÉßsNFŲ-ƒ2fŌŌ礝%kžjS„ß“Žŧ†lŸ¤/[‹´øFo7‚ūĩ?ļ˛ßÖÃpô6žž-æHėz¤{‚ąd ´ŽŖúfÜ[,QõÁ^YBØ'äâ̇ vᇖ¯5ö:φ=̆ÂB>ąĢo\ύąÄ)ŸaØ}Ö3’ĄãŸ°^|=ÅëŖ9 ?™CŲPcöWé†%9üՏu L÷Îæ œ;ņÃŅķ yü­Ģî+‹/ké—!eŖú­H‹?cŖŠdÅ9™å“ž=c×xß"L„Üĸ(ĸz.ĸáTž$tŨÉíTĻlābÁ‡ĨTɉIŊnHęŦƒ/û@↞\2˜P23›& Ė9?iĮĒĸũ9ŽēЖQva’Ž~OųŗŸ‡´%Ž{ö.dë0•d#ĪŪVOâĪ…›ËÄ=č¯ęœūāûÎĘ+¤’1a5)ÖĄœ8œ;w1,ķžô$õīķ}ę ´Ũ„v&}O§zâ'6ëûp+4ÅãĪw76Ė]ĖŖCĄa¨ž:˛â‡ã+[ö9Ō;Ÿî‹įŅĸ?ęŠĐÛ¸Â8fØÕ¤žīooƒųōiH ũōCÜ/‹ŲņF‘ĶAîßī˙læö]Öķ]HŊ{Æ`˸¯÷öK7a>ÍĐÖđ|ŋo#k˛ĄØwy_tķ(ē듋đ™_mT^68§(氁_ˆäûĮŗˇƒ[ĖõƒS'üÆ+•:đ%ã6DūŽßΟ<Í|ƒV™SˇŸJũ˜?G,ÍzX\Ë͍OwMãį sĢų„ß›#~ÅÁeaΏã¤zŧö†R "6?ĐÛ~ %lÂvB.ÛúäßJŊüæhŲsË'UûJ{QVũרĄ}‹UøĶG#žzÆÕĩ "ĶĮČręg<¤ˇ4?¯ŋVÔ|І›sķä/˜…Cã~›ŗîdķčíU!XD$*û¯ĢŽ-?­ ’ĩ¸.{’ā_KŸ1ĄIû~Ą€ë]s(ÁōŠŒŪ;–Ūúā[ŧÜė[€Čę›@â+õÜøÔĸaĩõ]ã+Ģ:v@rŒ\–uŖm,G]);›_5nƒvô”ŊũU—ˆĄ4 ÷Ī|öŸzüĩŋū˙yüÆo|ũņ/ü‹˙帟ũÜgŋķ­ˇy;“cX’‡k‡čKÚÖģ.ŧ´_ōĢ#}ƒkUŲžĐ\|Ŧ8ĮųUÎ&mT$ĒÜüˇ˛šE„Į—ušã [ WaPņÕĻīōŠĀ[ŽõõCÂōlÅ„[×vŲ¸*~ėoc…= d t߯ŒB.ĮúĨ¸Ô˙`6ew…7 GšŦqôÄËOCgķHŋÂŅ*ēÂâ›uųvšwÅ ™ē¯zqR–Ÿb?8ĪņŨ 9ęK 2'âœĩ>ŨëŦc œHˇ´|r]??đĮšŨFƕ5.ĨUįĮÍĄžė̈í Ö%č\VŠ)AžOÚģ9aÔ“ũŠFųC‡7€Ė8ŽA.-ošû^™ôøĒ°ŋí…DŅKgGä΅]Û˙ãūˆuėĸmĶÖŽO[c•Yŋ § Чæ`kLv„Ÿ­ÕȘ3čRũY]$~Z˙Äĸ8…‚w˛’•¯ ƒšVSÛYÍčhVë†øÖĮÜØW¯ úébc‘–ßž[Ā$Kã/_¨vķfßá}ĄŖsC˙áK×AÎŪx¤šŖÄą /ĸ’§n4đĢÁtéČϝēĸ”Ä.Ä⨏ŋ§‹9+‡•húģÅ[BōGgļÕW^äå eOØgũ2bķa“ŋģL‰Ã0_ÚbW䩄Ŋ_ {•6)ŌLä;ųåšuûaŪMÅž5Đdk85/†w“ƒˆđų\Īå׍Å^ķžáãSŸūČãį"ū,Į¯ë÷õķŲ~ žĒ ã`Íč՗m\GL^IzĨvĢë¨ĩ”ąœĶ; eOŠ€˜§ÉéĨŧ&ZŋÅDŊjg÷Ō.ũžMåë1žÄ ČųøņŠ;yáŊˇ~‘fÍ5NvĒŖŋD=)ü}/ÍXįīeˆœĄkä辖;2&{ķh¤ËŋXčÆžš<&§‹@ėSŪ5.ũ5ŪSĘ9E?ŋĢ\ ĪķĐŌy­ ~“†ŗžØg­ äĐŨáÆpmĐwåäâN¯\ȡ\ĩA‰‡Ÿ-ęĪØ.īØšrĪŧ]ēāī­KŗĖh\–§kîxmŊĻ]Ė{†—mÛ/4kļ[ī"Ÿ5ęø=™ÄfJ—zhŸąY%rí(<Ũ¤bŨų}IÉ{ä’Aėš&MįˇßæāS_úĨŋÍך~åņO˙Ķ?!ƒĪ3üĀã‡?˙iĻųQúÜŌ– ´xĘäõáĖÉ ~ž9‘ę{0ÄĒ؃ī¸Û5æĶîņIų§ũšôėƒ9˙øö=hús\^Éîæ||ũžPõĢr5Ą=ąŽ|õˆŧŒ‹éŋĻ<Ã}u¸&"Ų89ũ]>ķÎíáŠtÔsūßréˇ}=šŽ-í÷*GĮØß•÷E*Äō"_û–¨Ģ– Kģļā'Bû™į#ūŒåØ}b [ĩ6°AŊļsôŸsŲöĩg=M+ī)x|ũ7žņøõ_˙-žYʘÜŊ8PŠ˙ŒÜ6Ũâđ2‡öz´íO…#“ÍK‹–rŪ,?Ã5Wā>oЄ@¯+Ây°cēã‡ųg™°†”YŸÛrļaú2ōÜ×Ím÷fđ“´ų¸ąĐ˜:{á@‘ëĄ`ų‘Ŧ7úlŦ'–ģÁ¯Ū hpŲŠŋfĘ'Ŗ i÷f¤<Ļsn˛ŧ1×6rÚˇŧĩŽņŠž”=M+˛‰1ōx(žm=.˛&E‡MÉ †&6ßöŽ~×ię+ßļ2˛ŲQcöíŠ[äÛáۀĘŽ;"-‰§Í¯Ŗô`ICĐäĀ+yÅĄ íš‘œínĸzjKŖE‡ģ\ŠbŧÅ!6S„j~VÁÛlߜųôđD‡?Öõ­Mˆx0+‚ZvnpÅ{ŗg’úĮÜĄ´.ęöl?Ķ!wŪ¤Ņ*ŋ{éÍ;}ŸVK@ĩŲŌ´.&õƒč[˙sė§?õąĮ¯ūę/<~íWaŊ_ŪĪĀûx?Ā3đĨŸ_€ņ˙đzü÷ūģ˙õ¯ūå?āQŋŪûøĮËĀg>ũc¯~õˇxãČŨ˛ŸxîŨ—m7ĩŊÕö,íWÎFč|2'܇ėf@ļ!Gž=Y;*6÷nz§æîĪŨ :ÛmŸã^­Ŋt4÷ŽŊZƒ˛ģi?{#ũm;Ĩû­°UVIƒžzíņ:ŠžtUŲôŒq÷W~ŗŨģŪa’<°ŋŌĨW}z•{ļvcpxē ļ‡E_mĨŨãikđûÉO~âņ…/üøã/ūī˙âã7ŋņ›ŧũgoYyöAčqÎ,‡ ¤ôNüĩĀą÷ģô¤Üņ8ĮWúß3ŪËãĀ˧Cęˆbq¸~d\>¯c}—?¯ųÚB˙öÃģüŊ`ĘŋGæ]må2ȉŗvīųBŧĢ}mĒvôÔæûčĢKĩÃS˙Č?Ûō-¯pÖĄĐRŽûârWTžuKĸ¯á?ķķéô0.ū|Ĩū.{×Îsž\ũ‹ômŪ¸ŪeūSäØŧíwÉË;Œwé_˛8Č$ĻÂožųÖã/ũĨ˙Įãß˙÷˙=ūčãßøû'ū1k|÷ JoŸŅCbvŨmęUŠ}ÛŨ‡ā^ŨšId“] ûĒĄ㕀ûVr9¯tŽâņÜnŦ5ōƕ{¤Ū¯”ƒÉ*íÎĒũqƒO”íŖz0×ũ {ŦųŋWāl­Čņ+ÍÛ3ōŋ÷Ū#īį!ÛS>W~ÖtˇũZ1žHėäCw_ļ› ėKũ"íäē4+ãmŸ-8dūfȡæ%î0čc¨$ ēōæŨTģ D[ĮķÍM­rBĘUÆˇütû1¯ęÜ─ßS˙Fī­-M1DL¸Aö=ãŨ1ĄīhÛK0ŠßīŒ÷ЎúĘ'CŊ—z2<ßî3rߖĨķYP 4=ßĶoxg흔ú{ßĶg´ŌûĶĢ–*ķÕ<ŠįfÜíŋ˛ë(åwÃa[G–&@†vļŊųw<ňM7CĪn]-­ŲΞŪ8Ũ÷-ƒ¨9ËīÔ>Úž„­Ŋ‡ūnJįĪēūœO|ü#¯ūúãņĮ˙øük˙ڟāũō~ŪĪĀûøĪUūԟ~íûĪU‡ŋė?tŧ9öāc˙ÁĮ7ŋų|°{›í‰ļOr¯č^čķĀU#îsÜŠ´ņđǗœíēÔŨNŦö;GôŠŗũŠŌbÍâŲû´ybįũlŸ†zĖÍŽ/Ĩ=¤{`}ewŠú ō}XÚ~ 2¨éffõ\´0ûܚļ?R+WWB{l7ėî  <§ü¸9âŨŗũâļ÷—V<÷€Ë—ĩĩĐŖėü DZ&÷8´o°Ø‡LæŽ*n,M‚ēlÍķÚ=h™hŠČ9nk6´éFs–9Z9Xû~TIč듘ëŨĪäBDˇüŠī“ú`šÛîa@s×?Vŧ+•Ībd@\~´ †‘.Bht@žŊå ûéí.+ņÛōG kIß Ráxmļ}o–wN ×Yœ„ŪPȕūĖĢŗõ¯Ī$æ@_eĒpōų8ŸD¨/ēÁķûˇAŌ§#YXčUDÁįĀ<đGî˙løũŅ˙ķ˙ş}üÂĪ˙<ī‡ũh_é'Ęûåũ ŧŸ÷3đ-^ŋüđŸü“ōņ‰O|üņįūܟ|ú͟ú>Z÷v…øƒ–÷÷ãų'#ođe ßāUą˙ņ/<ūÛ˙֟iĄįŊ?ŪÍÅwZô R=mõXö•pb}Gėųbē‡ēúîi$ēoÛ^ĢíĐŲ ‰Ļ]QÎÎ ŠXî Ü/ļ+НŦÔí•y6i´ŸÅ›c§ŊlXîo_ÄÜË^$°ĀlŗĪ>ņėĶ„Ŋ\ÕÅɘz`´ß’”Ûyę˙|žtĪ0oķ>{ë{3´h8*“o)ĨÄŲÛĨæ—(ou7ÄÆŗ$œYs°÷ÆãŸMŨĖG1tƒī“wrœ“x NLŒŌÛˇ‘땁+’ŦØąb,đ6úšn¯NčŖÉô=Wa< R„ᨒMÅB× &0t= ŦڙøŨ+ J€+Ū‘NĘģēđk“Qč11Ÿ—›z9&§ÃĒ‹'äŧĶwŅ_6€0O y)‹Žę-QP6°åķ<,§ú„P>ĄŖ }w°qžˇGZļŊx/„y7ŽŽÔõ<Ž“,skįēžo˛ ‚?´Ī$xƒ÷}‡¯õûū7˙ģĮôũeŋ_ŪĪĀûx?ā3đ‘üĀã_ũĸ§›āƒ}?Ā÷3đ™?ņ¯ūŠĮŋųoūˇø–@÷ė5ܰœŊP_Ÿ:ĸ;~ØõyD÷dĘÅä´}ĘũgŦۃ‚wpļ;ģ÷3n–(wå&FĘ$æGĻ+WûĘetˇĪ’/Žōâ-<úÍJ=šžÔ_ŧíÛIĄįVtīrŒá„Ąąęî˙÷ÍQĶČđœÃ>ģŗLģ˙Ō+Ũ0Ô Í[ĢōC#DŒí?Ņ§ję˛įs˙ŠSzg4ƒŲÛA ãvĐŨdûĩ\~âZų؀ ąßŖ˙ŽĘ%Ęíe’'Ȟ—Qôr‹…’ônĒâƜ֎{wļÍsˇüĶĒŗiFާčú3÷ Ë ZOū5䆖â#ov@Š}īOēŠŠ™AUßūĨ)Xte¨Ú)úPŊŨ‘,¯ ĶßU‘ŌŖ÷Eö]ķÆ„ņ=úw|;:bũ!\=ŧħ†õŪ-ŧ”Y]Ŧž<$ô|¸Û|m§Ŋœ!wßŪĨk §ŧTWæų9ė>ØķÅ/~Ąæ?÷Īũ |ę˙7‘=>NŠŖíŖķ¤ŨĘĸšĖ3žĒWįč×Wūę{~-÷ē}eŪc?œ#ÜÆŪEšZ;ŋĻ‚Cŗņ!dåYYķŠ›kķun–n?Ž-ũęĸ˛^”[Ią9„ņ$ŧØX‡­­pr§yOÆYšöŽ­ŅgíĘ<•¨ˆˇĶ­ŗ"Ņ_ë8FFžĶGą“ˑúxõėáĘŖōŽžžrī=Žđ­ŧû|ÅŖžĮæk_ŦëC}fV¨^ņ—DŧžöZũ L&’ķÍyøē\ĐŖīü‹´įSé•Úģ€ÅēwŽKģvä]žŠŋKyü qHÔC„#nŨua´'÷ˆ¨ŗųīhĩí’u$Ÿs Kšå›Cktë Ļ´c¤9ŽÍƒũĒĈŠ@”Ņ^U#_žtäŸxV,žĪØxx|2—ļ đ'ø‡`˙īŸú>~ú§æņoü˙úãĪü™ûņg˙ė˙ėņĪ˙ķ˙_ŠđÕŪ"yf1ÚW7 ōaM. #îxû] ĘŲÅĸ’|y‡ŽlÕÃWĩņ'ņČ đ´_á<é* tËÕģmu¨ŋ"ˇfå‡22.ŽmŠŧĢpû>†‡ ôJ&ŪëļuË{e_ËĀÎi§?ŸŽB ũ§}ąüsŪÉ~iͤØļ¨{xéÚ7¯đëÁéqNî‘VĐyōÉĶÍÅkšÃö*Û|ēs/ØaOÄ:ļ#Ũ1"Bļ&5Í–ÅģâIlœöQA-W###ØkžõÃOŧO|âŖŸúŠŋĖËūņ}čw(…î~%ŨįēxUNČ-Úģ˛Ū6/u×ˡ]H…åoÉÚķ‹”šÕ=5Š‡9÷€š<凝ÉS1čĮÍŠv68ĘŽ@÷ƒĖāl ”§Žō/R}–Ÿ¤ö˙jėŸlONÜũj_Uuĩ{0ˆá‰I9ãLķÅŧ>ŗĀųėįÉY?÷ęƒČęáyÜęú”œøđ D°œuSŦø6Ė&j×+•ú}:ޛ藧÷÷ûĶ68tsđ|4pŠôšČ͞´븡Jí­KjŠú+ރ Į„Ū …pĩ ‘ęHš˜îÍ÷_íPˇMÅ$ ëw ™‹sDM˙ŧiČ÷#ēá3 Žjo0‹+”ZåBų؆wŋ6U?|‹“Ų6—ŠÎ‡éÛĒD§†Bßōc;jĸh˙Ø)âņOÄĻŋ`‘†ØÍV˛pŪ×ģâ2‰Æ‚‚fÎŗŋZĸxÂŪˇŋŊ#ī‘ü;÷×ɯ}Ŗ?žJŖXö;!ōįbÖWŪd@uCaæ6(¨S”SAƒ bûWā}EÖžs7vžĪ.2Ōž}ČtÔÍ1Ô+1“oĸä?íķi;dķ‚ĨB}…âŒá7[ų­Ys‚ŸāŪOņŖĀ˙bd#G ëXy)øĶÄ ĩōšŲĖL’ŖĢyąCÉ´NX*hogO §žD(OdįŒķĮ—5Ī$öˆt}0cÖÆæĶúŪų´4 kîėSrgŽÔ›Ūf4bD‹'Ą`†,6f÷†>{ÚXæ¯$"ŽÎæ­ų1&~ĸŖ“)!øbŲx&c>h˜§§ip´ =ŦŧöķHŽIåä›mĪ#cY17ö9múlŪĘK>’;)ķ’Uy"ØõUũnŨčõFyŊ7ŋˇ-Ō‹§ë0Œëôũ>kĨž(äo†ĘŠrûœ“ą¨dėÆŠĐ-ōĄ/ŨöožĶ6Ņ^Ŋ9ųjĒ˙‹aķ]}d…åģĖųÆå+Â­ŋ\“ylq°!—WslŨ‹cÖ< $\œŲА\ĶĮŊ}r†~܏\?ǍŒE¨Ą[Ží|KëÂáÁyúįŋųíXūĪ˙‡˙Á˙ßyüŅ?úGkã7ëņķ?˙ˏŊųáâxØæÜ\Ų;YĒŌõĮ_%ĄķoŌ—į8{ƌ„}ŋ]ŅŪĢØū? M\;ŗ™ôQiƑž.ס}ˇøúDc°/‹áN×čęōõņ‰7ė1Č*[œ{ø” Í6ŋŦÜqŅæJ9‚˛QÕÖ/…č&×Ęyõ[ŋęų|Î;ä=‹âj‚Č Ulí{âÕ>īRˆ°>#sØZmË[ūŸsĄ4ž}fŠš‘ĩŖgēĻK)šū?Æžņ8ŧŪ7Ÿ˛Œ ûÚuđŽėŠĄøģūldĄŧ#ģ‡RĐėG3ĸqĖ=˙s熋K$õ<  ža;z}Ŗ3ú,Ūw?öcąßaO°< CŋÊĶđŒˇé3vëöcœĪ¸rMyÖËĨ6ÍÉúą€ô‰ĩFÛ7N­€oÕŲ¨g;õųuE‹Æk>HâHæGq:Æ,ĘĨ'OŲ˛ČŲuÍ\hŪņBÅØüÕ˙Ŧ œLXč'"Sߌz]ĮtŽ@"Šj–× ũ6¯uĄË›õ„+Öw°ë’üėk¤˛gŸ Fģ´…Ā?ËĄPtXëø&ĖmtË1úæ°g›vg–g.Z˛žhÛ1ģ¤kÁž$ÍĀļƒÄÉ)ÆA&tg" 2 ōÍĻŲrŗ žÅËH_ŖYÔnnՖ‹ēlmkIģf×ļåž1)e,úi×Ā;“O'ë¤øęšM$‰iūČWĘü~›Ą&=’ˇƒÎÆŅŧšæũC)Ÿ~AĀ˙Ąy[9ņV; ¤Bõũ Ŧ$Đ0ˇö˛7ģĶ>đČÉ_šįˆØģ’zūR´Ķ+ œģpŪFāúŒYs)=(ÎÛ!–ÃŒlŌWŅll,.†ÅĨ]ŗ.6 Pzä + vôĨc1L2H`KëëãũĶ9íiųrbŸŲœ=ir= Ŗ˙æ`#`y”{cËõnˆUĸĨiãîo^7Ãĸ›1Ũđč¯ąHH­ŖîĘŅôÆĢhöņ¤vĶĻ7w1äÛØņ<ĩ0GũŖŦÖ.9č0îL™āģ Pá"tĮ…ąėĖđí\ĘāĖŨ6~üøP~é‹}Aœ8Vēķrž÷M[ĮŸđYIŽlũg úŒ ŒOŗŋö.Z¨Ÿ0øĻ}ÎšQgD{å-A§-Į–Î:rlŊEŧZŧõ V1ßíeŅą Yß?ŖšVI]+5•Ķ{ŠkUwŒč…+˛rœ[gė fŖu×ŋ¤Ę›y/üv´dé;@ÄҚö"Ī2ũXčû““âhÅõ ãO›ÃDüāM%UąéÜûōąXā¨)Ģū94™n&Ã"›ØqĖ–CO]`ĩOLpbAwÜYēqæÜ‰ũA}ž.‰Eâ2"ËúL]ûMkƂt!oŽËÛü×įmĸÔuTl-ĐĖr‰ ˛Ą ΃ßüúo?ūĐúÏ_üş}üÚ¯˙Úã'ō¨ūøíßūæã#ūo‰Ôذ\û6ÆtZ_°m¨ˆlt‰ļĪ(€Š×)ÍŽË­q]ŋŽvĸámŲŖ§ŽoĐ¯ŸNr˛C}¯¨×ɍ—IEšģŦ_ëJ¯ÁžëŠžé‡ãÔ›'úF¯íŽ–"6X~•Wچ1fG:ēŒ4ŪhHŲunŲ˙Đw’˜Ēū ‰ė‡—7Ȗ% ¨Ššƒ$đžzŠĐ‹Æ6ōÛShÃq„§Ää8ÕĖÆcŪC9†×¸ĩsÍ;ۊįØTˇ ģ뒄F×đáÚÚftņeԃøøĐ‡ßä[4Ū~|ũëß"sī<>Ø˙o_Ūãۏoņ?:ŠKŸNŪ3SÛ gƁ__šĶ›‚xĘÎnũ|曚°˙˟~=/x­4č;ũivĻ#6_8`čŊ* ŲÉŸ9‚Ų e}¸ūЎeŊžõėÚîÚ¯ˇK&doõSÛn*Wõ~S7ĒƒVAe)úŽž0ö7ה}Ĩĩ7äōˆÎ~3ā˙Öo}įņŖ?úƒŅîįūæãũąąú/üŋņøáūϯüō×{—@9iÜxƒė<9>€Ŗ Å\Ė–7ģđöŅü}ŲÜ,įüŸ”7ļ(>˛$6ũ3Ā5p„×Ķu .Ŋ§ĸ¯ÄŌË1Ԉ.'*ˇ´C­&3{:médY8¯¯Ę]ˇjĐoĻĢtČ`č}zŌk\˙¤lî͇ū#ŊūeäČ#[ŅGcem)Pleėæ•Ŧ"rŠO+ŋ[w°öŧ FT‹ˇĶvŦZŠKІô!>ãŌZ‡8”ÉŧrŗCe%’cT<Įeų Õüˆv#˟mģtąÉ1GœÆž.z’vÃ0žōšš9‡ĩîōŒAãÎßėË^ÕW&ü2F-{ ŗkH¨āŖby°ŗ/¸ômÚ7˙Í1coĘH蛸“ĨJžc$]p•˛tÍj˙~}í|C_-ķĩj}T†ˆ;gÅK?ĢlŦltę{˛!%šÚ˛ĨŖĄü´ė,Ÿ/ļ”sGČ s–s!ûdÂÅ`ĩ\Ŗ'H,Ī kĀĐũŠÅËÄĻ}ĮbÔ§ËOņ Bc&uŗu2„É–küČ2)TŽÃŧIį¸AŊE­‚QŌ ƒ¯Pī‰ŸÉŌ1,ØéŲ7=@īiæ:ĒŪ.<ŊÂĶdu qŠ BšÜܸ(5æĩ"Ĩ”Оb;ʰ•ŗz¸į€R] õPĒ^*gž”I‡ã’ -Xå–ô˛f/ģAđBc’ŌŒC3´aÉŖnžT p‹ŲxõĢNŦãĢ’•ųX>â-§zŖĩä’°E^؝ŋyî.`đēÛgRQüŨeĄ#×Íáđßäŗ ŸûüĮ{RökŋæôæjņīC]]~7IŲ8€\fĘōgīˍ>øˇxĒKžŗŲ¸OŗÅĐqvÁœ–Ų ?:ÖĖžŨŌM}䇜?ķC}üĀ|čņËŋôu6rŽmĄ ÷ä‚XuëŒ#ĄÄ¯čģš(wv&ŋ%ZŖQŽy|‡Mâ˙ČĮRûå_ú9RÆcT,‹]â‚Ā÷FĄL`ŪF˙ŗŸũAž•äŖüã•ß|üōWūvX_üây|đC|üō—ŋöø›G˙+c# [¤ÄÉO=9~ĢIuøo?žķÎwĀūčã#y‹<üfųé‰2ų†äÉh6Õĩo6´&a~Œ4šęg^`?ģÅáüâB˜ ’Œíe`æ~Ō9‡qZGVÜÍTFœnžH ģūXŠ}ĩŲį˜aņt<į“TúČl×ß7];‚Ō/åxĀxøĐßzücũ7žöÛlÖŋ‰ĖwyOėgû¯ąßuƒŽcîė[čŪyüÎo˙O”‹—Î}EæÍpŪdúÂ?ķø x?ûŗ÷ņø:o¯{|îņ˙Ėķ•zß~|ųŋē›փīđũô?ō#ŸāŋsėFąĸ!̍~ų—~ũņĩ¯ūöã'~âķÜT|ˆåčú¯ s Ŋ9ø­o|ëņkŋöhB~đCo>>÷š>žöŋElßædk랐`%yĢOI!ĪŽÕž4ë Ļ}ęŦũôˇv!6ÆČQķĒ1ޜ-•SĪoq[ ",íÍn1t-Ũ¸I—…ãû fį­c)b>œ*2ŖmžR/")í†]mķ3˙c=ūę_ųŠĮŋō¯ü×˙ũŋųø˙ÂĀMˇ˙`ëg^§ã~ZÄ,jkÅõņŦ[=ÔHZ;ģáQßöŅ=z]g¤é˛7ĐũĄ‚ŋ*PoĖˊ@rÎĢ=Æ(ûĖŅųčúĸū+ųl͆¸ ņdōq:T_‰ĸŋ\推`7ÆCâ:eZ@§—ĢxâÛJÖŧŸųŌU7—P8:ž•_†ßæ{90==~i§uÁœŌŦ/‡ģ&ÂŦ^<“[˙ˆu‹+yŨ wīđÆÄ˙Ā<9ûÖãįá¯'ôCŸũ/<~ũ×~›úĪŗá5_ūōo°15/xĐĸM?ŠSŋ-f=/×hö27ãĪˆ'‡’Ō_#ģ4)äÂ\ŪԚ6c  øĮ“r“ûíˇŋķøüį?öøō/˙M)ŸėĢNįˇŋÃŪÆ—ī¸qXŧÎûãéS~č1Đĩúŋņé8EŦXõāģOæÃ_üĨŸUųņ™OņņlÖōâÂīĐØÜÄįG`8מÍ[~ėĮ>ųø…ŋķ×_ų^ʗž´ŧäÃ?ŌÍ˝üĘoō^å!`>ŲÜøS~ÔKîUŒå;ôáqô+_ų›žųæˇüÖ7ßîÃŊ“˛>-_ōô‰%{ŲĮ§>ųĄūoį|åëoōôZŅI Î:‘Î}ōĩ‹%œŠ~™Ų|’‚ŦÅ1[Vüõ‚Ų øŽ+õ&Íg)Äķ6ļįX|â?đø ū|įwظxŊ„oŸ(ŋ>™îÍõ.ˆŪ\v)ÄōƒīmPQØ\æķÄûŅ}„~û ī˙EĐčĮĪ|áņ6o˙Ō—~Ļöīuøąû#|ˆžÍ5ũūƒÜp~–œéK˙ßwŠŧķøĘãoü¯DûÂ~’›ēo×o=>‡ė/ūŌĪņ÷.ņŋgÃ9øŗ?÷ĶOūe|áĮ˙ėŊwØ_EĩĮ;Ū IHH§÷*"Vl`ņÄ.*–cĮ‚]ąaÁލ  ŠGTA.$„@č Tˆ÷ķųŽŊߏ>į>÷Ūįš÷ģß÷ˇËĖjŗfÍ˚Ų3ŗˇlwŪš´9¤-{ø~ŌEG„câ„ŲéDlĀwAĒ<Ą˙Ї:ô‡š’uÚvŊ5ōšŖlwŦq%“xņØZ:#Ú0Ū\-^ü–Ië2'9$uëi$§BKF!a.÷÷‰Ö “ŗä[Œ"TÂ=á˛ÖÎãĪŽ| äÔĻĢÖ' 8ĸ„ĸÜ/]b§¨ĩŸ}B;čmoi{Ä nåEųN‚Ÿ8…,=đ<û'A%/K/¸>#¸8‰ŒXŅAāIúƒ9ÉÁôeų^b'RähÕa9•PéßōÃŅ+w€4…1.'Ž)v"„„AA6@ĸƒĘNā{åö­X) zŽĐ)ŋŧCĪ{勌vS‘*ED” P'âTJ#ē*:Ļ•˙ČXrÖfOe\J§kXL8•_ÄσG@ŧlã$nzÄŦ#ĸˆ”ƒ<Äd‰ŽåM˜8ĸŨw˙üļã{´ĪágėpMûÄ'>Ö6ž˛iF>īŧë–`N˜0“ŅR.EfŌá˜6„œC Ų(Tũ„; SŧˇQđŦ œ@ĶĒÜrŖ>‚.ËĄ-‚Âįā™ûqãG´;™Ûë1~ėŦ6GņŽ;nl_üâ—ÛŽ;îÔ>öŅĪļkŽŊ¸Mš8ˇ=Č*žOǤE "iˇCm)Ē9|äÆ_:ÜŪ’+Cĩ‘8>÷?0ŸE~ßúÖ7ˇ1cgˇĮxąf5zéh‚ŅqC†‘&§‰LÛdlœ˙ ÚôöŗcŋŠŧ;0"<…7ļE‹îj]ô×öū÷˙W[šĒáhÍř§ĀGZ֒Ÿ%Ŋų Á𑋙˜h“7…Svkûâa_n[mŊu;č­d´úVŪБ{pē7͖ķ ™pÜV3Ē=‘ŧ€4-YBXÍ4–ņí‘åkp’”ŊÂ,xƒ•éČāœ+Ŗ+}ΜŅ,́îBĢĖz]ޏ ŒzŒŗģ)g]Jū„qZÔø Čķā‚ļt)m“ Œrۙ+2Ēŧy´iū,9ˇ§šĸWĨO‡vâÄQíŪûokĪzÖ>íxo;í´?´üāģ2ÆžÔvÚi§Ü§NSWØ _–Ŋå–ÛÚ)§œÖÎ?˙LĖÉmÂ8§˜ŦÅŲžŠŊåÍoo¯Ų˙UmΜŲmüøqč{I›?AûųĪOlĮû“6męæĐJ§oųûÁöĖgîU;Ņô GO摙ģAûųq'´ãO8ļyäy31+ļoœeĀ7u5ąí@IDAT<˛"ģÜüđĮŅi˙G›ģéļm…g?ûyąĄSOũ¸ß o;R™‹ūupuTĸķ(ŸēŌa ëTæešÖÉ; ×âüOÚh8˛y‘vØĐiäÉ tmHŖ'~lĶeŪCˇŗ7Ų”ÉjDÜ!Gl0•g•jī­El ČđëˇāKĐ#Wlo…UGƒk eöŦ­Û:uK0î3feõĒGÛđƒÛō•k€íä ­"Z ĩi)söÜĩ3i'M¤3”€1Ø4GfķŽĘpĩkhéÎ ã”MxÂC€7ÖudIOŧuQ}˜!–đ°‘Ut2‘—ļÆWÛfÉTæÂKeĩ]]jœ4\L HxE^TyŌÉ\'yD•xÚ0¨;Ŋ-‚¯/Q÷Ęežc7đō)zŅōTR+]iˆ‹ ÆFöŽnS—Ō'Ny"ļøÔÁj-Ží&›Œg÷Ĩ›ÚK_úĘöõ¯…ˇ“éh]×ūū÷+ƒĄƒōîwĖ|å]3eáŪ{ī'-Û÷-'‡Ŧ\5QSnØÄc8ņƒ™Ē5†Qm–wâÚŸøDFi‡ Č´¨á8‹ oŖĪUĢ™[Ž™ §"Mž42NéžûîĮéÚI'ũē]}ÍĨ8Ũ3ÛJæQ;ĩjjÛāŅ ōFáŸN¯Rh˜GkhS—†wŠ ĶdōƁŌ<#°ú€'ē}Œ˛6”)Jv0VŽĀA‡†ųëuâFÕíåųō\ƒ/Īž^đ‚ĐؑŽÔâ8čĄÎ×ļ§=õil'ųĻö›ßœŌŪøÆ×ķa‹tæž˙Ŋļ×ŋūĩØå’vã7Ļŗ0„NÛ;lßž˙ũoˇmļŲē}ėcn;í¸;ėym¯ŊžŅ^üâĩûîģ4“š4ÚJnŽ’ĶĘNųü͟ÎIØË_ū2:ƒ“YĐz͌œžRuÍcØßsŸûœööˇŋG˙GíSŸúxāM—exŪŧÛōąlpS¨,ˇÚŠôŗ=•8z‚ާiÉÛžÕĨ…ŧŗ°Nĩ(=3451t¤•ē9Á–_čqODw'!Äp›öÚ5ā'´°ÆÚŠtéTŲB ĒŖÃEG"ŽŠgŖųŠĮL‘E?Šm ĀQëžãëj.Ō(‚WtxW=>ÆQš@GÛö°ocM[ė5mS¨q2 .fōÎĀĻŗNd`IĻĻIØĒjSéȎ8`Jr} î 2†›Đ2]Š&ū„?žƒî“ņ /Ā :ĸžÂÕa萄núgÅh‚B?4áų¤Ŗ‚×…§3b"€ŽáļŨ¨DƛūiIŖ¯.+:ōē§<–Q!āJ'vĸ/ĸ<č-š ž‡´ęJü@ášī¨4 C4Ë^Ā6šŋøÚv/]Ņå|sÂąjõÁzî”?Nm ¤U°æl• ŸåYÖ¤"BWИĐO"•A43V[ņÁgeÍ7<$xNėŸD¨Ŗ ‚­my’˛&jd3œĪĐH˜x§ŋVfxaČÛĮÄ̰ šÔ V4 x‡SL‚+/‘V@‘[ĄzãŦ4@ĮđÕNNŌ"@Ūe4Ūsk&å 3s"ĻŊc Ŋ<•ɉ)q l#+¯đ°ōÔøË0 āU Uqđļ– ũ’Iš%bčŋ˙"˙2úŌ­DxF¤UĢVáÄЈr ^ÎŌÛx…n˜€Ė“v^ŗÎ:í8#ĄÃÛæ;"ųĀ}Ëq‡Ŗ8”ŅëeĖe_…sēa[I>x8S&îž{I›ĩq}•ķîÅĐĄĶų3zX›4y ũšvß=ˇ#ˇŠā8bˇøNöîÆŅ7~8SZ['ĪW+ë‘õ„Nj—\r)S&æ+9ÁF'3Bû`d0ŊÉA’Ē#4köDÕØnŸŋ$ŽĮHĐ$Ļ3‚éËÚïf<´6lw1ŊBŨôZ\ŊŠžp Åy3zdæî¯dÎ÷`’ú'|FÆĒņmū‚šōÅÃ>Į¨üFí€ũߨ~ę¯CŖ?qÄwÚ;ßųŽvôŅ?ko~ķ ;Ŗ­ĀŸ”EÆČvĪŌļ#ßŖMÛŌGØ }EäŸãØą#Ņߘ˛ˇû—c#ã2Ÿ˙~lĪšü:Ö66<ĶgŒŖ34y–ĸ;ßđæŽrVWĸ§8€ĀzXAΟ{›;wv¤!“Ō&í/ßĢ}äŖhoxÃë uĐ´wŊë}|IķÍíâ‹/ĄķųÔgŨåŠ+ތƒ~֙į´sݏđ)ßŌĨĩŋđÕíâŋđ~ŽCĘŨÎËrgŨyá…ˇ§>uOž-tæ8&o´U{ų~{ĶšøŽíû{>vsTōŨüīĶ5nųķđ#m$v蔸{ą…åäWül¸GЇF ÔfN‡Ūū[ËÂôąc}힊ŠuËR÷l4yt[ōPkûđjŪ\m׎9æĘÖâ6vü0Ļ­NGxōBûŧ—üBįĖōģ†ÎÚŨ‹)?đąŒ9Ø0w“Ét˜–ҁ’ÎļuĮđÃȝ ț%ä׆Č366ŗbÅ Ęäx:kč=”<Ēę™ēyå锤{ī]’:gĶÉŧ[ƒlKketuÎ9įSgŧĩ}ä#¤đÃØŦyũāĢÚfŦÕxø‘åČI;[†-žkë|Æ$ŨŽÉ‰Ž°ŠÔŨđ´Ŗ2uc×qlѰa¤—?Œ”KŌ`FÛ%ū´ÛÄA¯ÄŦxĄĨcŊ_Šŋ÷‘Ã*ģĒ `dāŠƒŠđĀE7U°Ŋ)´V›lk¯ķiĢ öølGd„ŒĘ-Ĩ¨ŗĪ,ˇÆ b¯§ļ'E¨Gũę7ž#øĄm¸â]{šŅb'{’ŠÄ,~–g7jÂJâ‚ķ˝‰>d%LĻ%å*Ļp @Ūž}ÛÕë'ŨÛæ/$C]hūT(ÂÛÖ ģΏáI}E N^9Ä/úTév@!ųĒ+„Há'LŎKˇŽ& B%ŠJŌG‘Ā%=ĻC)‹}đčˆ0ļ¨JŖø†š‰Qsš-ÚāčWË ”Į|ēÜæ^LíŪŽd"ĮæÕązVZ….Ēëá€Â\;ųŖHɨWėD˛ááĩėbcD5…{<Ū•Ŧ"ú^<}õ_vûī:, €’f H9Īʕ4+¸´,ĮŌĀ0oÔlY˜>€Ą†ĨĖËC´NCëäšTŽ4B¯ž ąGyš'T|Į Nj}"4ĖŧGP[Î|øK:xWĶl¤‰Qr ā6]‘–ĮNŦĸR ģB-<9€ËT ČW手iíāåŅëĪ įč+éFļ(ž*´BU‰¸u†UdTžGáZž"y`ׅKH;IĄŦ –ō‹„2–ą1. °]ĄæšFŠS腧ŌWÚd s$‘’¨TYĖ“Ęã^ƒB¯+Hé%ąĘ™¤GŪčĘ…ÆąŦĒ<Ŧ 5Båėh’Į>%yyōŲ_/[Á˜NÁEÃ+ŋ¤ģ°oŧaQ(8b5ĒĮ-ˇ^ĶĻOÛ˛-ēë~œũ Ėg^ÔîĮ1íÛŪ[į OÜh4æRĻšŒË莪FÎ`ąäĩ=x;nVFö–˛¸rÉC —Ķvß˙@=N™˛e`œ˙›`oGÁãúë¯`”u—öŗŸgĒŠSg"gņ˜Ęô‹ZŦ[:y”†~ãĮ´ Žü„ņŗqHW2E`ÔŪ÷ž÷dôß}_øyķ˙Áë,œģš>qÂ:'Žp˛ˆ•‘•Ų‚nūųÖč Ō‰p:í÷×]÷÷gŪy7÷|útōo‹UģQØûîģ?õÄÅ_ØU+WĶi{¤ŨxSÍ%/ēu;zf{‡o*:\|÷-Œd¯‹]Œ,îôˇņÆčũî‡ŗ ŽS¯ßļž~į—}ųÆaôˆQČmŠntˆ<—tō<¸dyÅ1c‡áŪÅ›ųķ´paŨÄÚ Ū Ü{ŸĶžŦĶ6hS§%O?¯˙ęĢ+/\´[yXļ.•”×u„Œā§#ž˛ũúŋOČo1 ܛ7ūpkvœ"ߞūôWĶx{°;Į8˜ŖÛå—_ÜŪđúƒÛß.ũs{ßûß5Đ€ PPęžŋ¸úöFž;ڜvd8Ģ îŋ¯ũđ‡ßÉÔ¤SO=šŊėĨ/IĀD™R+ޏ8×ū4bÄLĻ­ĖÚkę ãGæíŌmķŽëA˜ÖčÔãŒé[ĨcõpįL/_ž<Žü…Ĩ× 7ؘ…ĩf-ĂÛËN{B÷wũš3ļjw-Z_ËÃŨmŲÍ]Ļõ€ë]gÍÜ›ZFgtå”“°÷/Ķ‘EĖ’ŧ­qZÖPäžų–ĢīŠˇåÁƒĻņ¨ęš&ÎIũ iŌäĨ…ģßŧ1ØnŪTĻj §jÔQ Ö7ĀÄĄķëÂYë{Ģ낍sr>ÕtNq”´ãøī]›RN´ąM*ŪĪD¤ŽįąBŧAîĀ*ží0<[ŪŌ‚ –x-Ļ5­ŲÂË#œËW¨įūŪ„ ÍUA;‘ŧĀ€G˙l%9‡ˇ0â"¤ę-ŧAI€?a(Lų(§t,p‡ÔAšŋHVČiģzDâŊüĻŽŪôjÚŊú#IžrB=ŗ(Šopåkڄ:8œ„HrKčAa$%mŸ 1’yę ’ØEh‡üÍ?Ɏ„Üg÷ÅĐ­°ŌŪāPO§ĀôB°ī h‚Q]’Ą\JeNķļ“7äNĖSŨEË@k7JŌų^Ŗ §q¤ÎNZō™.éČĒ×A“TdŅŋ4r}{įYV‚DxŖšíGëÍõ"Õ´&N§3,*ũčŧž jĄ:|Š2Ü)Ŗŧ C´?;ūښrGŸ‘HŧĸÚ–Թ#–ŖŌ@™'IqúĐHQmĻüø’Ąc~âæmžJ‘ŋÁ5â_ E „BĀ"3VFˆ#0,u ÛÁ ŸîĢsÃ|Ģ—h‹‚¯ĶjZ:‡3° ˆ<ĄŸ‚ŦQ€“ađģĨĐA—vW‘D“ÅĸFAԍ˛u„ÅP)ĘŖ‚ –WŽdā!áĻ‹HîןƓŠÍø uēā^.ũĄXH!¤<ųÃ!˛ H7Ķw€‰ČŠ|­•PĶ›?Å+yC'dH^TOáŨĮģĮÚ p#ƒ–Z‹+̇œâ ŌžÄWēÕ¯:‰B´„ČCŠÔ•z‚‡ôR0ÃÍ9z“k ę[)ŒFī3G"Ž7ŧņĨíÅ/za›Â„GĒ˙ķĨožå–öŪ÷ūķ’ˇĄņ-'úG?:ēí˛ËÎŒÄ Ãz˜šė3*ų^œąģÛÜšÛáH_×=ô“ÜĪio}ë[Ú7ŋųí8ēįžûጤn4q6RßŨžũíī3}ewF GÄɸ›–˙8įBŒâ0ÕâMÍ)S6F^Ũ;ûÖ[ok˙õ_īnŋãŨí ģīÖ>ūņ/ŅxßÄ´ˆŖpbîejÄĄČŋ9 F¤QÉäÉ#ã¸~îs‡eÎ;Ūņ6ænįĮžÔ>ųŠCÚæ›o–i5÷㈞qÆۗŋü…6fԌ6ši6ëŽŌŅžûžŠ7 3ÛIŋ8ĨŨƒ1z丌VŽ3ggAÛ~ģ=ÚŪû<‹ˇ'_Ęî/Ž„/ŧŊœ”‘#F2 ųŽōC8¯:'­sÎ9|ątįļéܝp‚Žj[m3›¯™žŗũõ¯“ŪSčTĖadô1œpÁáíSŸüŖĻw´ŖųQđSŠ`ˇÍģ2SĨėLŒ7ŽÜęvë-ˇļ~äķmŪ‚ĢIĪĖļäÁ‡3ĮüŠO}j:Gų-ŦÅíđÏāmĖøöÉOÂöčl c^ų{u_Î4™ũqgēÎ˙Äq›ĩo÷ķmkĻš ĨãŽ4NŠųú×ŋƒ œC‡Åĸžmš×N;îŲ>ņÉGŋ:¨1ūįķÎgŊÉG‘}ĶyÆ˙yîŧķ.Â~ÜÎ=÷Læm'‰íĮØÛÎ˙ÖŪķÔ7møfįPį˙•¯|m{Ķ›_΍éŅ›6ņėėļ[įS|(ŊŊíC:eŨúŒcĖØ™Ŧ#ۖãøctzãŠÛļK/ũ ÷­m‹-6ˈ#č˜wļ,ö-ũÃËV’Ž•”ŽMx›rIōsŅĸĘī q’īĻÛŲÁØũ Ûāp¯ŒŠRĨv:nž1ęžēŽÚæ›ÍämĪŖØ×jŪ†fĒŪĻ FžQÎ6ũ™O@¯ĩ¯|åëmĪ=÷ ž1Z‡cۉ'ūŒ˙-xų0ĶÆ§ūyå~´7ŋå „o<§Jų6Đĩ{íĩw{Į;Ū‚ķ?šúič€Üo|ãûˆ}°ũô§?kW^y6õÛöË_}2>ēö…/c˙SHk1Ž˙ ēšMGũ1ZČĩØÅ0Ū Ík¯xÅkąĩ‰¤íXFdGd(°›g}>ęŧčbW{@]MDZ'ķg <ųeNëŲÔ(7ĀÛn :âŪęÅA“áÚoč'\¨CĀ–\4,$„Ã7¯îV#”Ⴈ¤B6?ōÎ —šĸbZ$ŠäŅé÷m<ØĢ4Š2†iP…N°­lJ´”JlQ|*ņÕĪ&Ŧ“› ã_âŊæž §}ŒĪ¤.ÍĐđÃKP~^ RC…%ÜzįtGā’Iũ^f\“0‚ēŋrĀ ˇ0äœøōQ}$Æü|KHžĐĪxįQēÁ­ŧ!j¸īĀ„cäž‹SŽėŲī3÷fOō€+VÅYX~ã“PâęPæ’@[§rÕ§đLēčĀ™žŅĩž.ųe†…PáÉļšû]ú*ÄČä>tՓ¸rÔ÷’Ÿ0üĘgDg܋Tށ œwĐ/Ą*‹N˙˜žŪV‚Īŗ ÆĨ,’1ã”#Gi< WŲĢō,’œzĒđŧ’ŸrGfâŊį'„GĄ†+ĸÉ­brî€ ‹čÄ īŊyɛŧB\šfqO”Æ×,Ą4‚ĘN¨bøQV‘‹¤Qsøú§r:ÍĐ2Ė@:§É—…Ûî;Ž^‰ŅĢ1@ž\t´r˜Ąxųŗ€*PgHŲËy¤]ĶjĀą'Ž‚%Ë)hČoĒ,Å‡Y2â–ę7Æ{ƒŖíxéķ‚^•ÁĮŽ’|ú4Ē™ĘL! CđOŊy5ĶšĪ/nŋüå¯ÛAŊ…Qø) ÷t˙ p”ūܜ[íZƒÛoŸĪˆŨ"žāšeûÖˇžÎÜø÷vôč1lŸ91˛(Ī„ “˜Ē0.qģîļ+#˛/¤c25Ī;ė°Cäđáž{o§ė0îį'ŪŽČvÛm—ûE‹nÄqŲŋzÚ¯pPžĖ[€ģãČę zčGqîՖ=r#Ĩ–…:úŅķMqŧ>ķŲOāXŊˆĨL#Âąĸ2ÜhRéé㇞ŋ}đ|tË'´#ŋûĢäĶË^ū’šø’ķqR¯gG™‡“G;l˙œö2ˇûŲ8gą‚ļõ֛Ņáz‹;÷ ΄‰cxkâH0SÚōvđ;ßÖö{å˸/xœjņÛߞJ'ėũqė͗eŒTīķŧ}ÚšįJ‡â•¤g!ĶqFáNÄĄsô}œ¤qŅī=÷<Čhöļmß}_œéVŽĩØuםI? ‘pūW¯šŗM˛MģāĸSÛķ_đ<Ļ´,MžŨ{ĪŨtėžÆŽŖÛ“÷|6Î˙ÍŧŲ$Î˙ūLy:õ´“Û“Ÿŧ'žŌī˜ąc˜–ņĄČjcĢã3~üF“gœōŅQō˜={ĮvՕ״Wü[{ģ00÷1ėŧéf“ąŸÛg?{Ŗâ?l;ĪwŪšˆŽŌÂØéÛŪū–tŠŌāŗ+bũŊeŠ{KĩkUtū=fŅŲsē’Į7ܔįO}ŠN;;]síĨŧĄ¸…ĸˇļmąå: ;Đaz~;øāw ‡]ƒ“ōFYŊčB;Ī+ÚĨ—]ČbŪËqø/o×ōķzŨuõ&&띔'2ŲnRd¯ŊöōļÉÔíŌ‘õ-އë|cˇįž{đ֑LÂæī‰ƒúļˇÔÎ:ëO@-Í:ÔÍqË3ڇ>ô8övāîŊ÷ŪŦ…°ãũ‘ ĖũqŒĮSöėäkûãĮM <Ī ĢîāmďÛW˙o¸ĻķVæöč{úôMÚמöYį_n[mĩ9ë`^Ú9äƒt0?†Ŗ=)åmė˜ąíũ,Ęūō—ŋÖ~ũëZg!mÅšëfœ6į[Ā­ˇ™–rs衟Iįb‹-ļ€įÂüfĪžÍ@Â7čÄ~SvČ8&Sˆ|ā”´ņã'Đ!î§]Ęz Î;˙d: ĪH§ägžÚ8āUí ‡}:4–ōfrėØĄLÉB§ž^G|éKŸÅ_JüŌLÃë–?AîhegûbŨėæļ,Ž@{˛ŪˇĩæG/†y<žjô‘ëüā'ĒđŪÁ‰đÔ°ŌđččdKФYĪ0ByNĪC¸ŽgÜđČ t*Ōžƒ°´rŗM RĩMám[ UÛ*žŨҝFl-AĘ_xŌĢ8ÛqŸĖŠĘJœéäō@R¯†‘I˜P ‡gÚĮčÆetZ@tØĩqqФŒķ7ˇĨķW`ōÂĪ\Y'Cā“ö‚íDé42ēļiéôAŪKĮ´ĘN°āqęĶXž—xĻĒpŊWŠūgl/O$ ąNöÎo0¨hxŽ{;YBĘF%hŸ@}|nģS—~åU@ŒĢōŋōĻjC@‰ kčT‡V K֞šņR‹ö •)7”įrą Pč+Žžļ#rtX¸qĘŅYxgšũEŊ=ÉGú}š$~ŌÚr$N/}… SGQ´ĸwÄ]n‘HŠzŽ´ŨßN>ĄÆÄhhš{ĸšBš+аRyhKˇh,lgOT1=O;}–’wŦ_%ČPVē——T ˇ’a…Žu^ėxX0ƒ¯€ų1pĖé*ĄĪSŧę˛CNņŦĐđĨIŅIJ˛ĄaĄ2U€’ĘĮ#¯n,đŦI5„ãė—rÍĻęšC"R@ Xø™Ö(E! H‘FāSšTæJ÷I`%…Q:ũ&ë*ˆĶ˜xH:ÔŽi0(iéGĸ3ĶÔņî^Ķ b—‰•Ŗ˛™c•Ļŧہ_*:B•KŠ!%šÜ™)u Po(^˜T^C†C]>ˆ–ƒĶÕHˇlHšz-`Ār82äqâ ŋmßũŪˇpPŽĮŠXSˇGÂgÎܚÆö†öģßÍbĘímŊŗũęäįéÃūxûô§ͨŨ›Ūôú„;īyŌ¤ߎ}ö3_h‡íKLš•8G÷t ?˙š/ļÞøŲ„õ§… ņ&âuíģßũ&‹*š…•ō<ų‰=XœžÕĢŲ>G}süŠŽÆį>˙éöųĪ)ކķƍš˙ÁĮÚ>˙å8;vF<ĩš8 ‡eîđŦYO"¤ĻĻį|üWŊę•q„žō•à ĘŅ|qÄWÛNûĮņ•œĪŋūĩoļ/å ĐûėķFGÚžđ…Īāt˙ Ú˙d÷šWâtŲÉz#¸īmũõÜĀ?ųÉģeD˙ŠO}J;ķg#ķĢž)otn›wg;éWGņæÄšņ´?sZâ<ŊčEûÁã˜öžĢ]´ßŸ˜˙]ķ¸>˙ųOņ6ŨtîŦ7't2ú}EûŌ—ĪbŲ×ŧfŋõäyōœĶœúáqüņGâlioīÂŪŽO˜§ĩˇ[n}°=÷9/nīy÷;éČ-Ȩõ07?ûéqtš^‘aᘝ;\Ŧî AŖSļ_ÅÜ{_]O9å÷¤y:ož—€oû¨öŦgí•NâYD­s}æn×ßxųĀš-ļØ1ķĘW0ÕËÃ˛e~pāŪtHļÁ›Š*ÚĻĨÎéKwÜqW;ûė?đTusú=֟:įô¸ņcˇhŋ;õØä˙‰'ū*0ĻĮNĀ{<§ųFö?•pOß˙ūQ퍔Ĩ|䌰—]uÔOÚ>û<ˇũøĮG3R~ĐŦ7×]w=Īwˇ˙=„ßäwꊧŗP}—öÄ=ž0û’2zūyazÔŗÂŊ9ķĖŗÛÅî?úŅ÷é_Mũŗ6Jķü‹ë•÷sĪ=Ų>Č´žÛûŪûĄvâ/~6@ëvۚ9Ō‰?m×ũãītb_•]œ„}ĘS,ŗëéXīœū‡3Ûī~÷ëü,kģīîų^î ĐßätævÛm7Ęá_Úk^õ–vķ­5eî¯Ŋ¤=“Áፓ\`=‘ގīg—,Y@~:ļ˙ķŸWį:–ĩtē]īdM›ē;íĩ7xܓÁii{j†02ŪŧOû’ēËļ"­jėĄwlęAHë¯=îũËāøļ}ūÕŧéLf =Ã(î"s’ŋ÷žõW⒱ÚÍ@Y \…/Ÿ€!6ņÃ8DZ2$=įÖK§v$ Ģut¤ésXC22ģVG3ø5ÚnÚÂ_pÜ9ÎnUiVö‚5ĩ9 ‹HÅŗB¯2’imøŽŅn‡_ĶĨz}K#q\ŖÜ°ĮŒ5*Nz+ŠdÄ×U Ę[öŦ&*ã—v"ę.%'Aqp8z‰6´ÕĘ+§nųJSd°É¯:WķŨ8AJˇ)S†øIš‚Ņ`Š$hHē“ ęáU´™€ø&Ögî‹u…™Čäs'-).ôÄ6LģsLŽüâŌrôÕôQDŸ ̃pŽĘД–Ēī"xæ^cŐŗ…Nwd6Dæīø¨ŌL"åH‡xĐg†č'øFÅUĶš&¤—F0bŠāîúp‘°@-|ĄgĄŠŒ‡¯âzŊIԐÂOšąæAąhĶ yE^^rí’ lÉF0Ĩ[ rˆˆČ¤áŦĢXŅ*oz-žÃ$v‡qI71ėė"׃3dãM&&B‡eâØvÛŨâü;Ÿ}ûíˇo?øūãüoŗÍNŒ Îjsįlƒ3ũÅ8COúĶĀp´Íˇ'娞tŌ¯âüoąųŽm[>z؉¸ä’ŋÅųŸ5sĢ6gÎö4°[&î˛K/kŗUÄŨáW#¯%O´Á‚Äę”h§*ĮłG|ëkŲÍeŸ}ž“gGE×tÎŨŪ„šĶËwžķÄ}ëģĄß¨Ŋû=äy#Ž;0‡X§sdãŪtĶM8/Ŧ§Ōũ:-Ÿwūm‹Í7oĪ{ŪK=Ŋ•) c€ZĮtâĉí4œ&-ˇØšøēÚˇŽø#ŠcYDz0S ŽÁą›×ūüįķé4}…‹ķÛŧų×ŌAŠéFVNë¨ "1uå焁¸pŠĶÉ'˙:Î˙öÛíÆ[„]Ûv{JûãËhėۖ[nÁTŠOeũ„ĐŽ’bNŒZĪ)dÎ~ôĘŠXķæÍpūgĪŪĻŨ6oa`”˙8#Î˙N;=‘)[>‚NÍÉÁŲzëĘ?wmúÖˇžGü4:_înģ>™|ŗķ6¸ŊúÕûå-’SČúcܸĮËsį]7ŗĪÁmíŅd˙­ˇŪ=ĪYĪŪÎf.žöVSŠ~į[y[1¸ŊtßׇėŽģî=h_v(¯ēęę8âŊÃ/ŽöėŲ3ÛÕW_“N¯Íë¯ŋž]sõĩt$nĪBßãŽ;žˇ=īaäëöë/ĨÃŗ?[ƒ^ˆû“Úa‡}Ž)M§ã8˙˙›éGļ›ož[{0‹b{ŖGŠã{ėąGcƒGĻī´īķķúŖIį͑yōäMŗöf÷ŨwÍ4eQ&×\smûĮ d{˯ŅŅs=€‡vîŽPų˅qū7ßlûĻž=ŪÉÛĸe˖‘Žzö›/zņ ŅÅUqū ˇũvģãœWü^˙Žt”ŋüUßÔ1|8‹uŠĢ,ëÆÖÛ6?@¤wūŨNx§Ę)wGĸ|ˇqo G]“éb—_ū÷č`3ä{⟚¸K/Ŋ<×ß˙ūqūwŪy:%Ë 7Ü@™,~|đ[™Ę´bĀųŸ3{Ûlûiœo—3]í ˇOÃÔÉØ1cÚū¯yƒÔĨĶYs4?ģdšŽãyĪß;Î˙[ėDėxlõĶŧÁZœÎ‘đîDTNJŖœīé…ÕšraŊ ÔŠl`Sß[ۆŲrâ—?n3@cũOH—vČęÄ6€ŸeQ:Â—ē†Bį2m‰|¨ŒõHMÄ)<Ŧˆp“ļLņáŅxĄãTÉۀõčûh`ÚHœ2§ĀĮö5õŽm˛UDŽō‹ķĪU\ÛiSŽaĻĮÖŦäW ˆ˜!Ŋø„veGAØēB 5Ę×tĮ –r  ¸'ہŧČWtC˜6QZ50ZáÅ9ƒ+ßHƒ:‹‡éķ‡M‘~ķLNI‡dŖ>|p.ЃŊR–låŪԘWõ+iĒgœŅę[ī•Ápô;úŧįēÖmƏJPđŊh% (ŊZ˛U“ŧġNÅ,Durđ˜4rŖHC'2„¸ąę¸KCė"H„wz†Tü {́˛õ\ŧÄ*ŸßĀ_Xƒ…#ãđŦŨ'¤=>mp€ €¸ĻŗK_â€S}ōį?ų‘4š’Ōn§™E_eĩÄĻ“„tč:ĸvtLARhÚ}āĐūĖ˙Øiø$Í<*Įžl'ļ‰ŦĻ5:OzJ×ÚPuÂ鎨 5Ôéš|:iR6Ō+0UūdæęoՉ:‰'š@E°VNĸ™S h¯ãĒ0U@ĨËsX”ÂTožŖBĸbõE8˙]^đ\ĒDĨȁped•0Ĩąĸ <Í‚Ģ… R|@ .FHCŠ.Ũ…KŽH ?Šē5§Á3ŪuüÄ >ŅĨīžffuy¤Oú4ōÛĀQõ…‘g*9Ú#‰æ Œę€É•ėUÖČÂ÷%YéØ'î8ČāŽãTië¤Ņ IŧŠŠCį°RVŌÜWœ)D1ģ#ĪĨ%% žFĮU{ÅģîΧ~‹NīŖGÎĩ°FđųĪÅŲ^ŲũÄQ„˛MáÂÕŧ_ÍZĄãT\ËĢ÷1ėGŋž‡á8<đƒí¨#Éķ°lyįĸ‡Úˆa3A˙x{Æ3tۘZ´đFœ­k™ŗ=iŪĘÜ㝒oŅéŋČ'ŧO(ŊĶ_˜Á ‹/ūÎüæí9Ī~!ģđüĮú:Ļ×ŧ¸mļŲĻŲšĨĮsŽąSŽN?ũ7mڔm™įũķĒn[oĩ}@ÜŪ9ÆŗfėĐŖ<îúퟟ”<Ũ˙ũîSį=īšėŽķ`ûėį>™gõ=—ÎÍû™4kÖĖöņ}’ŅÉS™S|:˙ø!íĻ•xā›qB˙œ˙qĒĖ낈ևŲÍK?î8õĖ’QvÆšá†Û™ŗ_#ņ?ũé/á÷@ÖlôxũÛ wGę[_vYˆęD.\đŅ#Ų>t‹öÚbšDŊ¸ęĒŋ15cI{ oqw?üʇĸæ6Îåw:ÎÄ sqŠī$} øÎÄ\&ˇ^sožƒžKú7Pũ"NŸŊ­ĐŪ=’§ņmņĸ•YČúØŖëėMīu–L;í´龑„.§ãĩSģōŠÛy^Än8ÕAø#o5¤?|ذđėO§t¸\gáĪ:c SœœĸvoēA1lķÛW0­gˇvĶ͗eĘÖsžķ’tėūvéĨŠžķėgą0ũGä¯oGūÉšGoVōĄ‡Ÿw"#ōGōM‰cpú2đsíŠo<îŋo5gꁙh ŌdÅ ëc~ķ›ßļŊđ•ĄŪa‡Ũƒã˄i÷°[´čŪÜĶĨįęļ›Ö'Šioį-ÜHÖü9Īc™víuˇĩyˇ.f­Ėæí˛ËĪŖŗÆęĖ('k"ŋ°˜7 ĪLŲøÛßę ז[îÜŽŊf!å~ ŋūúãŊ˜n(ā¸: ÷°3˜‡Ûē>ĖŽEvp<fGž؅*ˆmTƒ >oˇŨļŧEģ6QÛm÷$Ę÷RÄ?LųĒŽä"ėÍ)„ũĄN†ŪvÛ­âG-Xöüļg ž;Í3#hJ8ũčiOۛMŽccĢé¸îē—ūíŌĀĖŗr’O4$Ļ-õĢ1>äŠŽÕ k(m'đšˇn6mMœ]Âõ.W™A89b.Nlˇm×ĘĒ@æđ›yØNéXØž¤ã>Î,DŦFĒëëhch‘ŠČj{f… aąA°ųåØgÍČLé¨Î­[1ØčD™Ōĸœ,L ´ä.Å ŧØôŌéĖHĐë'0ųqÆAĒ)VĀĶZ×&X|ūeGk@ĸÍđęÛ;<—šá l9ĐJf<° S"ōGZÜEnå7ŪÂĘa*+%Gˆz.xũÛ€;šq#(|Ĩ“´ëqÍž”+ī¸ģôëį98ëĀbÉkžkKę{éyxíåõ9)1ŧ‚ĪŊ ’&5ĸŽš(†—äƒáʨ,PÜøđņAØŌMø.ÍúS*đCVRW&–‚´ātbßĨ ĀDōW܃ŊĢcƒã‡*—ĄI{^Ζ¯’ŗāEœ×ÜįFh…ÎŋTšMŠŠGBJŒØ\`‡ÆĐøŨœMCdžx5šÚ\ĨŪXäÁ4aŠœ<“/úđ¯"æXėSĀMBԓÄD4ņCĀH!KPcˆØj \Ÿ,Đq%ch8æGņ‚đ—îŒ}ōŋ ĩRû¨øŌGĖā'8:Ĩ”é@Ÿ{ĨP1I”—ģĐ!L…ČKųŧ—@ŊrČCŪČŽ‘ío€‘ą„GyōđÆ{˙‰P™š'\õ'žūENq”)B42C7ŲĸÜŌ2Ėä“ļDMaĐĒ#ŪÁŠ—LŊ Ú¤i*ĒîkÔĨ!‚a¤F&éĘĪg)’fÉoeĢÂgŧÂq~ŊIZíÄ¯{ȞÄļi@äP<ūīqhz6‚Åy“&mD#|ZŌ^˛ŖĒŽ?–ųØsč¨ŊķûīēĮÆ}-ģŪ0Ua°ÛTeąäÅíe/{ ÎÛūmΜYYtꨴģĮ¸V@yČ Qė ^Nlqyü9)@_ÕëļĐÚ/N<9s°_÷úũÛŲ,–ôxŨ¯É4Ÿũô„<{Ō!Úlŗš8-× ށ‰„ irˇ!§Ô8ōšûÛĩÛī¸ĻĶoiÂQ}GÖ]ę(°Į•W^Ü6Ût7 Ž–Ŗ°ŨvO˜ÛŊŲĻ;°]é-í;LkZ˙ø SqūëŊīĘČčÕWũ#zqäō?fâã§#8’}Í ņN,;ŗ3ZjGi"rū=#§ëļēŦĪÕ ääÔ8Ē­Zĩ"Ī+pZŲŠ‘_ŋ°ŒßÍíŨīz?#ČĪ'6f4x $ĮÄų…“íˇ úc ķģWŦ(xCϏlHž:œšéĢXŒ<’‘ldŊárœēęô6ŋîĘģ…ĮŲ–jŲ0Ķš ØÛ„qmîĻŗÃv℠t4nÉ} ʖ­°KĮ|xͅŅë¯ëĐyÖáŨygG˙ũ1sÆļŲķ~v­ēūzGŦGķÖ`6ģí\_õä=ŸÃ´C™cūÖŊ•iNÆų&ÍĀ;>„§{đsՖvŖåWļŊöĒQōؔ)ģO­é~˙î¯ī›:ėP ļÔģ­Í™;+#ã7ŨTzĒ—ßĪøgÁÔ2;í›o>5OEŨ‹ģ:‰Tŗmī’Ĩ:â›s?˛ŨŪ<ÜösČ0m°īŒķN ]?å){R>öbkāGR_ôN§Î8íŠë_åĢōkûģܯ+ 3ĸ7 ¸’5!.@OŨŨQq1úÜšsũ~BĻķ[DŨS-ÂļÛnÃ33÷Ķ!c+Oū2u0ˇ=]ÆöŋcĮ՛É_žôëŒöŋéM¯m\pfđŪöļ7¤žøŨīĢ>ąÍt Q§˙hĘœ:Ģë$Æ|HĸŒ ëëjKÄ0”?×ēøT<ĄŦãĶJT94¨4ÁYępKųš-WsžŠiuÆŗ}Ē6Gߝf)´C0|ÂNĻühO`‘‘\â Č}ÉeZ‹¯"(u' wn‰hHÜᚠËS”Ë6!åI Š HÃēFpw5ŗĩĖ ›eÄYƒPo+Ē3í&tLšá r­û´ÃxŽ™ö‚v„ˆ/žDø‹ŠdŌā@?÷Așâám‚l“Ŋá'!9×S‰¯a;oˆ}ū‡Æ¤Bō“ŋí¸˙ú`Jc™mŋôį8šnH¸:ßՏI#á!×éŽK›tŖęiƒÍį-¤æ)ô"Wž8™öđVžžF/‹éÆįŠSŽJT„Hūˆy–ģøb)ĄL˜˛Fb"Ī)âfdOšãcūFãtœxRséx™—$Ā4=B Õķ¨\Ž^FŒ~VtĄN搋ˆÚ–7úŊLZ°ZąSUyŗîí@؃8%Y%šl~`D?2Ģ/S[éŠÎaȑN“ YJõ†|Ξ–žĮp ąäa9ŒŅC—YÅ1ĩpA¸7ŽP“h¤”‚‡D*qšaUJ fb5Hā9š0×ĸpî䐜øS=Ä%đÄņ•…¸Ōx°ÖÉ|-ŲI”!+-˙ü÷§}ÔŗøĻŌ'MVfŌ—2Ôa˜ .§Ú “†g{ae„QŠŽ ;i<>Ÿ ”0:uŌe€Døoŋĸ.wî:Y šjËЁG! įЈ\Ļ#rv0œÆš(2 —o–„Q]Úō,Ēz1ŋŦT”CĄÁÅ#¸u[Äwō÷E>ąĸ­w<­īučWŦXŪÎ;īĪÄĐ åYx¯~~⁨ņJ×Ö1’ˇĶĻfÄ˙ēöÕ¯~ƒŠ6oĖT›nē™×ë÷ā@ŦlßūæŅíkßü4ŖvģæcO!ô¯§RrR›‚–ˇšswh`D˙Ö[?žųĐ=Ú÷Ø=Îúš>Ŋ͙Ŋ#‡×åHĘzÖYgáĖÔqŸGŖVąļĀ7-˜ŋ0dĒ“QMˇĮYgūŠŊëŨŗ#ŅY„¨#˙f: ÃÛÉŋ:%ņîS˙Œ§ī…ˇgŸyŖß32ÍĮ’č´Ĩ{?Ė>ķLABw‡ō~vŽ9€w.N*˛ßŖŗã,r¯°=3¤1€JvTYį\LwIžąÅ&^€;ĸô,˙×Ŗ/?ųÚŌyíŋøUœ#f;?ĸŋ‡˜^ōž÷}“ŽÎī3ÚēŽhŲ„Ī:9v,j$›}áGfAō‚€ūO9Ö ļÎŪÎößۛģŜ{îųĄUŸÂˇĖ™gŽŽTžjÖ˙jáEWÆΠcÃW‘ųĻÁö™ˇ¨Ū|`E{ˆu“6ŲžōÔũXãp_ģøo—í)u€Ĩ}Ņ_ĪnĪyÎŲYëᔔMĻîĐî\Lį‘DúĻīĪ6öyrϰ= Ōo'/õã-ˇXnŦ÷¯ŅęšsļÃVãÛkØæuH~ĢųđŪÂ;â Ō$Å8útZ×VclTGŋS´rŦΎâ)ģŧ rm‰‡åX¸ŅŖ‡âđ/*Ë魗yÕĀĢõ˛S;ƒ6ÂCéh›ī}šŠÆ¸ä9œ`ŪõĖK‡îŽtƧ3}oÂzõ‹ĸlˆü)š­ļژ´-ę)ōÕķ‰m›Ü÷ā 4¨3ÂŊ1Öĸ1Ĩgō¤ÍÛ)ŋũeûÄmm{>Š:öÂ<ũéOaÁûBēÃˇ1f1úΎ'Ā‹bŌ^p]ĐjX-ķWrļVåžĢžŽ/īŽö¤4¨ÛĒŨ“ĻņÅF jŅGĩlûeûāHļ4eTm@nvÁ? FÃF>Đ €'„)¸Ę¯8Ų‰Ŗ-ÕáMŊ ž˛Ŗo}†ĘsŊʛ4•ŅļŠ:uđQ>%ä)+Ą“˜špõ(qļæ5=GJļÄ%›3„˛Sb<ˇ´ÉžDäšdp mÃtÜx€Š8R3ņÜ"‹&Ņ×ÔúMâ˜Ēâ("‡(Ļ‹ÛäŖuK$ĒČũ5y*—úrH•ņØÉâč´Ŧ‘Ļ”ÄSžrÚ+ī !"eVåAI]‡4^^yĒėA‹4Ģ뒂€ĸéå0ŧâōØGįō‰¯hŠĨLEKÚļåíɟēĻh ŲņÉÜŲ0ģ0IXXņŒĘšäéĸTå¤bŊ7?ĘŽÁIž‡-pąŊGˇĨ €ø&^= Rå™ü$Jų!ĩäM¤YvV yÅ÷¨mWM`:¤áá=9}j‹ÖKŌ⇙ī€ÁĻÃIš€}iŠ ōŠ2ú8ĶĒ|Å_œÁ:•ášÖ!KÅĘE\ + åŪ¤ä0ŦА¨z돉%×´wH ˇė˜ļB3ÃC<| KĄOäēT—2Ä p¤‘RŽōB#ąRˆŌr >ņ}ŦøÁŌi4Q4$Į)O>‘A[ŲúizŌ8ãŧáĖ–Ž…éKaqä‚}ÁR‡}áΆ!4Š8ƒėHØ ÖeRGcUzõ! šEƒ—TWUŽJaAņdx2=DúÔ#ŸĪ“Ū(Pū›îô{ž æ”ŪaĨ({‰”DÔõžs[•´ôALŨ¯7€ƒL曐ąpėŠ‚ŗœí ĮūŸ1넞EĸûJ§`×°ÛĖ(ļŊ’šé{ežũ-l/ú¤'9/üņG9jëËTņŊ<ũÕPīû)Jc˜ÎāqöŲį´÷ŧį]í5Ėũ%Û2šâ‰'%n,sÍsūģĮ¸Ę>fã´×Čāú0ķQ%+‡C?qx{ũdNøķĶØk¯§g{Îcû1#¨;âĐ]Ũž÷ũ¯0ŋzįũpJ.ÍVŸ ™ē°!öēÎ3ÚUW/jcjķ—§N6IÉīíÛr8†ˇ‘ÃØĒP;XīĐaŗãąå–3ؑæŲĩÅŊãũˆÚƒ ņĩ-ø„˙ÍXEÚĘëņ´Öˇ!§åĖ™ŊĻkĶÉŲwßeúŌk^ķĘ:ũ°ëĶr¤Ũˇ=#ø"ī lĮ¯DΚÉVŽtßú–w˛ĩë”ö˓~ۑ0ÁëlÎ@Į˙ŨŪĻ )xwŽ]‹Š‡ōu×á|7a5Îų8>\vūā´M6f$—ŅnôõīŽ|ŅGØe#ĢysõĀ+ųęōcÍb=ôČm¤c§öõ¯}ĩ]ÃõŒōŋųåĶyãp7€ Ø_~(sןÆôŠ ōb&ß~Øy—­Ú§ûöČÃNŸvÃ^N>~ģ­Yˇû…ęMĻ—ífĒf!Ö¯ĶúķKēĢŲjô~ž-|JwW>Ģ <§Ô“}9¯›W[nšY†ŗŖÁnPc™ūŨļ‡oā/žį˙ö<[Ž-gkøęŗĮ-|ãb_ž9ÑvwŌ†īît~ ĖcÎÜŲL×ZĘN;gąæãĩ S6 ųo˛ƒ˙ĩûų'kMˡn@IDATnoũ¨oRūĶą)oöÆ&ԎްãņĘWīŨ~pä |'bÜã•Â7T2•qĢ-7â-—Ûōū™š”m?í`Κ5‹­‰Ūôéĩ[nâíäÔāúšä,úIrԓõēΝZ­3„u÷„ēKPŲ:pëë!4¤g[tuK{ûМ„ęzõw9~vŽą¤ĐŠ4‘ËEÉ›pqĶ!†˜õGęfîĨåNwCų0\<`ĘŌZgíRžú:5ļ[Ž10ÕԊŋŨ~ô2aÅ% |ũž’yĻŊ4!oI]ŌYyFE'ŒŌöę\/ 1‰ēŅ+ĸ< Áƒ2Šņˆ,ŠĻÆ ŝŧ=ˆ OBĶÎFņ’Ÿ¸†•^ŠŽg9‹_´ĒŦĐË ėv記Dĸt@ƒŲ•+ƒzÚEЍĢwŋacyOÉ#m•’‚Ö÷Ë[ąqâŌĀCė^úĸ\˛y…EˆXVr”]†…‡õJĐ4ÄđA'(GŸæ°!$é%Ūq éĒĐŪĐœvDŅŽ%cĄđδxh¤‘6x•rS*Cé$ŸUEģč¨^jų@VŊ$—ŌĒ|éĩÃ)hÎūA=ĸ(w€ 00ë?W°NBī(õįEąKÆ3÷báãW™—}H{ÖŗžĮüŨå8œCx]ū§l÷šëŽģļũ÷sˆ¤rí¸xņĢĀ{îškFŨO9ĨœŋgībĻܐEŽŒíč€ûÛuōôN†iŠ­pžNëíĄ‡ۜˇũŌ—žˆ§ ˛§øg?ûIîuū+­W2õįÅ/zA{éK^èß/X€ ŒZ>ōČ#L ē„=ø˙†ã0˜ÅŠĩ•#ˆ¸ĢŊߌ=ÎoŊí*`¯fJÄ6íųĪ{rOo˙ũß5ú?ŽŠ1ÎŊwŠĶ~ûŊŊ]Ęîā6{Ž_`ÕvJ–9L'ĘT”ûîŊ78v€Œ.G%ÕØ5Gôö…+ۛŪōæjOp&EXÁâ]÷<Å~ûļ ūr&ÎÉ :“âÔĩ+Xoq@ļŧúškBߓöå|÷^ÚJŒOĻ…0‡ÚcĮˇ~-ģ6}5ĪO}ęŗá…įäyŌ¤É,†ž'÷žŽŧâęöŌ—íˎAûķ1­ŗ@t%NãíˇW?øĄ÷fÚŌŋø™åYßf.ŧčâöĖgio_!O?˛žŊ ÅŪΎŊųĻčÕ¯z#s×īᛡĩ]ØēÔÃí5]ÜēžÜĪŧŽį>÷YčÆé8åˆĻ]õ#Ô~DmõęGÛÃ|‰9ģģ N¯ršĖĐ!3qÚ¯ŠŗøDvÛņ¸öēKÛļÛ<ztvp|įŗS’ĮÔŠSą%æŧ_{KžJW ąA”Ed(įŽLy t˜÷y`Æ7+VX?7ÔnQú3{zē–î˙qôíFW{7Ö]ü¤Ŋķ]īČôqėŒlšÅ””Í̝ž„…væf­AOSĮŅē`9ŧ7n.‹×ĪhķįĪo;˛æÂãúūÎڙ]˜v4„yūĩŽeįwfAâĩĄ*§œØ}ęŅÄv§Ūæ”[ãĪQåÛ5#­ŨÛnģm^Ū 5˜üØķI[‡îĨ—ÜĐV­šŖŨtĶ­Ėí˙åūÅÁLŪŲp ×m)ŖŊ^ķĀ)50,ųŽ9L‡ō­öZvíÚwßdʘo3~tTuėøā"#ŋ`[†‘]:i; ŅJ̃Jq¸mÒ^$>Ŋļuԁždyōû1â÷ŽNˆGWÚBÁ^Z‡ĸ„Bx9ö•øHØĩ“Ē×VKÛ1ĘāZÖĢ,Î:y,Š\‹ĪēgãL‡r÷ü ķ^)úĢaÂŽúûūJPâ|o}˜¤ˆ°>N~ļã}8ˇÁ1^ŧž¯WŽV{5ž—ĩĮíaˆĘa¸aũ}5L|žžWÄéåëųõtˆJŧמŋ÷ęa¸4ü™ļžˇnjhŖFĻ]˛“gˆe‡Ā5M ī“0O`âũ sßûÄ|$ÕÅąl,â@3֔˛XōIģ˜ō§\ ­ ŌÄļė8zčgČĪ)ÅŽ€Ō‘pŌ2\Úm')øØip€•ŦĐ Ž>,Š'íD×ōŒ„Ā+€˙¤YÃö1ĪĐîäķíK}1ã‚Ō—u÷Š’æ…€'2\á‹XâkĄRäΚéœ#ųS yōSI&°­4 jÉ7ÓųqŖé&2ôž0ĶáĢ™ĒÜāGTŌŠFÅĪÛ GĪåm¤ŠÄŅÕX‚KÚaáŪÄö/,ë”gÓBqBÚ+ŋ¤[z%Sđetāá鄗|ÕP„¸e`…%ēąöÚC z•C/ƒÉ‹B ÆcŦ•:R¤ōĸwų‹č¯;äŲkÎĻ1î‹` ×ģ÷˛+ē¯0]uõÅL]™•]2.\ČÂČ7ˇŊžņFĘÎ`ôúü8˙o|ãÛY´šū<æĀŸÛŅ•Onsz´Aŧ}á8kũß#áįw&Ķ'ūį˙‡?äƒO8N⛯üjūvM)ĮíĒĢ.ŪĀŠãątÉrFņtīCŽËpęŸÎbž§āĖ_Đ-ˇäã`÷š¨ĩĩ#ptœ´Ū |ČJį˙k_;Gcæw×bØ ˜Ž_üíGģŨĮ5_ûúaų@Ô 'ü:āK—>’ëģŪqsĨ1MæŨíU¯|}æÛq¸mŪ5đú %wnī}ßģŌQøÁ‘? Î1Į“:=ûŲĪĖķß˙~aÖ´6w`—GØŽNɯxÅËØÖķ}í†˙};'ķļå™ųÚđ]w-fËͯ˛ûŌļAŗ“`œvÚ:]–PåÆ-2mX[[|×Ũ™w}⁝Ęķ_ūō§VÎ˙&ėt:Nī:ƒŨâM ž÷ũŖã<öÅĪ^Ynŧé õŪÁâ×cڜ9sØRö´Äyrú‰ōœū‡Ę+įqvØgčô,Ėw öÚk}{;›^ŲÛŧyķ˛pUŋ8éäė`ãBpôÛnģ:ß&8âˆīąs+dZC'@§§;ëž))XŪ‰C§î a>?ŧlM>B%Ɖ|pjō¤I,ĸ=7ūqũečújŪŽü¯ߚ­M§ŽÍ-\č”ʰwm§ŗŸ*áj—č÷uQ€é„(O?§GŠ 4°Ą‘ēQ!ûcũ{Ãxā÷ëĘûígœ…]믇ęžÍvĩ7˛¨ų h—ŪŨëŪĐ_üŌˇ Ķ¤SØ9^öĐmė~3/LO<á—YōÛSNÍķ 7^ÁÔ§ŌûŲg“|8öØ—0 čå´îúO‡‚wƒ´ėÍŪō­× š`úŅ5 ŠwÎbįŖŗãüģ]ëĖ™Ķ3zßSv˙oŧ‘5ŗŧˆ…ëëá†3ˆNÕâģĄū؅č”ũ+ŗIÁŗŸķ,ŪŌ]Mųģ°mžŲ,D¤ sĒT5mo 3ŸŖŠQŋĘO“`û(XôožÄ°ŧØ•ÔHė€<,Î{ŸŒ­vĄÚķ’éđ3ļp´Ų‚7T tŦ`ˆKÛHt|°]7q8uĶøČWņ†چbšuÃô1”7(CŠÛ†pBš-+Cyƒ6”7{Æ ËũāØę0ĻŦY§Į+¯Ą–­w(ë%¤zā ÂÛĸšÂöĪEÛ2 -ëÖÂ_ūĘQ?é[N¤5LŲā1Ly AūNV;ķōr:šTßL†.t¯G˰aō oî“ÎJ‡iYđ~ĪËúĐ×:ē%sô¤ŲĨũ–ÔiUîÄIĮ_‡K:FŽĘ×Ųōë[`āKīŦŗ‰¯FŪZ$ąũŠz“T6‘e—˙`Y“öQ‡vĶÆ­Ģ÷CĘkŨ[7zĢë„”Íam†ÆīŸčŒgę›qDčæ3Œōۇ‰å‰˜Ž÷@Ÿ+ƒ¨ŌQĸøt=Oe&éœÄ΂Ž<ˇŨUxîé°âs3pũd$‰p°`‡ŌHĪa*Ė#4âįņ–`ļœü#{—‹5ȁwÜ!ōĖģm~>†eÅĢėĩ؉´ ŸUĢKįĮsBĻi8ûīü$˛¯b$uŅĩšsv`Į3pÖžÆÖ˜΂ÅĢŽē2ŖœĶĻnŌļaŅā˙xĶŪĘ×^ˇcúČu™âŅë”=˜đĄCgŗĢˡځŒîųä=âŸ{îilgē- N:ĀâÛ¯ãK§_dŸûĪļã~ūãöÁŊ'k œG=lØFÖwˆÍ|áķ_aôōö¯ßƒQÔKx“đ[øŋ'ūĻ=Ė‹C5wî\ĻfܝpšÕhL`>´ 7/šøŌŦ)xõk^˜QĖĩ߅•\0û™ĪøŊ,âŪĮ´1ZŊ ‹xĪ<ķxč/`W™Ã32îT"§NxظÎŋõ~Lm:_ķũh{.ųõÎwŊ=__vĄ°ōoļŲĻ|ôk!ĶUČžC;æ§Įņ–āāŗ˛ˇ˙GY㰀økŽš:v=mÚ´ėærúégđEãe”^G}ūŧųČ3´ņĮãōĨg;RÚÉ!úųw8#Ō˙ÉŪöCŌĄ8nÛŗ¯ũ÷ķĻâmo{ k7n§p]œo÷ņ÷ãqŋ§ÃņZžm0ē›&d}{2dHu2}Nųë뛔5C|“ķ_ یi_a‘û\øî—¯ņúm‡GuŸûųw§šË/ŋÛ{'ķÔ7'_néʁ͞AáĀN­ãōĸŖWŠļ9˜iO+ؒĩFŖŗöDâ]=VŽ€”ãޏÛ9ļôų& ˙ŨaųÖáĒNĪ[ėÂöœoÄŽ7aˇŽ7ķíˆ'QļčÜ Æ(ųļûô§?3QÛeį'ˇ+Žŧû_Ԟü”'3ō~skÔÃ˙Ū/ņqŽ-ŲötŋėîcĮßômļŲfyō}ļ ūîwŋtZ/ŸÕW ¯ĖÆųĻÅCŲĢögÕNeÕžY™š­†7ŨtS:f:ÛM7Ũü†|qx3ĘÚ vWúYĻâíŧĶžíĘĢūšéxOxÂnÍŠyv`ę¸3üz}X–­ ­ÛŠíz;/ĮķÃđ?ü+•Ž 6ŌV“QÃŗ=˛uq*ũ’ŲA.ōŅŦ˛~ˇ.2å¤uu5!ŠëIzڍĒĒ„*ūfš#¤€ŠĄĘBė›ŸĄĒ(bÄet č΄„WŌV›>ØRH7­GŌ;rä`Ū8ŪÔļŨ~ˇļĮ“^ÁW¯7Îē¨L’ũ˙üŋŽķs ĶčnæÍÖO˙=ōPF…­Ņ~8˙žėÍü]ĪbČúø :Ž]ÅR‘IŌÆēk.yĀv**Æ@9ąÚÄŧtļCįuC_E/L?îZ×nģīũĮXŽÄ;MICĢ|Ĩ ‘/é Üō?2ļLšŠuxËŊFûŊƒ‰eÁō&ā#GdânV—Ļ™¨ô:5;Ī”wq’&Ű_ä —@` ī×H¨|°JĪĀ:Y•'S•’˛ÉUŋ0zŌßS ã=§\f@&hōŗœ’‚_zĄõ\|ˆsüg’¨ô=Ž8 ”JŖîâËŲ2˙8sY”„ų—?ÕSAāXy ĪÆ b Đ?C]Ԑʌ°2Ž‚ )qÅā$ī {:ú&• HŽ|WĪ“æĐƐ!a¸ō™é(Kdƒ˛éˇ÷iãa˜- éy“{ΰ-Ĩj2‹=ŒmîRY¤Ą\¤ N@ûČČ̌KŨ•LJįŊ1Ō,đpƒFôđä¯\OXū¸íŽĸQt RŌ2ęĘsž ^¯š*wSh扉VĖQŖÜĨãnœœķßŊĩ¯ēúīėâ˛1‹5א†’SōŌĶy>|SLF3jŖi°EāDFŸīĻâYC>3ĶqœüģßĐæā8;ēÆéĩYŗ7aÄmfÛGÔÆôėŗ˙”šđö†§M›ãŗÜYl]¸Üŋ"#sœÍwtąrå0>õ#…émî?‚59˛#uo;čƒí[ßū2€§åĢÄ\p!NĮÍ8´ĢŌČΘ13Nđoû›āûEÚ?žqķ•ŲY„/Ö>´t ÕÂökŋ9f´Ëé¤üđ¨oķeŲMÛ#‡–ŗ Nágüši sļ žÅ‚: 'ũâ—ėA˙džŽŗ8Ú÷ļšs7öf:gĐPŽgj ķŲgM$wĩ§?ãq~ōãŸļ‹.ē€ÅĸSĩaw‘e+Ą=3˜ /ŧštŽc*ÎÆŲfĐ=ōÍŅ+ŲũC>Ň´ŽF†mÛĸ;–’“éĮô›ŅĖ™Ÿ™ÆŲÅŧžÕxÉK^DžĖĶuūųį!#ßT˜1›-?īá{ûƒ3gx+pĻr?"ûÛŋįŨ‡0Ú~pîhô õŌ¸ŧĩŲ|ķMqž6Å6lūå¯8ęˇ#ûx:GŌņ¸§ŽoUĻNC‡į>ė3øÄlė`2NĖÜęK.š¤Ŋü/a^ôĸļ%_|ĩ“tä‘ĮBgz;ëėSÚ-7/ˇŧfLŸũē'üņ?˙EžĘ;tđŦö(đ˗ĀöÎD/[ ČsöŲfôūFŊkŲĒõ"Ōü¯öv6ööRŋ"Ę;-ã ŗˇ“O>g}oVĻEãÆO‡ëđÏH‡dģíļęŌį۝Mx#°Ŧũí’Ëqڐ¯ÃÛZæÚcĸ)+–A˖äU̜䁹,†ūQž'°ë[ģúĄ3;dK—.ŖĶō{ž0ũđ– × ļ„Ŋ;åā^šŸ~Æ9ųâî AėvĩŠÚ›‘u™Ą~ĪĩüF€7*2ũ…Žņ5LŲZģv, t­1ĒĖZX•Xīč3›/cÆlD9’/q›oÚ§Ũ+Åʜių(ל=OBÎ øőąķŌ)KŖGÁžoj‡|øčđxėn{Ū–=Œ/aā.ô?ƒ2@ÎÉžË[ŽŽjŋũŨąĐZŽ=ĖæÃmĶ™4ŽŌwįcd_ųęaäįN”ģ™Â0^ÃČã‹č(^NGel5?˛ü6eō4ęĄåŒęŸKgyŖ¨Č΂įGš{Ãۛ;Š?ūB:†ĩŠoBŊqtĘÕ,ž#˛1kf˃%lģûīÉ75>ÆļĄ[đ&j%r.iķoģۛžo ø…ķ;7pWŗūakę…ŋˇ ūr~t‡ŠȡžkVģ[Õøö÷+ÎŖūvFĮb‹Ū×°x[Ĩsā78(0e#H•6‰ Š:ŨŒĄ.OƒŽîÍ^ÛbĶî oü˙Šëŋō˙ŋJ÷ŖķŋÅ÷|˙īÂũŸÅīųõWđF°-ķč&Röfĩš›mŅnøĮ-”÷•Ųla-u‚‡¨„ūSŲ%ö/+Š@|Ų JYŊ§í´ķnLm{!ƒ>ķÛņĮ˙ß`*eåŅŧ}ĄŊĞĩ;ģ*+ÕËæú5%ÂÄč¨'ĩo7†‰íåÉrŽ„nh2ļŦퟭĶÁ“\ŋŧĒlu˛teÆ2U~Ĩüü)W13Nō5í§“Į¨n͕~HôŖŧŅq ôe3aĘ ŠxŨ]ŌC á=ėOáƒÃIZq”oZō„§aęA’;=¸–3 ŧŦ¯ö¯Ā`•Ô8¯ČĨ+qĄĻäōÂĢSŲ.”%"‰‚BdĢÔĻ\FšPiˇ51 ƒtcX?×[ÉĀŧrĮƒėtŅ`׃Íۘ8Ũ Í˜9šÎÅŋOËFā¨į{XΜûŅ8nׇפI[áPŽÄ‘‰žÆ2Īû’86~3`ĶMį3Ge&Ŗk~(ˆîĻ7sæXōåęā×iGŲ„Í™ŊÎ˙2>ÍáâL #͕ž Ü8ō˙@î6žŧuŽwß[pͧoC>—œ‰Xī4gÎNŒÔ/%ũæ;ģņĻĨĩģփؘ{*ßVķØ§Lچt.g!- xyĢ2eĘplCøšOŋ"ˇ.}x Îü§ŒĸSą~ē×aMæM„ß xpÉę6v”včäuúpß˙ŅtF'°ķÎ…׮C|ÜŨũÉ|oā!œJ_ųĸÁdÁī˙Ū<Ŋ˙ū›Baʤ­ąÁ5čáÖ<Ŋi[ŗŠrl=hÆYF([UAFųw}Āļ°Éŧé;ī,Û.qfqšŊnsžC<ļŨy×ŌØČŨŲ׈MÚļĪ\ž|5ōZ÷Øa ֝”§.ø6tÅĒųĄ2q–™~„Ųōo`ŧõyĩš7cĮ Î(!ī(‚3™|{ÅËÚšëū‰ŽWŦ™—¸)3wßũ0Ņ‘ąÍ{î)]$rŊ“ûęßžĀŠ2”UŌûā’Ûrŋd:ŽÃ¨ÄŨ~ûŋ×õÜ9;’oQ.u6vwĐ'm´ ŧŨ⭿ĉ#ÚŊ]~´6‰7‰1ÍhĨšgšÕ(ė­ėzäČÍqĖ˙Iū˛Ší¤˙ŅfÍÚ!‹¤uhƌÔņ­5'ŽéšđÂŋ˛č˙Ŋɘ2eĢv?å4oNÁ_Åô°mˇÎ›¸KÛSŸ˛œ$8+o|srËÍÄÆ´ ˛Ā ųPuō&ųbfi;æ˜yĢÉ7x2÷ˆ!žĀ&K0“ZŲÆųÔ[ląÚgŠx€Jĩ*ŽûĀsågŨ.q0äĸegNąÍNaí¸Ķví¯|1ĪŖąëõφō˙ú˙’|ç^zÉe|Ąû8ōĖ’­lķĻŊÄA'ĢÍmũŽT_XAūc}Ô-›LÍģkÚë_ëYžĮĮĪeģė}ØäaĮvī=@ŸŖĄ‚ÍP>c§:äúD*‡Ø'mĪC¸ōYô‘CĐļÄQpŽøLú†õ`ņåŨĀI‰Ä‡ŦŌIÛ Ą,k–Á(hŽ‘ø“ōĄ\!dīĪ•ßVeSųĐeRzūY"…hĒ_à Y φ‹O¸2p¸¤m}ã|ƒr8@Žøi囃+"0…_ô ĖŽ˜Ēl|€œĪŠãx–;ˆĶĀkß6Ē Ĩļ_Vüģm‰d“Ū*)ĒāYū Wž;ĩ†”€cŒJƒĄÎ¯‰Ė Ë?đĩÄW9b.8îŪIÛŖđŊ‡Z€DŲ„%%+E y(˛–˛@‘`Œ˛25@`™&Âģ?ī}ƒÃ‡ÔsOē… Lĸ ’ pĶĸڐ¨¯^ĸR%ÔP%I&!ŧFYŠ[úGŊ‰)tčŠË˙ƒŊ7ū-- üNßĨ÷}Ĩiē›}§Q‘   ĸhĸ‰Ų'™2ŠdæĖ™8U“JÍ$5SSŠŠ‰3ę2:ÁQ\0ŠŠ( "ģˆ44ŊoôJ/÷Ūžˇīí%ŸĪį9įw/MĢ€S'œßīû=īyßgžw=ī9ßFĀK-ŖŖI"Žļ”°ä&ąÉ*ˆˇ–,ÆÉ ڕDĄhųÚlWÛ(FcŦ‡×;—\h_ųFŒáoąđ­'§°åãœķžËĒüŪ€áļ:NVÛ/8˙ xîæNĀŠüBíaG{÷<˛ÜxÃ}m‹q?â 7ÜÀžģŦT?áĸ3Á;´|áöȲ•ãŽÛ839‘‡^t(Žƒ(Šĸ&‹‹.zûĻyÅâŊûģpņÎbPæ^ãC  xũ!­ÜanwŪqÛũL(žŲT}‘wąŸÂ*ɝw>°œ}æĶŒĪ`{oKpA•õĮũ˜Ŧ\üŧŒpë­ @(Čüځ N $öՊq]ž#Ë9Ü 9ûœ3ßr?ös"ŖŽģ°Ëã/|V´ŋĀ>ás¸ÜЎEŨå[_ô:ļ´<{ųu~ÜËãiO}*Ģ+÷ô°¨ģš và û˛™ļ¸Ö:üāĩÁ25`Ōp+ŧ.7aË]îeÛ̓øÅ;—^úîZâŨų÷r—åô9ŖÕĪ={×kīÂÚՉ›@_Wųä'_Πņ~O_änĮÉŦÕöß<ä ŲIęAHgœ~ vdĢŌöõļãŽs‰…ûîãÁYhĀžS߯Ŋ÷ø]Čp>aīKyĻánüū@4ÎŋāôåŪ잒2Û¤.dRĮÛnîf ~īr AípŨõNZäuŒ§.gžs?ĩošŸ;2'ķųAāĪ8ũTVŧžÉ¤]ŨúÁ6 ÆˇđÚGœ<‡øØË’Á÷ĄCŦ&ŸLņÆīJ|áb‡ŽĩĖüÄŨ“/{~ŋâzãōd:rņ9øcöx;HÜŪ]w1ĐÄxŪq…˙f~ ÎģCļ7îKĩ^ÚæN%ĸîŽv¸ī>~]úȏË9á…›€šĪ֗u7Í­nˇ~Áģk'V§ÎcĸvûĢī¸c_m}čÕŪa÷Úâä0ÚĘ_›9Ų¸ƒX;Ž˜pÁVÆÖbÚ-ÛŪŊĪ„įĀž‡¨Ÿg00~ü˛zëā×^ÚeˇV˛ëEȋYÍ÷7n§Ŗ÷!ô/ŪÅo1œē—׈ž8#Vîđˇ ö,—^B,ōƑë¯ģģŽoāPßķXUwËÚmwܡœÂÄîAڞ}äG—a7Ũäd7ŧÎ"Îvį7cę |O9ųTlsą{ÉÄĄö‡Ûb:8ë˧ō°ú‰øã å<ŸÁ`Į˛;šd?ßî&&îouĐÕųģnŸ÷Áë›o–į#Üi9›‰Æņ\īī­M'ž°ĢúÖYgōæ§K–+Ųb杉Ë.Ŋx}÷d?ŨŊĐw°c?z:wûü{ü­ŋũ7;˙ÄOŧĨ큇‹!B™cÚېÚdÃ$ŠKäĢ^ŌaÍ1ŨĶ jlxz5b4ėˈ4bAüĩˇ!.Ĩ#îô¯´ôūLĪydũ XNŒí΁ˆ4kę!íÖËŨ§ŋŸxņnĘĢ_ķ2INĄŨ§ ~,)ųÆņõgīJ;žxÖsžšŧčůZ>ōĄO°ĸ}Ĩ~#ÖpqMĐ>žā Ķ|Ōm#Üqđš?@8&:œ š‡øô’‚𭍴;´7[ɑō|ۖ­^ŖŲZ2c™‘I*Žī|88â%Ģb­„ũԏZģXĮÂą7Ļg¤8ʤĒfŽ]›ÅõL" Ķ€N™§NȲq3íę6Væ!*˚oDŸUÆí9ԇ<á; ŧŽŅČŲ´¨Ë­Ņ”=õ]ÚËØNÄE\Úc&*0‚Ö߄II 3$ĸŲRΌÁ)…›Ņh )I0ģhd åŒLPŌÁŪ:Ââ j•–ÜgĨÚk’„ƒÆ$›ōôčÔCé’M)Ŗ6is1ē #›vÕv„Ú“2ũsö¤QĶ5TŌō!= 0Ŋ78a€Ayƒ3-<€Ũ ‚u&ģĶ0ŖĶ4žŋ”Q ¨ŋų~—8J°ÛVĢâŦą°}™ŌY3ŗŠ´t¤ú†å8øU HĢwÜÃWŋ‚Jã§ŅČú ‚yø$7JRēhYĄ= &bØs^)ÅŖ ÍÖTFw20°R9@”‚¯œŧ“„ƒ„ķĪ=­NÜÖkv˜ˇÜ˛Á‡ĢÔ'#ęIë á>|Â*ƒ1'KŌÚˇŸ7Ē0ȋ(U¸đ*;{ËO=íxV—y§7w$a•Ę[ņŧø°AĮƒäī‚Ö]wŨO ėyL˜ŦėâívâŽâ6ŠA~Ÿ…đÍPUXlv'ƒLģģėŠŠ}ũ#ļÍĻ(ēģÉķD^SyŪ9§ÖpĻąŊöšģ˛ƒjC4wß}8Ųá) š¯ä}ũ˙oDzE¯øŧķŽ;y¨ĪHøŪtŪ‚”ŲÃØØÃdës>(æŠę‰'=§2ˇ•xgæđ2đ×Cl10ŪüŅ7ÜˏfíYíķ“„û’gī^ô@?ˇ€hÜ{y…ĄUÛũË×\ũEö‰j§sķÃđ•ô÷oô8˜T'Hŧ[žWÉxwÃ ČöĀo6Æs~ķ„īNāĄē/2ē—Üéܝprᖈk¯Ŋģ&ĘĄ: ™­—Ŋ= ˛ząÜxŨ}Ü Ø“}Ũ‚fgv58x ģ0HÅVģą¯“€‡•ķÎ;ē&Ÿ“ã™||IŠzįh7ņæļĄë a;a­IŅŦĐU.×\{/ƒâŨÅ.`Ā{U÷#đ;nšũNb9{­*¨ˇđ[  {-!€˛‡U[ĒíŽ]]‡õ ƒø0Ūaļë\{Ũ=ÜIŲÍŨ^-züÅžæ|9p˙Ų_ŪđŦĪīŊI-“ImS‹cΏ€X—ák}ĩĶ•īõøŨöƇį!9Ę jÛÉ:i Šyk$ô™+0aåMY  ˙rHdĶw0‘€ˇ6”ĩõO{_eŦđë¸ÖŋGhŧ˙ū¨&îãęŖ[æNbā>žņ÷w/ļtr¨N!FĪĨ^'wßíö›‡¨ÃļĮŖ—Û›nŋõ@ēāęđÁūLFĻLh~ ũíû¸;¤Ŋ%‰üãđËŽģæîå$îđ{Ū™ĩIÆî­Đw‹–~4Ļö7N0žp‘áąŪë¯_ž…‡ŗ=ŽžæV&ņgd_ÛBįÂÉäĄÃ'.?ņã?š<›Öå—ŋ€­GīāîŪo/—põVîŒãÖRĘč§ųĪÖúnĄĻĀ2œĖ5“;Ņ‚6ĸ˜^o¤TwÚPÃ{-hąGųÂėÄøĀÚîc!`] 4ģ9fŊ.2ŸĀŨ˜‡îdõ˙™ü:÷YÕŗė ¨lŋq|ũZĀį¸üņģ'?å&ŋO]~ÜrqĄÚ)bːŠ=!QO{?‘hQ$Z"ØÎaœŽEŀ[Ŋí?m_¤ģí÷uëĶЊ*'Ú÷"Ø/(ąHŌržĶČwębwV*ÕúĮkFģ‘<1<ƒkŠøG)_[ 2Ā!p]4iėKŋäĩ֙ōD“üꏲĘgFcŌ°ÉôÕG]_4(H*ŌÁ$„@ŦøÖԐÕA:j AĢXRÛŌã‘Å—“€“ŨÃVrwPėĐK˙m8šžÍĒ€Ėü×8x1(ËÖXd8¨ôZ¸˜ JJ áÕ/ĮbŦiÔįu-]aPT9N;[§Ēäs4hö’cdĮd N)F3ĄlŅQųHŸ4€ĒQ/„ūICĐ8pPē…Ô4Ŋ”ŒÕÁ'm&2WIÍfwr’ ȟ=w lŽßč’-ĩŨčēUŠ&)Aü#oj˜¤—Ā­d:˜"(˛ĩĩî[~[°lúJ¯a÷‰]h9‹-–,XE=nĩyƒÉ'OØcģUŸr(/ašM"ŖīíĐędԕ\`|°v?t'vĀúRiåĩ‡ˇ!t/+ÅĘĻ>šÉUÂâŠRWUŊ5N0đa#ŦzŲÁ{›|?ûä z°ŗ"ŗx§Ë–C Ž…WĪŽN@ž^ ŽÖcwC<Ĩ¨pŲ#%'xnŋ)_šÚ?ˤ9éš#á$ãnVOĩå>hš1`)ÆÃNqÅŧ7Šžv?6ö-í/˙{īq-ÛŖØöĀjŋƒÖXƒ+=i9`ōEÚ÷2¨qd‰6uĸp<{rõÁ&•úVÜ˙ÃŊšTÃíbãōŨ§ađMh¯‡°ĩ6ßÀøûË2m%ĖÛü0wÆnĖ|×ŊuČÕo†ËÕŠSÔ¯ĩnS —ú×_Te¤ū“0upÄŠā:đÃÔé.öÛÀųKŅw߇ņ¸Ļ´­.Â;ČTíŦīXr(ķmß­''0jŒ/Č jlĩŗ>’ž96Éģ÷øÃYūĸņÔ-íŧĮĀ‚āÃŧWÛ‰ČŒ6ÕÛģĒāŠÎ—Â`ŖäR_Áz%4ą5ęū;|M#õŪúo~áč¤Ow‡ÄãĨúÚé ´đūww[š[ķ}ƒ Y‰¤-ŒÛ.#8rØ\‘5ĘÔeĘ™Ü c[J‹J>e"[?‰ŨšŖQ'zĻh{æ“#ë fņŽÂL|ˆ}xh?N´÷ķ*RI+f įûđ`"øwęėž­ÃŪÅRŦž†.=ËĨ2Æˇ2Z'ĩG°\i°ĪĐęM|ÅąD°NŖĖ~P?ŲŽ‡]_ƒë/Ž,.ÖX÷žq|ũ[Ā:äë˛= qWœkS ?føß:VŦáÛyQ,ŗĢRë÷DŠø3@ˇõ“ž‘kîÄpôÅtwųrǜĒŊáĒĐ&ßã˛mĘÖõ5ž¤\ßÂĩuFIŒI9÷āM^ĩˆÜ ××#|$(žX)hķŸlƒm°eQ =­¸ãCDM°Ē¸”Īí7Ģ6ą´ U&æËąā.;Q׊ãĒ”ëÃäsŒoDW_HD:~ņņΟ—]/Ė‹ĻiĘv3Đ0¯ ĩĮ"ž6č3ũ*5‰(YwFļ˜r`¯ô×H!­Ū¤;n:î*öĒŖ6+f‹dŗlĢ?j W/ofKĘé§>mų˙āōųGĐe™ã"Ūäö(ˇŖčE(ȲTĪĄ`&Rˇë¸ŊäsmyFú å+ļŖĄę–xÚwĻŌuõ0í´ūiö:}@¤ ĩlEĖ=0,Ī–ÆÜ# ŖAūŅR#L#iž3Ÿ/rElĢUF)›ņÚJ­ŖvŽm/Ŧ's§P}´ĄĢßĖ#uįBXúŧÕnāÜ#+yå[vžš@ˆn ‹imZC.ũļ:­úž¸°ˆ bŒ ‡*L‘ LŊBQ¤œļa{¸~˜UuÛãļ^=ē ˜í”™tmrĩÆŽkô.˜8)Ŧ^ /[.A%Fņ”~^B'iЉdŧš‰kmÕV[īāÚ†G.Ä‹žŠb†ģMbLg Ž0 KfāŦĢʸĮX@f§á 4[w‚:“~|0Ô9…ꆒQļéŅ'8i›(c;ĄW$ĢG%E&ųq;˛ķmĻÕŖlĒôĐÁ5 Ūœ=‡ėžŗûīÜ;đNfw ëÛ÷p÷āȑKx•ņëdą÷<Ô=ÜIđ. ˇ)ä—ü:ÁPį¨n`›i㑚ڧÅS8ŧcô{8)LC@ ûÁ|_Š:ÍFģķŪi NPhDūĸæEãXŌÕ=|@°=!î8}Ŗ[–Ø~Uļ:iÔ7|žq|}[@Y‡N?ãtR'2!§vAĻH‡ÄLmõĮ?cĘÕ{˧'8FŋG웓öN ūÔ}ĸĮļĀöŖĐ5Ž)ŋØ3ČH•kš—ĒÕąö^¸.,°ZĖyĸ–Ļ<ÛČä­Ũ–zWũ‘v4¨-ÕkēMé…ąũģyŽ ėå¤đSˇŦļ?–X¯J÷āqŽBĨ‰œpđė\žI:YÉŖæęj[k0˜ąå#Ôšøš´Ёgú‘ÅH+žicđ™ō8æTæFuę#!̞tlķ@ē‘„0ĢĀĢ ‘] KĮĐâĪĐYCŒ`Đ͆œę¤mÜ4čJ‡„čáį˝$ÄO؄ãrÂMø~ YÁ)€1Ÿ2L Ę‹Ģ•n‰Ŧ1ŨC#ގí5RŪä’cL§ŗKœ´ŌęD‚zV@Ā ĩUXĻčāĨQĻŽĒd: v†$üpO^K|‘É@ĀN6¤ķkÁcƒãZâv0ĸ}8É{¨›P‰U˙ĶŠ‰—‰ &0C&rē ‚rB́I¨P̊s=6F>ų G:v6ÚpxÍÖ0s„ _"ųZÉF6K-DBI‚[Įˆ ã›×"”4˜Å{X:Á;„€‚É7Zå+‰ŌŦ&[ą„ņZ~&gĀ9ĸV<Ķ•…t„›_f´TD€9ąAat&j†ŽiiėØÕéŧ6I7Ĩ•>UbĨzĪŽĮqâ:ōxSŋnë;ÄŨ¯ 8‡ąĸßIzͧIëŖdˇzž €Šņŗú°ƒ¯ęŒ>.ŠÄâL{dékQąš*Øü$W6j@(ĸ"Nđ^KM^ÃS)šĒ1%ŒĖ&žôĪ@ķ]c˛Ųŋã?-^y:ÕE ­EƒđV…#r˜j‚ Ŗ`á:ĀÍ$QSG?C'01œԋŒv:¤24G­4ŦíüŒƒÄ§ë“pD3´v#iGæöMîöĻ­%ćLëšgžĶičCeõĨ<Œ•´Ņ”ļԐ rÃoö˛ä@š\ię×ø$PĻÜ|I7˙b>YuwjF‹˛ígKŪĨ—žŊ\{íMl#üb?ÚJ[´ŧkæ6@A} Ũ–īŋ߇áoãš‚gņ ŠwČ/ģO&bWåPKÛĻIiøx›¯\Ķ?Sˆ~ŗvO7n~#gj#ķÜŲ˙HY;jngôÚļĶxŅŋÅ2t´Š–´k,Ž0F°¤Š%Cŋ;.~xÁƑķÎ? Īrø0‹6øĮסŦôkgō\ËîãĮ]áÃls<Ą;SFKœųŲ: O­ÜYŦž=ŖŸAågķēū'Žŧĩ¸2ÆĸõļšR˛E áečS('m[)ģā9[ŦōJ[H'ņFv÷ô×3žYĄáá2@2°cÜĨÆšw Moĩ¤qéš'ŠPƸúNÚÜu"ŨĨ˛lV €8ž6xõ°ŋĶ|pæSmâzx§ie×vČTčpļâŽĮ´uBIsÚS%ˆ<5ߨ&dŌōM•2 ZŽIkDÍĄƒdĶĒ+Eątoąj3q €xŽ‚^*R䭹┑c'1šBHZ~āT@ü5הM‡8Ęę,|8Ë*C͏i˛§T•W„H§ŠGÖČŠŽŒÖ$k4V¨ë5wdIËÁr5zu2EąŠĖpÄ(ͅdÕy$Ü*#eu8+Åf¤`ŽB%ëxAYŪR*%+*&\+Em-nīĖ…>xHûÃËUm\ÛĢ¯ŌŠĮėņTí*[B°AŒåæéG¤wëXޏuŽâsĖĄ1=ÔõuʲõüŌÄâ |´Ķî ĘˇúVÍ\AĐn5ņø!ŊÁˇ7s5[WlâČ­ÍĘę¨O 7´)Åxŧ–$ÆĐœįQ„Y?æ­r 0üõ‰ņ¤]&ÚH?úžo…XÅ´xUž<åS?2k1„Ûą;9aŗú‰ė{>ãŒįōāéCËn>Čļ„áÛ*€ŧá3ņ1K)Īgā,¯ūĀÉ6ļUí4ü‡FĢ0ASˆÎų eõ§4fj‰„ŲCš#­c}˙ôמ×Jˆō‹+ųŌ”r+Į¤ŊޏK¸ ‡YÅáč¤įđĨE R*›Ã¨pxģđ-ŋęŒ– ĨoÃ]Ü$Q%æöII )WåĒņ›˛­1_‘Œ “[ Ā c:5Q$íĩ{\Á×ĻrÚp™/ŗåW)yIÃĩr›ŠūæĄŸøÔ7ŸųąhôĻ äŪôBžPōJoŽŊ{zļÛál^ Éą´Ĩ>uk$ˆLļN“\ŦãR,ĄÉō:š”M34ũ‘›Ä¯­—>ŨC™ûA+€Ņmë@[\ôQ”BEġŪéŽĘā–¯ú°G=B€ž$sYJsĘéÁ[¤­‰ŧōŽ4ŲąĨnÁđ“Ÿ ŨkŽž†ũī<”Îáī:œ|"möí gō\;ĪǜÍÃčđ É<7Âŗ{Ũžg}Ö'´įę5z*—mĘzĘU9y]p%ûUßė öCč=qjaH‘Ž—dB…ƒ¯lŽũāT|(‹Eä š$<€Ã+ģ Û=?1ėÖÍ=lĮâÎ7w›Ž!%ųãëØƅūōųLJ\-Į¯3fšāÚútãoę›qR„K šÕĪ,?asqcJ\c3qfØÕÖLPƒA Ī:cÛ"Áĸ<{'Ŋn›1´„q§]~ Ęˇ˜O#šøÛ–@MøjB/Žō 2E›>…Ļ>R–ŌĐ&IŽĄĨ Ę3(ų‚¨¤´¯í~-gqj;ä%j|d)ļDĩƒŧŧ€kč÷ė€0kŲĀS_“-0Q¸–ĘВ…yë×´c\Íž¨7å5ļŖ¤ø Pa¤ %6rę)‚×ēĮa‚Y62vZ“˛Z6æ n™ę‡<ø ߑ‘FŠYĨp0…ŲøtQ^v¨Ōۘk ÉԈ¯ōKOÃø_ûq¨‹ØĘ•,zí ‰xČ<†v ‘œō°\”ˆh`ĶR‘ļ”ü3)Ŧúiõō ”ҁēú3˜jƒŧtĢ%:ÕLŠH?~#ŗ2(ŲL™< Ë`â)~l%jÅAJņųh¯ (–ŽTĮwōãMĸ^ŦvUˆė‰Tc 9ÄæĄM!ížōŅ?ĻČ Í(S5™ŌęĢŦšúÄģFV˛Č'Mv(uV„ ۑŖĪ`aP ŋÕ2q•GzđNŦÉ´tō°ÃčÉu˛(8ņ “|.lķųŋŒ|†ZõQYmW ]ä𕐒&xÚNY0„7Ži ´—’ÉΏä¨MņRhtã–ŨažQđøäŸæ7;^ąŧô%ßÁ/ŋsšāŧgķĒ`ž9Q/čĐ ôĀŗ¯ĻĨŠŗJni õ͂$Ŧa-Ɛ˛~¯áSā$TyĢ‘Ļœlņk $kũļūĘCÚāÎļDŊ7úæ'ũÔŨĢđΨ>FVÁųkQE_đß Í(A<3ã_Ų|ãøú´ĀŽĪjô/‡_%đ§AbûáÉ6ßÃúe]3žœš4chņM 96TøYÎáŋØ3YĖpeŧKäZÕiŒ@“ Œ8ĘAžĩØ*Ŗ\Fš˛Y›ŧ/ŪVFŽĢũ€'¸™Q…‡0ÄđĒéô=ĸÅK ŅW˙Ą¯„ąÎw÷ HiX<ŦĮÃS‚A"_ĸJU}75ô#Ž€Ã<{´]U[Š­čâudB\ĘÉS„á?<õÅéēį }V›„˛!Bžqčڑl†’ßJJ$JHBi䑈2VXū´”Rņ?Ā|ƒI˛Ļ4¯/t‡› 5;ƒ.Ą‘gxŽķWîĐÜ8r–„bď ™š§3*”āT,īŪ‰§Ėƒ˛ƒWžå6ŒvF Ô%Ōá%-,"ĻnY­&ÍøHŽŌŲt͌|ŠČuŒĘK`lƒ~RĒķJXŠ ĪĮ`R…äĢ Ē=äŗŅSeF.˙ē\“xdWķí $YįĨŝ˜(5á€ū €á €Ę_’Ŧ+đ §D &DŒôøl*ŗ‘ŨĒ!oíÛš˜—~dJEZéąŌ‹đ˛đ’[-߁™ģP3K ãĶs+›,cZ™FÄŅōø˜—üj LĘC&Įđ4aųVč„'(ųr°°É°q4† åĪÍKdšÉĩ J–°a€‹`ęāŅA†qŖ‰3"‘ˆß|Đ ē„Ž’ĩPxmËĮą@IDATūd,RHs ŖJÅąūks}͟Ûf‚ā0;XãV¨ĄšÅht€¨Ž$Å0ÎŪRb°"=áõ‹rČkęŧXföæoĻuȐė|möƒ„ę5Ēķārō,0•Ąˇ&7ßČSęŗGHu¯åV2ƒL}‹§ ‘ƒŧÁGģP7ĩÍsŊ[(] Йį—Vy¤ëˆŖŽ•Sl­mąßų’Œ1áLHô2ž¸ “ÉĮgē„īƒ.ųŖĀ,™líRžÕžÂƒ§VRëەxîbŦ…ižųÄ<ívxExōc:-ß X˙00s×Čũá žÚUMĩ7XŅ!ŲŨ=7O…8”¯öH||aafų:đÛXœvUēV iGRœä§]ėč; yp+Ė~ŲZZ†Āõ‹”g íˆ}}¨kiŋņī_ü•č×ŋáÕMÎåw8p2>iâĩvĸ:MžŒÅš:/õĄ9.S(3Dû.ŊŌØëx¯ą;åęJ!|ˇv§;›\YîßÔGëm˛z ށĖoëœYԐ¯â Á2(sĄ'P—d^íá…čũ†öĢĪWYfĨˇĒ€Åá/)ÉËÎĩäĄŸ}ÅōĩoĀ­ÎŊķ‘B˜ŒŠîÚļŗ‰<Ŗ_ŽŽA¨•.ˇŖrHύĀ2¤ŋ5ƒ-o    Š’ä`Ŧ(rí%2>4•``‡ÁęÔĻŧ•O IYZ…&ĪFÖT¤DÕŽ&쁴:q-y#ÆŖ:ōAķ(k†‰­LG„Tv˛L'ņ•QîBYŠdīö÷ÆWA:֌õĘ Wc,]A´g?#īĀōĢQœl¨ŗ˙ę/?"ĀŋĀ2|†VČųf@´]ÕPēŌ‚w˛ü–å—|ãp¤m\āxŸjęĐĐq€<j5Yn+}ę:“OyĢ ˙O (wōÄ;ĮKI%”~Yŋ¤~5Û’×NŦ!Ÿj/Ÿå‚h°U)lzÖ!…ô\ˆŅ>~Š€ĢG Ú?r×įf ėnjpĩ‰2{&G9Ėöđd^YÖX>ÚXLi%¯˜pYņ#ŖcEÚ%NhĀĻ RbĶF+ų՛‡æV~ņŅ€Ä gę:6‹?ŲžÚ¸ ãÁ)V”%¯yéÅĩyĀ—3ø[uÕ>4ÔÆtÖĶ&Ø\ũ´•~Ę^kŖĨ<ä Ėœ!Ī"WŊ´ ŧ:¯Z×y%)§‚ŗ-}ČkŨlOhpÉ>⍠\}RĄlŗr+?ędšA9rŅáŖ[SaôVŪ9L[.žzšˇY_í@îfCĶŌę<%ÃGú\+ƒūŸE(ǤŨÚ$ũÁu4ĐæMBbëˆŖņ­ĢC‘6-ž†,#Z&å5ĪW ĩü•{„ ¸¯ēU<ŨX hoķäˇŅ3æL+į‘™,R™]|Ķ6*ãŋ¯žaZņP<}’Œâ𥠁{m”ĩk×ņ v{€_ĸđļ<ÚāMv­cĀOKú—Ļ0đĀŪŊOäu˛ŸāuŸ÷.—^zŠÂņzØũũ~€¯ŗU'Ŋ]\*)ičīcšŋQ høĐ 9eq}Žã-nMöĨÅū¤“ ÍÎ0 HRfĶ&AhųÅ6eė"xmķ l<ĮÖ}[ŋā!\~Tn#Ö|&ƒ ;ú|jū7ޝk P_lfŌĻãųāĪ|Ŧ‹sIčĶb€/pļz~⧍ ͰÉ~^4aMÖÉGGËÅŲ˜Ÿ6 õŖ:mžtd$Îp´_˄v$ĘmįĩĄõÉô¨ĩę(ôĐ“Ã~%úÖņ1‚|§Ũ31Ũ˛ôČöäp$āovæ%aĨæ­Ķ6ĘГ5bĶ"ĘeŒë9\…‚ąV””ˇ]ø|ÕLAŽQSŽ…ĨQ7a‡ŽoŖĻ7:]Õ´ĄQ^ƒÅÆØ™^[ŽäIi˙ĸ7‹4Ų4 eRā:ņĐIŽ ¨Č S6pŖŗxČčGxĢäējG­†ôʨŸ¨|5ā‚ŋ~nĀ Y­8Ų4¨™Įæõ Ē r"6rÍĀ`ØĩRo…Ųô’ˆiĘfpddeEŠ[Ę7gôI^evø#ķŦnôh4ôBŪžF¸šRé2æĸ@2“ë*™él1ių‡$‰ äŅNĘF˛iĒũ•U8ũ5ŗ||ēŊÚĶĪ\ëdI[áveÜ ’ˇ×Đ÷îĸļĒh*\NuhÄß80˛WY@—„xæIm|bÎĀbŒNƒ—žĢíãc!™ąåĻĢÄĀ(ŋq"Š+ŖķF‰šPLļ+ä=VĄÖrOÄ*°Fl<¤­ŋ†@2ø:ÅĐn%ŊՁMGũh™×mŗá<ÛPķ…™†ŨģD4ˉ‡w9[ú0ĀÚãsô“´•0¯•ŊÅņ8V^{uöØôŠ‘•=+š).<‚Ä/^ĢũŲĄˇ‡e6¨v0Qækŧ6eÖų1ŒúYĄõN“6]aÕ;dų“™WĐkš}¯Æ?id,Š3mŽõOž Ā)T;f}68›ėę+kđĻrP.ßƟ"Z”€–(Üz”âkhHÛE \mŦIÔs"iQuÚŌׇ͑ČuP널_6á+ˇōđW{§[dR™SÂi^Ũų3ˆzĮŽNî´{āĪņ‡õV˛đŋkQŪ ¯‡.›VoėŠŋēsa?—.Ō"š Reƒ’sYJAĨß)WûlĨũҞK.=kšúęeųčG>žŧôeßēŧôĨo@œ‡yĪ˙§øqŗ“yU-4 ŗk}ĩŸüôÕ ŧ˛-T=™ÚsmÔĀ(Énä /Ēéeį0)Œ44ģũNöW`íoĩÕØÅ^ĮC?ĀÛrĶ”zA^-ÔØī*vsĶßlˇ ';ĶgÃCŋeGō„ūÆņõmëĪzî°¯4F 2fą‡å…Nąŧ}’q;XņM‰Ôâl­Ÿ˙FF­•qŸú[ˆÖĶä‹2 ŽęS3+ŗ0FQĻŦÂPTģT[:ôF/ ęøĀO†‘]•­SÕ7ōŖLŨmkĒõų-_ÅNęMŽ62aÖēŠŦԃî´Ã1āZRGĀéŋŧNƒ’ī›$­‡âŋú#ÉT D#‹ũŧm°ļŋÕsÚĻtąg&äC*ké[ĘĨą‡6“à Τ@TDˇ}AĄø× | ˙NkC!ķˆH0Z*&q;ܸ žōŅ!0 šA9 Ûū`‰Ā 4œZcĖIŲ&ܐFđŊ–†ĨĩwâŲđ×<‘m>y5“98Bā§ˆčԊ™û(‚—A´1!eû m žäĨˇ0\|pŒ1 ÉSOų­(ŖŠļę,k2¤A ”ĻLš"Ûd‡2áÅC+Į\CĻ8’đ!(Æ^˓kķsž$ėœĩ Z) v4HĀ”›2aĖįnŽâ)#לRÕoĒãÕNęnöÆÛ"l”?eVÃÆf; ˞@x JYÔp&c¤ š„G[(¯ƒ•Į*“X­F”Gޞkƒ›-HĮüę>Uc+ŋôšđ?ø‘/%ĸåéĸcžō&|1—¯­á8´v+ÚП7šXĸOÜģ’*ųq˛…*mƒ(ÔÅbPۙüBNæ›/ŌŽš†h!FūĐ}4%M$šMLõĄ¸|EŨ”Rū$L׿a¸âÛkņũ␇IkCqĄ~ʝ\ŦtĮwŲČ2øĶi’” ė,ķšÅƒĘwÜV†Ø AFČ -D0`Ũ’æûōl7KNS}p­ûsMŦ¨4ŠH#- lģ`žĘgķŧŽ2ëoË×ÃŦ™€ŦėÉW—…°rķPŸãø!œ&D̟‹ÕčKH-4ē˙#‡œôÜĐPN>08ũô“ü_šüāūåU¯~y¯}ë[ßĖk|˙1€w.į_đMüđ!?–"knzfdQ°|íJŪđQŪņĨCšyømôįļ ‘—^ÚKxĀĢíĕÖ' āÔÛ°LWíPū:ŗ?¸Ŋ|ڗ,„ ÚAū|ÃFû:š^DéÂHæ6@j!yŨŊ%o°#ņ˙ų—ŧÕŅv{&v•Ō|åę+å,ÄaËcâöą(ôl0GûĐĮ‚úķķ@ŸŲîj[§įũY`% ā‚slh›ÕĨp;‰øÚŽ"Îæ%ôHØŲŧ3>DîüCĐ^éęˇyxZ Z/=á" ĸ3íeQæu¤mĻ-Ē}0fÅÉFSĢ”Ŗz\æyŗžŠ×zYŧh{b\ŽūN†íŽíTú#*ÚZą4ƒyîT7VŪęƒ$ũørĘtĐE€–yđą˙URš‰ožúˇ\a„ĩ Ôa8[Ģ\å’,mÂH/<”l+ČËË\ģĪ 3¨į—Ŧ•FEŊ‚ËŦH˜,šržž×\&@ÉŖ_‘‘k4¸vÅE­F1ŠOžúã@´Á/ŖēąâĶö#%­Q”‰üxšžō†Öæ@Åš”ÁL#ŒÍ>ę}J$ ųœ Ÿ×Á9: H8 ŊĖ8õF“tŖ đd–”ëúq0UOU4;ÚtålãĢūũŽjth%mAũ žäŅÃNĮiEčU Š)'Ë •ŋâų%wÍģá Üz­í Ö 72y Ĩŧž9)Ō–mŽ+Æ\oƒS,ŒyˆÂaĨ4u,‚Õ>Ģ­„h4d‹‹€ŗŗŗLíV‚`'Ķ?\¸_*øްr'Ō˛ģé#čs`’Ņėĩ(FBŗ­oÚvĨ”bĐVaĨ3[3œ43+Íé¯ņ#ųūÕ â§& ŒŽŅ€ą,ŽqŦßđįÄ?%“Ĩyfz$üš ŧ8ˇ\ųO~ĶŌ]ņŌģą+ō8(´”Ėą‡H6`úBŲpJtŦYdúßIRįĐĖū,SŋVĢE"ÃīĄm]–”.rz‘œ+@¤$1ŅĶz\šÆD‡āÉt0šæ's•%€Ž•[@ŗį ÛH?–æĶpgkĩŲFB—TSĩ%ē×&mē¤/¸ĮˆįÅHU6_ĢÄDæN/&&Ž6“9õNM&†ĖŸÎZi|Qi2smŲapČcŦVÖ.yURÛ*“FVRŲdåŠ !B_lūŲžŨĐū‚SPÛ8"čãs­g Ûoøw'đ6œyÛÍ~l˙~¤NyŖÂyâöh^ŋ9h+ųoƒŲōFâUp=6úĮÂNÉQ¸ æØüīąxyášō[…Ô<Ģsķ>ŧ<éIOäWOėĪ~÷w?áû<Ā]‚¤ÅÛ>Ōßxö¨2éË÷Xũ ˇ] ŗŅōėqLL”ž-}SæˇrŒ ģwŋœqö ũāÁG¨ŖúÎA“Ā3ÕeØ8¯-¤Ļ2ŦiōÖ¸’ŽõÂAĢ}Á&˙*Û5åöĀũ÷ķ‹Ō'͝Ō§Ā_…4_9Oåž˙ĐAę+?TxÂņTÅĮ°¤.ä8tĀ_ßËoIøËŪ7`z˜´ļ)BáÛ°ƒX}ŠcGP˜˛ûZöŽ_*-†¸ĐĄXZ;/øĀŲ2ĶÆT}ĄÄkéŖ,=ã/čpÁÄdްÄW<Ûæ€íķČ]r˛*UĮNĘžhÅ÷& ņNšoG´o°/čų&mÁúŅØmđfŽ–f—­0üSfŸ9úqō‚ünKHŨæ¯|Øz'ډõÜ ļ€*ų‚ŒÄ€2ƒ‰]ā] )y’ÅŒpE˜ēŠ”Ōđ‡?Ų×.qM3”/â´ę6ėäv(ĸLrĻ0áė'ĮęĶ€ė´IÂ)ŋõh +%5dX`ĸ)Aį"=ã!O‚f†äĢ2߂'•'cėĒĻãā "M¤č"°¸6JŦŊ,ŗ&Ũ”Y2Ō2ŅčȄ÷ŽˆõhÛ Î ƒ"{ ^ÆSOx Ī˜Ä[ü– ‹^8@Ŋ5C¨AęĮH¨zŌ"ÃāWOe&Ëj×-].ŧ ģ“qh32%)xüwéē‰ž˛d6å„.nåøbc'ÖLNsŒ<ęĨƝFŽ–č‘ÚÁđܗ$ŽÜĒ…Č č'3š–NP¤Ģ°dGŠ”M|€÷–ēųÁģ]|+`¤˛]~—9+ƒ&šō*wLė•ö5Oĸ ,ņ‰ō8 *‚ämÚōĸ AMpÉį;˜Ežzų7ųøį›y@Ŗ„¸$רÖ&Æ~˛˜+ô XjÆĀ@e@Íĩ>€ ŧ¯‡õׇYęŪ–r2aô(¤×ÉENPJ¤ˆīJĘ(TÍ&AäŗHã/(îŸWyhžĒKEÆų^X.=1€×5Ų11•XKĨëy¤ĻtÕĪe-ŋs[ˆû§ûÛã] ‘ŽĄėmg6Ūˆ8Ŧđųí]ÂYÃÁĻđŸÛ­J m(eĸ%€ú)Â4ųV/ĩ +E8mhy+CÂW/Ã^˙jqô)x;¸"ęcié[;8ü Í—rAĒ ‡†…ŊúwĩO40\ÛmT{kqûcđaė‘~ȕnú fešú›ĀBÜ´€ž!ę䓿ļ÷íû,™g2;›WcŽîÖīmƒĮ¨DHZÚ׸ڒÔ+ŗ ˆ´‚Ģ,Åį*ŒeĮȂčĄjâ{&{ք|ÛˆŪžÄY[vŦ>’ŧĒĶĪÃ,‰•‡÷Ü{˙rÉ%Ī[ŪöļĩœuÖéˏūč˙ž\ũ Ë 7|l9÷ܧ/G<¯ws÷ØY ‘m!ö„PŖ¤sōĨÛ*ė6G7-áā!ŨWūMö”AG¯ąŋÔ:ĒĶā:rᨏ ¯-ũÁ˛û÷-_ŧķ*JŸ„ü'ņzRāQr"¸ęlpš“ãö!ś/y lą/’2"Īl¸‹ė+‡ÄĐo‡ôu3JUŸ­Ž>ŌĀ˙éO{ęrŨ 7 ÛũLÎ|])0Ú0č.%méo㒯L~ëåč-MˇsũgЏw°+īNƒgōĐ,O¸čņŲņ–/ÜĘ$™Qߘ:ŋâgY.}Ō“xödŋ6Oē%Ãądŋ‚´ĸĘ$÷Ŋ$eĖ‚nÅ~­Š.Š´›õ ^&'‡„å[LsI<Ōę¨-•T_]>ũQeĀ…ŌŸVÍ\ÛsėGI[§ČÍŽČĘß=ĖÂĻw;ÂWū87\„PÜi'å ŊĄdü#ßfˆ* Ö:L*ycJYZ @ēã6žâ'Ņ‘S6–oxō$ís­-ėōĀkųQ˛>†86*M8É*í Qáöԁ‘nĖŦ C!'?āæ#1ĘF÷KŌ6Ú}\ë;:Ԍ=Î’ĢC”AŪ*?ëąÉÛ \ÆāKæVōÜ!Ã4ōRĢžĨ‡ã´ƒ¯ŠáŠĢa 㘇}÷ēŽRšáŖ2ę=iJÄąãGˇU„hxU ‚[‰xВg1´ƒ3­$tdOeŗc™JŧVu†A!iąļ-5b#oüaPčk7Ēp°<øj,}äP*ˆŪ(˙’đÕņę ?&Ę&ŗFz‰l‡es€ Ž×āÜĀ[mí€# %ø9“,Č$B_ōAJIA2J1Ē”Š/G1`ö‘šl9 >ÅiĒ(äÕP€?0’šųGh*Ōĩr äH)×%šÜ:—¤†B0yĒxų¸4q ¤ôRMaČSē*(HsļLDų "ž Ž1z!åM$6š6…rø02žd$~Õ ZĢ^Đx[õŲ„ëq?ųđi0žeæ×ŒņŖ4!ŊŊÅ@V˛7*´ÁhlĻŦæqÚb\Øl4ô‹į_Ņ=Šnb´ŊHG'zÆË毜ÆKYTl?Ä ‚YLތJ¤9^JĶsö@đl'ãÕ~H5[qĒv槕A8ļ Y(č„'%RāĘŦūžĢēā IrŽāZ9”)ĄÂĶNp阭ŠĢŦØA7+ŪL`AÆŠÕb &6WžŲš$ŒŒŠÎeí*W^g“5e{UlJwĩm2§•1~–qdwŌę)ũm€Ŗ6žŖķĀúøSNŲģÜs+ßËō]oüžåŌ˞Ô 98›CÚ ŧ^oéíŧfw:6Ī´ĮČ6écŋ7Ø îXØ­ėXxĶėŖi>Vū–w ė¸ë¸ī_nŊũöå=ī}/ĸˇüí˙áīđ[§gOc÷K?‹Ī—B}éÕŖq,=–Žå^op[zƒųōrcéĀūLXnZ>ôO.ˇÜ|įrÎŲį.ûöķëÅNüŦĪ´cn‹õ§Ępxا›Û79 ã—Z^Jėđo㐊¤ŦG‡8ÉãweŲāį„IJC‡¸ÃÄųĘŧŪĪ€˙"ŌĪîs–nŧą_'vĨü pˆįģī…õ•ŽG¸[p„0;á„Ę{˜2é}ųaĨ§ĄL‚ãÄb4ü¸ƒ ā÷ú+ŪС|‡>pōx4ũž;CåR¯ĀÅË^Š_Ž_>uåg–'<ūņËũFK\ųîŋ˙ār&ņõšo{õōŪ÷Ŋošúēë– /¸  įcËüåZėääŗ‘…ņ%žGíͯMøjÚlûĖ¤Y”Č1;¤ŽM¨wô8›ôĘvËÁļe+§)°LŌļÄ—ĨŽëėe,žö˜-/ĸ „Hš˜ŨÄ´xJ+0ōjŗ¤<'ŽÕ–ŽØ×ö/-Š8Œ””§]œÚiM­ö õŊøzm8‡[´e(}NĐL õ3ĄÍÉķõééaŗHžE5(Wž¤*>´&:¸Ļk›Íå_SžÁĘ{%*ĶūũøömŒ“†péĘ×k ŽĀũE8´!XįH™’ÎCŧ)“Ģ8Ŗį@:÷š’ĘSö0Ëų‚k$ø5ĸqPž)T9_vēYkęU˛Į-Āä ņ‹ ŒÛ`M! žÕÚˇXžĀĨǰäÍcG Œ, ´Ĩ÷,{€o` !ø=äČdë`P)Ĩ6ĸ-Œ,Vbtķá:S€V• 8˙ŦųˇÁ÷"ģ40R•Ü}įŊžã•ōUŽÁvŪ Ĩ—ÉiƒfųÔ,aĸą)­T Ėûü!-ŋH¨×¤ÉŨ9ČYĶęŊėŖw„Å>ʧ.č¨Í›L)ߐT>-™=ČuĻmÅ´Äø˜x\ũMŠĢ“öޤō“?4Ĩ&OíŊU4ŠƒÔā´‹x^4č•×ÄDʤ(ÕØyȒt1īÜŧNå#p”bČJ§É1ƒëĄŖ>â9˧Ԅ‡|Ô ģĖĸ-6#ŨVœė7ļ18§ŖÚæ?UÖČp6ÅA^ .áđʔNũ}đ¨˜RIąˇ‰ŽgĸËže&›âH+ŋ(—4窃B§ÁŨŌ˛=Œ¨ƁÅúŽļDå¸ôä:Ō3Ė’ēåsWGu[Å$ŗ˜€¯˛Ų ˇÚ",xŅÍÉ1 ČČXäXŒG)ˇM-ΐ)ą°@û'Zu,e€XĻīBŊĄ“”‹:%̊ [™‹ r,ĩß *ãôžņ#k&ļ Ę4r†¯ŧŦ˜[mŦ1Œ+ĸŠ]'&l›Ĩ/–4Ĩ5×ĩOÜUĮÚ$ęIÉKRҐ؞ø+kēˆGú$Ė­_ę@ųČ@R*ätĘ.˙û—ĶN;aų¯~ø{–‹/žđ‘ " ũGrü90_mųŋMzËßΖ›ūŗāwōÅã–sÎ=gų䧯`Åöøå˛Ë.Ɏ_ĩžÅ÷ØŧcĶ˙ĮĘŗlËßÎüÎų‘åėŗĪZžđÄ',OÆS–_ûī,ŸēâĒåŦŗ/XŪĪĘĸî\īÔ§CÅ ôļē´C*f´…“ŧ&ŸČQˆ¯5%-Ānį9įėŗ´ņî{–ĶN=Ĩ­/–íg+Ė…įŸß ˙N~•ŲÉ׊lûšū†—ˇŪô‹ČĩđKĖāCƒ_ŧŊũÎģō™4”ųļ;îXN9ųäåŒĶO_nŋãÎVŨO:ų¤Ũšo; yëÕJ&ė⸠˜žƒås°Š×šä¤OŒÆwŨE}8m9ũ´S‡>x':Ņ€ŪŊ÷ŪĮÄáĄåqœ_™[z¤yãM776¸õļÛš>;j'-ˇŨ~G˙ÃG¯ėŪq’“™mÂąÉû•œĨą=lëlŅX,éHĶNÔŌ^Z{)€v° MZ›rÔæčĄÍ%V_/ 2ęwû°¸,y–ÃS‹/ũvUp)ėČB×[ģVcdƒ% gF@&Ëh¸ąæÔf9ŽS@&CB]Ë2ņ&Oņɓš‹ãöc‡Äõ0Oc'Äoû%e’U‹ÛeđEQvŗ%?,Ë r힁W7fxÅß6Õ1D: áEÉíŽ<Ûå-ĮŠĐ@Ūd“Ž#ƒ—Ŋ&\øœ%/öčJšbdvĨ1֎ĖQ‹A °ŖÄ…_ vō MœÎ~ØŲNŗŌm BųÆC9äŖā$‡´!=xg‹Ž*‘2”K#?ghHũ8Ŗũt˜3I°0“‚ĸō§UxySXŨ´Sm8°6¨éž ļ ôBpň!šÉ›Ģ2„Š$ˇ¨Ãä™ļXYįîFž˛öŨC;r¯ Ņ.Đ#šÍ.§čÛ]“*pUb ānĻųĖ€Ë ã’1Ģ4Ŗ M’āxyėAN—ŞxÎôų^ûŽ•Z~ÜMÉnĢŧbwŋ­ræc”p3”’…k Š|ûR“eAëŲ§ē5ä0āŗ¤F[ļō5&߁šŧėÉĘXHÍd#LéįĐŋY9lq–ŧ槌b+ĶČß _ĘSf}ęÕ$zŦxpvŌÖõĄá§ß‘{d_ČzŪ&Ö 9¯SÕc`€’Y<’'ŋ¯¨ÚBŨ7Øá!pą8@ŖG@̟4äĀĘÕ-vų'Ŗ´ŊwāCČÄ:0Ū5PqƒŅ—küč1us`ŠōŒrK…÷yëĖĒž”ÆøÚAuS+`”K’J ļļr¨.ļGĐŠĢC§•ÍM|”ÆØ,9Eg¨Œną/f‘SíÄEkĀC:™¨c§`F6¯:D&TŲ¨x’ėlez X:!ŅžŒî‚ø=íŠOeā~Įōģī~ĪōŌk1ŋ˙ūå—~õíËL4|ū;ŋãÛˇ"rō)ËŨ÷ÜŗüÚ¯˙Ær/“„C‡Xžö”'/¯zųËąųË>h_Ëjū&47Ũ|3?ˇwyã^ŋ<ų˛Kyžä¤åæ[nY~ũŋ• cÅßģ7Ū|Ëōø /ėîBũ_ŽÚ$ū‹ĪÚÚ;úŦ1ĪÚÖÚĐáūڛé§Ũō›V vĶq_~”‹,ŗNDŒĐˆØÖÖĻ`ĮŖwŒi"dģe, Â7éßņĄ-jĢŗEāâ\`§×˜vihΏĀ4š’I§Žr[fą<ĖņDÚ6Nmöė—æõĀ”+y[”ĢŋļŲMGgŸÜØPēĶ.Ęíō5=vÜ`_f3š^Āë䉖ė€Ņĸ%?‰ÚųiĢ[’īV.ž5MŌĄ*ŊrŒElß­ÂōҐ â`ļiŽnsyǎ^ZæĄrķŧŨąŽSŌ8§Á‘LĩŦ˛t˜1”ÆĩŖZ]íE`ÉCYĘ’Û ›į˜i%v3™%Đ) ,Š0üIcŦā•5ą•qh5c„˙ēvUak°ĢĄÉՔÎ'ø-ˇŗW_¸JVč œ,/BŌ‹“a§ĖDCSÛVhp ž Đ B¸ĻŖ6ՁŽ„wŲGSdZ|G ū¯×+V62ôånĀšĘYŦx*ėāŽžN†ė'q>ÛyŊ\c§¸ŅæÔö Ô´ōĮ›TvÕf0"]ĐFKŦ2(ÛØEúaķmãŧ2`˙ÎŌ§ķČ$yžlOTe/ÂVšwq{ķ“ōŪâ!¸đ$ž„ōmĀÛĘafCIžŦk•.-õŌv6îĪ›ēd3 ÎŠkĖ)ßčāäTŋõf˜l#÷ŲįČkWß5RĶÃIĻBÜęÅš™?YåŸÛWûywĻCŊŗ Wë@„d_••ŧ ^¸Ā$?6qEÄ?J›Ø!nÆė@W’Úԏ~–ĮQŸJSmĸü–‡€kÅ–Ŗj–2ßō_|s‹eB%ėčåP|éĄĨ|BĒÚ Ė.y'›\ÅTV&GÅŧ¸Ŗˇi˙­?úÕ3Ū6^Ĩ×k™hlæ>iäRæ|†˛¤ƒØ)a|ŽĪĻ“ {‡ŋ¸$˛ Q|ī"Šņgŧ3|ģĐd=ÛÎM¸ūĒĪČdģv7Ģã_tŅō*&î…Û/ũĘō‡øāō2ögŸyf0˙Ņüû ÷o~ņmËoŊķ]ËåĪūōĸ^Î*˙—į<ûYmĢyړ/ãWš_˛|ôc]~ōgŪŌŨ„Wŋō•˧ūôsËk^õĘ˙ŋĪVšöĪ˙Eƒj'n!rđŽO7ų­0wq÷ᅗ?ųæžpųėį>ˇü̟û×ËYČōŸũĮ?´ÜČVŖ˙ķ'ēs_õ˛—-˙Ī{Ūŋ|˙÷|÷ōėg=sųƒ÷`ųЎŧ%™ŋ÷ßŊ\ÍŸ§\véō&ŌˇŨvÛō3˙×Ī.Ÿũėg—oíkáųČōņ+Ž\~āûŪÄDãqËÛ~ųW–Ÿy Ī™œqæōō—ŧxšūĻ[šøÜÉãĀũ‡ÚÂäņ6Yŋâŗ}>x~jøÂœƧ kW_ÛȐBíˇm9_v”Wąt€ą_uF`íĪÖΚ ĸ~Ūĩ—+ĸuŠq$ĸgČ+oûÄڞ6ví˙adK۟<ĶËŸô Üŗ@;ēIoâēžÛö?û‡Ú=øû"3­ãŸííŸ$šTzŖĸÃÛM6ڊZõ ĨC‰J“C™6W×]ÜR}9ŪHˁŅäŲŧę%eBde TeņÍĄ }ŽmŌɑ8ēX{UMŖ› õVūŊF;eDeC ĶŨęn+ Å!ÆĨ% ámHÔ§ˇM–ōIŊĄÕH‚fЈ(ŌĐW^—:}{SŽN(Í5"$=ˇ=`JĪ%' ößn5QĘl¤e*˛§p<‘…=ĘŖÅĘ]ā uK@ėc9ĮޤAą´<”˛đŪÎV›k“­6ųŲ"ũiŗBÜŠ°’ĒhŲJÕŧXåĩaN–•…ĩ\ŲUR}+ŗ‚ōī€N{?ûí}¨ÄuwdÔīāUîØÎëĨåČ]E KŊä%oĒ­RžVø{>Æfâ˜īI›û`Šr ߤDؕސc; )Ī–éŠdĨžxę hd Č Õ¸Ŧ‚#@ö†~|UéėG‡H†VéĨ¯@ONÚŋɟ"$ tL'Š7\åŦId>˛de„´á¯5ƒĄ<ŖÉvđ]Ihۘ˜Āį[u‡bĮ0†+ŌĘ&7¯Ķ ~A+HĢ{V‹āáīokčGežz’´‘W˙h{eüJ‡fšÕį)WŒ™s&ŨŠ3Ž´Ą-Ž*įKmõ w L‡YEzĻQy€ oë9ƚ˛J"!ĻŦkžæ5›čcq °.­ąž„ŗô˛(eߊ'Ā /›\ˇ3E%>ĻF.ä "éž)omsôāšČ–ųÃŦ¨É(× d[ØBŧų(ČŨíąÍSWr,Ņ~:Aí”Å\ëtÚX|䨝$whŠ90M_‰å!sb€T“7ņüŗŌH߉'ņĄĨ âĸâZ\ø֛ŦĶ6€c'GPš—']r1ä‰q'Eƌž–üģ~Ģå_'}kS@ē Ŋ,g°}˜ÕįŨ˃‡Į÷Āğ=u€x˛N[wŒ ƒÁ J/­&—gū×p@Įvßm;_ü„åšĢ¯YūųŋüÉådļõÜđĐōâŊˆģJ‡—īųÎ7đvŠˏü/˙ëōŒ§>yųŊ?øāōŦg>ƒí1,œwNûí¯üĖg–į?īyü0ÛIËûūđCË'?{{čyxû‰/¯xņˇ,Ī{îs—ß{÷ģ—+ŽüĶô¸ōO?ÛāūdļÕ¸­g/Ī?lí­w$Îf Ņélįy÷{Ūŗŧųį~~šøņ.߅×\{Íōū[ÎåŽÄwžū;–ûxĀúŋxĶw-Oa…˙Í?õĶËŸų\õsŪûûËëŋũĩË…įˇü{¯zÕōųĪ_ĩü˙ų./˙æË—Ÿüšˇō;/Ynįy’×ŧōeČøÄå-?ûŗËŊÜPīn片ĩĪZNC—3™t|ú͟žúŋÚŊžđĢ5÷ŽĪLđīĘv>ÅĶkŨÕ÷ŊļšüúÚhŽēyK­gN¸¨CœØŲf*˙ Ŧa”×ÄíDhŗÅ“mŒ‡pŽl{]ëdvrq†Ļ/P°ŧą•pĻåeĻ4_⚏’#K2ž `]ˆõzˇķ”ŗ•uģ“ũŽ*˜“î‚C@Zæ:¨ŽX -ëÁQ]WXˇaJ!ŠÚįúRŠ_åQÎÕĘ”[~ämå’etMĶw…Lģ,|ŲW”=ļŊ•ŠÜl°Õe‡øË/ę-ēIđ ­"~4Ŋ^&¯UjÅ!&Ét ĻĨaŅ–‡Ã|˜;xŸíō“ą64LË ų$āš†Ë:ŊĸI8Ž_PQ„Qˇ"đo˛Ėt%­y›mÆ{v´ŽĩúםŒö= Ŧ$GÁ†Ā‡YŠÉ5P2õ'Ū EŽĩzÛ8Ę0deeËøä Ãapų'Üėˇã€°ōčø6[=uĨIšz"’ÖP7žĸīÉKsWv7ÃbWÃųƒ0¸¤”-@($ŗ|(JŖF0jR\ķ‡ļ8Ôg Ä×ûS"đÚØrÕ[^ƜĊá5ÚDķdKāĻʓoGž`ādƒˆR†ĸx”)î+^+Ø0ĢĮ'ÚÂÛYÃ_Lø ŧ%ųHjÚÅÆ([Ād w˛ŋIcƒ‘îę­ōÍŖF¤ÖŽ8ŨË{Uß>=2ƒ}îH'{H[oÕWšÅPøÚeĩzkmß]äm‚˛ęŨŗXSû(orrN2Iv;ŨaiëYÉJ͂6ÛY'äŠĪ°‡46{_\{Ē&ĮWšÅ[p+åä9ÚH”0ĐF9‹āˇ ĶäĪ]*™JGß!püĻíƒB/›)`ur`ÄlĖVwáĘū×6Œ+ãHú†Aūdđ) ĐáCyˆ8ŅR:đ$§oĖq›\¨[ūĸ^=Z*1WÍÕ'ų…ËyÂø7ōHJ~ĩ!ÚŅĨpp’ŸBDƒTņ]ŽņEPé ÛŖę‹ue3ŋ˙áēEœĖ1´4Ļ2&įĄˆÔ!ß*ŗŸÆÚŗįœåô3N+ĪüņаņąÁ*Ã_æÎ_–ÆŸĮ˙/ĸ˙•˙y´˙*Ę´•“ĩĶØkūŒg=yųĶ+o_Î9ų´&S%&čõ{Cƒ#Zļbbõ—t¸—Ę_â8Ė[|NæĩĒŽŽ;ˆ?‹É‰Û`žÍ˙î{îfĨūđré%—´•įļ0š%ĮUûŊė¯w›Ī>ĀCÃwp'āŖlrūī˙Čßm‹ĪâË?ųąŸZūņß˙yVā”ĘÜdŧ:Q¸ũöÛxĪ!ĘN%ĪļŲģiŧ5 ú—^ō¤î |ⓟ\^€,OxÂãÃyĮo~dšgs'åtöđŋīũīīa^W÷?ōņ?Yžõ›_¸|áÖۚ<(×eĐņĩąøÁ./}áķ{vĀÁ™Ī\{Ũõˡ|Ķ { čˇq7BzžÕČgޏâS˓yåė&&W_{ír&eōļŠÚŧ¯ÁäúĖEˆicđ°í-€āvkbÍ´m#JģōŖĪÁŖ‚—×Ķ`e›Tš-šļļ3ļõģíKĖ›ˆ’ÄNŋ]ÛĩR vņ܀#´„Y{Ļi+lĮ¤5풒×ôK7ŲåíRĢįs˛IŦ6ĶžJūpØîj9s‘ĒŅąŅsÜÚ?@“Å>DÄB]à Æ,ŒŠ |m‹x #ĨɓW2÷ÍEĢÃwŽÕÅruM|`ŒĨíB^pęĸĒ\×÷ļJž# ú? ]LvĄlR6ĀËI° M)Î=¨*÷ã~’q°"˙&¨ŒŊeÎLLkĐē>īYk ō)Yû ÄĸÜ+™¤gģéhˋŲZ@¯Sk §COŪ>x¨ˆƌĐw5:åŊ /g"ÁŌC,ˆtúk‘ s{ŠgŽd02`šl°`c Aulļ 0ÕCūtŦĘ+)až'†ĐänHČ[9įtÄc&$´3XĘvˇ‡tö¸ˆ|ŌMĻ,#3{ĢŋFąc/™šbįĪA‡tÆá€ĸģ\g‚@ žâifˇ5ˊéČENĮŅ+ė™ĀČGC̎ČPŲuš:OŖĒÆVžU˛´[‰Í@&ōˆ¯ÖČĄ˜Ėä˛%öU.3E‘˛˜QæĢ3žQSG{p& ¸–•ÍąŒž#všk`ĖéĮUiD*{’Š_Ik/ŖO)æNųę) ōÂĸ‚ã-dķ ˙Ŗ) Ãrxĩ/eŋLŽTĐpEåāK\%]Ķųwt•Nø— „§aˁÂģ)úBkËxúŸŗI­ŽüäŽÜķ@Ž7Ę2(žā*—˜DÃč¨2´/Mô˛Ū×fČ@ILŦ_ŠÁ„Ÿēˆ+ ŦjÔhøÕZ=CŽÂûÜéruÄī`ŅdNąpÚ\šŠ‰ÅĪú['Ā?ŲČŽ—ÆąÆŅö2á;¸lD~åäWĄŠĘüâ.ɔH+׃Ŧ$;˜œļJ¤úęˇncpQ%až´/Yé(öÄũWČ˙ËũŲg%íQ0Úvęá€ņk=Š{õXëū×Jį+Åŗ˙v``_įlh¸Š7ŖĐąÕcC’Æŗ,ŽjKjl_ĨC|đˆ˙jžĨĢí|¸×}ũ{÷îiŊoĀš‡ÁņšũSدīÃŗ7ąŪ7âø€Ŧīû?÷œŗ—}čÃí‹wÅūāĄhYūéūØōėg<}yʓ/[žũu¯]nŊíŽå|Vá¯ŊîÚå'ŪüĶËe—^˛|ņîģÁ{æîC†éo­_ęĩ‡ëģīšoy%[ÛÜ{īÃÆ‡îô^ۇú¯vŧy¸ßzë­ Ö÷V'>sõuËúC˙!ôī]NäÃAVõ¯ŊūFÆ>ģįžûėg6čŋœWđ\ĀG>úąåmŋúk‹“…{îšˇÉÆ•l[zá ž—¯ã`',GØfWÛ˙¯öȏ›Īđq‹iA—ī´ƒ˙ģŦ›ĶĒ–\¯Ļw2Āzé?ˆ'ųt4ĻjßlŦ\œ0{řv×hĸRa€mŸq'Ôs"sĩöU‘Ezö-BŲN5éáÂ’ōö5Æå÷ˆ[{Ö#öŠ!yl.ĮúĢhsU™ôÁŌs Ž|.抚=š"ÍaŠ\øÍ$ČKų›?PmÃŦ–°ęKmúÜuc2’ üíšŲÉūƒ2bS+°pį,QĀÔøĻI4ģ'áļ€z5zRë˛@•sÜS‰ äIC§¨SĖQLu%E5MĻKˉ—ĐĨ|5ÆõÃõŧ.ËÆ\;ŠØ#>íŦ[lú“)ä^ b<Å*ČäœÚ˟獟­î"Ŧ¤åŅ›ąhPáeE–kåéĮ"āQې)mŽ—éNjH͍jMHuˉ.A†*ĮęĮÍØéŒŅˇ™ôāŊc;9é`õ0Ųø:œk…đÔ}`äíuAj1N-01TT•ÁOôÔ÷č!„‡agi˜Ėžqe!T¸lkg+$§äĶnöt'“™ÂsޜīéT"2tÕA\ŋHËĢm`ųÁ2”ŖlÅȇîq6#H*C&R2bøÁöŌ¨eS)™:ÁŲā"6šé¯ôĩ‰ug°ä9×ĘĨÎöûųģDŠU WĖ“—k ĻaP*‰@“ú#]c%Ōú"%2W}ĢwœÜbShũ PpāÛî•× >Ā̃ąäĄ˜ų 6öÕķ ŽÜ‚GĐú,’"’?ÛtÄõŗ2Bä,(GnōŒ?ÛĄ?~åô'Čũ“æQš‰oרĨĘ-•Ō|5ȕ¤ %ôԃk)Ų`{ˇd BZ{jŖXxŦ&Cᕆ´í’ž‰×úĨ Ai}ĸ ÂķŅ\(‘–č'ɏDM&:JkÃnb .YÉ6-8™hąeäČ ĘÕŌ˛!R[€0Æî´ēH¤ŦŠ;Dƛ™ĩew7U†ėtZËÔ˰ą=Žb“–”RØöP{Ŗä↰Ĩ_/I¨sĢrœøˆķ˜ˎ-']ũA'|vĘ w,ŊĘEßI„´°Õ q öXZ•~īm œæAs“ÛŦ–tĀ’îĸķ%öxT™<Üzr°é/ũŗŽ­ÛŖ¯Å{ŋž6sâæD hWNĖëWc,eŌõĩ¯Æ‡qg‡e›ī7ē_õūŌs˙ŋÛt|Ģ’oÄąÍņU›>đę`Û7ëxGĀë~ōĘöæ˙Ώ%čīúŊåŠlŊš™i/|ÜųËßúo¸ˇüüČßũ§Ë/ūƝņ{ŌNŗe ;앿Wüéō›ė×"oŦúŪ7žą‡ĸŨSīäÃɨ|õНņtOžoîņaāøčŌK.éYßÖãĩėjēōĶÅālGē y?ÉŖüžīūIËģy]Ŧ‹‘>|}š+?ûųžUx#[‰|;Ї?öĮņ>ãŒ3–_ų…ˇ.øŖO,ßā7|jŗ-Jž>(|Ë­ˇ÷ Ō¯y˙?‚Z/ļz:‹:ŲļÄÆ„.íĪëí'õĩ˙œĮí\pĖ÷ŖRfnƒFá°õ°ëālŒØŲzxö°ũi!‚k›Xcˡų\Á{m…ĸ×ÖŌĮ™ŗ "­IĪyÚáKŊ”\}ãΘSŽ@ ųäĐ~lį]pU´t6!¯•]—(d]ŊĻīĀEa1wÔū’ŪŲjo‰|Ô§ą”„3œeœĸI3yDifėƒĩ ôV[E÷˛ŌúEÆ/* Ię m3 $Ô+bbSÖœŲ*ēĖEUĸC m ØWĮψVM) /-„ōŸü8¤ ×5i @ ŸĄ—`fŋ}H•´,nžË$! /ėŊÄ#ŊË'9¸l6 }”^åcļĐ.°&؅ūŖ‡ŗĐŠ$’SBhזįĖé<Ŧfe\ ”z°Ņ^Žg6Ā5=t:lH_y+€ļN{(ƒĮH OR ¨Mrˌ f­œuÆĒ{ƒWá,ļ&YBē"ųz;d÷o­Fé’HŽce(mŽd4 ß&*ĢũDčÕŖŽÔ†5šÉēm‰‰~´Įs=ē‡įr¸¯ĘFÚŗtČ${Ŋ6nÜļ•/ˇtĩöÃĀVȤ‹úv—:ãĘą˛Å{Õɧ;4ę5ĸ˜Á–†Sœö ĸøf‰ŽęņŒÔFĮ3Ž6\hĶ“aŒUßt ˆ ÂW‹ ĄĨ;HáĢWˇ.ų6Đę2( C’?m'ą!§‚ÂË2nËN`•/3eŪ7{ WC4¸Āø—Wō‰:{†Y6ĩ=AWÕ3>ø.ÖR@Îöåa'Y’æ ¨%r"‚ü‚+–ž6 ”Uē–‡Šu@UG #ūÖ(§-_mģ’pų’“õGē2‘‚q­-G&c“"?)¤ÎÄaB ,_ūļ;<\NĨ€ečŦŅú?˛ËÚą€ģļ„R4åUŨ‡ũjœŧÚX ņ•ŨGulĸ#/ôÔf ĪeRą• ģĸÆüdNŠ´ü)=žĻķ^qĄ×[.āĶ3JIJ’xÛĨ/ļōlģöĨ4ąŅc@<îeU÷ĩßöę4ŋũ;ībģƒûŅÁOpėåmrƒ CHfyļkųŒA›"ŧîĩ¯Yū„-ސúúEĄŖLļŋŠãkŖ¯Aā ]ņ÷ņvßÚōŒ§=}ųŊ÷ž'ž}ĐYyöąīûO{ÛTžÉ֔ßínČņĮûCN+xžŌ}´<bnKq xĮbų1$ ēj˛ŨØžšhōKūą6Ķ>ĩ/Ú ,a3–~•/õHģ()×oqô!—Æãg1Ē íã§ĢũŦ›īŠĘWų]íík?Ī$|ÎoĶqkÛ]đō>~Åg–ĢŽēzųnÍîŗ÷m@Ō˙īŸ˙–=Ŗ´oÍšęšë˛˙˙×˙åōöŪ_ÄCÅ×ķÃ`ī˙āGŗĮūŸüĐōË?ũãŦ°ßĶ|>ūņ?^Ūņ;ŋˇ<îüķđįÕ&ÆĢú/mášęĒĢōšĪ ¸Į‡ŊķĄķŧpįw.wߡųЇ?˛|ß÷žqų˙äk’p!wŪĮۋ>öĮW,—]ōÄ~‡áŋ˙o~xųôŧķbŦ|ŽgîÛw`ųčG˙hyŨë^ŗüqd?qÁyį÷ŦÂįųŨī,ÜÄķņ\í5Nú*m-¸õĮ8"6 –\ÅHĘIō_ŨĮÅJhÆE­ƒĨ}oö!ÚMû3ÖŖZ,Ōĩ-dÖnTčBÎÜmöRy+‘ŽíP+÷$f]jEŠQå!éTŊ‚—Čġrûī•C ¸$¸Æ\åIˆĐāÁŲėē1SKŧžgP\ڑ§ŦāĢ„āĩįÚž…1@IDAT1_û mäŸų2YÉj/›FŠī˛~Žtô‹ˆYĄÛK(Úqép[É]i뀙lHY3Xˆtŗ8€ŒųâŦĖ‹ų ˜hõ3‘Ė×l0Ya”Ŋx97Ūfpô›žŨKĻrm1HfĐŌāŠY'ĻäŨT^ … nÃĸņ(ęCŖB͉ĖCy‰ĐX%GBR$†„yķ'ĪNŲ ŧ5nŲ Ûāųå”Ą­–+šÂ*¤JNŽå~’ŅžŨ´Ykų8…ŧõŧFÚ:ĸ‚PWķüwpߊ˛Čfi X5PˇÃ{¨ 0 W×MđĒk,aވU,PE’–6H{pƒō ĀōĐTWƒ§į9VxOŠCslƒV ×ėđáņ=ō4<•ewƒj‚•UõG~ŽŖ'‚„øôjÆŅ&bpāJí&nŦ\íUôGĨ´ĮĒo• ~’™_¨ÕFåõŧžODč0ø)Į<”yQŊ˜]Ëԕ/LN´P°;eb™ČĐëfuqP+/éĮP"sŦúIë¸õõš12y°ã ĀMc Ą§o‡ReUr¯WōŨk$fGĪ[sęŸēú'ąlËy| Ü“7Tđab<ÛY&āÄē–+˙ØĖëáJ>y ԕ]Hh%Û ų-8yk Š+,vsp’%×rķŠųuÅŊx¨›^ІIq+ŗ‚PŸōQvčÚáĨOŒ'>“Õ2G4*( Žg[™ŒĖæˇĘšÆ×øĮLč0‡$ēL™)—ņĮ5üeŖĨ7clKrĐØ–ŠĖ ĢŊmįXåĖ#ņ›Oģ`܊2Ė4ƒp3ˆ—ŧmWVAVy¨JNTĶuĢīÃcrø%čÆ@ŖQށņŗán2+áŊ p|#\{7ú‰'œØĀ˙:ی"o}ī ¸+›x`Sîvp cíÛ(` †Ģŗ§ņ~uqˆ?žZˇŽÜ˖ aĨ!ėaad9@ëš-uāKë1ņÅdÃmŌ78nĩđõŽŽ8Ÿxâ Ëįy0Ձ˜7ßĶîC°nqeúĶW~ĻU|ų܍žeĮē!ŧoŗQVCpvčGž¸>ũÔSy åŠl š~ųŪôŊIöĮōIŠ—DëX;*ĩÛ_\‘VV÷žk÷Ëģ=ʼnŠƒaĐNäí]ķÅvĘđ†1‰)Œ×™iJŨ“…Ŋ*‰Č3–,ĀĮõGš1ONėįwh–eŅ×p¸˙ß÷õûCXŸâĩžūÕĮΝžũ×ÛĒ휧?eyËŋ~ëōʗžxšˆũūŸgũŪ÷ŊŲ‡-.`đū ŋôËmąņũū?ö/ßŧŧė%ßÚļ›÷˙áxøƒŊG˙O>uåōæŸ~Ëō‚į=Wô–ĄO~ú3˛žë üXÛvvâ\C,Ũ‰øÍwūNąĄ­÷ãë?÷s7ātb@[˜m;níyŅ žËJūĮÛŽô|ésüŌ¯ŧ}ų(Ī\ō¤‹›\üø›jyōŸĀDâSŸúôōËo˙ĩîj<ûéO]~ãībâsgw2ŒŸwüæo/ŸfûĪKŋųËoūö;{ečÅ]ˆ_ãœāįk9ô=>­3ŨFĶ;ÖŪL›Qaķ˜nš&I`;ĻŲáŧ]¯‰tL›eģ7RJü›Æ[*cđoA‚ÜŠ(¨ÆrPmb ][E&Ũ$q~ ŅkîĒģKE.=8;ŠP 'čAGŊwā”IŲ×f×"č PĐļMem‹%ÜaŨ ŽÉßŊ>kJą´–}—ŧ á뇔W^3ƐĒu~čՐžBsÄ1•Æˇ WRmˇ-šoÛĢ 7vWžĩ'"CIø;cįå,AÅČCŅU-„+ŦäÃ3ƒëlÍŪ æēÄÆ^2 æĩ¤,#?Í(’ļkßí€ËāÅÕôŦđeBOGQ ķ{G58[~Jâ@P7;Û €n†hŽ6ĩĸŦ6LҎ}ŲRXāƊ?d•+ČĘO/ņŊD.XÎĄ\O*ŖÜY!B„*C‘|=ÜN"lōĢŧČ­ŧ˜]šÛ4mËIvt×.ʆ äY×ÃAÚ¤É/ Ė˙Ų{¨íŽĒ@ķ&ų3B !™ dA$  hQ DK(ĩŅeõāD/íĒUŨVuu[mŖÕÚÚļUH—Č ˆbĄĸČ Å, !„@IC !’~žgßûũ_~Ŗ’î„ĩrŋī}īŊįėyīŗĪšįž{_A¤¯ũ9 <ÁšM<j É:>ms°S;Ž<‰ņDÚÔ ū‹Syč'eJö‚†HYfT´î?{ŧÉĻoäVCŅŽS" ËK* *ģ ĩą‰ēΌŧVZ Ĩ<:ŧFTĖpž¨Ph¨ké+ˇņ;ōgPŠm•”|X60ĩLRa/9ŽÃ×G]~S,28#›Ņ‡ķ™Í§D+Ōj™HFŋå@éŠ{”‘…'VéŽÚ2¸-“G\ƒŒ#˙ôÆÚ#ĸņĻ­Ŧ)fÔ™$7–ØÖ&“ å+ĮŌČMrTxwŅkt CĄöĻ%{a ,ú rJäržÍnåŸâĪ×ĐΉSŠ›<“Ÿ¸˜ÄŦŌR?k"~ō…Ō#žŧ8pĻ<œ8ŽøIĪ×A(s“4ĩ-ĨÚW9ÚV›Ē! Œõ+i(‡āÔ~’oå”éëČQ&ú¸)#+ėĐ øĮļĩ™9…–`Âđ-#ūuŗú¯• LíāÃįKąb˛įģ4K€B]Û@ąÉä̤ķÆ¨Ņkë zž‹zåņ"pûh6wÔŨĀģĪ}=ãi§žēÜĀũīzWŗŧžYÅjrÖļ70>äĐC–‡yfč7ŋõm-Ų0n˜?ö+Ũ[RŽdæÖ÷ÂûCJ\xaK4¨ģŽúpžĀ›U|¤ƒķˇŊķ †Ŧ#]ßÚà ׏Yé;@—ˇ€*w\Zōd^ųPds­÷ÛŪņ.4ž=ũ•ŲKŠwyÉįo¸š_ĸ}āi§uAãāđsWގĶxĮãô=uégÔFÔAŧ¯ä–yđƒ™ũ˙ôr˙īĮ/ËōšGbЁuvœ_[>b9öđ#¸09ĸ‡X]˙Ú×ũŲō`ŪKī€ķ:.œū‚ÁŽē܀]ũŅĒŗyõĨË:°|ķÛŪVŒx1Ѕ>ī &ļKÚëĪÅ Îs†§íÚ?ãÚĄ{‚ĩöŊĢ×ĢųbŋäŽÎū(—ũÚáÜ ąEęËKX¤ũÁ,ũõ{¯y-=7tqpßãkö˙Î}Īž[^|úĘĪ,˙ūEŋ™ŋŊŗ"œyÆ;˙‚,ozËÛįŲ{ôÃÆkAĪl-ūÜ%ĄŊŠ0Û5W_ŊŧņÍoįÍ?÷é‚U}}–Ā yš}ôō¯Œ÷äÂôŊī{?ôßAÍ-ČvNæũ\ā(ĮU,%ú÷ŋņ’äōĐ ģîúŌĪķ7ņÖĸW˙ņë°7?4wô={°yīM{ųÕéO%¯ŸŨõ‰ķ?ėKģ6Ģžæ2wõFĻĨv Įõ€Ũ5˛_¯ėvÚ÷~ėĢ7N‚œ\réGZތĘĨN^œ8ã|9Ÿ~†åÃy¤Ļž”÷Đ;Č~"ožī‚ –×3ũ=ßũ]ÍĘģ\ã„î×;ß_öÛŋÑOŗåi=ę?ŨįŪĮķcV߲üęoüwŽlvūîzČĄËĩ\āhíl`eGfœŋâŦŗ–'<ūk– ščšžÁūƒøĀåÄûˆŊ¸ãĀų)'Ü@ō?ŧč7–ŗ¸PųƧ=ĩįĮ?ų‰ėā’™—1KîMyMŸŒ~ŠvŧoÕ÷åæîiSúylgLĖÆƒ?~7ļ žøæāĪAĩņäEĖļŨ;1ÚÂ;æĻ0 ŽČ€5žģ6KoNŸēLၧ<·fŊ[$ÎQGŨŸ˛ÜLĩ9˙8kų_ô_RũÆsöˇ4č?éū'ŽWÜüą04oüâųa\$ēY¯žŊČW‡FŸ//bÜnŪ{sBŽLnōîĐB8ßnäg|.§n.=ŌWÉ€ĖŠūŨÆvä2jŅÅ\‹}”ÅÔØ8ĪSn)_č÷ąįđæd×ļsVŪ J oŠ `ę”Ūq3ųÃūRB3FJy΂¤Üœ§3ŲA KT*?ãF =Ī0”)ļÄ B‚ä3qũĸ\LņoqËwælr¨6ˆŸ°ëėžĮĶn&Ø?´Tg%)Nt3Ō𷠛gâ-¨ĻsŦū#ŦŌ$Tōįˆ$ōĐ´įŽ0Ž#ŨæM@ÚP€åŖ ĸ0ßĩoäöŒ_ÆĐˍā)Āü×ŊÛųÜŸŽâ\šälYaoĮãJV~ ŧ %ą„dĐ[I$pę!œC`žøĶČ%$I‰‰‡~č+í*,.n=—¯@ōtmĸWv‚G å>Áô6ų„›ƒu„ÖŒNųĘ$Úō&pmÛɁ}:9ωˇŽ,>æeGš.”Ã6"iđ“‰3÷€fg“ĩũ EšõBčËĻÆ_ę­ĒÄL LÂC9õ§X[#Ŗ6`ÔĶXĐúēk=ā8ÚCiņ1W¯{Ø×6Öö9åĨS°Xļąe'éÁbbą¯ Ë ē/mNŖ9ļJ:Ęí‚í0ŗôôs/]ĐÚåʗQöĐA"Äe*CpI(Λ‰Ą~Ãđ•˛‚n¸Hž% 0`ÉcŒB[˜r:ÍdÎŧ‘Ä(/BŅĨ0>´č ĶŲéWąÔá?ú#Ë?ûĄ^ž•å.žkũ>÷žwōŧ‡%öŅ~ÉË^ļ\ƀė›xk‹g:°˙†§>•™Ûģ/ŋøK˙÷rŗīžMå§ūõO.7\uÃr¯Oŧ7t>Ā ØõÛūÂę•ŦŖ˙?ū¯_ZÎŊø’åšĪ|Æō]ĪųŽåŪ,úīZī5æm@ė~†yúŽoũ–åP<ũų_üÅåJfhį"âĮ~čŋe@}ŌrŪû.hyŅšįžË ņ7—Ģˆ=wžķėog†öhčĐ ũ.\ü)gy˙ˇūėōnō|Ë:~âĮ~´å).ņ}õ/åĸâmųŽxü×?đ|fĻo=ø™—lF›‹môöwŧsų,ėWvqåR¤rôô'}mįž'ū‰,ēĮ‘÷Xūïŋˆģ'pgƒ×F2ˆu‰OŲâ–#Ę[]°ÕόyZwĩÍCF”¯(G ‡>58Û¸ĩíSÎ!6ŠŲ‰hÕn3nCũ‡ėk‡ģđ#:”Ĩ[΍ËČāaüvÁUÍųĀ{ļ…Č0ęonŽÄÚûđ+ĶG[]û}įæ7ˇáŊoJ•ųÖüĩã&Aˇ… Ž’e­—ō{ŽĐŽ3ęŖ=ŲXŽŒš–ŅPn5ãė^˛=ƒáM“AīL\fį|XŦįw0†, ؤpų›ų3ĶÚUÚ7YW~ŖmIŲĨozĀåŠÁb'aŨ´!…Q`vģcy4ņĄøhWqWûŠH*krp1Ë šōv‡ˇsžŲ]x$6ŋ)›¯ĪģVІ[ōi´´XyĶ‘ĨKQĸԐ_ĶH <ž:ĀíãMIâēÔ×ÖØ$›ŌØ ģ_wͧmũ¸GZà 4„Ĩ€˙´)+L!’qŧĢ8r’ļĐ]Ú.Ą¤Eō' ĻŖĨÉUZNŊ˙gŒuä5Ĩĸ„$'Mg ūmäŦļ øŧO›úėą7†_Ũe¸ōÕ,¨N’Xé(§Ũ›#IāƒôÔðŽ„€l@Ĩ܆†œÄSwe1<‡WÁät!td=5+p2¨uâ9ČŅ^ĸÚŠ[žũMžž'P҆7įÚB_” sĻČ R^ÁԄ=ES§Õˇ7DČĪëâƒcīÔHS|‡Q:X•ļ‘ōžM87dŪIáÛ7hÅ-{ĀĻz -sNÚq6СVkå˜öĨø S•zC͇3ož™dbcĀŌÕ™ƒĖsjģڛ~™XōoļąŖWķĐß:íšo°› ųü^BKyŖobŖÎz×ī#ģBūŲBä§˙Æ0#ŖZIŪDį€\ m ÕÍ^ÃÛv!+ũ-Īkž* p‰ÃaRŅ#;éB€™-}åÂ+Đą~Hl~‡—6:@û(į]ĄZūĩüĮĢėãųę@ā8C(×#šĐ!<ž–šļÍN<ÛGFKBi?Ģ“E~ÃC:žxqáš[ŲļlM](ׂ(  ‹WŖšt l) ۍô-á/<™Ø>aŨl+ÕS%maÔŲÜĘ=ŌĖfŠH\ŠLņŽ<đq!ŨÚ8ĮÚÕu­ÎjÍ$ÄÄsЧI!ã,Íę#,ĻbļÉô˛Zš•hÛąļĻ?ųĒĩ*›ry40ÚĢŌ]1’.˜K†ÛHķâE ÂÕrE*FđÃ\T˙&ũˡķmŋņŨ˙|+woüFî–yžmŦįÛņßˇß ģ˙ņ{žņڍˇ•}ņ{t5å{´ÜōÂĒpéŌb˙*ƒ˙s§˜ōn3ĐÆ?xĮcØ_Ô׎v5ÛÍE‡Pö—eāŒcĘ<•žšNüƒÖI ŖŌ>(d d¤åˆeyĮq2–=ĸhë¤\ÕfouåĮ(æ8ũĨw—8šŪ€Š~:"›w#{ yœl† y# <ˇÁŋ{euŠÆ- ļŨ>˙‚÷/?ņoūm?ĐôË/ü)x=}y#¯Ftë Úc,ŒW=ēŪ߇)īÁLøī¯Įô ĀŠA­ôŸõžŅ|~Âãß]†_zY¯Œŧ‘Aīũ۟YžæŒĶ‹ĮxŪw7āv°ī~ō´]ОŅ‹”h&Ū7ŗø.úüžīi€ūFŪĶķԟŅŗé§Y2äßāĸāŖW|œ •{ņJȏvq!­ßâbįŗ×\ׅĮķyŨäŨYo ¸Gō6 ßÖķá\ė3Ÿņ =¤übî†x—āŊį‡<\ÜbˆHrÆféŖˇÚ¤A˜œšĐŪx1\&;‚ŒĮŨĄļųw#åÛ„ÅwØöËFāN>ŽĨžČāI‘i'ČÁĶ÷”§ bĮhÆ´:¤‡â˄­Á7rˆã@ŨņĀÖļ<^Cpö ¸žcĖŽ †ÆdLׄŒūŌžžĘžŅ6ŗAXoėŅĢĖlh<›Kõ“¸Ųm=Y™ŠĄNíĨ3ųy&Ā,§^Ō6iā}~›HX¯cËFy%BQŨˆ•$™)_ŨÔėtZĻT2Žē…ÁąuÉâNéšCā :m!B 0āāĐíęmÍjŒDpeĐ@ĪAˇˇ•äŽÜ6Jā˙ Šu%ĀD6įj€pŽGÖ` ™/¸2Tķßf< ēąåđFŽ ]<Į:8ŋ¤J7$HŠzØ/ ú%ØÎF̐KŊüÚt1 ĻcuVMÛû/ßR끸Úb,j5<2%Ž–Ų<õBl<…}„@A;xÕXB‚šŪ\v+Í]įÕAGäŪK@:`OUhm*į„ZūT[( iúĘĒũFWŲ΍%ՊŌ‡?kI*ACņŨēđĄzfyŅ ?9ŲŦŧI‹YúŒ‹ŒŠ%Šh q/XŨÍNr^ãå‰~ä¯Ö{í]3ØĘ@āhįdŋ ×/a-ķ΍ Zũ-ŠņC -ŌãnŲI×xÛDJ Û[NÕĻĘA)n&Ęq áÂašG ÛEfLVY`%×â]X;ˇči]yĻUĮĘڅ2ē'<„aš‚+ÅiWøCÆæ põÃ68SąhC'\žĶXۍ•]”‰ĢLōSyčˆ[žŅūđ’mĶá]šM„ˇąÚÚ]ôĶ–ÎäģnŨ‡Uĩƒ¯ętF˙í,[9éū'öíģĪ=oų¯žįy­˙÷‡–\‹}ƒ]´žÁįrÖĢĢĮķžë;yû[ųeÔ{ÏĪŧ˙ƒZūÉsžĶC°×1¸ž˜‡kũ¨G-/˙šöv ¯äø^đüŊWŋēÁ˛~ß7īļ—?—ÆxņU,Īųžį=w9‡ßxč™g,§ž|ōō*pîĪ _ãéCŸ^H\Ųā˙QŧéåËË_ņ;ËCôĀŪČķIäŧA>*/˙âŋ˙ąå?ŋå­ŅQī7ŧņMËGX˛ä2ĸo|úĶēč8õ”“—G>âáËëßđ†ån\đøÃTŪAđ]ö_`ļĶ⇂-ģ„ĘAģ¯ôü!ûƒwSŧ›qöŲÃ(ÍÖ;a€˙Á‹š3ņĖŗ–üŪįqĮâŠxšôįUŋ˙ęPöBŖÁAî~bB_ËØx$Bõ?‚X7ƒün;76Œ/cCÁ(+ŋX4DØĢE˛Ũu~gą‡ū˛}ē9Ļr%€[ 4­Ā÷ŨŅ×û$ŠÍ‹>rsĪ$›Đvm@ÖMNŅåyŨx1g8öĸÄö<šžÂĀĪ(ēYo`g` ŋ ÍļūÛX~‹?ņgE’„Ŧø+@'6ŅIũtReQdéte[ëÕø¯ąŽ„3šļō ׅ íéÁļ#%‡:ĶM‘Z~QE]}´ĩŲ™ö$ ō8.Ł#Š…•‹l4gĖÔĘ ëkDŸ ‰[\Ф/Ą­j1—›;–déL˙„SH r,QVZ.Ā~„} fo§ī6ŖīNc­”+Œĩˆ,;õĘKĄ†ˇÎw–<ŖmåŅ:â#›…>°Ģ SDʅ‰Ös4‘o÷võ ™¯oŌpLwÖN@HCä24õož4žt­Ŗ^c698„u@¸‡ø­­T„j؈@ (7ŽÍ‹)ž! j‚ÆŌÖ /=˛_úÕ•ÅJŧ€īHAÛF—f'Ā-ūæ?ߨ‹ĮĀĻŖ2Н#lh5đ°6ŲĨ _Ng9 öPŽtz֧̃œĘą!v36|ÎĮ2‹ĩĄÄz…ån3Ø{"ą„ŽcNĨąŋš‘ōÉFs.m=.mŽa _ųéÕr€ŌC<ŦĪ/ Yfą Ŧ^(:Į”UũĘŌS/} Mė*Ÿü @´Güė)=,r\éŗ%ThmxAL;ūŌT…Ö82N“Ažų}øz ]ŒkÎđš?ZžūÉg/OyËsxõ]ŧúōuoüķåŒĶO_ŪôįŅģö?ĘĢ0¯æ` KãŽu÷]ūÎ^ŋ╯\ž ū“Ÿôuũ ĶåĖ‚ģÄåXÎ.yņõ™gžū Ū!ôQ¯o™‘Ī8p~éË_ÎLøG¸¨¸W¯åDƒ6]Ŧ|—]~ÅōGü'Ėę?jyĘŲg/Ÿį™…ßũũß_|0ųÁ ā߲žŠôŒ‡‡pÄᕞ‡ą”h.ü17ƒ|—$yĮáRŪPt8ĮŪ9qY’?>åėŋ3˙žõÍ\xxáôž÷ž×˛ vÚŠŊÃūˇų*dô7|›Ž‚ĪRƒŧˆ5t6˙ē˜/íãfh`h‰…™JĀŗŸg™ˏ,ŗx­đ€­‚9ŧëüNes„KËžđã‹×™â<ÛɃ0pęe rŽ^ÄëŖ˙­Ûĩí;‹ŦFOHÎēÆBæ$é×ë ÚËf,Đ|h f-’U1ęX ūNũŽ­|/ēšŸ0ŪúuûŸûIķ—ÄĄčDŸŋ¨y ?4*}éĀ{{đÜ~\;4ūĨßnœ‰>Ž“|Æ ~ŲLd4bœrĻG•éGJ•5QÕÆö1yhĖäęÆÍɑDb€.ž„Va96ãzA†„Š ¤țstö5üŠ6ũ7úr,™ƒĩî÷I%Q1zZŲčĶÁ-Ėø œč•DŅB ž2 ^E×r./ j|„ãÎC…§sžēD“.ŧ4ކnˆƒeXGlP–f§āI8𭠔ąß uÄX~Oƒ7ĘU˜fŧĄ—!ô2H4›|2Y1ž§Ž¤Ŧcė!Ī FáäWŨĘ߲ô5ú8Xuz^pM„ķáhč˜âø'+×-'õ–Ĩ›>˛CD‰c^ü+Pöoā,[åS>ÖOĮ$ÁNnIV‘įYvÔŌ¨\ü;Se;ŸpŨY?¸mŽv)0¸WŌĩļ/ŗH ō"Ár|O…†kęfĐ. 2RŲŒįđsf_|xd—ā tOktW0O•Ëml‡•4ŨˆlŒE1Ŗ<Ɵ¸]İWw;Ŋ Žöš…“I’ƒUaeã?:ƍÜv’” sCÕø[ũ& yÉIŪņ’II\é:Jœ8Âŧæ2vÆ× ‘ÂHę´Áƒâ$$či_qnaö/~ÚüyĐšģÄČdP{Bõj€ÍžqƒADsiÉÕØWŅu|sĘ8iĐëŊŌJcŒáä˜oĨ%]O¨n˛rā,… ė!/Ę´‹ā’õB‹#ށĪÖQąCč@HbȊ­EO9†ˇą6ŧÕĨ;KĐĖŏ~Ú2‘d¨> ŧŖ$č+ [åÚĩ3bÛxÖ$ˆ˛äSeƓž¯ÎÎuqÆīæÛŊy¤¤´5¯r'ēb3J¤ÜąŲĀ\ŦåÔ7$āz6a=5ãČl°ÚO:ÅÅĒúvōĄāČCŦöAeõá9îõŨüŋōĸ÷ęÅëŽŊžu÷.›šâã_>pņÅ=Dû—uŽ{@•ƒ^įųz.Œ1_Ģéā÷øÉ˙y9éÄ–‡ņ{žÚķΘ=wūģÎywŗę­§‡īk_÷ú–ų–›ËYĸãúx—ŋŒ"žÅ<\Žs>¯î|ŌøËsÎ釹>ͅ…“5'rį @ĘÁü;‘Ņ×=:Û$¯íôŧ\›÷ŧķp_~“Ā|ßū—īB×{2ã˙Ėr K„î] štį—~å×zs Ęâāß~_ōōßînÄSŸ|ör,kũ÷ÉIŸÆ_1ú:î$ø~{Ö?īģđ‹o ROŊũ!.rŪĮ›‡îÎr"cõŊĀøĪoá"įĐ~UW8åöbm`ŪpbĻ4Ē.+šęÆ/çßČÎJžĮ|ē'~Š7éÚ °QõQƒl-cČԀŠA”ėąģąí0Θ‘ž2fEdV'ÉUÎNŨËâ?dËõĄąĒ¤ ´ĩ(Ûüa-qŽĮr(Ës7īJČ:~ß5īCē>T{5K•Į[xÅ2žˇŊũ•#u@-:G˛LČWēž_{mv‡ ߓe;Û@߇s=>—ģûËŠ|ŸDNīÎô÷Včųœ„¯ÕŪnÂ)kų ?xáã…ËuüB­›§ŧŗåÖb˙‹_Üh<Ę›‡)áÜ~ąn…¯éģ&&|Œ¤üˇú}ˇīęŽ¯;Ĩ&ßå<ä›ÜHøā[>ÄîÆįøŋîô§;´2)ŗmGŗ¯mņå¤fŋŽŽĐĻ­é'/ąõąöaüB‡Rō΀Æ]}4'/C§ü­\ c$R‚ėÖKO¤6ēƒ/L4ã#a`õ”‰ņ$/ŧžÍ’Ųú•‹6”ŽË´ŧ8ÜÎåXœč×HņĨTu•Æč‚ū jŒ0ßAãEŅ•m Ą¯ĀʓXo0“|ä¨^ÎhR;íJ‚kŧuđ’?ÂÂČqčį'…›ÍD­zF„"/Öä["P&°B)m&¤Š2H…­*) Ŋô íRg*TTN}WĮŊų(‚žũŅ×ŌAv åNe‘–0ĩ銡5ËGÚԌŽĸɊ/ËŲl1…V‡8Ī%76˛kĐÚ\‘ÕŨrãĸˆúķœ9Ž\>â[įŋ6—įĘQLô5Võ.Ę<Č%ņ´e°ƒ/2€ÕĩÄēĀzåėŌĩ°ŌŌū2‘OōÆYY,‡’{îptFŲV„h‰´Å‡<…eķ•vŪ‡In €Éė3v•°V÷į9 Įä^Î5¯0Áŧ0Â_áW|ũMęÔ&¨QĨ 7°9œÂlu Ør¸nZ[ü™Q’Šö:“—¯Ø/ÛvķØ ĢļcĄÛ&ŧ’ŒĖ[{ßãî¯ļū§?ū“å\–´œÂƒ­ÂŸĪ{ų/eŲŽkŪô§ë†§*Ā¨ËžƒãŸĪķĢēī˙ĀE­—/~VXeq îāÛu÷ļģÔā4øG/7ŸãđMBNVív/zéû@í˘š˙ĀÅę7”WYŒˇŅõÖtnä÷"äŋcį¨Ũ&Ž×ß%§xŽų˙Û6Iš4lŗüØá ~Ú Ÿíy2õnĶVŒu#Hƒ(ÔĪQoŒęīîĀ#ŖPę06į÷=%S7áĢŊŨQ‘įnŖģžīTĐũ8Ō īĪ_w},c}îíÔ2DšôL¯1ŗéļúz#d5‡Ā„ FŖÅ㨠zå jœ`čąņœØ*ထ\v.+Cå0¯Â`ŗ‘ŲrcX õŦŠēĸĻ›5ö7ė̓éÉšr™Ūꃂ`čĖŦ;TAōNBäŨíĻšō–Ž4ŨCn[cA;X™`ÄGļ›Đ=CK3D~}Ūxû2™úšÕ. 5Fn/l8jr›2šMÛĨúõ}ąŊ”{ߘd@Uގt“%ƒüCPŌÄ=š"ƒ†SS Ŧœi`ŲȲ•W{–Ŧ‘5 eņiÆEÆĐ2Vī)Ī™Đ”UƒØį6ė€OÄ?žÂ(WĖ•‰úüŋ)ŖÎuu$žt,`qöyfā4<´uõŌG*ž9ÕAz\Z:ÄdmŨDÁė×ãčJ_YŲ tĨĖ ˆw¤JÎ̎CA¸‘Ļ÷úÃBlŽįaÜJQf¸CDYčø­Ã%ũb[‡Y“ŧŲ‰ RķÛąVJõz H‘P)3ŗúĨë&å°Ž-ŧIĒø[ WäÜāWgäė*^ž2”ÕMĒĐ­``Y}Y¨ÃkëŒBddæ,dŲ†ÆÖޞ¯ŧÔí%Į,Æš6ŅÖÍ2ËÃēlE2`• 5U4y¨ŠÖĀk;8]3|FūÉ–ˆ‘š§[ÚŠ*Ī”js>âÛN¡¸é‹$†xæ_u/§Ņu1fЁīąa,†Eûļ]úôÁąįĪ‹—Ú”X€‰įgîÔ˛ŗ¸/ĮO&Ŗ%'|üĨ8)AXäÁ ö@Đ`M5&Aؘ‡fŗĢdf xĀĪFĢ ÆŠĶ6ūq,KÄJ>Ōą&ĐļV(õ(Ytą°Æ’ISžkōRĢņ•ˇ•}[ƒXîb—4PÄĄAkr9×ōƒDÛváQéZá I_ ˇūŌôŦE¤M^÷ŨŨ"ēi/uīÖ#įæepœMÔWNĶë ū˛%§ Ęuĸ´ZNĄÔZËE„ūKöžļˆ’'xđņ.K‰kÍGōk™Ւžšjė4˛ö ķÕôéĄ\%:Ą˜–ü\čkŗÖÉː­īņj]čČ ķƒcČĢܑ¯†3+-fŗÚާĨ^ʕ–Ã_r^Ü w1ÉE‹0ŽÁõåĐp?ņė>›ĘĪMqÍôÛ@IDATĐ$š “GŠEA”§d•AxމKšTD ‚ĢlƝ”Q/qÔ]zō8ÉÁģ¸Ufkĩ…õ‡| wŅÄϰ-ŅTuUoĘFÆcãš ūŌ‰°ˇŪVn]ø÷œsŽ}?’e8n71 îrÕúûļM iümÛūtļ6•~ččCÁ^đy§ üĄ›øĄ#ßĒãŌ!/ôÃ5×^×—eƒŋ!å›\ûƒ|1rîûwß?Ëú>–˙rŽ1TÜëæCF0ļëōŊ3-ÂyŽË9dcŲĶQ‡-žår÷{ąsė=ˇYëgŗî@‹ņ˙ÍļŅßöˇ—UÕH§MŸÛ‹ūíMgˇūĢȩ̇â„oĖ—.ĮûĐEgÂņ€åÃl˙zŌv‹˙×I€íŲ/kŦ˧öĢĻ€ŋî'?„Ė Ží7N"\^–īD„Ëá›ŖdĖĪwpßöc"u&ܰSīĀ͸lcÔüŖĀÎoˇĐr-[œhöŋę‡Ü ûĘákđĶ¡)ŗąīš.NtĘ8´?ąŧI@í:x@ųA.S ÜÜVŪ]ų•SŠ´ģžÚ¤ÜO_čÄČFđŖŒ}ųCĢ)ã0Ų°‰äØ-/˜ō÷ß.VKb¤žiė¤í‹øFXä?„Ûw×_#o¸zyôc°œqÆŠ @)ÉÉoŦݯŠŲ9í@)-wīļf+Û]ž•ŨÎF+Bû}íÆŗj˙ķ <-W)v˲¯~;Úˇŋ5\Î\+',7^û0n­įîōũ7ÜMߍ—å8G}ęSW.ŧīCË_žķbu÷å¸ãŖ“㖍NĒų,ĀR`i4m(ĶΆĻ8›×ėômĖaIֈJÖ Bĩ˛Øš1Ã%Uƒõ66ËÅĶü6Ž`āČŋē$S*Î)´rENšōō¨†bõęZ„ļŲ{‰fƒčJÛ'u+m€ņäđ–æ$Ē8 îppĩĀØ)ŊVšI]ŪĘm€ÛJí nė Ŋ]úŗķt$ÕC_8xQŽ_L`I1Auĩ^pāv1žŗ¸IHĸ“X ÎF•@ĖEŅØÔÁ­Úģopߤ Ž65Ņ _ÄVŋ „äĀ…ŽvíՂŞĘ/‘•§‡ k‚ŗĸjâĢmŗŠrZ‚0ĐG†=^DĀģ%m [‰2ģcWgŪÅ7Ö SŊ8_Ĩš&É_냹xŠĸĖ—|ˇ;6ŲKĸ–66Ví†.eÔՑČĐBvYÕƒŽb¤U+¤ĖŽ/ĄV^ėIÄH˙1˜ū‰friGtR„ˇĐļ$> åZ¤8cˆé(`Xü!į->s„MõÚ$ßČ:4ôžb/û<õnđ6 âpÂ.ų:ãĨ^$wlį*ԔŒÉ*¸đvÎڒ–ņđÎNöŽâ*í—.$ö—ķK%ZŒ`Įm@61NtÂČvb´cnņ´#Å ĀæĢģyČrŪ9—/'vŨrÜŊ^>Ä •<õĨŠy‡ákup…ÄühÚ&ĘíÆX=Žšú:fū?š\wõÍüXžŋaū\ķJˇ|ŊģËYō —ZHۘ ´M$}ŧËĪ[nĄĖüå 0î8ÆR`náĄŦ—Ī^åCM×,ß÷ü§.§?ø´ÔRˆ˙˛mÕ*Š |ģn퍨įnŸũë§ö‹ûž=hl÷—Īōūļß`o{ŌÉ÷_zÖC–‡?ââåÕ¯zËrõgŋĀĮđĀ—ŗ°¸´ lté"-a0ëúŦÂącÜ F`VSy5_DĐp'0A Ö 2Ų ”Î FQ>ۍ‘JækWÛĻĀÎ!ĖˆmCuaÛrkāÚüįぁ ŗt‘¯ĸÎ ƒ2‹áo§â•ˇ•5ŒöĻĶSeŦ(%ęáqôU×ŧGF€|M, ?`4˜2Qā8hÔé^›xkPšIˆĨ:(ŸÔø%IHœÍP¨Š“ĢWûęĨ™-—́ JÕŨ …XVЀ˜IR\FXČÚėĘjv%“Y~Ҏë€RNČ&O“§TÂW}Ĩ¯)Bļ-ĄŠ¯:˜ˇ í<Mô™é*•mņ(”›KWģh4;ĪņߜTEÆ^ĀmŋH>2Š”‚A’Ą™Ü+đē)×(`ĮPWvuNėouÄgĀĖ␕Dņļūę¤ Ÿ‰ņ‰=%č™cu+?Z ŅääX÷ԑYĖ.ĪÖėēÅ9AŽž{ÁĪÆš˛)G1JĻŖâ\˙’4…–Â]ԏ’)ÚŖíš6ՆÂ×HĶoŲržsKãT_k\nįņĐgkbQ6i$õŅ Ø°1ēÛGŪŽ[:ĻÃíHĸú×I_éųi–G|đAŊžķaüęī9ī~wŋipĖ1ŧĮ7˙\ÃCŊĮßû¸Åׄž‡W~Ūpà =ģ0īOÚÛQĐۑ6´Ûõ›ņ„Įŋļĩ‰UmSŧ €ë8V;ķ́ä.'Ģ.:īĘå˛K?ģ{ÜĀC`h} ÍĮcžÛAC_NQåž×ÖjęCā ĐÚÃZˇáÕNæĢš­z؉´2ØÅĮèƒUíáæ-ķæįŋkšįŊN_îwü=x~…Ü@ኲ×Ē֔ßŋt´–ĪnĻRØELŊåīÛ .ČĻëV'Č´B+9Ë C,vĸDĪęąéĩ×ܰ|ę_āô=Ëa‡ŧÜhĄZ=GŦņĮŽOĀ7wČ<Ÿ{ČķNƒÅqÛ&ŦüėxËTü€#˙KWzå'ö{(s5ĸXĻī!?įŠŅ'ˇ–ĐØFųŨüc![îž#GPŧy­˛š¸3^)'Ųɯ>Oj빊ĢėĀ­ĮÄü‹ÚŅ $éËØÍĄ.b'åUVãmP9ėĪĮ~iA}^0JаŽrÉI|õ‚āäĶ‘“ŗú'nãŧŪtŊL@ۜ Ή›kƒÔÉf†ëČ×ķÃ)Ī˙§/g>ôÁ%ī™ÍXēk÷˙ĢtŪÁŧĨâŦ‡ÁUųáË/üÜĢ{øúđģų“í4ƒmįĩˇ7,f`nqĸÄĶą7—š%ÔbÉ âß@pöü°đM|VûŲŲD*Ę+ÚDč,YqÎWe4ˆi[üÚč@™Øl€ļH ŽŸæ×í,V\;PQ&õIRĢ8˛!x^t8qđ>„h*ĀģÔc8×nn$› Éį^Õ¸Vq’B×ę3!D’vā%dĨ”2ž1Œ¤•vžƒžėŖœ(ėEOIGģH›ķĘŦÆ/)”OOE€ŠŪŊ€U.äĶVpé"$ûB+û‰’Õ][‹Û0/úœ4Ģ/=mPūŅ;KrŒulĀm‰-SO!2RΝÜtΎ-´“xę^Āpœ ˜ņ¨›zi(Aähä zŗĀ á‰#}č'Ú‰›ąåĮĄ]Ë0ØPÉæA#ŽŗHŲXšĒŗņփÁĖŪ Facũâ{åŲ‘Ŗ*ΐŅCušÎ š›ŊĢ›x°žUôMw°¤ÅiUō˛€Íą­¸ĸŪ?qã%OĪáŅžã Ü>˛/ÔŊvnâiŋ|´Q;´††î–æĖöAOpŠzã—áëš|P zˆ>~ËŋĢŌŅĮ̘Ž@E!ôaeG+ž<6ím|\ÁëFķČGđ;gôcZ—ōŽmōĄ}˜ ˛ƒųŅ­O,~ÄW,æ7.ũčG—wžë.>ŗ<ūĢÛoøN—+ų& ‰ƒ;§ūúˏąéĸ]܎veŧĒ÷ÄŠąĖ1đzŨļ¤{ öúZb,%Ą˙Ús o=úÂMËG/ų|“ FĮL6t”3đ‰-ŸOņBלë˛"hĸ>…2Ž)āAĄ^Xw0‡ÎŽwĀƒÔļA7Ī;L wᘈ^‡Ątąą æot;23P‚)—†I¤9l§h‰:÷Zcōe%?plŨtQ‡ˇ§J7~嘯lŌÂ~D{ÉĖ6>‰Á—gY7žŸ™ëAΰą=ŸID‰ˆhĪģ*0˛šT”S]ŊC`Š6.ՔôĨŨ5ģ4đ§2ÚģäîŪÖ]3ŠúÉøëÖ1•ÖųjŌYëoė@G;P§íęd ŋÉ*į>ėęđ’™"aō'6‡†w.L„ę{ā-,=ĐnfN5įp¨Čøĩ=t‡¸ük9Đ5vYLpÕŊ|8Čģ_›†'ØŅ×g=p›!WŋN/•ßlŸĘÛōŽi6sĘ^>ŞpžķUtŲ&9¯R” ēŠ§ėÆ eɁlúE°‹WQÕyî|é͉ũ)V´ÁHģ֕NÖJ[ĻĘ㧯-–Š7ļ(_"ƒũH6ÕŲ_.›6ĞWąöų˙ôģųÕâĶ–ĢŽēj9íÔS˛õõ×_Į{ū/é5žßĪ/ëŪ—õēöēk—G<œ ~āY˙͏/?đŊ÷¯īü>€Ëƒ¤ygŨ”,ãOۖįë|mĖčžU|}ę Eĩc†cŋˇöîąo4Ļ}=éÁ‡~´-_Šm™X^•3`ZÆ@9 Įg“§YŲPđíŲq(ļ9HōÚ=܇2AGB6GPn““oÅŪĒ$āˇŽŊÛrūg߸ŧā˙ryōŲ_ˇüÔ[6˜ģ39wôQ“†ŽZÚDi[ŌåXjhá-b/Mą[j\-Pn\aÅR4ET&4¨m%™ˇäĐAxÛļĒl|„ŽB"ģÍ>Ö)‡Ōī]a봓įbÛÚ0ļThû6ŠļdSzœčęų’ÚôŅâģ‰ĩoËg”ÉÃ~¤žQÁ(1à ÄJ¯€_ũe™69~¸îRĸâÅQŠ]i;đÕŽ ?:Æ)<}%¸yŠJâcrĨØę;õjÍš34YyGĻ(ë—ĩ|ėa›˜˛ä^ņŦs\$wcb]ėÚ>´Ą´”%8ÎoqėĀy1;N Ļģ'ų­ė{¨Ģ*qäRí{IS.“õhëquāpŠ¸ŠžūšŊˇąœrĘũ‡äP2úŽíŽąržĐ<⤠>ž¸–wdž‡r“N^°6đFJ}ĻkkF|ęL(vŅĸOP&„~ŊØČ4 Č×ÔųoĸЁyŌ6ûíŦbq(÷c’…"§ ˆhQˇ6üĄGāŌ)ÉßŊi˛ŧļĶ"LŖQŧ4™r@›mÃbœ÷ōĘO“VÃM†\žTéŒm&ĐAh`(ßhԙ°ōAž}†ėĄ¤ŅØ&Á¸ÂdOÁĮ†›ģʒŊ¸ŌP~Ių͖iä6r9@ %–ž´Öæ˛tķר -3Yæõâ%K!˛|‰ˆ Ŗzh#Š@Ŧj‹ <–‡ ņâ­,ō3qYb­rŽtÚe.TdČ Ŧ`@ا ŗč+’ ĪRZ6 ÅđŒ>ļR>u×ŋ770ĻnķAļ'ĻØĮYģ#K§H”ĘÔ¨_ļDMęo1øĖ‰ēeŅ䞝SžÉ*˛Ā›eC‰ËWš(ĐÅg ~ƒ_Ņ@“ŗD 4ûEÉÚ­ô‘SēüuģĘbW¤Ž3¨ =¯]„ī QųčcH{ņ#Ĩ Žēq(ŌÁËʖøAÃķąÅ*?zŒ/Ø+¸ŅRā%õāIqëčY,ŦŸë ;Qiˇ¤|Ylˆéo\ōŅ˗úĪæÆ¸üƝŊhųĶ?ëō„Į>jų_˙õO.}đƒËË~ķÅËË_ņ;]üÄŋüÉåO^ķûË ~üŸ/ĪãGĘūņãšÍ;÷?Ξ}ū–?`3_zgߌ›–m6(ŧúŲųO‡z ũHģ0sŧ ãfV !˙›æū™%€ŗŠEĢĸ͕OÁĢ^byŨ nÍw¤­§Í˜; Bf˙NđîúR4/¸ŠļŖ|7R9yÁ87€'žœjōAņ…ļ„lĀØžlÖÂ]…Ë?öš$xÎsžuųĢŋúëå×~ũ¸+ô¤åōË?Į'†FķõØÁ>^+MĨŠt÷5˛ę;ĘÕ}Õ6zY„M+Ũ Ûä…bÉ,Û¸Ô8Î|đ0ŽŊ:ųDœiĶæÉY6Œ,Úgå¯,- NrЎ´{MnÚęôąų§Dɉ/m˜ü_ķtú‰Žžûļ.Š8MxŌ7;ĄÆŸ}­}ēšbî`w˙ÔCuÕžæ÷.MtרŖzüfõJ;›ÂĖ?„ƒ‡¨ŖŸEnYŨÄOę̎ö•§ åâö áŅǟö™äWŽCŖ‘SZr•ˆq$Ž9:ėbpØSnŋE$ßĻË8H`¤>úĀĸs`MįĢđøOœÔVÆÍŠˆLQĻ2lV “omč-mb…n Üđyæût€~îÚîx čßmíOŊ?ô÷ZŪsΕËũîÎ2 |e2ô Ökę–ŖpN ųO‡7@ôIũl¤†ģk˜—°6.ƒÅ°š€)Œh €RaāíÚ*\Ëbq×Āŗ!Y§õâՀ@6LВ3t•aëĀŊœIB iĖkCQÆWVaĄü—_L* Æl āĨ@Ō‘gđ<Į¤|ôZûž"„îļę•Q}ĀÚ k/žc˙I†"…ũM—é‰íÁ*m`ģ3zKŌĄŸnR¤ÚQŗĪ,öQ>aԉ}ãUtl¨Ūų™rÛ1ôR ;4åōb'*ņ@˛úČ`gž¤b'€ē™8ÔŊˇ+ Đ øŒeļĐ \íīąré›Už$^łƒcGši§q;&!ĸ (5ŗl9ôwŪd¤ÜČhČĪ”Öæ]ŒØĢh­ú ‚éÖXqŦ~*#ĩCŠÅƔ…ęëCiŠb’Įā+Ž%o˛ë eB6%đ>Äč čØ=D› ęØ1õI‰@šļė’ĩ$>fęŽ`0ž 偎6ģ놉Š•^%ˉq71)?q)]åĻc'î* Fxuę.†…b§ĐO8)l‘›ËåŒA?-áPÁ;ũĻėÎŋ×ąËŖXúķÚ?ųĶå•ü†åŠOxėōŪ .Z.ģė˛åŌK/]wöĶ—GR˙î÷ŧgųæg<}ųŽoû–ånüâŽÛ ÷9~9âđ׏ņËÆ>Ŧ}ļ—/ܙM œēÉt#Äōņ8Ü8q™ČÄĮô&‚ž7=ŲæE–ÅÆEņÄQchŌ8¤CƒPÛ†ģ‰ŠĸRÁ˜_<>dÍõåxŽËUœow!ũY^Ô=ŅđœG[âܝušŗ‡-…Ą 'Ŋ´Œqâv.ĸUGÉŊ ĶrÜqü‚ôeoZ~ūᙎ<ž|ûŗ˙5^ŽŊ†ßĀ&Û]8ĐŅG\sSRSį™2sž­@My*‰zaÍI ŲX8Ô0ķ8đĶځĩBŠÁ8ķĘup`bēqķ™- û†Ę´:[ÜäŒļSļäҜEŨļéˇP¯=Ë&Å-#AŽa üÖ­÷ŅŲÆļÍę€ į-7Šl\ˆēÚAŨ܌3õŧŽÅD äGķ”6UWáŠĪČ`nÜ@}ŋ;oŅ4ö6fŅW “ŲŒ‘mu‚|ļ uSĖđ(ốĘGq@{ĩ3'ēĸÉąŦ-`Ā+›wÚí'=v´“Ŋô›R§ŧ"ØßZd>Ígĕ}œōŦ ‹Œá'"›ļ‘ŋ47š•M?§ĘMû8p𿆛—St–-˜Ŧe˛jĄģžî@ ā¤Ŋ,aņsĮ ‚|luĒMƒ?\ĩ]ߚîÅāÖÛĪ&΁0 „ŒÂāplPÛŧŦj īmU# 4aûž=Äø›ĻZ…4lƒü™\Z^nŧk]6õvR¨qËŗÁP4T %Õ!ē.Ôûk˛@A^ŅQndkG=i˜(æIĪŅ.ŗ5Đ <Ú  (Cåæá 6P&“îXO@Ō-ce7;”ųXŨ`ZY¤ĨÚ6{íWÍž+ÉuÂAI1_ÁXąUŦ2fŋÕāF;8dD/gYĖaũȉ,™’ĮāhūfVųŠ Æ] ëiI&^ØBXĘcŠrHRRąt“ĢäC °9˜[\“O}:XnŠeaäÎXĀ$/įōmV>>ĐWl0šũ0qqâšlžˆĨī”´UËaØģHĖMđÚ GÚĢ„*.JŠ—ƒy¯ÅãÚVĒãX-ĒƒŠ‰Ķ-q.oKÄëzƒ RōupÃI6äKJ Œ+ū,ŲüQGâ­)ę6^PYåšŲ’2&níuĨ¯–Ũ§Z>ô+Ŗ“NĻsärí°›1Ŗ´ ßČ8˛å?„ėGķ0EOmCĘ?Í6ES|øų5V@|hą^Ձ¤ƒôv÷ļzÃēėņ`^†ņŠ+¯ZžōÄĮķđîįųuáķ–'}ÕŖúa°ûŸp|ž—_į}Ö7zĸ#3û'žpB:Úoūų_üwÃÆ-¸čâåČ#ø%^Ęķ­~ZpgÕßvf\/ Ļõ*íeî N[¸úEšĖP1•ÚZ…åFĄp'Ɲ~¨õbØ6gô c?eģ-Ī;ļã8šÜi2Œ]ZēåæHvĜôøŽŸGT”ۍsδÎÅŦšž–ĮđAëøI!^tPG}ørŪ{/_N9õk—¯˙ú'-¯yÍkÁ|÷ōPf˙¯`ö˙ Į`rK^ícŧ7⃈m ĘT ^Čø×ŨÚ.jFCa˛’"7•¸ž?zT6€V0i!˛ļ˛Q—×Ė-âYĮ–sC <ŒĮšĶĸîø˜ƒ¸…ÔŒ:ô4ˇ<Ĩ—Ž p¤ĨĪĩƒ†w ˇAd×Ö ÎŖÜKaüŲ a@|‹åW°ĶĻW6Šõ2˜”Õ<áŁ>oāÂqũI ąn˛pģ3í•×íŗW_ÃĀī(&^öōccņFŸ{-īŊč’åGž˙šŊöķ5ú†å_ŧā‡{.ā;žũėå˙ęåĸwŋmųéūėōáK>˛œrĘÉŊôŖ—]ÎķuÜ­ĨŊíoƒ;“ŪęŦœ#Ģ$1îômM‘éĨ_“{âUšã$ö•ÚČåųŠ#û`”A“ /qͰž ”ęĀ`I&JŒ}iä_€œˆÕī~ŧ+øøÍ=õæįęŦ@§øx¨Täy‹å¯Ķn”etN6!9¯c)kü‚ū%-öô.‘$nÃNÂõOĒŖ( "š;ČíøŽũk‹n á¨mPČÍKBeBWÖŒ&ü9D °M#  œ‚˜Fgc2J ]^Rđpz0ĮĻÂÂēŊæĪ˜#E›Í˜ ĸĶƒôj&j‰ĘÆ[)´3i–DLĘL"ی4%ɤ?2—h€3œ%ėšo|˜ŲRÜāIūčŠ}jü‚ķWRƒŽĨ;ëG9[Ŗ^ ČB]ģA[|˛1āPišjčZ\kt¯ÜÚNÆJW;ŽUu3q™pėØ4Š2ĩõĘņųĀ'¤+8ĘŠ‘đšŨ¯*Í,*vČ–CųPÃĒĨgū‰ÎCŋzÜGîZÖSŋ­CęøÉ˛‰"÷$?\:å˜ÂzmÎTæ<ŋQ>3<Ōæ#ŊĀLhę‡d¸øR6œ<§˜:?]Bģ‹0ĀDé_V[ņ„ßĨT˜#ΉōĒŖ<-+æŖĪšø5}ĻŪŌņ‘F?YŅĶ=•ņį[9ķY˛ —Ėå˜#įØĀ:1Ն†—€ĄŸŸÎÎs œ °3P–lÆaõÍļ)Ŗ1ĘGšžfą=@鉤ęŽ<RĮäR?rFžGîœb“Ûā bŠŗa6ßđʘ(ė;Đšpŗ…Ž„ĨŸ \Ü­vUGņŲ9`tTâņ—ÁĮģįŨīļ|”Ĩ>'0ŗ˙ƒß÷Üå¨{š|š<ãžÆdŲuË_ŊåŊņį>Įßgyáŋûšå‘ŧ5īWũ7–ozÆ7ôüĀO=ušæšk– ?|iK7}Žk.†îä6ĐWą}Žąåk< ĸĮqwņ‹_ÕÅXt3vMI}pÆį{VXg@ˆclrčŠ  ôŠNčy.CęĢ8*Ūä)Š36ĢĩįúkF\Ra{ˆÅÂYoXڎlĶĘßė,5“?w'ŒēS~ oíšīŊīžœûž7,˙äģp9ëŦ3——Ŋô•Ô,Ë1ĮÜmų‚oâ+‡H}ÅŌ0îí2ėmmÄ ģļŽf"› ĢmČĶ2W'¯2#Å^^ŒmÁ•}9Žc§PmŸpŅ'ŅāXE˛!ŧ B_›ęËė!-ękö°č]kčĢ—äË'Š’ŸĄâ ՓĘōũ7s*4ãšö9čœ_Į;K1’2!ˆ“‡Ę1íÃúP ē TdÖūÁRnŋāëDŨŒŠõ0Ŋ,ŗ^û[PîŖl˗Ū5ÕúbgLh1ˇäC}ûž?ÅÔ.œ­ņem‡Yô%ĘΉ„H–dl,5“1F„6ƒŒ„kŒÎĐĘxd_?ļ2éq´ņĘvK‚ũšŲwC.čĕÍÎĖēĮ^´8ÔWöˆH>úĖ„EU8]Œ ×Q0éÆ Ĩ• øî’H\ᤧ–, §Ú ō& }´˛…ž “2Ņũ“?°š(Z#ūĪ [ŲĀ›đ|(P?ÖqcšŲ”´=QúŲKz֑§ĖO÷Xįũ‰´pÕV9‚áÜ.‘4@W™5cą Aå…æU¨kâT ‚ÉēcåŌ¯Đö={ÚWđøKG}°‡ĩEíÃqúČš}ßEœąSj*oėĐŌ"ÄԈrĩ5o3{];œáÕ|ėão7Ã+:˛zaKAy‡ōĶ2kYxņĄÜž:Ęڗcš+§KäüSÉüíROĄ6o[Jc›Hꂇs(ņqŋmwļķ…[ŧqš×1G/oyûģzĢĪžđ5˙gžą|úĘ+yøķ~$ķŗË“Ÿū–?ü“×/§žrōōO{ęō¸¯z,R{–ß}Õī/¯üŖ7.ØÃ–÷ŋ˙Â娪î™īŊãVLmjīėī\úOĸm§nļ7]į‘ũŠ>ŦŊ:Ąk˜įsŪ ÖcĖ<ČžÄØlŗ|<ęj3nņX€Ŗ¤•)č+„mo‡Žų™œíŽĒä8y hĖ2Ēl›!å\Fļ ķē"éH”ôą܍×~^Í;ōŨž˙ųĪ]>ȃßŋđ ?Ŋœ~úyģĶ ĖĖOžÔ8˜‹ėéyŲįĢÜæ;ŲA:ŨˇQŽĖ~)bČRŊrXŽ|Ķī‰/ßé"4į5vĘ–Ÿv@_ÍX` n&œå}•cÆ*?uÉ" ļĀD Ęú<¸đæpķ•(ōíˇ/ëGũiūæLˇ)@^ØÄŽØ×XãŸņĻ•ߔ­’Ÿ2Ë•”§*ų.œėŅŪ.O—!ŨÔqn˙!ãV&ĮöŨLņŖÅÁfCęëëAnh/pRŦĄP§|]Č4āļĮÚAūĄ˛—}‡Xáqœ”ĀÖO"L°ØT\ķąÂ8û /)NĒ’.UĀ1ėá0kā/iŊ#QjîÚî` 芉(œĢGˆ@Î'iN(õ78ŋæÁzgŊģ5Ũ,QĨšzœČ{ ˛6ކží': XžÉe 4HaŗbÛ-Må(Ę ŅßÚúøDHZķąŅ+šž†ĪdŖŧKmO‚6Fëd8éĘZÆĄŽQ]Ō“˛i\ō°œso9fKlä~—Ŗ˛Ą“NvX]]SŖlÎ48‰?4BSū ØMËąLûŲQ ? 2ÁíɀQ†cG{xîŸ˙°Vųôˇ&ēę{/njāœ;(T™Û,ŊžŽ :úR›)ƒž´ßđ}ĀD}ĩĻķ‡I:ÆĘHÅ7Fņ‚ĢTMĸš€ĩĐŪO¸–(´htËQųáÛ;ēŲ}‘EģĻÁ–†(™Ô͌‚)Gˇƒã"ȝŪā*Sr"/N’Ĩœō€­&Ayžô;n–i E7Ņ[l|\Ô°ˆŋ‹  ´×ü N4I"|÷XTÛ ōˇ-cB¤ly¸PÎ>Ú T‚SæčZž$ų$‘cž'Pw…ú|†įžšAYxöBxQęCmÚGVëąčJGHø V§øØqÍļ—ČõčÆŧHYņzFŗUASŲ•85šXáœĀŌŽž+ˇÆr Ųá§mÛ¯§ zĢíŽ>GTä>†ÁûĪüŸŋŧœyúiËąĮĩ\đū‹øą¯Ī,Į˛4č(ęœ,û_ū÷Ÿ_žâĄ§{Ôrîų,W}îšåkũ°åĨ¯x•ÆīGÃ|•sێūˇR–“;Zß]ü9l°Ĩß8nĄ„øŌfXéZuķyĮ& 1ˇŦZÉ1đ7ņg_Đã¸H歉 #Įūĸ=ômeĶ´ĘÄŊ5Á’üÃėlæļ‰/ŠĖ𠧤R"&×|Č´iœŲFˆÕø%Ōô“§Ĩ~ ?ēyØrŪyoXūõŋúéåž÷=~ųg?øãĀûœäũfŌÁëĀĩ~OäøK~ĩ—˛{•¤Ú„¤ĢQƒĪډįļäĄN]&-ĄESėE5ĐíÜh-Ī™ÍEú„=$e L]Ø`ÚŠđÃCļųŌI˜: ëlįZM†ąģtbo@h?ë|ąB0²åŖzE˜)õģqʔrĻæę wĐ8ŗ,FƒĻjö­ö•å1ŽĮƍŠáEŠbÛbKĄgŧ)[Q ĖTa éŽj5QÎ)ĀĨŲŪkŽtĘx€ËÃVŲBļSé‹(š¸ÅŌķúéKéKFSüîũ'mA^EZ…)>ŲZ3ūĻ`Ék éę3éēDŧ'˛J*ģ}‡Ŗ?c6~ĪūƋ ŠJčÂLԐ:Ví5D:ŨĩŋƒėÛ­ ėÄ/Ų–ØpPNÖQ8°eøĪ™ô @‚Å>xV 9^ƒ(§{ėŋ4ØZÂQā{b,LR0åNĀę´{ĶŊņbīFež¤ŗ2"H=ö‚ŗ`uđ ;ép$Ũi”ûdsvŨ™]g aåB$‚`ņh8ŋ[w˛<˜lÛĀĐFRVŊœV†„°lķ@Ōđ¯Ņ i[ūŲĘāŊĒ‘$Đē‰čÍĖĖø§Jā&Ž ŋĖĸ˜ "/Uų­æ9•ņe Ô$?ØH[¯mŌPĪÉ8ᤊ˛§Zä„ķaĮąŗÍĻž)Ģã¤Gjđ)ãč›Ü3ų˜,:Ģ&š|ĢĖ€{Ļ4ãKĻČŗō<Ča";ŪpĘĸú“”Ō2ņŗK<ŌÆĢÃĖC‘Ž ŊŠ@Јĸ`ŒŠåąĮ<‰ [bīDc7š+ Ō‹@čfh3ÃôŪįLžßßÚûĖ!o|Ū?Ūäũ¸Īšī{īĩŽžŽu­˛×^ÚŌŠË¤ ļÂH˜Ks=­Ōpƒzîi-oųĒ4õ˛ķRKô˙‡fÕ÷SæŗčÃtū÷9ŧLŋgÖķû,–ūžTÛØ5v‘&˛h˙táÖIŖnÚNĨĻJWûČ;8Ā"—|ę7í Ė2`ō,Gih GũūĶī˙—vĐiĘKC -ĸ üxf2GL^ŲÔ4e¤_ÁŖGQz‰-ádBƒhiÔŖ’βįOŊķ ;ŗUžĒĖüKĸ:64ŒIę­Õ;¤ž×žI8é{jī¤7nŒR(ŸĐĢô ŋ֋´ë€X’J…Į*)r-.Pĩ7?˛DÚÔvL,āÔ2úŗwtŧkyʧļ¯.ģ…?ŸĘ`iēOVūegD9šōNŋ"m„Úyßõ86Ō×°šûOĮ Xēĩ,ėÜæØDnG‡Iˆ#4įŠ œÖÆģ.wŅ÷)];Ņ\Įj'ÖQHؑ5cÕĐqÚ#@N•ĒŠdš×Ū –­ô Ė &ÔĀēvąŌCšVĶA¯uČŦđ¯Nʅ5'ēé¸Õ+•/kȐĢ0˝=¨T?ë@‚ŊŪœŲ h™–Ŗd•/´ųæ:úņ+ī$XYá¨ë UGZĘQeH#'­¤Šúp-LۈÍ9ŒÅņŪoȓĻų•?ˇ8Éë‘zĮu ĻŒ¤Õ.ƒV> @.’TV˙Ōų–f’„t;16Ƒ‰tķÖǃmb3ūy.­Z.$ œ¤ĨËđHâ܀`3ô#§><åK^™÷”–F…JōŧTžœ˜Ĩ,$fĻūÎVaō¤{’˛R.e1]~\Z~J?Ž]åė!Ī ËiĻ?H‘&DĘ 8gĮBZ¯׏‹eä]û|ŌL鑷›gÍs* Œj˜‡Th)•ąĪ"4Ææ$†áÜËúSˆ;Ōpĸ˛tų*‚ÖA6vɌ“Û#Ĩō3GYŌáF>“Ōg€V4HZz^BežbPQ ˛Ü•3mrČë¯æM)â{Ūɒkâ'ļŅ5į*XqZũĮRk}TVФſ&ÛąË‰ßÚVÎ8dl—Ë*ģXú+™Uļ†ŸéúˇôĨ#žôp+•(ÎæKJŌĐI) .„Û&tĻ’)Äu†ĀÔ Šü˙ķh;˛ØsØ;ûđĮaũ1ž6~cĄˇœu` Aä,u‹ŅkNČáĪЃsĨx-Sän*\ Аœđ `´KO§9œÉ“ū§IĒü=ĻOh…öЎ„íTJ¸Š5T`ĪÁˆ{éœę! \ŌO%@˙4MMĸ4ėfP!(C–q%<„÷Ü_ųĩ¤"gĢmŦ%ąŖz™Ė‘E8ĶųMÃŋØI‰¯īH3,›ĻBdt5‘įąŗ˛šŸŠUĶņ`"0wrÍ3¨UeC1,dŌ‚I‚Č_m•Ģ|ŠQ:ÖÎĖđ0ߙŲŪtNSˆ€EB  m¨âäāDĪ1­íÖũãT†Oū‘! e#VۘŲā™äq@ãŠVFŪrmŨ:ĶAA49—Ží¤@¤“%UGž˛8 Ė0øņQ?T žšzĒ‚Āw?ƒ \~äP.c]į֜揿Ņf î$8f‘ļuö[ _>T=qĖį/wFrĄ¤É Nø #y‘&žGv ĨíLg: éOÛm”Øtė˜‡úĐMõɨvŒŪę$.6Ë(JKWú‰lŪīÍ.BJŪRá<öĶŪĘė=3}QYIŅ߸ļS&˜ČąÛ ē˛lÉ´Ūŧ™|ëÖíĨcãÖ2gÎmäv+ãĮYV,ŨPFŽRz÷éƒ yˇ@Lr´į˙#Ģōi"åöķ\ōšC^`*ŧ8Ī˙|tÚôŠũßŌiŸ‹^›VŨömw:VŽØPzõęMíĮ&æ`4å¨OKôWũąÍ˛Jz!¤§‰Å&dņ[k_ˆƧĄ%L6­N$Žõ?MJ}ŗz^밍‚&ĻpbPŽHīeõév N_ˤžķĖ3(}ú8"žWŪ˙ž*‹/)úĐûËÔŠGķđ÷ĻŌË;Äņ‚Xg•1õXZŌTw2ĸ§1DÚpŌ>ō§Æ%T6dŒ ā[å ŠĒĢ8~Đ+m„¤­žŅLŽmŖ  )šķį516X6ģq,KR¤!ˆA%Š&Ō.ˆl`ÕŅÃ,cļōÄ0Ļ™Ø\ÔdJ4mļF°ũ0ۜ›¨ŖŊ8¯ũŗ†>°U6Ž#"0ÄÂôÅȋ/Ä&#ŋ‡ēsčęŅ8Īs\øgCiõ7ËNŅIJ9øĢžiį%$í-ŊœUŠßšDzë ”@@žMÁßÉ\ūâÄGá“rŦriGˇ­ĩ|2Gƒčŗ]­ÔB“D›F9¤ŦåYũ#ö¨ Áu@g{ĢšŖ Ęõ?>\ĪÎN06RVö: ÷ŋr4ļQ™įˆa8k.ÉÚ}4đIāŧ6 €Šû,¸įĖkđ;å”PWŧ–“pĪĻáõÆÍ›ÍÉm2ēųz–\á…|ëŲē_ŋžé0´xĪĻßIĻ•>q gŅ|N™;‘ģœ<Ž”ē2ŋË)9Ę+/—‰\KŒ˛õƒŦ)tAaTוã  ŠøVgS)=ƒųÆ(pí úŠĀY_kKNō͜ÙláTgúvûPf5ĨbmŗVņī‘ ĮšƒENį(9Ę-0ˇ×áe',kįHj\ĘkëÕEįđ&0,Ú ŸsáĶúh ž2xR?ūŖĻ„Ŧ*?ļЊHåĢH\Ģ~tUs`,í!,5*"om ´*YVÎĀ4đÁtFÅĀ O\ŸžŽ,×Á‘Ĩ*܅ą‚gÖֆž$PŠĶIĶ.m™`įlJ֝C­>˜ŠŒm ßĩæ2˛3KMāP(! Ÿ4Ū˛äŌ Ņžcđ‡€ö¸Á%lUۅ 2“ÃGē9˙„ÕY4ŽÍMŅ7T,-åڎ€ĪtIBt&Õ1TŽŗP|šÚ9å¤ėŌoüôČaX=ŒšߏŌĢr˜gĀÔqc#’“Ĩ­ČhPŖŖį–ŋ*ä%\ęÜēŧ‰dv"úžƒĶÕ íÂŖŊ\ˇKÉĪĮ |"ËåRb)G9ŌūÚÍ÷‰™oRΤ ŋ8 íŦšqĖ­-Å4dæ‘ŪŲq˚BfîāŨw@OfI+Î6ļļôŲ—Õˡ•-;îi|ŲkúÔō–ŋž”å0;ËĨ—ū ģāô.ķį.+ĻėÁ “zuŠį iū‹ŋ-,āΉ÷|ųĻwÍkņÛ´öˇMoMáøÕ2Ī>ZŧöˇkūŗĶÚëgĶi͟ë×´ÖF]Īåķ\tZæˇį-\{ŨUŸöŧ…¯õÆAÚĸ§——•K;ʁũĘļDĐ˙S¸F§húŠžœIa¨ĀYķĖšqĘڕÚÂuüßL$a M}9™ ëFfD¤˙%ˆ@IDAT% #ũ°ģwģč'ôŅt ŒYQĢÂú¤`ÆÆFo/ņzx#+į ÂŠˆk\”dĨĘZOŌŽcvlíVƌ\îģīwåío{Ųk¯Šå#ūĄé'ô*ĢVŗ$Ę6S0Bļ]„~Øį-/ū3kŽ\hüWžlրėycē×ģ-ƒŒ^ƒŠ`éƒWÁs)_ŨAJģĄõģņŊŧ‹+ĶÚQgyiŸCFxuhÎÖ&†4>¨)ITi‡äL(ĐÆJ_=œCņÜ6:M[k?aɏa+™Js“cŲxaX09,ĨĻ#JķA^˙ō,šņ)šļ–ĸ6fˇÜHŽ?é7čGđ°ÍŌoôĩ´mö3Ô!rāČÚúKB×4u ~5`#Am ƒJC>AŦļ‰niŖMՊč…Āļīĩ]ąčāÃĪeĒē’ö—oÛOJlī íS~V-ųdÅ3iyh›$ûB­ĖzJŊ‹ŖnĐĀ&ic ĢLúLÚ|SĄÕėTU”Ąž ķ˙éPÕĖžkđp­ZĘģûÚŲRyƒ˙›įáÔ;0†Fp]ĒMu:û\{8Ī FōëÉšŋž°Ŧ'ˇã”š“~ #A•‹ŌOžŽ´äģmûļ2yâÄ@Î_°0|´Đ5Ш‡xv$=ßēm+Ŗ˙^e˙Yû–ÅK–°=3$\Û ėJŋ>ô…‘j…kes›¸?Ô'&iŲ?į¯ēÆv‰3 [gĻܛ%&ڛLuôW-(ŦûÂĒuI'ÍŋtŽŠŊ5 NšeRåđ‡Š• ̐D0 Y‚×'ŪJL” zŨH\ƒ•2ˇ2%št<Ü,GŪAˆzFā­Ā dâō¯ãúfQņĸk(z›¯:åĩ–Y[ķ¨ˆĘ‘JȝgԁExČ˄då Ë\šTŽēdH˛ēÔÚŖ,¤å¨rWu*žz&ā Gđ”3u b¨ŗŗT꒙ čkrÍXY¨gsÍ(!ōjéŲ¨æy~ųvÆ\ûēą+_ŠËP ÉĻ,K&r¨ƒe¤ ģ/ˇ[žŌÎ`"§Čā5ļąqŗ6&đ€Ģ=3Uŋ!čVŪ*Wã‚ô’¸{ŒgH9 UĶ&~/ ā ëĄŌį ņaIy$á:äUõWË܀Šhņ‰Z´Ę!Ū„‹L‚“‡ĨAjõ¨zË_ƒĻ 3€ąaŒWmž„†‡8Šs\[™ŠRFsõOē6ãÜ€ŽœČâķĩE:Cęƒ„æ‰“g8ˇŒ­ßZÔ8mēiYHMGw5|¨‡ŽÉ  4“Z;F⧁jx+sŒ$=˜åīס7Ë`ļ§Ŧ|={â÷`ˇ—Ŋ<ČgË$Ļ–‹/~73˙Oņ ä-åŒ3N-÷ß˙yKːá3ĘĘe›‰ŸKĘØ=‡ōRĨžF™?˙­,`ŧĨc[Yĩb}Yž¸˙`6oDîÍķë×ō\ƒ1?JŲác5ëÃø‹ņ?Ë8,Z+°ūn d|Ņ_k&žŠ/æēņۆ†Ŗr'/Ŋ4„¨žAW¤Č`~ŸŦÆ; ¯ßęZ•oíĪØ^)Žtpo>Ā‘ųÅ÷ODū ėY–ąÄĮãŧW[xđáōŊī•ũ˙OÄß;RW@);øĘxßJ bėÂo&Ę eĮXAw€­vƒŦą™ÔRdIŦj¨xŽ æœs;ļęåŅwšë]ÂÚŅUõ“zbņ‰ųvôŒ•äĢēÚøģt<ŧ"Xĩ“ņÁ#ížēđI§3íiÅÍ@LČ%Mã‰45c HŲ<í<´ąrÕôčg°âË|c_õ‰ĒƒC™Ķ O™iS?2¯b~å* ?é{Dėk2_îZ–(z.cŠ)ą…‡žrŠ“>0IŌËd>įhîœų2ĐЀâÃKŲEÉãLÆpßāÂĀ|yÚĻe°(–tmôúyõ,‡Eâ É˛J<4đ’éˆÎÃá貖0ÕøT|AËIĩU-õ)éĩIk{:8@(ņĢsYéA¤Aēō° 67'ŽļÛSâ7["“|L:Ą¤:ĨōZ)›ÚÁš×Ę/Ųtœđ ՉŨplõŗŒâã2įËČļs×Dū|¤Ēė¸TéŨ×e=KGĮzÜ\æĪĸ ęŋgYĪCĄ#YŊĨ>įž{ū/–ë¯˙}ší?î)'Ž/ķæ-,ˇŨú`yåyo+ķæ..ÆO–‚ÄÅ Ģļ—ŲĢ–•áŖ¸{Šũ,'m‘鸨%ÛyEBtõ7GŒOĢ×Шöļ|wƒÄ×P\ŨĨēûhācs:ą8Įo!Ļ™sč{JÂĩ<¤”2¨šõ[úä§:@ŌœvĐÄãĘsË –ē`ĩ;a]*dͯį vHĘLŊ"™@‘)~îE`<ņhčE>B.ŪĐŠõ`+eŊvÍ6`ģ—žtúu×Įŋ‘ī™e˙ũĮņī˜7u?D(‡Ô›ĒŸõÚÃø˜3ŽkœP^R"ŗíQĩAfŌu6ęVî†!ä  < tj¤ī¤‰ŽÚ˙ĒēË#‚īķ.a!gl,Kõ–Vd% uĘāB8Å'­…0"ôЧ<ųä5åsŸûj2dHų‹ŋx7ĶãėôL=t.÷Ē“ąG‰ė :OŒBŪēõŗô9”Eæšžvüc?ËC•Ĩųčg–ƒÉ†N\Ë0´Ũu$X>ĀUjyę‰Ägāæ.Œ2‹ŪüĨėI ék˙"|Ņ˜ŋ‚ļqŅB“ŋĐÂx´í‹"äŒUå`‹J˙ ?í* ĒĮDj""_5F į pđ Œ:Cŋ–ŋņ öįôãFVųeā ¸<ō'q€_`ũFÚúcú/ĀzäN?ˆjš‰đŧ+"zøÉ“snâķÕP/ĩ­”ÕWŲe‘¨'MWDšŽúE˙ǧxU ō4h1,ĖūŗÃ—˙1ŖG—ķĪ{%ņ!l‰ö/Ėx2û$ß|Ë­å€ũö‹Ŗ<úØãĨ73āƒé ī?kVyø‘GĘę5kĘqĮ]^pĀŦëQzø‘rũ7ĨcīƒU§žtBŲgī™ŅKø›~KΧN™\Ž}ŅŅeĀ€ūeŊåw×\ËúŧutŌûĨĶmg{3åy陧ķÚî=ŌyŋåļÛĘŨ÷ÜG‡˙Ŧ 1č9/=Yf—ŪėvāáŨ-÷įvN`ļlŲZîēûžrĶ-ˇ”—žq:ģ=Œ(/>îXîl ėįŋnj52ū[n…ūŊ÷gĪā÷ß/˛ėŋß,ČÛËmwÜYÎ~éKĘĖĶŖÃ#čsũMŋg`ÔĪrŌŸ˙ˆãÕ˛Đ zôæ–}īžĨ? éæÍȋ#õÔĢlŨ˛ƒˇn/õKĮm3 uŋ~uįæf8/Ķ›ˇŖo7,=ŌČ[azõí\;`ƒXĪëëȡnŨAPŦmܸ­ôgmdßŦlDÕ?"ÖŽÜ"Ũ¸i+~ƒ,ŧ@%;fĐfôØ oÚÉ` YčXD71ĶÔ įS†-4úiŗ­ČžūũúõfāÉ9ôúĄŋʸ#GøSņ-Ko÷a`jēK\äoyų[^]ŋ“%[°Gŋũâį)˙ž”3upÃė¯ü”G‡ōķg9mÁ_ļsr€Ë< [°MĘmÄfv ĩßļ­†Ķåņ'VRG†˛Gz?tڊŊšeOLč Āėø‡Á&f¯Ú¸KÁŲyģÛ9ŖĨ(đEäT‡´”ŸAOw“s€Lā°>HĮ<ŗä͜Æ.§1M°ôÜ\Ä2ˆ×Ρ>„z!rnĐleB/I߆ĘRųåȀ žV9j€÷Į€3–;ĢĶĘr–Lš<–;´ĘĒ =¨ßÚygYˇ˛ŪIHû„4;‚mĖią¸&Øü΃‹:s+5ü’ę×>ķĸSœáÅöÖŌÚŋ)6@°‰p΂pΘ˛ąô0ĻE(°/‹˛Č=ü‰Â,ƒQĮėĒaų厛 ȧŠ‘må“K#N:0éœY@đĢŗŲ– ú´s'0ˆÂØéĸœ ę\YwôÉČåƌ"šq6ķ@wiĄ:—Û)[ôU&øäŽŦމ!C›ôîăŪÄĮaCúŗ÷ũõåīø@9ãĖąŒë åÚkZ>čT–Āl">TŸ2ÎûWÕŅR÷§Ģo*ąyÔ˛30ķDĻ~â§nOœnP-ŸJ$xR4ž„vĐ Æĩ$bCaŅUÖ>XZ3ÔÉO•ÉoH‹Mėį$Ī”\"Kˇ´m>°’Aëąå¤“Ž/×\{w´Ž+‡rjY´h]ÚŲz§!@.ô)yĻJâã•#œRTÖ7âX8‡UxYa‰āą_SN’ʁ>Ī„€ĖĐB.5nŠU5#ŋ”§}6ÍāDŽžPm@šöāĪ8bģ&/ã‚õ3lQ NĐä [^đ°ŪA°ÆkōđS—ã暊Š(›đ’qâ,‚ļ6WZeŌ_õU3Ŋ6ŪÖgčr҆Ī•ōÔ%Õ3ô9ąū+$4 ĨŽú Ī?&hgümF´iôpA~l,!1EA6Å3ÅuvųŽõŲÁGŪBė°Ė~{ø,ƒ+}›HˇÚČēZc‰t=‡õۊ×ČgšØÜos,:+´ĐUˆÄĶA­*i_rÁMŨo4CŠm'üM€U5Ž“Zab'd­1EĐohbcr%žgĖPB9>ĪĄí` `f˙ŧ?yYĪŦüīé$3Ļŧ’kߖøÁO|ĻŧáuâhÛËu7ŪĖ>ÉŖĘ^SG–“O<žÜ@G˙”“O,‡|oWŧ›ÎôŽrÔ ĖrœĪ|ékåŊõMÅÎķM7˙>ŊŖŽzaōnäúœŗ^J#õdšķÎ;Ë <˛ŧęĪ_QžōõË˛åœŗãÎÆģœčœŗ^†Œ›Ë7Ũ ߊåäN(˖-/ .ĸã´ šIŊdYôÎ(]×0¸đ5į—Q#G"ãe„ņå4Ūô¸uëÖōôĶOįŽĀ2fō`ŧšAĪÖ­[ʍč˛×^ĶĘ)'Čëá–ukחSO>šŦ[ŋŽ<øāCåÁ‡./;ķŒrĐ ¤!ŊšęƒŽv`úķ_2€Ú#ƒ…į1uõ Ŋ“cÛŗí¤“ļ~Ũ6tY[Ǝœ€>īŅåeøČ”G2gö ŅŪeܸūåąĮlĖ{”Š“–'žÜ@Ąo/“& )kWo-+×lâU÷ƒR鞚ŋž ãõæƒčÎyl%Ŋ>eŧøsցŋ“†}@™˙4Ī?ПŽGŧŲô‘eî“kpņ]˜ô†?wnēõ.ã'„Vş8i@™÷Ôf͡•ņaŖ­ė˛‘å ÁĪv•yķ——aƒ•ÁCz”ŲĘÜŖ7{l÷/?æ-Ų]eō¤AĖÎlĸ!Û ŨAÜŲŲBƒ´™ÁÖ`|vW™;o5ŧ0Ā„˙œ•Ĩ/úÎc̃?iŌ`f(ÁĮ'Bkü׎^ƒíąá?ŦĀgöœåtÎû–qcÅw/čR‚?#üŽ2NīzđWŽZĪā’Ãį·?øƒé|+ī^ŊąmŋōXö’.eJäW—íāÂöeú߆îŧ5eč°ešŲČߋ2´_ĩÕ˙ŠyÜUÚžĨLš8„ōßZžZšŽė >‘¤ĖÖ~#” Ô@¯>eø?ūŨ(?ė7w##ūmøô|sKY9wúĄšk˙•eđ°~e¨úĪ^Uz0đ?ēO#˙Î2œšOü{ŒęĪÎ.Ŋi(g•̝žžfYČaČī]8­E‰aƒĄÆMđõ„™jˇØÛɯˑr‡Į˜Ķøv‚˛­'ąĮÎf}š†2n&9NzČà Íĩūį:ĮøĄ×éŒSWR] –ĩkoēõŨŖ6”â IŖkZĪÉËÃl¤ÕŲ<)„ Ŗá…NQˆkŸ¯QėZ‰ÂÛs°í Τ­mi! Ōāæ§ō6É lR–É$‰)&ځØ-¯ƒ×~j‹Ú¸C7rJžu€_• ÚB…žöRF—%dI#ƒE—ö(¯3Ms› §ų|(§6ĢvØųåk_ųuyũÅg—sÎyiųüį/׿•n¸šÜ{ī#åĒĢīäķcā'•)Ķ&B~bLŋ‰ōčąĪɔ%_ØQ•ŋņIģÛ‰Sž5/åCz÷(ŽR‚a=Åįˇn_\ĶkĄ–NúW=Kg@ŊC¸–—ą,eå4ųŸ†3d› ÖÆiōk_äOįĩ,*Œp)néĸáC¨’`ĀĶÁā7z‘ēŌäĩēE*-4õfåō°œģŪ ˆ"Ā(^ŊÛF‹ŽāĨÎ)n-Đ‰o ĄqÔģ>ũ{Dģ{w9ëėĶËWžō‰ōŲĪL._˙Æ?–}ö=ŠÁ3,‡éM;q1ŠuÆzâûF)íé]ũ>5Ļķb=D"ûŽųŪĩ‹ fŦO:{õëjëÔ=čāõ˜>ÍŨsÅ×F|ˇ')?mŦ/Hʍ˛’Ÿ`Ęר™_˙ÕÅÛĀHÜ.åŪōŠO˙´lÚ¸ŠŧåÍo(S&ŋ¨ŦZš1“6ą˛ˆĻ,’ˇézU/ģrș~™Ô.õhËDŧ:kŦU°t1Ļu"9ÔY;šŖM†üļĨ^!¸"˙N^*kTVGéx÷™Lđƒëķ Аˇ´,ĐPä—ņ §X4˛f)ĸЊÎ9T‰ƒXËĮkËŪ|Œ•ˇūj4øTūj -´LE žņí ŽÎĢŨŗl‡NsîäãĢŪē#šßQÁsÛm€î=H”—ÍQÛųš{ĸæcŲī„@•IÛ¯*Ÿ:ˆ÷ž2‰Ž˛‘ÎrŠąJā41_[nŒåUeJŽwĮh`ŒvŪ4ųjīTKø×ļPY´hxxĻļMrqpœ˜žrAæq´íˆēˆ$[e/åg2 ZÂ6Ę( Fƒ€JĮ‰ZkJđY‡3t+x5ú+Î9+âŋũđG™…ėĮMå>>1K~–Ė™,išŧĮ™&…q™ĐˆÃˁė_ŽšæÚōmļŲr ą‰Nų‰ĮĪ[g ąysGųā§>GGw]ųČ{Ū”ģ 'LČ2›ĢŽŊļ|îŋUÎ~Å)åÜŗ_–ŲūÕkÖŌņí—mŲ&ė9Ž<účėōíī˙ Ŧ\ģĄL™8Ž|ōŖ*ûî3ŗŧöŗß,įŋĘÎûÖrå5חqp–1('‘ņãÆŅ!šWŪvÉ[Ë´-oũË bė{î œË@įö;îŠ}|5ø×ŋųĪeūS&—O]úá˛ĪĖå7W_›āpīŊ÷–‹Ū÷wå˜g•Š“'GŋöKeÁâUåīßq1ģdŒ‹Ü[ēͿΞs{™ ÁE_mwßČzÅĄCG1ƒœ ŧž|Tfí8͘Á *€ˇę÷š6<ÎåúÎ)Sf×ķ!Cú”{ `֗`œcī{ÔdĘF|ˇuFzú :ˆä;S ˛L˜ "ŠŗŅC‡ôĨ#9žČũōgÆ\˙›ž×Ȕ…3đ{Ms™´¸;0i*ËÆøķŽĖāĄ}ˈQË6ŌĩŨôŊFĄoeG~ümÛđ÷b€Áálö¤‰ũđfŨšk2|h¤ũ3ë­˙Μ1:ōēvÆtdÔ<Ëe¯Ŋā/>2Ģîi$ˇ–AČ?bdäréüąŋŗī.q›>}Ģūö2múĐTæ­āO›6?r}Gö)cG ÂWÁ'‚NŸ>2ŗūÚofĘų›ŽüVC×`O’ĪÁÁÄ@gøđ=Hš3gjŋmé`ë ĘáŒūŒŊ†iŪāO<(¸ƒō<¸wŗĐûyĖĮ;.ų™ž× ôúkm6ûčß§<úāō2ˆ­?ųÉ ķÖō•/§üËūƒõŪ‰;Ą!OËJ\å÷wĐāūÜéāŽš[Á ëw­v¸į<áĄŪ>Ĩ)Įæ2KB`ķA¸ÜÂ6g‚Ģ6QN^‰_đĸÜ*o>&™a$=aŸˆŲô‹Sn™ŸhhKG10į¸ŽŅtļ„Ķ”g42ƒĢāĘę•;ŠŠé s‘A‹:)|´‡iōÍĮ<2y*#o!€Õŋl 2Iá¯(Ú*9œ“¯_!…SéžËžÜi˛ā]Ĩ^tÜ6nؚ;K=vƒČŗĘ›˙ú•,õØˇŧķß`Æ˙Å儎eōbņųÖ˛`ÁâōûßßĮĖđĸrå•˙üøČxɓ×Ŋûㄋƒ;×ģnsđ”Ũ?Ë3 •-rē<Š6CęƌšWMoYĩĢՁÄ-ƒ}„—Ž×ŨŠļmŠøÃfÕōKųZF–zW˛û Ļö‚čÂ46ǎ܁8én…W-Mã‘æWœJ͸'„ųÕÎMzd"ŨB’}Ā:.˙ČD ™Aäø„)ĐĖ-ėa§Fë*耪ėö9Ä[­ėčgũ0:)übcy7ãÔ ˛Ä‘ŒYžh‡ļ.!dā­įã‰ĶwÜņt9î¸ŖËwžķ/åũx{9āĀ}ʛéz(“!CËō%)˙zˇLUĨį×΋ä:ãJ‚iđË˞ÉĢú“ )ŽY‹ŦOŪĨđî‹ËYˇ~ĄŖļôB?Âēŗū%ŗĨЌ?¯OX>1jyRЃ/u%ĄR)ķÇ,÷ŪwWyå+/*‡zPųęWŋ%G&9ú–UĢ6ĨîDØ*Ļv“žČŽēI`¸_ķĶ7Iyk§tciČi™ųė„žÕ­Y˛“úДr:‚ŅW~^ōŨ*ĨfuVīžę7ZĖM‡vj/}Ī‚GīvfYö5ĻHÉ í)MąíŦB9´ô‹jK% #÷ęĢĘ ĐĶo !%ĒöÉĨ_’Žõ;æÖsm—;Ŧ `ʅú$°ÕÖ*Š.ĀŲ—Čv–Ģ1JžĻžĘ7F‘:(Ú”H8 ŧ—ŠË i Ę] ĒąW&‰ ádNk;ņSļúôųÛ­Š’„…` `ÁčÜā Ų͕ ę,‚JĖë oÜWLËÂXŨ@ˇn*Éēז§åTžÅåZÛFFą% ŧ$õÃ:Y+J$qY=hą›JFô"A xáĐg~IP.7nf–slYą|Ef|š1ŲĪ'Ę#tŧŊī?a ģ#hōŠNÎzC:ãĀÆëĶd9ΧYähZpŊčŒiSËīŽž&w~ũ:Ø+WfũĘß]Sö;†W˛Ī)Ŋūõå%,ÉYŧdišōˇWá”=˜=D'hkÖä?9w.ĶöōŽKŪR@Įˇ'ÎÚ13~K x˜™F¤ŗCoÍÁ‰ŗ!ģáĻ›ĘqĮSnfÉŅ*9ˇŗ|įöģî-ûíģw :œûo|”øĐōގ_B§ØŽuOîĐ e Q?ž9ذqCy’g Náát´†—k¯ŋĄœõ˛—”˙öe œVō€ŅCåįŋēĸŒĮ–PÖĒ?ĶĖģ¯(ķŊģqɃJ?)įŊō4fęN._øâ׃É%—+suųŪwŽ,_ûÚGXžņdšøĸ ĘĪ~vyčœsΙåßøNv6xŨ…ī/įŋöÔrÚ駔Īáqގžõâō›ßüŽ|˙{ŋŠøƒąøWÄņÎ>ûŒrŲeâO+Į˙eč2’ž,éW^ö˛3ËûŪwIšâŠß•ī}ū_‡ƒ˙ķŸWūgŸŊ›˙đÍkN+§ŸŽüßhäŋˆÎÅUåû?¸’ŲĻŽøWDūŗĪ~ ō˙sø_xáûĘkĪ?=ōņ čĪqÉ%o,W€˙ŊFū'ĸ\„ü?˙y‹_åŸ6mZQ˙ל*üOŠüÅG˙+¯ü]ųöw[ųŸˆū?oėwļöS|ķ Ū×āŸ\žûwƒŋöŋĒ|įģW”¯ũŖč˙Tĩ_ôßUÎQ˙˙uŧŋŧöĩg ?úcˇ5øßƒ˙WŋöágØ7evˇũÔ?öŗüŋpYAøc˙īF˙ķË'Ņ˙/žŠ?å?ō{ũ…Øm˙Īkŋ*˙ĘOųi—ņ]tŅ_”Ÿ!ŋAޖߎéøM+įŋúŨŦķ>žėŊ÷ °}šM~P9ũŒ_–WŊęR9ŧ•I=‰q6b„=rMtŨũ5‘Ķ!<:a?îšĐāąę¤)¯ lÖŗ ´éšN Í˛ŖD+C&ø00Š‹ÔŸœ;ŽĪæō˙đŔáËĪebdÖŪe_âÛa‡īYū鞟–ūđWeŪ‚UeŲâuåēëŽ.{ŒŪߪš3˜tāí3ãēĪ{øŧM:+is°IråWpíÖ4Ęhla ã XĩĨvĀv6œĐžŗslĮŋĐ2ęžü LUöĪt~ÕšNJ[ƚ ’…ÍC °’…I-Ŧô"QmPũ­t=׹"53Üä[FĻ×Î ˛&¤Čy§čI]4€ yē4úÎJÃ|(Õ*ŸéĘ-.XžYŗDFåĨMgņĨzŨĀÆ¸Œ‹ę‹ ķÔQEČŦ4ômbYáô#ˆņ/*\p^ųā?V.ŧđÕåōËWÎ<ķ5åū5OQį+K—ÕįûėФn@ĒŅĘEāP?.ŊÎōéÉ(ÆA‘õŦ–>‹A2P bĩŋ51bcũ"Kj¨ÜōĒúqŧô­x W•ą6T_ËUž^JXãÜ%™Õ`+˛Ūta™;w~ųĖg>ʄʋs—Õ~‰KAē9CĒ4ô)LĮū0­üšN°öƒ¨˛Č+o{ÅđbģäŲ8,ūĻDŽd™ ĘÂr‚' Ä-ëtāĨ G}ĀÚĄĢuE:ÚB]ëRIË8މŊü­ėųæZßsģ`Ķ4„vE?"ˇĻ$[î! o[/* ÎÃŨ‚,1eT.ĪMŦGĒYÖ}-"iÔĢ‹é#R5´ąC|ŧ^Õ˛“¤eŸúJ*åœtí õÍ#]Ō$KüM'žĩ.+¤2/Cˆ3–štIˆŨC1ׂ7¸–g*šH,8g’iČ=Ķũ——>×b]¸ōIK˜@`îŠKZT -{ JJU \O  >P;üÕōĐŪ>Wg9V̇}b€X\> ĮņĒ`&ũÁ ą‚čÔ6Ä ėĖšũēsŽŒ $ë7QxܲĄSœ žËŖkîˇ1ķwĪ=÷2Ø”õüŽöÁ]×˙/šķxųâ—ŋV?ô–YŒ*§zr:_?üÉŋ•¯~ã›åˆÃfYȤ,íyû[˙ēüđG?áIũuš“°rÕęrčÁ/('xËvæŗJ„ßį-t ZˇŸō ˙ļü´Ÿü=D˙j?íõeeų/_ŽēŠō{ä‰čĸüŽÍļüvÛ˙Ę˙|pQ|$å/ū‚å`ī ũĮāŽũ*~ĩŋu9úßqü•UøßC>^Öq7L†ˇŪvĪÉÜW&˛äjŨú­ÜĄë5å>ĢŅŽč: ŪĄųāŋà™åO+WđčÂōÔÜE4¸ ņĢ…eņĸÖNė<&s6Œ:ؗA}OžéÁ‡æÃ‰Ÿ8“Ÿ;]Ŧ[÷eã ˙éŒZâ ęūZ)øOpä:ƒ†č¤æ5x×ík‡Cā4¸Āęá'¤4¨ļ„QōėČyîŸÛ Ɓ9×.^Õ/ đÄæāË 0~ø¯¸|§9ɏ:ú—ČîšrWˆd'w:^vF yöƒ:é3>ÎYB{ŽÍÆ}Ë~ŗÆ•[nyŒŽũ×ŗäđ„ߔ‰ 'cfî3˜ŲŨ/ŋnj16wÂVŗÄpËÆĻŲéī=†;€<„mÜ!‚EîĖč"ˆ‘Õ_ĩˆ<ȟNE5Eä÷Ú2Ē;p'<¸ĐÛ}'¤ŅG5ÕUjÉĪ×õЖΔj#;dĘ$ĄØDĢ’āĖž ŧsäĖšhŨOĮŪY5ā­‹öŽ”Á¯4–âAĶ$ ÉúiGĖC>Jhšˇ)ôAĪÍUŖøŠ0ęOzšh{!v\ã6ĄĒXŅKš˛dL Éâˑd šoŗä§Ī§Îõœō¤å—MģzčWhH{+‹(ĩ“ŽLÎÁ“•ŊCŌ|ųœúœĪ<ņĖŌÁV>úŅŋ)wŨõŋīeŲíīĘßüÍ'Ęoû/堃NĄ“ĖƒšģéŗAtØ^+Õú¤L1)ægˇ+#Ģi‘#‚vʁĄW}†’$/K€¨įŌÖö˛ęKš< OfŌ•E ō[GĄŅßúd9é'[‰#Fö+÷ÜũÛōŋ˙öãLfŽ)īz×߃2.ƒÍ›ĩˇ4 úÚYŦÁ?å@~;ķ~ˆž–{qúOS^Ąxĸ JKk7ø!=ãĪdÆ?‘Ų2Œ?ę;AŊ‘ÆēÅŒoë_ĘøxČ;×Mš1O˙Sī:đ`–_›s-ŠzÚ9‰3eJ"ē%)˛˜ŦÁĢ˙āߔĢoŽÎ_ŊōÛëđÄlj )čņ[+ra–ÂȇÃrRĻȍŲĩ‡ ›¯­€ĖGđÄzäq0^c \ĩŸū/tk94ō†ŽåœD…‹wÁ"vŠMTžŌÕ NĨBîZÄÖ2NáōKlŅFP–žwŗzĩ´ä}‚<Ķ”ĻÚY&Âč1œ‹ī_uI\kĒ'H5u= ;\këāød­7õáë*rˇiĶŽŨĩbéϞĪū#Ę~Næ)\8=×Až3Ū yIÆšgŊ´Lgûī>|)`K:đ_øĮOō°hof—öf[¸ë(œîåU_Rƍ^Ūûގ”Ã?Ŧ\ú‰O•KŪü×åō+Ž(īy÷ģā2Ļ|ėī,‡rpųÜŋ īyt´7–7ŧūíä­-_ûÆeåxžũņŋū4Ī |˜Ģ¯ŧĸĘiå_ū<ÚūGųįīũˆŒ÷)÷>@°úģŋÁJ;Ë1ĮSē›YÎ:ęāōŲO‚ŽŪÕåĸ7ŧ—5ŽŋČs oc‰Î)/>*­Pļ Ãy¯<—mė,xß{ĸũ¯~}9ëĻ'”K?ųéōąũīōĄ}ŧ\đšķs§áčŖ*eiåėã/ęũŗŸ˙ĸÜøûÛĘ'/ũpųˇûišswûņaä ė ô†×ŋšĢ™åũzvúÔ?~6wRÜ1ÉÁžĪøBŽ8Ĩ{ÅåˇąŖF÷˛nc ˛ŦâJ‹‡ëÖŨÁõ~4ÚÃXžtįŖXʲ/k{oŠ™3^ĖÚō9œ/âî1t„}6ā!ú:"δfõY ĒXë=ĪuŨ˜áŽÎėk9g‰ÎôãĄeێEĄ¸ĻŧøÅĮ°tāŊ埞ųŲ¤ĘH֊Ës wrö.sž¸>y3˜Q™3{6į‹áq<ä˙Ž#tΞvŊŨ}x¨tƒ„›9ߓeA3ķ ĖHŊ˜õđpļ´Lž|,Gņä™đŠ U~ôŸ<´Ė›+ūIJ÷ĖiÜ-ēŽs—!˙ųĢ˙ąčīŗ”!‹¯¯Ûx'×ŗĘ”ÉÃé˜jŋŅđÜž×s˙éđėaƁ˙"đŲŊĒ<Ę͇3´æ™ŒČŋ/yÃɓ?úƒīƒd3Nk˙ÉØŽø”Á§lw•ĩ,?đ'‚ũĮÂoøWüéČ_õ_RĻL=Ž<õ¤ü‡yā|ĘãåߟĩūCËܔ˙sŲĪŲßåe"ö›?ˇę?@ų‰7Uü‰“‡Wí7cæ^”YSū3Oā\û/ĻSXéļŊGųČ'.`@ļ€]3>F:Íæ¸Ŗ˛Dí‰'){Ž™–eR.Zŗ‡B' /õÆķĘŦũf‚ß—;u<ŦÎŨ#—´-[ļ’Nkqs;Ē%eáĸ%<‹ŗ:ƒŖœĪĀ”!Īđˆžlšcŋ.īJũTFíŅ/w¤˛dˆFĀZäÃĘ.sr‡ÛeÄÁ&ōҰžˆÂģčÜĻ2xcĶDÚPĸYÉøoŽƒ÷=ĒÃL|‘t:Ŗ˛0›Ō$¸æUēmĮU^5øÃWĄøĪLē1ŨÆ$t-Ũ48§ķ=tcđĀ}ŗTl/āšŋ Öûq{Î(/<ĒÚų{ßģžûųܕ<á„7°lëÍi$ŋüĨŸP‡žBžeÔhžGbpÕ˙/õBN;>TøąRÄē„§ĒÕXŠĶfÆ[j‰î6\"qj{bŖWõچJ fæ?0āpä Í/íĄĖ…,8˛ĩų>´ÛÍu´ē„BÛB!¸ÎŧŌŽYŠ…Ŋ´ 24‹Ë 4r8™Ĩ+Ô;Bĸs @v!Ŗ8æģū8V %h!Gíøk¯ZVōŗÁNŧL8a‡!í+įúw$Úíø´}§¨{:z×Dž6wPŦíô¨Qô4bõhūŌŽ+Ēđ¤Õ Ѓlx¨WŽĩƒuHßôÜ"3fƒôûaÉė•?¤]ŸÎŨāīņ€đY:x|:ËĢšËäîQAnĩW­#ØDāožjՎ’Û›Ámž`zE䇎åU;KŦ ŗvlũޝĀcëÖVÂbŽ!_āą¤!ā+míŌy—°”iŨē5<‡øS&°f—ķĪEyÁ§ägÛéԁhíŦŠ…â˜^xč“ņíÆšryâL}œPm’ūˆ[`´o|ÄÄ(ŖūĘŠ?6ōFnQ ͲVwY^{‡‚Ëz.ndŠúŠUš{\Ž„‘rZÂĀ_åNį‹_Jā‘oqZÛrĒ晊ęöôEú°Rã>úW¯)_ūō§čûŨČ*…ŗĒĪšąˆô”_žÆ]ĩI™Ę[÷ĄĮo–w\„sŌ͉—„šáÉvbzĒ_ŲíH#§!ÜNqUKiĨė5~Ė]í‡âŌÁÃÅ+|ė^íāÃŊ25ļ:yāR}‰tô‘­ĩ`ė-č{‚b˜Ôé<ˆLۚåfj./ˆ7ēĒŖ´”X%ô…žĐŗœt‹R;×ē.° ‘ûhMãH(¨ŧ&ąOâ•īęËķ-Đ]Fk|4(ƒü20¤sŗk%ģLŸ9ŦĖڟ„8€yžC\×oãúfāåvį]ẃ›XbYĪŌĨKËy¯ûĢō†×ž—Ĩ:÷Üwk§;čā’īW_đ†ō–ŋē¨Á`āVvįqtućŗžüŪōž}ĸŧå QŽņqt@įäÁ\vĮŸ¯]ö­ōŽw\’ĩĮ.5šĖ]×ė˙‡?*OΛĪ6ĸt x÷ågŋ4´ī情įNEûîŗ7kũî/ûŅO”˙Í{yâ|ųŲ/~ÉÎEķô„[žb%Īŧ´°˙~ŧä~œ­{ŪõaŪüøßĘg>uiŌŊ{!Ė=ČĢcųđķŦYû–ÛĐå×Wū6Kƒ~úŗŸ—›oŊ=úŽü'žx<đĮØ=cm9ä dyŅWŅĮ;ÚĶāũ|GŠ?úđSeÎ#+YÎ45z3ˆĄã„ģ e{žMėÔâÎa<Œęzđuļæ\'Zĩn¨öaÖēgÖę÷a w¨Yģžž6„%›6máîËvpÜęoÚ­<ØŲ/ÚUV­aW%x bī[nų%˅Ž)Ž~ķ›ßKcđŖrôQ‡–ËÖgžĄxÃx×%ëxØmúS4Ø ûą&Ôu¯ĢyxÚˇEē~ v1ßgÄīĀnÚizˆ?txßĐ_ÃC׃xĀםÜMŖú`Û9÷—ļöEį ęßᔟ×õë‘EûQÂĢŲ†ĪoŨųgģ#eą|ŊÆ%46lÎē˙aC‘û­§l}­ŊawÍĒđĮ~ČŊzífčô@˙>ŅÅā¯ĖéDúLBøŖĮ:ā†c?ékķÁė.åîGĢ×nb׌,õ[LÎŖ|¸?pĀŠåžûa0đ2eʤrÖY§ķŒÆĻQŽ#y­Øŧwv‘ĘŗĖūíān$ወˆw4Nu Åa…ąPųI€Î)_ œœ&ä—įđOH ŌlēôYĪ%Ĩũ*ļ…×ĻÃy ĶļxА#3ĶüĻAĻll7ŒMvČÜŊŠ7õ­ÄŪåtēÖŦtā<ēLēOšđÂ32ņąKÁÃē_Ė„Ã™gž…uũ¯¤no,˙ūŗ[¸3į n ö~uÁ;*lš@ˍŗQ´ØÁx’ž –Ž,į¨ÔÅ&\äŋjĐŲIˇ1BI;vĄB(Dp9WéĖf{NŊŦŗŌZDV~k7™…9¤¸æ<4ė|dp×tØ)œ”‡4‡Ļm_fJ‚tš*“´÷*¸ĤÜęįú .ĩSę‚JQ^îJ“Ž–ÎúīŠ,–ŋížbĢ€üÔŽ­ U§Ö.ú’šæƒ/<€Rßv¸ú”i˜Äļ|‘ƒ#ŁnĄ ’1ۗ~}ũëßæ]GĐ_x=íŪV^f7˜XĖRąq0ˇáM} Ú5´ûziu˛#,] ڎi+gĩwr8­e! rë˖o{(ĩ•Nĩ…ē%]gSĶFū¤æ˛–´ƒØi¸Ē}ü,õŠÎžu;ļY&‚sHŲwŋŠ ÁŨ=įžuŨžÛyŽĻ“íƒˇ#Ø*s,ˇž˙ūŖŸd]üˆr4ëāŨúĶ-Cīš÷ž,x$ûõ,…9ąL›:•8;™0CüĶ_üšŨWÆŌ1]_Ž8ôā,Ãņ…[KPüæĒkčT­Ī-lwr ž;ū¸ũæũ>œ-:ŗüš\Ļ#Œk–MģãÎģhėé´ŗ-éŋŗķŽ!ŸyúiĖ6?]ŽøíUY‚Ãbāĩ,%:é„ã˜AžÚŨ˜žË3 ×bđ]4¯üíī2øØ;ž:ũžĐÁgíģOųíUWgPōô‚ČģÚ2°8ōđC”/ PЇ%"W˛æŪATß>}ĢS§xūĐŌúGʓßî{Ŧ<õīQ`ģļtblÄëLwe¸­ĮˆXŋ’T7d֙ē1"ÜÁ<=\į™™"‚ŊÛjڂ8skLŲš°ųĀŽŖvT|Ō˛-åƒ]ŨeđžĖ͚õ*ęV:*tDKy’O×ŖWŌ˙ rĀÁ{r7g\™2ewEÆ1Øe‡ĻÁyh4’QŽ pķPą„•+×B`'†õėÂĩ˜ÁĘZlĢŗkÍå—?AžägSITƎéŸÁc?ļ@Tŋt€(doÆå œpĶļņ ė ]Ōaąœ,ŽŋÖ7í“k~ĩ÷.§ĩ€ŗČHLJˇėųĪTŲvø%ŋ–Ÿ\¤Õ‡mg}ÖÆc 3_–ûĸ§7áSŪ­™ZŽd õ6õv™Æątä-üI_ˍ‘2áŒÔZ2ŅÆúĄļđÜŋw˜|g„īŊp9ÔČá#Ø4ĀÎRJēœų’}ËÛŪözÖbßS~ųˡ>ü6žÁš‡đŋÁRĖÉ,e˜_&OaØSKá7¤Lžj r‰ĖβŨzōžĘŌî*$aĖYâ×,ĄŅˇÎGoKGVÂO{ā›yģ0Š´rˆžtøĶ_ôĨø3!Ī—guíŽ>ƒø§ü=¯ügL"An‰ŦuŊLķKJ›ÕęÉ`X͏™›6*D5•.žĨ¯šKšre˛ŗĖ†qmᘞēGē|^Zĩ €GŲ*#IĶWcÕR•Ŋš€œēljö%Y˙æˇÖ˙ˆLĸų& ĐĀBW^‘Ã4lšōƒžæËĩė1ŽũĐ7 ИŸN–đœ+ļāâ'&A_–QÁtBEؑ ũ[Ų+Ũ:øÔ¯<đŽîíw\ÎjĶĘ?}ãŖiį>ūņ/đ|ÉWšxJî>¯įŊ+žI8|‰Ī–Ŗ\Ę ]RŨ'ß; Ę5€-Āā ā1đĀu,:• Ūö?…ĨáÜmÍdC†Îe+ĩ"C Ŋđ%sũl-“DS÷RN:åHžÕ¨/ˆāOĮiííDŌSOÎ+ŋųõíÜåbŌž)gh9Äö‚8¤ÎFH÷Œį9œȋ¸~øQf§•Wņ ėË˙liėũÎZį_^~%û¸ËC°cGīíJ¯V˜úÂ-+Ȅqc̃ÃĮĖ6:Ũ-ėv?^ہvüöŨw§ É“&Ô Ŗ˜/žōęĒÖ_ŧ,w!Ō Ę%ŽœËm¤#Š“&ĻsžYŠVOë>¸{ŽaĻQ™9ZšÄÅ2…°Āá}8š…‰S?”Ųņuįī<čôĘå‹ąžĄ•Yz.%‚]•ģ•áŲŋŠ!PœˇˇŪIˇšvB ĀY¯Ķųå7U[ĨÅØ–’ß`sjZÍĐ_ ėDĮt”#¨œ [MĶČU_6ÖžĖ ScPØ×4C°gU#Ą §ŅOĘUnuĒ‚đōcbGÅķ@‡:âļėl4ą@•ôä¨NuöK8ƒdĩĄ}}y4ŖŠį–n5xą¯rŖŦęŸ¤'ítxÂģ^;8Ņ´šųy{ŦŧĨÂĄFĻBķoš6Oc’‹¤gĘŅ’ ŗh;äÂ֎$8ņíh%#ŽÍl'é)¤e`:NŽX†ŽėøDžhVvÂC0-'ĄBBŲđqe€šŠŅßYņ>YVUÖįAíņę€ũQšˇķėąį°4āŪĸųÎōŨī~†™Ž.gœ~nwtļõũŪY‚<‡žQų:Hļ8žpá’Ü=pyŅœŲ ¸s8ú øxˇĸ=q2ŒĪ`–ĢņüÁčže ņDŋÉÐč Ŧup ëōmí­)li<퐺DŽ:h í€į~žË9ˆu›}Ę+^qŗ”sË9įž ŧņ¯É.[×ßp7ō­œˇŪúĪ] [´Nž@įŋ6”ĀtŅž…ĨĩĮP׿=)=ØhBdÛĄÄÕ0’ˇÉÕĘæ§}áÄlT3 žá­Oé#ÚÔēdįĐ\M¤jļņН.ω'„w—õܐ‰=)âȈØ|äfvũywvžúä'?ôālJą™ēB1…?‰i/h* #Nˆ‡ą#ôęDMŽYļ×İœĮnĘãŋ6ˇļ 5‚AI”æp°ĩdÉērā &˛ÜnTųķ??ģ|úĶ_dĀzv™ ­Üépķ7”—Ŋ|īÜŨĩ{Šü§ã˙ĘN@GfîŗW9æØ%å{—•‘cfYwŦ\•nFw,`ũg‡7Ļ-,ßdģŠ[ŗlÆ­/Ũã_|ōŨEօVNģ^?ķŧËtąæā¤ËÕ3ĪuØŨ`í)ŋģĶëi—ë.PĪ}Úļ ũŽ4ģĘķôŸĶrč‚ų.W-Āü*Emô*g+šiâֆ¯ÖÍV*Zš&*’ ’#ģ4ĻV<ęamæ ëvl• 1ĀĨ Āذv6TĖT\+&ggĒV+ļŗ3vŌQ>~ĨQœ6O8R%h‚Bü°ÁÎ8­ũėČL02÷™<Â/§$"mķø¯”ą×áĮoŊ€n`kđ­ûƒ+Ĩ#m‰kEŽ †Y^x7ō“glíør māVļ,k_Ŋö°ĄOƒĪy-Mƒ-gā!Vĩ#|´ēHu°Í*]â>žJ9ßb[é†8.לĐâ‡Ķ´ l‘˛iRЧžšåޟŲųûŪ@oŸßqĮ¯ŲCūKåøãakĪ—ī˙æō’—ĀCŪGĐiŪZ60›íģîēëīađ)všš÷.œD{ 3üũŗ}`&ō BƒKõXd'ÃvąŪ@ŽČˆpđį|vāqFȉm -gwĮōĐņDŪnÛāĐØŲ}˙”—/…ķīÍ|šŊŦÜ}Ī“đ]ÄĮY÷ŽĮatÂĮ”)<0ŲįøĐo`ž?˜6m2tģ°+ƒ.įû°3Ō9Ėœîū§ObiĶ‘eŪÜEåÂw×#åC;VŽ–>m\ʝ~ŖbWō™â.îHäđłõEŠŠ[] ˙éü˛€“æŊXÆ:qō˜rĶsËc%^å=6ž–Š•„R l ēÃrJNßžŦ•å# é`ú—ŋ:ūßY@ÖÎZĨ㠖ըš*SM2RKįŽĘhŖEŲdģ ĨŲâŲķ2ČBØJčLŌxĨĩƒ.0 „YÁ áC‹“ÎōĨú‹Kz$Wņ…“62(O˜ĨK™jüAŽđ Z|‰*#ú Āsí$ÃMÖ'W”j“ĀcÃjđ7DÎrčâČâÍÎíuía&FJĀĒYąG5VÕ]zéÜ7Ū`ÖŌ¨ūĘĶ œƒ_ë‘w[ü5 ÛØ&¸šŋt(P;yc˛– ĄÅ3]=Y‚Ą6Ōm>í,uõôßŖęĖES°žlJ}Ō9XĀD‚š¤#\4į7fmĮģéÔÎ˙/ū_åÜsĪ*?øáŋŌų˙ KjN.×]ķX™0‰ņąû‘kÎŨĄæ°Ãa°đˁŪÍr O3¸†A‰LS†ĶQ_ĪJô§ą'2R1¤ė•ĶktÎVo Û"Dom•P¯r8ĢŽíXf㝀JJ…Ô§SƒyVgČ`ŸÆgBō|ž'vrPą–Ģnģm ˇ|}Bä.‡Ö;Œ—!ÍäęžÜņÎÄCyCôØė\4mÚÔrøŗ;T?v‡â%I€;[ļ|ųĒ<ąyķÆėd4šģŖ{˛ŅÁ;ŪņŪ=•—líÁÎZOūöŪ`¯Ēē÷Ū„Ėd 3áMB˜įD@ĄˆŠZŊļĩ*–ÛĒĩÖĄĩV[Djĩj­­ÖÖëtZEPQ@@d™!1@Bæy ÷÷û¯sB¤õŪīë×øMž÷}žįœŊ×^Ķ^{íáėĪ‡™:ņ>œß~.î6P˜ČڛssRķVoŅHŲîˆūdF^mcRå—Ã˛uōœgíՎģ—#Đy“beLœåËmX[ëGŲb‰ŗDq™O1M:¯ ×Ļal°ŧĶŪø“ļųšÆex)zõ–šüQōG<”ËŋqÚˇž*|ņ[­ í¸Ã™°JSūĀ{ŌÅfHåĀ“æ™āExĸå§ BûŠKD?D~ģƒ‰ģ2-ueÅÔĻ¤Ā„ŅĢIÖûĄ*§ĸ‘—Ōs‘ƒ „5˙âSöŌDĐIjDÖâOq ū‹/÷žS?ŋčČōA¤2Ŋú•ãŧedÂh&ÄßŅ7 }…yj>kŋ.ˆ0_Ķęø3ŠĶŨܚŅ7YĮwnûÂį?Įį'ėūō|Ù~hûā/f˜§ŗEčāŦʛ?ԚÍ`††´('JņøVų–žŧ@Ã|$šČAõO˜‡}Ķp§Pį¤Ę_ŸĪņ#čD:Ž_ģõ&ĘÔ´S™›Î.F?ŧŽíĀŋĘ`Åo°Ëžŧ┖ S¤}ëdXÅi¤üpŸ7ͧŽĢŗ,ųņYŊÕŊōäJn/dō¯|tåĢpæ:’hō[$õØ^o뉕­J?mp„+xĐž}ãh'΁ícˇØĀ|GßÁC:čX”Ė“ŽōĸĪmDËæÍyĨ4 įRvēÄ-7ũˇyT6­ĀĄŅ C2;ÔO›RÛ:k¯ļIÕ寯˙ëpÚ}ÍČ)Ķî˜rEFWîė0ؘF÷ŋēúh~ĶqčŸģŧԐ~}ũh=&@ežXĖ8_ü‚ņ|ZSƒ˛ųŦîĐ ƒ —Ŗ}.˧iSØ ČūļÄĨąJ¸%4$ÃxÖÅI™†û_¸„a™ŒJĶôHĄåNgŠGÉĨ#āž0Ûąĩü›.LJT%-ŋš/gA´iØÂW¸eÁř:(|TęjVâņ Ύ'C*^:>„Ģj[×cŌ nUā°F@æVÆ;ët-;PĐpŌŦÛ`o"ϝ\́O!)ūH+‡ÂXŅq9ųIĩ *ķB5en‚ü>Qnՙ"ÁŌ)ŠSE'¤Qŧōá)äōl…‘ŧ–$7ōŽŽŅ‘Ī-ė5ž­]oúņŨí´§>¯Ŋū ¯â€ąĩ?{ëëYđ{NÛĀ!Q3öqëWF…ELē!Cv§!ģžNĀÁ™ēō’—ūIû˧ߎãMĀöŨNi{1Ī߃Ãz^Ԑ„á4T5ÄNūngß:õ"kŠķÆđNNxÔVđŪÅlâ¨gĶla*Û ĖŒQ^qG¸SĄ”_YŨŪõā‡1ę8Ž-OįÖúā6ųö€ÅšĢé´\~š‹†/áķä‹ĩl6pŌ LÚ‡ÅŅSŲ lö2„Ņũs˜ī?ŠQԟæ4s×( °]ęr6:p4ũsŸ{§//`Ŗ€ĮØ9ėHU/iˇŪŧ¤=Ė>/ö-…‡Žyã3šĪmüŽ?¨)FŖÜŠY6ūˇĐ ķœ…-ž˛Á ¸~"5dĖ V§[ž”—lR…\Ĩ¨*yn¤ĘQ}P;Ä t÷Ž2Ɵh”Đp”ÖĻ´0É‹¯Ōį;tcŋ$ąėÅZųíyIōĐHí(Š>> nZĘRŒS))‘Á˛Ātąƒ„C]),?âˤQã äg;ēpšœ6†HgcĻčoûĪr麁–—<ęd”Pa°Í\Ō%˜ÎøÃOÅ?JL#QÚ/÷!~ßŪįįÍN0JQA[M×Ö đk6JŖŸđeú‡_dĩ3–´ +öôí!ž(‘ŠDxõĸ>+Z:Épå^œ ĸXnęsĖ1§ļ[~ōh;ũôSÚ§?õųöÚמ’…ĩsÛ+^q>[ũžØfÍ×Vāk2ä=ŠŌČžŠ?3:~ËHY‚pü§ôxŦxisŸÄ*Ī{˙ĩ#U“/@•ŸōAk쑺0žÄ åÖÕü5ۖŸšĒ%oČā‹/f2(§†'eéIT#÷ÚA߉ķ1ī'ĪŪTgŅP9ŠĀŌšõ¸ž)Æ ë‡āÎ:ÂNŸC?ʔ&|P¨CĄAbõiōPéxđ‘Ģl +ßŧX~´ėŽoKœSGĄ‰ŽSnŦŖ„īėD;ŌÜ-˙‘”ÖEN+TŌ⟟.ĩ)o'ã ë9„¨¸H‹ Cˆ_šjs›1{lŪēēģß˙ã:ōŒ8%ĮNBūŋāÖōæ›5­ģüå›,@€^Ų‰Œ1ũ'¤úĪĻûOú˙K’TBÛįKtĨ¯Bš2Ĩˆ3Đqë üār #"¯Û,˙VîæÆĮĻōŸRšŨm¸§ > E”ô„%Ąŋũ%r*ũ8īHÃGô–ƒŪB(H腜_ÂRá O:$Ũ‡õŽ ÄmÔü›ŪÖl*ž„÷MFFáā T,‚†*ŋėԂĶRŦ#S˛ęOĄŖđœ‘R S†žQ¤7ĪbQų5ŧpŒ<ģŖíX)ĨWņgrøÁW¤1ëh¸1āōQ%g•k$ꐆ†™\…1e’géėЅŽ%E‡ŠbĄ’ĘųvđFTY\ŠAØhëa´ËĨyŽĪ^Æú›ƒ™@gķéíĨœđÖ?Ŋ¨]đß—ãžËˆûĢ8Ëd1‹†kKYÖBą1ĩĸŦ–mų†ĩäw2}GˆģíČKD…&â'e#Ô.ÁõTnX2ŦŨvëŧöÜķÎįmÅŅŧ‘üwąuųløfg>Ξe4HԐ2goøN>1[ Â]Ę Īf„?ųVĘâÅ ˙ƋÍĶ•ĶiŊtŧh}ۃf4]/mŌųÆ(øüŠh …>ÄŧWv~͇ ëhJŸĢc#qæį dˆŒEK‚ũ.Kڂú‹­’¸t Pļē‘gīÃ[OXË.–RD —?/ĶųĒ××ȝũt(:ہŽļšāņ ËU:ȅéWūŊB52ÃO:el8ãæ-îHYáÅwę(yū¸TsŅĻ8ÍrũJ/iĶö™8i‡vŽĖ´ŧŨyĖ"āŽĀËüŽĪ¯”ĩ_ûßh *‘ŪĐ(ôĀ—™Ēmž:d~j’ūž!iCM— eŸ8˛ÎÍÛb ŗŒTŧ^+šzN _U@tbP:Bį¯xē†:aŲˇŪ†<CĢĻ€ â‹tâŌ¸CF§Ä„[Œˇs•ÄĶ@Ą2NĄ#qh‚¯.čvŖ`V†JÅ ‹Té8€Vąs…N@ ™°ąŽ }#':OFE€‹n• Pņ(xú zįĐ+į(*¯pŧ Ž‘nĖxŖzäß&n!đ؊Į–ˆų›H}'`ōÄŋøp’ÄđIhŅë—×Ņi,”^„ˇēîĒĨQüč‡ ˆy°á-'ĩuŦŋ6ū™ÚG#ÔÜ]ŧč*ōīEÎĶOķøODMŒ6ŗΒĐyķ\R–ÎÕõ Ąģņ&ĀNĀatnk/?˙Ít\Ṕö9­7œQī ŒūXyČG ę.+;ÛRUōK8”˙Wt)6|Xî<ÎŦu håq›÷Š“§äÍŽ‡Ęæ-§Õx”[Ķ; hŨŠīĒoҎDîÚ‚íg€Ęļô^5čžëeSˇũÖn,ēyg3 VÉV”žē‰ÜĪž2x×3õk ˙Į4P#8sËAFIŧéœ e¯o¤ē;ŒE9EÆŒ•ĩˆâcĩ0úŠ…``Đ] !^ЍåC)ĮĀ gĄ¨Ģ wŸN`ņęĄtÖA ŗņĪĻ4|å[ wæęå9ų¤’N4ā”{ã*5rB$¨Mšûęœk8A”Ã\Ƨa,ōÔķ Ŧâ—6xâ]yæ> ãHë.4)< ´}xuJd?uŸNQę9••ĨKá=ALatôRĸÃŖŽÜŌTdyķ á~í€ōIát’„‰"LXĄā,å~ŌįāV÷+ sŦ”ĸÖ á+s`ÍWņf$QXI1ŠJ„qĮRH㔒Áė`3„Åŧ˙Ú.šä‹mÖŦ™íüķßHĸ-mΞãŲņk]íŪ#ĸ0ÎHb b‡q+6_Ŋ/gtđØão?žņŽėôÉ, ŽNĀŪÛY0ntN|Ŧ{ņ{@ 3Ÿč#Ŗ•œŅŊņ”|3ÜD`c7ÆõųŨu"y§8Ą´É4zc(ŧį„dOdv y5'mc—ûᝧņīb‡´3Ÿ~P{ɋ˙¸ŨqĮŊíÆooĪ}î94pnoüÛo §/1}įaīžƒ…Z›Í”ĸ}Ųy虤Ũž=ūg a‘ōîSx$˙ā×QOįö{rķíˇ-Gß ]ÂgŸūĸjG´3ĪØˇÍ;ŊíÅ Ę„8}úäėž60°O;üđCXß0†3H6 Ûĩr‚˛ë–ąPyĶfÎV`›ĶXØæŗ‹Ņ÷=Be>8č đë.Iķ;yĘPŪ" mÃYĪ1täāj@isØ‡æ˛Å™VxîžSąŅąŨŠ<ęÜ7:„ųÖ1ļŠ9˜‰üVŲ-<&S/ƒ°5m'ļĪsōŌ7u Ą}ÖĨ7´rˆ&Í[qʛ EløŌMY€€)Rūģ‚`xŅ5!˙ü¤!ĘMŸIžTÃP>ôĨđ™Nà ō*46.IĶŖxČ[i!nüa|‚´ü@ž“š–NŲ(AÂDa`& 0†{pŋ )⑮¸ڀTœ„pō°ú%/rA͞åēoÜ^XYlčŲ|īØ" lÁĪ/˙ē ”i°ĨáĘoęšČ_ņōRœ1ā4:ÉΊ?îøgˇ+žķ>įąmīGķöđčcjīxĮ›ÚœÎu’š–kuMĸģĒĪä\~Jˇ˛å}GTGˆ†X’‡O4Š-čô{ũˇ™…ō“÷ÂäËÚ[ßzQ‡Ŋõ­.üu1ԘįŊŒŊĪĀî¤3¸#ČŲĩōį3\Đ#<Pų!Ô*$áæŦ@"G9ɗ2>č•Lt€ļŨ¨ŧ…Ķ5Š m"ZxÔtĒ>6ūœgĶ›ãš áŊ¸2Øe‚.ĘuņûŌ7LäÜz/˙vB|Ž_íĒā’'ÚWņ„ĸ_;A˜ÕĮ¤+ēj W—Ļø…yS‘Đņ^{’ŦōĖŊĪÂvŸøelĩČî⨕_%‹oK׎[ËQ{FËŲŨrė˜19`væŪ3rĐë•ßũol'ą3ÜĒ4üŨ~5‡QęG1hc#ߝ(]ćx+EÃԈüôŅVz-hīpŸĀ&ČčÄÆ…ĨQ c>rŠІ2á5DoŠŧ­\­x ŠĀü[ąŲŌŠWcĪŅÚä(Jů°ŪG97JzņŽ˜ú1PŦUšcūĮH¤Ü‚ÉDĻ `3ib€/‹˙";Ēĸ ÂĮ‚ '+Ōˇåa97ÜđÍö—ųŪvĘ)OaįŸˇk¯ũ*¯ĐŸÅ"_˙ža’ģâ<¤Á!†ēŖŠČÜK]KŖāĀvã ÷dađ%Ÿų›́vtZö˙_Ë"\×TC^+\7˛éd¯9 įЌäĀúˇ;ļŸŧÆŦ,ũ¨C xđ9\“_Ž’âāá#sPÖ&v,ŲČ~üˇۚuÛpØŠšÚoũÖQløWíĘ+Čūŧ6{ÎŦ(öŊīß×ŪôƋ8wi›5k/>^î)Ė}žØöžöLFčˇe`ÅŠ7i8@ đŨE‚4rŨ*t?c4hO*”ė´b>9Ÿßs,ûW^u'Ÿī@cį΁ōÄ!Œ{ˇé3&ĩ)SY_Āúƒ)S'1Ę4‚ÃŌöā~{žoo§æ9 Ŗ3’ĩ|ųĒđą’=ÍJnéŖËčĀ,n7^÷`{dÉ<đē•jOË­]ƒ0}aĘŌH::Ŧ— įŲú¨Ítdœå˜gF >y€Ŧ–?œļņ„…€ŠœëŠļâŋ™,i*wš5œōQąæĄ #á’ë ŋŌ<´ ĸâ˙:Tvä„Mc‡_Ô}2 ĘfÉޝĘ4‹I'ßąÂíR(ClR”\Ŋ/ ;ÄkŖūU#=%H˛RJ›$DMÂÃf'ĻLx0ˇžĨŪ((ŊØ-jČTOØPx$\4…17šĐæXãÕ<Š‹moā&ÅíqĪŪĄKp L'œ éäę Év'ŋÂ'iEc™\ēt o´öĨĄŗž=‹˙ū>Ö^ö˛ßns(;/~ņ H|,[…NĄŗāaŸĨŗčŽ´˛`Ųõ.~žå9aŪđąl˜SĨ/¸K–§=¸āqôČ!mårĻĩŊŲžôŲíϟÜÚžøEÍ{f:ÜĸRĄņĩŪ–ąE.iÛØÍ%Œxc0ĻÄ!,ôÜÁŒ/đhŋōgÖy ß4ĀJáāgO^PVh‡EFCÔ>€ĀkÁ+}ɡŨ‘?~ũԀG—FēÖ¤…ũˆC0ëđĀ ėq#å.ĨRYBŸ@"*ŋÁĮ}M#" dcëŊŦrĶÉ#V¯žŧ(AGLS”S™K[Aä-rBWi,Ãų Ëjgˆm×^ځŖõΓ?÷ėßāėke¤ūĄE‹Ú7.ģœ âđÛŖņë[ۑ‡ÖnũŲíÄãŽm‡tp›ÕŽūá۝wßÃBæ)í¨#gƒˆ=rfÔĨ—_Îú°˜&z@ũËŲRúÛßųNĻy Ė÷.ŧ,[ÖÉō˜Køē¸ã?tHĢüqv’˜¸v]đā)1P0âØˆˆƒíčˆĶđJ œ¸ÅĢG'<ÄPX^Sƒ'qDÉgœ-čÁĘxŨ~ņo^ŸJ„+Nŋ¸E3ę nXčH‹WļÅątåÛĢôčoąƒäņŽV"Uņ P M)žJ#ŦyŸÃ¯ÁĨOFvŅŲ¤‰chü_CEũ‡4Ö_ÔžöõËÛß˙ũ_1‡÷9mé2NØĩrō-TC!Í$INbãֈwí2ÖÜíŪŧüåonŸøøģwtf <ÆéˆļŽũ¸wo´T´{ų”7ķ=y‡\JT”ÍOĢFŸĖiād&yŧCU ičԘBŽfôqč°AĄõŗ;<äëÖ6yüŅmOĻÆœxâ‰íļ›įĩß~ņÉíw÷…í _üz›˙ķEøFö×߸ŅĖÛø\@š!lË9ž}įĘû8S`LÛsa4xŽĸŒnmë6læ ¯}1dˇUUQöz)aû¯ÜqZØVŌ•s ĸd‡ŖĘcƎd”S“öÆĻ´AFrQރ6ĢV¯oˇūt9Ÿ‡āĢo´÷ ‡ĢážīܙtØŪtŸiė64]&°r*Ŗ\¸¸WÖ¨ëÕĢ×ĨæÖĻ‹/ÍAiëÖ¯m <œĶ‰—ØI˜˙hģ˙>Ī€xōŖ}ȁItÆ0Ę5¤a:ˆ§?;jģ1ŪpŋÅoŧ2ŊŲÍßĘsą/§Úŗ@ŠĘüʔo“†pâa;zŗÁ‡(\ĩ|PģiÂS–bO…Œ íKdĻö+¨éŒšôÛĸŲdžč73 MƒzņIÄd öf ;B‚hĮņDđŋē%›üɓŋs_SRŠ Ępį¯4ãūlō3POĶĐ'ÂnŌ`žsž)Ęa]o=Pœ(ŋÂęwmā™ÖM%Rž|pœ„mM;I‘ßĐ´ķ†§`ÄŲpÂ[ •+6°SÖÎ9§Ŋú/h?ģũMí5¯š€MާCũ6Fæ¯o'>å¤ļüą5Y›âÛBų‹ŪĀ]ËĻô(2U2 'ÎßžaũųĨ2øŒâĐÂīüfûđ‡?Máílj86 ¸ŧíŦQŖų]oJö~d\C>öË čŸoTĄédDV>ĶÂāĘ%[WčH˜ÔC„Sg¨7Ų”ĻŠo;¸˛SHK(ëâ3xŌŠ†žņō/?֛âŗņĒū #:—y*”†Éwĸ|&Ph¯|  žãŊõ̜ūĮ˙ġvá„I!ß*k§K=į--0^Éŋž×ŗŨž†eÕŋ´Aa´ô˛…`}#IØwŊÕŗÎ9‡ēhlûŌ—ŋĖŽuĶ9pņĖvȁļģ9äö´SO倯ģÚ 7ũ¤~ÚSÛQ‡ŅŽûŅÚ]÷ÜĶÎ~Æ3ډĮŸĐŽžæēvØÁėŨwˇ¯_úMęŠ}ÛūûÍMŖƒš/øÍį͉8<‡æē•žyļK/đ?î´/tšÎ¨Ų„ĐžŦõ7îrÄøęW)+‰Zv)_ŋFūŋŌ€Aa ķÖ­å+(2.ļBAÉhNōĖ%Ģ,WžîĻŽÍ•í]šˇq–×ānĢÁĘ}ōY RͧCAúū PA@ũB %˜ģ¯Đé,H§a0p:”¸pÕŸm„û €qõžRįŪFwáĮYú˟#įĘ*Y#4\G‘Ņ~Ĩ—´‰Á5 Ú¤R&`B°Kd&ŠŽIXžC#ÁKxŖ°uŨ´Ã…¸: ĻĢΊ iõj…e= ęLĖę@qÂĘ T ›ŠøjL€ÚĻĄœĘAî+ŋŧįcŪČ0 F­šä]I¤tJ‹5ązI/ŋy6?xg…y3ÄĮd”~q8’i,¯É~ū¯{í+rúį *ę}7M)N‡Ž.}ô“nŠxà ¯ŌĄžĘ•;¯îtÜąQų˙´Ŋü÷ŪŌ>ņ ×t YsžÆˆI- vW›ŌŌĄ;ī%cūk˙ڀŦËIiĄî*#ė†ĶpöMÅV›#8ų×ē-4ĐoģíįÄŪŲF>ž}öžíČ#ÎmŸūä•tvžÉ”žgļŋû;į /cĄí4vļ¯}õĻöĩ¯|Œ4Į0‚3‰ŽĀF†ŦÖi§f„ŪŅū•Ģ6”=ҁšQ v¤ÚÍ1 \œ¯C´ u†LŅĢyŪi?ĘNIĀøļ`X[˜šô8ķŦ­,œģL˛äé˜ŅÃÛavH]ËĘzœœ•ßˍÖJĻl]ú[‰¸š„GųôבíˆÃ§ļYŗ§ĩ}öáÖ ė9nOFĩF1b;€žF"ῌr å ņōĮVĸic#I[BGaõO(fjŅ]ŅÁķ ļy|Üδŋ¸ĪūŪ#XX>ŦNAÆœNd^Zܲ˙ú{m×NƒzĘéģüFĄĀĞ‘SŨúW;åh×*@íasÜÛ1÷Đʘ?_Â{éliÃ,tØmč0eSȔi‰¯Oëœ%,¨ø’G¸ bƒP”HįļЅ˛y”`“h Ę^ĸ /ú ĸ Gŧtrņã†ū%H](@.‘Ɲ~GtÜTVË7ųÄ Nē„w¨ûĮDģå§ĘŖđū—lÂęĘˆÂdŦŧJ>ŸpüsÚ?ūã?ĩ̝ž­}üãĩË.ûp{ßû>ÂũÚQė&Ļmoäc•°ŖCBŪ)Žöaš‰V,ä°L„ mG}ųdTŖ˜ĘwãKÚSNz^;í´“Ûˇž}ļyEļ,ĩcĸ/°1D .āÚQ¤z‹ãÍ ˇ°Vk2Uō'a—^h§؈5}ô(é’ÔŧŌÖHoų¯ë@ž4Dô/ ņ+cŲr“:SË:ŦSåÂED PgŅu ŸxSĀJaä!WÉ/};ôƒÁ'3:›*Æ@¯ė$@Yą×ˆA@”ci}ŌA>õ%ō”5øV?vlWtDË:–tú@a¤˙ôõ֓0˙—=*‚>ÂŅįņ˜Ų~žāįíēßĖ´Ęû˜Vš–äÖgÖZîoøņMtôm“­k_ũÆ×ÛíwŪÍēĒŊ,YKĮaļrõj6x˜†o\Ō>ūŠOˇ?Ō~ãėŗŌņŧîúÛzÖ8PāT!ˇvp)zũ/“čß#Š=æ €ļf~AŨŗ$Qķb<‹9ŖĢ—ŗöØõ8hē!ƒĖā__˙÷i@ÃˇėŽb_ņG—02Įb”T;Jy§Ŗ¨P2ÕRM!ÄSÚ¨ŒS&uúVtG˜*_Å]NĘׄqHfˇøØ÷ōQ-SOT.NÂGŲ|Q†b\–yņ鴊áĩ¸ËbeËÁÉ& (÷^y ßÉHLäđG¸€'`d9ĢAJœŽÅđ¨@Xiāhø‹Î€Ĩ>ЛDTĀ‘7• •’´鞚 ͌0"ƒŖ=’/§Mdō@§ĢŠD ŗ’•e5_ŧčd)+Fá°ģßāˆû¯•Čšú –EpöŽŸ.4ŋQŸw]ĻĨĶ O|úJRg›œ‚åH[9›Û.Ždˇ øiíūöĨ/]•yŠž:?`˙3ÛĻ Ėų†7ûCi“NžŊc;ōjgڀM ņÍtšOŒé"4\WđÚ˙øãã-ÃíüķŲč“;- xj7nS^Ø"ÔáF‘HâæIŠ:š—Ž´ę¯hXÛØ°ą°‰Ãŋ„ŲČš˜uÛm÷‚č>Į´Wžō\v1zCģøâiĪxÆŠíôĶOb~üƒœ`üŗLĩģØë´ }å+˙øÃFŲ›ÉtwŅņŽĄ)æ‰k§Ō)Ĩ\1€ ?đ[–6+Q\{xėķH§û‹å{æĪp^x‡Ō„ņčē4ƒļĶBŲÛ,ˇüiVDvÜvcîĩWđ˜ZáA…ČƒˆŒŽtŽ6¸ø]¤AGU?MîĘN9āąŨe‘O š_yŗ&õsQp0Ô7˜ÃOŌ•ī­vlņpF&ĖĄâ%Üʁ`šÕ¯Hųá_Æ˯ˇ^ĸ7˛’¤ôЃXųũâeNŠÛēī}!7…VyĩËvųˆ5Ģ ë,ķ\Ļwņ倪>b5k¨nēų' RÛ>đîwąšÂcíģW_Ũî}`^;ëŒĶķFwŅŖF2mt=[‘ųßČ|˙}öamØmŨú ŧUĪŲۑgwŪėߎŋáÆöôĶOožũmŧą]Í×ļ;˜*äpN—ÜÕWōl‡>ĄÖ12€Ŧp¤ĖL_ō'÷Q)Œ3, ŲՌũ˙/׀5l“¸t1GĒÛĀ …ąÍŒŖ°ØĐ´Y4úōéčƒų™õ”Ē*\"éTŠrT}bœ@Ę_ÜX`3b@œˆĒꐈ(WŅõģđjIŪ–[ŅĒō kļ Áķ’"N™/hD(hčwâTŧ7=đv$bĀ<Æšî€Ą%߯ŠCxîáĶ(6øÃ„0đΜî[bGžtLB˜ú߁KįhÅajyôˇôOÃNgeå.9ųČ7‚ ËöžōMœiō–C\ ´ė #âĖaO>ĀEœŋĒJŖā8FÕ)3āKzâÅ':Pˇ~#„83€KĨO6DŠ_‹ŧ"wģČæ}—).ßbÔwT;ũ܆Œ8!‡č¸sĮdZi#ŗ8̃™v:R÷iü†øŗB 4o­œ]|üņĶ ¸ģ˙˛ˇ°;ĶęM@ۍNsÕ}ë%Ŧz´ŗĒ ą]õ Ü„+–‡üÃ(ÍfģŽmKWnmėģ#~‹p$}H{į;_ŘšíūčũŒfÚ:xÔîÕ.Ŋô*NFŧžŨ{īÃ4L™īū•ˇŠSN͈ø°á{ãĖ72gžŅI>ŌËBG;ĘB…."3zvŅĩ<ĒsuÁzŠîi­ÔÁF•6ŌXC.u–|Iæ—Ũ˜NÜ~×]Ä'8K' /’+N´ĢNşAĖ6šk[A<ƒmÕÆSų”tĘĻMEŖ~÷Ķ™u 4ih͘w ‚„ü`!ŸkĄöäi?pÜĖļĪĖŊÚdÎ7˜>c2‹ä&sJė˜6ĀiʸëHXfėú*ĘĮØÁętžÖļL-ZÆųË[Áũ’vķ´ûîŸGü&>–Oŋ@y$8Ærū„!ÔOČKŊåÛĸ­Č¸•iFÎĪu*ĢĶH˛íĄúߍSPŒš5Ē;zP_ņw̧ōeƒ< ­ĮĀA*yKn%CĖ{m.å8ÆāSáÖÔq<ŋąp•_%Ô<’–™)3Ūb ž;éˆ[¯O Ö@IDAT*ŲF8ũO?ōmy/ ŧWđ˜Æ?ã Û1ˆ rDģ”O1[0’ŋâëMmŖ˜đ, :Ņ… :qËÉĀëo!‘rŨĻü‘.,‚dúāŅGMa0áŦöÂ>§Ŋķĸ÷gšŨÜšWpšøųmūŧ 4¨fĩĮ˜ˇ/Ģ~Ĩ<ņ›ģadœŗ‘ô5•RúđÎG2N•ÃĻ[nžˇŊüü×ļÃ;¤}čCv䎯OŦ`ô߅åa,‹ĪU‰: ąĖĮÁ•<'Ä)ŦņŅ ņ9Ĩ8 ú:CŦ•ÍĒ},aõö›4ĘÁ_ĐcpΞ1_ŊĘ=›Qõ —XIƒÎ Võą”{ņ^>< wø×bÂ#éŌš#,õ øDŋŧĘZüš÷ĻËĨā¸ę'ņuBŋÂCĸÂÞFö\‰?pæ_îü֚”EĨéŗŧj‹Otäe×_čö'M˜ØŽe”ūÎģîÎbß#8ĸŊāyĪmŋ÷}m<‹‚WĶxŋwŪüöėß8ģzŌIí.Ļø¸#ВGiŋųÜķڃ-jŗgîyū+Xôëåâûx€mœl{OŸŪa-ÁyĪ~VûÜŋ„ī[žÎ„ūjW^Ņ6Ɩē&úĮØĻÔ)–zÉFΜ{ģäÁumÍč mäč!érį0Ž“L §}ĻŌ_;‡í|ßĮ?ųW˜Ķ˙Gņ†=Aĩ z>žœŪ"¨ī|ũâĶ/Ōëã~}|‡#ũ÷ė ŗsú'ĨyR\q^…ǰî”C÷dE+]ÜúĩTžëĪüE+9Sų_•EĪP•ŋ8:—ĸŠY˛˛!ŧ:ãrUČÃĩ6G\Yål !zțÆĢ˙ŸAļ‰÷ĻŖv(įXÜÉĢxËą’WáUC,zqę 5mPjoŊc]zŖâđŧ‘¨Lsų#|îũŋ.ĨF§Īŋ… FŅâ|KžĀĢ–ē8žĩ[ÂLJIą D”.ņE¨(b'įŠļģ„ĐęX"Ėp̓E}š^yšņ^}Ļ/, ‘?$\•C/>ÃGĀųJ>û‰,$Ôa÷ĸõn’đŠÚ­í9fhûŅõßä5ũ§ÛĐŪøÆ Ûڕ‹i¤ÄQũâT°2Ä^ÂcPČ öŠÃæ×JHŊÛÄčy/šžeōOšv8vŖŅˇŽQÁŧˇũŪīũ)ĶūĒ}‹NĀ؜ąÍšÍku:„C‡ ã•=‹ëĘ |Íîā‰!f.åʛ…NG¨qįv'û˙qķ vTm過æŗv`YDOfN.ŅąæJ _zÂøÉalĀ՛ đznū§|šsσnĩl{’IgÎz™Grƒ¨ģ<Æ&ão įŸ ŒIǁå¯<„ÁØŠD_)鎟°XB“LÃVabī˛å vîŌ!"LŊ‰Q9lĐĄC´.đ_MŲ>Ė­BĪmūļד—÷ĩˇŧųÕŲpā-úļĶũëpÎã­÷zŪĐķMhTū7˙‘(ŠÍۇč>mÍ”4JīçüVģŸÎäßũŨÅĄˇŠˇ ĸõŖč)OŠĪ%‡æ“aڌeI=¨‘´ŠtdâĢō1ŋĀĢŗ‡tŪZpbę"HÁÃ}v"ZXÁĸ(qCŧ´’ŽįLĐižÅyÄsßQQnĀŦ_~’/É_‚äÅhž•Q ŒŪš´ŗŽãŪĮøõVÁa~—¸| ™+•|¨•ŠëŋmP›ĮžHÖÛB? hy힯——Ō›ęGw]' ãļĮø_ūĢĻĖ÷å4Ø_ô‚ßä4ûĄíĪ.ē˜ÁŠÍíuŖGŗ‹ÛX˜ĀĪŋˇģ<Ô.<ę(ô ÛīŧémíėŖoĪ9įėė tËį>ßÜŋ ¨?›ĪœßrkæĖhīûā‡Úį˙õŠöš—üN;oúŒØ˜Ķ3U­˛Fų˙åŌÂĐ ü”Nņũ)›øĮ¨$nžĸ{÷ģķēxķF*×u[“lTG%¸ ܂™OFĨđ˜&ÉĒLæņlæŠT°æ[ã§ģ-x˛ Rä0Ō¸íĖ3–ĪĶ6ŧ^›ÕVÜ=f€Nد]ũTØo­NŽžËÜüĶ™ZÂv,"\ÃÂ`įr;<#ŊîX掭hsfīĶÎ{î4&G´ËžqGûĖgßJcr0rüĮ4æŗkl;˙åīūļ6{ÖSS†Æī5„‘îÁā5Ŧ ŒĪ§õļädPHĢÖ|RķI[1JŲ”“pM7å†0l+ Xā“Dh¤6œâ<ęĮ˛gōø)á ôÚe"‰n“{UŽ|6ŧN‡a:ĶÁöČ ˆĶGP/-_ž|ėo>˛Œ‘ˇeLYÕĶI¸įŪ‡Ú5?|Ŧ øHŖŋöá†…Ī“Fązx:CčTēū 'cĪ6ЕUõ'[yŽ=Ŗģx3Б_3!Յy.ôĪ­ų¨›áĨįz m&ړĘæ›>Čä„úãrĐ"Š$ãÅīy&Dåy áΐäĄ*C¸—'Ų†­JIKͰj8r î„!ņ vĄIIŪ–fí6ë ž4ūMāIôŧ€Ē “–˛Ā;ņŽofŽŋ[ߞxÂyíķŸû6Ÿ[ÚUWŊ§}ôŖˇ˙ķAí¯ßķvŪŌC9σIįM=„ōŸ_šČtJÉ$Œ7@Ŧy™¸×čöŖ}é}›írßy҇€8;ŦĻl!\dŽüépFķD-s‰TšŖ/n­ˇČ+ã’m&6ÄK˙ڒņ؏ōē››O SR˜ą…'Dd…đÆ—Æļ”ŒĢS 4úNûށ61…žėš>GΆÕsâė˜Yˇaãi+`¯â6Î?éz%Wš˜‚/xƒŪ<´BL§Íŗ@Mo~TēbŪ ÂkhGÉÛō9ÉôbÂՙpá+ UuŋŅqbvÕū´+Ī‹ØņጧÖ.üĶ7‡įö_síulĨĖ,#FļöÛŋ=īé§3ą°zČÁí˛~(ej$ķų]hžëŧl[0/_Ūödēä‚ Û1GŲūč•Đ~_5ƒÆ˙­ˇŨÖæũ|ö:1k"y‰ŋk„$c]ĞˇSÚąŲÃWZ—å1ԑA.ÂŅlu´ōåkĢ4Ôŧˇ@vÆf$æČ>FîÆ…×‰Iü6åRđ #~įUq}EĢë´ŧ˜>¯ŗH•×Õ8Đø7ôŦŸšå^۔įģ&ß<×?N fŪrRō °X˛i‹_ø‹3ÖaYĨaGÚ‚FUI'NÕYņoH÷Bßā0Ü÷,@Y1>$įĘŧ~;ŦÔH‡ú#­Ē°´É0‘“ÄõęO’$ãü<÷‹¤aŖUãÚÔiÃۘ‘CŗŽdõ_ĻŸé„”]Ûnˇ‘š•WęļĖ=X9Ļ )Ŋ6Ŗ’ˉXC g‚Ô›7\Õ¨#OČ‹‚y`=hŋ¯…åc íA[tT6îšh|-x‘rĘĸ‘ĢÃĢ)z|e¨†dÚVėĮTš—b؜k„Ō°_p)ƒ,PŠĸŌØx"˞ĄĪeŲö&|™€ĸåō-Šr/cĒĪņ'ŌZ¸ĒqÆŠŦú\{åĢÎos÷›Õ.¸āwYXyJ˜=Ž­<7fW!e,Ģ-îÁ§E앇‘Ŗ†ļŽ`ŨČIŲzôškŽĮ|–ųÜĪnĢØF7]ųˆĒ”G=qņ•i=†kû0šÎPjé>#PšA§”¯P†˛@W•‹čĮˇÚTCŒ`’ņ&e#sõ¯ĄĄž°Íœ>­}˙?hŗØų˙ßēâĘøįú/]ēŦũøæ[؊ųĐØØ=÷ŪĮ"âj?gŗÜ{˙ũís_ø"ۅ„ˆÛI{EģíöŸąáƸ’ũīj cS”mÖöØ`Ë5:õäwrĨ!J]…ōSYč˜T<2qāÁèFĐ5icøF™qițã`6m*Dī‰ €C+Šųi„Ô@ŲØĘSZšˆ&)!ž¯a¤‘€—ŽGž2UŽ…\.ÔVqNJNœ‘˜W€‰LE“?e0‰…HÕ¯§đ–ÉĻЀWîÁØņë=§Áĸ¤į7ŖŲČeš!Z•G\ôA4IķIAŖ`ŌÎ"č){ôG¸bŦÜq~0Ō2_<ŪŨŪ´H8j0<UŨ‰OՈδ;dŠõ l•(a€§Â1J/Ō2ĐÜå—Ėˎ02­=ķ2æĘK%ė=Ôŋģ)ėNžeˇ§&ÉXĻËÜwī Fø'ˇ‹.|#sæīmŋ˙û/ÍÁUĢWn(ŲH—„0q„<„mDVŲ-d Á7üûe>:„ ëę;ŅFĘŖô50õBĐ[™zrŌSĻ3rûßųÕz/û+Û9įœÉē„ŖÛ™gîßŪööO0ōō`ģö‡ˇļįŗ8ųîģīmī˙ĀwXPx)ģE4G°ÕßC˙āƒāĩ˙‰Čļ{ķÕŋ~+o5ÂüißĐ¯…đp#úžj YĘĒ:/žÕŦÂÃqđWá"/0Úo”ągG)åØ)”⏠gs\T|âČ!SDɓMAŅĨ’ŪsÔ§<ęīĖG$ ué5oÕ QĨi¤{Oœi¸ōƍ_ŨPŌhÄĨŒ*“¨aBŲ".|č+˧č˙ĨÜ×u&LpX'l5=÷2(ûšõ‹ĢĪ/v¤¯O}Hžz/p~H—ÎÁĄîЀú‹3 0ˇĀyĨ&Nh§Ž‡P$tŗŌŗā}ÂøQ,úį$]`-;>Ę+Ą_ÉE'’ nģũŽv ëԙkáÆŗ#šüÜq×=íēojh¸`÷žo_õ]Ū px.#˙vínā-€&š†í˛y…ĩ…5I#FŒhķy p xUÚhL`*¤6dgJÕíj Åog0žFãFĪ–Zof‡߄›)ÉLRTOŲu<q163Dh2:Yi3]x~Ō%>ļfw™ĢœĐĀõéŒĪ\2Ąp4ōŧ]U†h\b¸?ãu FŸ¸”•|¸Xē"æĢUâ,CLjņŦÃÆČL!Å´ã€MîŊŋ6„6¸ĢKláœōOķKԐ,_˙™&N9qTĻÖҐ*QdPwöÕRđ+Å4”‚§”o0Čü†'9€ ō„ęA(oĨm!ôÚĄĨ"iķ—Š ¯ˆá \Q?iœ8Ąw¤“!)1*ĩŖUđ•Ũ3Ÿ­Ālā„qšž°žS§-¤aRãø”íérKō+˜¨ã™sã—÷¤É¨AžáC`>å (Ŗ l^šk&ƒŊô$íåęí]Ā Lø…Ųŧ “Ō¸Ī{ä—@ÉwmG‡ÉBp9jgz„¯ŽŽĻøFNų)ĄģĪā3OEdeŧ5*Ŗxā3ß ­>û…v`øģo ŠÆŗbÎH§kM[ņļæģ.Ķ*žųĖW1ōqfđ썞֚h9`“\ĐáÖų¨É7xNå!_g7‹DČéL™J_ŪÃoē†˛Ë Žsģ×XAßyĮ˛6ƒ…¨û0ĩũô§—ĶÆt ‹iŨ×Ūņö÷ÁËn9ív͚5íę, Ą˙~âfzĪ(æø~ ŖåĮvĨ‡§ŦYˇ1S‡úLPæäĩ°–” ín”¯ė$ ĮR>Ôv˛HĢõG€6Ŗ ʙ<7¯"d~“GĀø§ĀÚRæ=3Ú´†`ū€Ŗ|’ŲŦ÷0•$Fž-GÜA.„•y"m'Šĩ,’.t…—ežd P¨•mÅ`¤Ãŗ6ĨœÆ¤,ig‚ ៿Iî@ž*W)‘gА¨DŠÍ…?ąŠ(‘ ¸mđgÃbĖõ;ąlqw%e Ö­Ũ^FqcŠa4 í$,fŨÁ…‹Y\žŒÆå2:#.‚ūž \ŨāGhM€ÆXÖόh#˜^ä›4’ŽA(Û1āĶŸÍ’ėęōWĀtâCQĸyĀOžĐ_b]™JŖU}Ÿ |á–RŧyoiõˇKN¤5E:¤ąen–†ŋÂw&Qvb^đ—7Lĸ"]l´Œ‚t1;Rɇŧ†h§=IŊdđŲXƒl|€ēä!Tû$SÛzŪĀynĀņ'<ģũÍûŪ×nŋũ>Ļđŧ‰ģūŠũÕģ˙ž}áķđļ¤M#ŸÁ6 Ņ•:LC<žššū†‡Úŗžũ2ļú<ē}ų˗2…ų‡íđCžÃi­ëSū$§ *R|%ŋáMĩįŽSũf•åTģŊ~Ā≋ô z’/–“Č"ˆŸō&søō9Ĩááˇ3(=˙ô)Á >y1{-ÛúķŒB‘r%›ė'ØŪGp‡˜#M:5rCŧzÚbī;†¸įY[ nGį|Ü10š>“†ËēIØøņđۅķ#oęĀZA6ķ•_īI— ôŽâsęĨĶY‡“—“'k×]ŋ¸-øųÕD˛æĒíێ9vFôo¤ÁÆE˛Ģ/uįf{āö(b<ۈ7ܑߨh×o;=¨ŋäsiŌNīœ˙õUœė ë úËÍ ĸôę]~™N™¯zŒé’ƒ3]…˙4ô`īĩŽ.ÃŊ§ŠÚ1ĒC"S×4šˇ_Æ Rķ|Ükē^æĄa1 žP`Qq,đÜ/X3QäM}į=¨õWü‰‹ÎôoāAæčˆ8õĨį ’tbČĀ™MÚSė[œUļ!aáq~ 0€ X˙‚˜†„Äã? PökJž„‘ŋøâųĢ|Š2pđãۄ$1 ­"!*k…ˆxÎ+VÕ¨Æ"KĻ‹(éʗ˛øÃz"NO؈•’o‰”]ũKc‹>SÁʕŖëN’čS@tâ›ŖĶÆ2ī–Ņ4}§˛ :"ĒÚėŽ@ŒČģ ė÷ŋ˙SAn&Ŋ*÷ënû3ĩhöėÉm``*Ÿé”…ŊĄÉöĻ“ØĘoļjŊ4ˆ]ņFŌIXÃÛ¤ĩ¸īęđ÷g đŧŖˆÃéAC€)FđŊ•^ŒĮ.›ē†$zÅ@tĪj§ĄjŅŧ%Dņ§M @-‡Ē”Ā›VÔ.LEĸÜ V˙F›ˇÉ+°gŨUú#ŧ˜FS“žöâŗŲ{æˇËfōĢJ†üÅ'÷ô0^ІķĶ{9SŅ7% ~ũ/t3;ˇ1ĩjÕĒMŦ;:ŗ}ûŠģøœËndkīŧđML˙šŨæî;+ƒŊëŗŧĄœÍ Á–PĩlSă§ĩ›ÚĢ_õžlŨø6{ŲjvŅĶ.3˜M5™ĄŠ4D-;ļqŧ*LŋžžK˙ŦöJĒīĘ  ,†øFė×7%õ†ątkn‹QīžzšĨ#SÁ#ĘĪā@á7ĢŽĢYH×üæŋ|ŧ¸a€˛* *Úļ“ŧķ#įĄŠūðá¤ĩī.|˙4žM t¤,v‡ôÛŽŊ2/ÅĢÍE—„‹ƒo‘zÏŋî¸æU6č }§“Ģ_ëáVō6gáŧõmĶÖëXwv'¨_˜ˇšķæ- ķwëģîŒ~Ōa„¸ūĒŋ*īāIãŨE׿m”]ÅéÉv":evg‘ˇŅqũāÂÖNĒņŲÎ^¯Ēqė ‘DO|%ßĐՎ|"*w˙ģøÁCՃæš6đĄ>“Į–uō&¯Ŧ~›éú˙č>g)ĶÉVöj<Æ)g-d‘¤(Á°â™ ŧŠGžwsĒ qQŦNŠˆŒx†"°]KM8™×Û<ÂŦtSˆäĐâ_Ãŗmš¤2ļĶą…Q~Ā“xĢ#°t 0øe˜ŧYdT 5Ÿm8 īĘnBúžĸƒU6ÄeˆÆæ­¸QAHų Kú$6ctBé$ë•Ēōx>ĐyīwύK€ww¤ŲČ<Ŗ`Ķđít”‘Oaāɯ_^…7ôyŠŽIW,)Zķ´HIgęēäIÎųãvwĶ„˜ļ¨Žžt´üɀčЕrdĢq‘Q!5ЂáŽtĻŋ<ôŽšFĖÍ'ŦØ HãĨōR…TÔvtjVZĻׂ?z7ŧˡųjÃ-ô\YDЏb×ōŖ •šhĀ2TúWH V*ukssŅ0Yéŧōd…B-šŋi¸Š“„\é36M`UÜāDVĶHĶ2'™ 2<Ãt%u‚ĒAFà ûß“ÃžŽšæĢÄķaFĖl^ø~vqš“ũš`[2wü)›KBD.yĮ™›ĶL˜üĮ†ŅK‘†?Šž ŗEŽ`Ú;˛Œ–QO;ŗˇÜĘ6›ė3?s`Ÿļ˙“Û{ŅŠíōËnl¯úÃsÛYgŸÁiß`^åēvĪ=÷ą#ÃĒvųåßfĄāâö•¯žŸƒƒŽhžķÛŗÎ=šũ/_Õæ°°×<¤Gã°Ę˛ĪŌ—?ķ6vĸŋAQæ•Á^Jaˆy’’I˙ėPlÃĘCĨú8s°:`H 8ˇÆ’/čWtéœTĩhGŌ+;€Jg•Ļx&:ëäЖ‘äą8 éÖCjņŨ+gdI&Ä b]į<ņ˜|ęBQ2BKl"V/9gœŧ¸„īüÖ_æ}dášėx•ęĮKņüŌV„ÉSŲņ^„āJgdwI$QÆ\üXö"ˇ¨øD÷áĮ2VäŦGĖZĪD`žPę™^Ö!6φqŽ€oƍßãƒL\iÔ@l=;H­fzØŋũÛ}<ŲAāũŽËŅŊIí˜#洝™ŠEcĮ°é¤ņœU0œãØÍh/v0bņ3L­įĶģ/ÂvąY˜ŧ›ÜĀ[„•íį?ˆ7ė^´`1k\Ü­j>ĩ/§äŗG›2ÍŅE:´fŨqË2ËĘ4OüųäŽ8ڝû†û&@ē!̊Šfch¤ė_•dg^}ƞÉEuGŌ¤œhšDƒ:îŦ|„듯&Đ˙Õ?AÕų0?ŠĒSë¤~Ōíz3āą<šœ?~å)™ Ŧ8,nšÃ ÖsÜۜ˛WæņëŖ.¸āÅč| ÛĐū¨]ôŽŋ€Âė4^˛Ä&pnČÕ×ÜÚ^˙玺~`Nģčĸŋæt8„| =€-<Ņ`”sāŠ…J_šøuq´QõS^ys¤¨$&˞;×ֈ˔ȧíôģ nŌĢcŖQÖ-÷Ä[¯%C,# S?ō(\Ļ›`ú‰‹t(+~úĻOCOøā–Yo WĪ!žgŋĖë[‹ŽųĄȆ'!,å>ĩéa™p˔Y–ŌÅßđd'VRõÜ5ō ¯įą9Y~ūĪWĩU+\{ã›:Ž˙ÔöÜß<œœįĩĶO?…í™÷Ëg;žũÃ?|œÁŸkÚI§‚Vߤ/Dųōi[f+o‹×Sƍ˙?{ÁfōD|OØÃ/Á"pí|oØÎĪŋėūÉpŨsō—ûČMņī/gMx0ŲМũdŽüâĨíĢ §ũ˛K9ŨMá4¤zãĒ?ĐfĘnYPžc”+šOˆ`儚‰a”A§˛ Ķ5ú‡zeN gÕ ĶcČ‘°eT5GœK¸Kî:=-ÔK|–€L˜PĀ(ˆN!ä7•¸ŊĖg8Œņ;ÂŽlŊ|)”ĀÕüŪ –zâEšÂģäÁ%Ą\ +Ī6žÁ‹ßUā( åđp놀°ûŖŧH"°8ZøD^qžI¤DĨAL“ŧN"•Ŋ8ų„%ÂŌļŸzQ×ÅŦŽA͔nô-Ä=KUųāÅ-6t¤^*?” 20čŽKŅmG—17Rp…ēÍJĢNüŽp•_q.ĄTÁڛŧ*9ŠTČ'Ž([\k…“đĘœđZ•ŠĘ1Ėŧ†Ō‹[Ę2a”ąE#7ā‹öy´“‚+nG-xĢt’‘zr1œyÉLˌûDM‘đ‘Ÿ]:¸žãH.Qvld)?‰/ŠzēVöĸ•+õ)OĨ+d—/ūÍqᒟ6.Āc~ĒMC)äaâÄŅ4ū¯môš?kĪŪŗÚ%ŸųSmŪßN9ųylËš!¯âĩ‡‚¯tņ•¯đfZšÕŠŗÎÔ6â´.7Ũôsöį˛0ÔETˇ‘p; Mi/{Ų‰Lᨎ}đƒ_k/}ÉyíČ#gßäG8q÷œúöáŸßŽģv!üü-¯‰Ol'Ÿt2;0Ėk¯{íEí]ŋĄ]véG3įō{ßûqûņu Û\φø*ՊAÉcĢĒI… &+aYF3‘'Qŧ é+;GNÕė'‰É,CŽŒcM™Ōjá%ŧJ.¸:ũ—MB:ō0|Ѕ*Ę—|ŋü`¨ČˇxäÔr(âœ>Ãˇ‚qÚЍLŌ×'$?:ŒÉŗ NŸ‘VâÕI°—AEęāãFą˛ xW„NĐø`ĩIę ž+]„Ą7ß˙Ÿßõ‹T3ĩôÕōßRHxWˆsŖ ō’ú†`5íŽšWĩú^õ FđŽ „åßķĖ×:dĐĖ÷ÕŊ ˆSŸF˜ŠÎ{,ƒ{ė1œåҤŸ‚í˛}aŽÎoæŗ‰ˇK^ŨnēõjŌ.äãۃ)‹“g͜Ä),Hž˜7{ī=|c™R4Š•g´ā”iļ;†õ (qŲ˛ŧQØ ūÍŦ9XœS”W˛Hųᇗ˛Ÿø"°;Ŋh Ī?°“0‰¤ C†ámÖėQm ģba§ ;Ĩŗ`™v­Äļru”QŪ14B}DĨŧž•M™Vf]ƒęŨĖF766Ôk <„Ö‰’g¯ â%Œ ŧ%—0wÄ!AĶd5øš'.uyfūT9‚8—ųŸÁ’&[ ƒOÃųa`S0lāĀiž?˂`ĶpÂ1íō§íŊķÅvė1syģÃ^RŦēúšĘ›ŋäLŧƒˇ†`áøs耱…pŪĘA҈đļ)ú‹A <§Z؁VōOågåąĘeĻRÁ¯h,áéĐF0žI ~Sîˆ,(ųUßĨ{é“2$i$QxĘ}˙L´œĻm$ëä̃˜ņ¤ˇü¤Ëtp 'ÄÕŗ>$ÄH€lĘ8€@w%ŊôX0dŽYž2+!x‘…į´ŠËõ/ÃFŌŅfĀg8ķŪU†sâŊlxúÆíæŸ<ŌN?cN;ü°ĶyÛ6‡ˇm.Ú—yōŠV­ZÍgMXøČG>ɁK˜4;ĻŽĨākß ĄįđáÃÛŅGÖî›7/ĪžÜÛ_åëęIąÍ¯č’_íŨ7GÚ5š)9;ĮlåáÂFVîŸĐNT™gáÃ[/=¯'Ãîfš5kŽæô§áŒK<áĻŸŋâr Ņô´†ą^ŊLú¯!Č=fÜâ×ū‚|ÂDāÜĖ6Ļ{OŸ]ßs˙m 2—FūÍC  }hĮÅŗū:{É3 ˆ6~­ĸ\€Sᘎx•§ũee™Ĩ ȀøeÎK0îm@x•ū-˛ŅLāâÈOel02˜gÉk’y/ļhg˙;ž1Låæ>׎›|Lc|ĒMgĄ į/ųND4aõé eTĸ*Ŋrã_*ÄĐ4\$"5{āĮ 8xŖčĐŪ‚[ržštc´ūrY™Ļ! Ķvũ7OSV +†Ū¤’‘)ų„ķ\ë;ˇ*­į+z•(:nƒ7ŽųkŽYĐN;ũií_sķëÛÛūüõŠ,—¯XŊGā1ŊÜ*ˇeSKËV ÜįŌ~ųĶĒu$Ciø?NãāĻ›.g~ū{ÚK^ōBæķūc;ųä#Û!‡Ø^ķęĩ3Î8Š °kËĨ—^Ũžōåo3-ãjFûļŸŨž”—ĩ}įÎeûŋÉ4ūĪËԋ5k6ĩcŽŪģ}˙ęET6ƒŗ­Ÿ´Ī:û¤öŨī^ÉBŦSč8°E¨ĢíģĢ|0?)6ÖãđW—åŖĢ8#ŸĒDFt?ŧib9QŗxĢCĪ#6(u‚ÄÜ&/ųŽĄH Œ—Ŋ›Īâ›6ĶA^åždļÆ|CJtĻīh+¤ZęÖ´ō{×ĀŇ™#yûhũ‚0’ e‚ ĩ䋟09áڋŧ§AĄŨŋđĻ…éˆI¸ø.zŌapoy3•oŌ9‘—Ą‡vlG(ŊÄw´ôŖkSĻņ v"ã ¯={ĨņhBÃ`¨ôČ]đ[ŒĨ ;Ę'ÃЖ<ôL­I/1•_ßZŒZļ!ŪMÛXwĀBIG;€ōc•3yúč6c`O:sōD‘,Š\Wq†Ä•W.€‚olø¸hØÄî,4¤M;šyė´6köŪLĖvœZ4köƒC™Z4š¯{ŪoÍ´še˖ŗĢČcŲÁčŅG—ą×ø#íÎDxhŅRÎËx ŧˇđņ׌rģSŌ¤6gß1TđÙg<4 ՓznajsˇQ6ĶHŒ=ĄlTũĨÃDC?yĄî@ixųÖĘ ßŌ–?@ÅŅģÚåŠ]ĢRęĀÎfŽčķƒH낖÷Ļ2Ę[îãÃxH^’œ#ŪÚ:uB_a>˙Ļd͝;‡i=‡ųYlS[WāŽŸÍڀ)L!ŧ ųöîwŒ­OCL“•¨Äø‰ŨĮL bšĐfā16Lģųž˛šōv ֒bƒ– n­Ī´ĒĀņ\­Š’ÕâoũR˛šÖ$%' gų€§Öæ|Ž;ĒoãÃĢéųSĪd(Ũ7Ōá%gʂ72,Á–G1Ž/×0ųÆ=í8iĀÛAėŸ4wß{?z› ųėdX´xI;㊧phŪ‘í“—|6!GđM¯>ŧä‘vîŲOoíŋûČĮ>ɴ‘)'ž)ߌ|¯]ˇŽ]ˆÆōũxüČCdëÛfzOšĖJ%}Ža×ŌĨ4ĸú݆ˆˆ);BëlTŠH ËĮ¯ ĒtS ™iPhAÜĮéÆŠt—zƒH9cũ ˙J‡mHWäX )0AIA!}ę ¸e€RVāÜÅč H!âtDßL.IĤD  īĨ’ 8O ™Ģ6ā9 ėū7Ō…ąÂW†9)–!qUøLĻĢ* ą¨Ŋ¤Qœz_=áę ā4ö‘[Ÿ“mĸäC•hd‰•<:,„€(Gé%•L>›lQw>ĒSÃĸk,Oph\ÅB8Å%Š„íŌšÍ\2lŊĶ ‚sFřJŅ|ļâÁėH%.ĢB‚Ša|p A‘yĶæCđ>?´*Ō@ Õcō)鸇VäFčLí›F×ņŸQ]ųôŖÎųã§tãRJžą›m8Î(Íg€´ĢŨčøá6ÂŦ &+\õ×įĢŽ!ö¯ũo0‚T;MŖ>=KQ¨5pķ••|úTÜÄédä§l“;Îcđ˛ķ`%?ŠÆÁ‚ų6´ˇĩ÷˙Í[™š°S8_Āüųgą×ļøŸ4øą?pD@Ų čÖ<*GYˆņ,ęʝĮqÛÖūéŸ.i/}éËĢËũŲÎĪQ÷rØgö7īũTÖP‡°ŖÂbļ÷ÛÆkā=ڑ‡O`.˙ԌĖ8o{ Ŧŧ†ņFaŲŌuíŦŗfĩųķи™FĀ)'ũŊ8„Ē^‘*;mĩj†oîá+öŽRø zTûeŠ0NΉÃöÍc‚2ōNƒĮ2 2Ĩ†{Ņ狆ŋÉ/“^Uļų+"U^Yž =€Gū斯øƒRŖOi!Īá14M_ų'nIš?Ą"#uĮˇ´}æ‡øäĄĪĀh‹FĻģÃétö‚.4"1“h‡_IĘFG0ŠĄŸŠ&ŒWŲdYŧ„gŌÂWŌS6Ŗ.*…ŋĘËlÁāØĩ6L¤§Îgä\€Č/.•ÃíøÚ€-'\Nzí‘'đĸĪL%Ęmé“ôČk&ÂŧIųHá“ ÃG^Ü >2'^É7‰Ā×3:LMŪĸŋŠ7*ˇũÔˇSÚŗļ8ģ|ĘQ40ĪĖŽ[ĶmžJãÚFĩëĘfsF‹Sāîšįūv-S´ŽŧęnŌ8ÕΊoSs(ã‡Ogסwķŧ­§ŸË”ŧõip'ß°Cßj fĖ‚ĩŋčY ûĪ˙Tļ^Gw dæ:-h‹ųĮą€×ˇ….ZLyT[č ͧMiķ<ÔN<îØv đ¯_öoÔIL5cą˙jF҇ÆĸŪ ”ąU)WcØũg- wüqg ;ž°‘í6ËúŪ`Lc͐ōzhĄaåŲÁÂĨœōë›ú[Iˇđá%íe/ūmÎ+šˇ}ëĒĢÛáėT÷õ°íš÷â9cZŪLgÛŅ Öˇ¯}į‡í´cĪ´Bw#ō$ôõë7°öiZęKO&v›å‡×Ũ…|ģ1’ˇ 9Kžų?ķô§ĩÃ=¸]ūí+Ųö›ķ7vaÅ÷Ž_ËĐ(43ÂžæA<Đü÷ŋoĘ`ļQr뇝z˛R œŠM[†iĢܐTĉׯ—ĪĨĻpåÁ“4$­‘ōr<Ļ”ģ‚9Jhœ†eJž ’‡|†Z*­Č#x;‘(\ĻĀoAˇg§lĖÉŖĄ ÎÅ)‰K=`Lq–ōY2Va6ēč­J^‡ZsđL+sč„đR>Ī\a“†mJõĘĪJY‡€ŠÃTjŨșTĨĨS“˙ŌHÎ1ž\ vôĪ`Īȸ$$­â0z+øäXĨ-ցTüÕUŠ{06ĖËĢēã~äHŪø' yU‚:TŽ$…A:ŌŸ¯ˇ+@'ÁŒF׿[ĨMį"Ņ⯰6%fč„F€8ĸåõ$?ÄÎ…ÎC*+nrÚŦĀÚSlSHi–D“˙‚pë•7?ü– VēŦФIWeÉę“uŌá4OíÔčZQ.yl™<ų…ÎúÆSæĘK\bÕČq:Žxöå„âgŗåãDŪ ėĮ)‹ë2âY=ų1o};GjôŠ>ԋr“…äeûĘIT’đe¤-fŪA Ō`‰ŋáY-Û1ŌVŽz(åŽ8vÜbmDk ’ĩ]ĄŊô'‘ž‚^šāąœT>’&qĻ%Ļ”Ãr’a–0}Ū6uD\Éâ Ü$]ũO^æĄžŽFØ 7?Í=?‚ ķ%˛ŠÔpūô3–-e ~ŌÂ@ÆבIQĶ&áD‘ßøČD¸IV'†I;ë Bi”ŦŪ .hˆˆ˙đ̇Ŧ”ō .Ä*&Ķɧr•ũFwÚņéŦ@ģAZø’9!Ŗ(ĪÁ$6žKwŠlLĒƒĐ…]fĪ؍ė}YĖÔ š1Ųwšb¤t,‡×Í⠂ ° žė~Ô˙dī=öĒĒ|īŌ{#=! B Ą÷ŽtąˇQ§8:ŸŖsīčØgœųĻ}ã8–šŽut@•éŊH'@¤‘é’@îī÷ßįŧ ¨3ĸ#/ßŊ9īû<Ī9{¯ŊöÚk¯ĩöÚõĀû ›Ą{ŽÜzû²úJ7?ÁĮ=2^Ŗų .{î5ąėŊį^6:Į :(o3„†|(ĪíÚißzė,’ÅË2ōˇū™uė;X\æĪ[T.XV{dEydÖlāįđņĮ§ž˙`ܘ^jį vēõę›yįƒš_9čāá8>č(§uÉG9íHt(?#4ōŠ{äģʲ\¨|UŽ#gí¯‚˛QÖk•@Qhų=ø|OĮБĖĀL>6KŗVŽZ_å¨ÖA,ƒęEŧËąØ7N~•rć”ĶO?ĻÜvķœ2f\˙lˇ!—īŅ'4v XmļTZ%Ēųüū~€mLŽWxČ/ĸ–8é&e%>›‡0ö~•á ø“˜WŦH`@[ÛākY]XåČrW<ĻNC¤MŊx坴gé2)ī2‹C”¸øntBõdÍ<ö0y‡įX:`ā…¯Hgwü ¯t&Õbä„-:¯ ¯g6J‡ŸåtDä°ˆŠœœõ–2}\ŖqĒu:u€×rėå܇9Jzę>{”ģœ|Ōq¤ķfĐŪŖŗ×æ°3Ęąšjß}GAû–āārļî=+/M×Zņôʲī>{ŗŒh¯ōŨī}?{;tÆuˆßpÖks°t_ũŗkĘŨ÷۟öîŒS^C›ŗ;2Ũ“ea÷—ķ/ŧ¤œpôáåˆÃËHû‡TVŗŠ_˜ÆŽÍĖĀ—ŋúuĨWŪķŽˇĮļ3pÁŗ”%ėéÎĖÁžûLĶī ĀFQ.\T~páÅe eßoßiüŽåČęųqøŊ?x˙yIálÂÔ­÷ŧũ-t”vσŗšėšëÎéÔŧáĩ§q’Ũ.đ§_:—]~e:ŽÜ¯æĨa_ú›O¤Ã"-^|)Š§Ōáņę>öXYNĮcôčQåŊīz§MÄl,W_s]ų9|đ$Ŗy/Č2Ą'O.ë7\Z†Đã×<Ë/XëQõ&7GĮ䧗pxKb!„•‚ö;ÃZ2 ƒõDœ‚ A¨eŠé’V|R ÄeŨUŦüÖÔąæÎ@IDATüōĖ­ä„wÄČÍÚqĢĐŲ“Đđ)ģh’Ūü­ĨĐcžš,ķOxo( æ˜:„vų’ÍĒŦ;•ĻĢõD¸¨p<..4åŲ{€¸Ō€‰¤üƒö8 āQ’§õAūN‡Âuģq†”wčô!4ƒā“æ€ENLÁŽéx3^ū(õĻ‹dĐ 8ZúĒt•-Zsę&2 Ž”—¯˜m)ũŽhzЉĨ.¨ķ€Î8(ķtĩ­›t”ŠŌjŊ5÷„č”H™H-U:KA ũdĸMÎQÁ!Jū“V6´X ʏ¤Œ‡Č^+Â?iSŪ67ˆ í°š’0'+,“Ÿ¯ZZé†ęŒéA˜úŖE8íÎ‰Īœė‰d9Šaņ_MŽ4đhũƒ*øHcûaĮ´Ž§ŦŧY8ؐ˛ģ× ?ËuúŗIžëqĸ& ÷Ú,öĐQöDĢ•Œ†ž÷ũ;Hĩ„Ī>\‡lbŲiÜ$Ū”=”‘P–4ŒŽ^ģ˙` YôzK‹8wĪŊâ ÷ĸ#ōÔSĢč(p–>Nߓ,)Zžƒ§qŦsĖ霹ŧgáæāu”})Ÿ^,Įø8Gķ>ĘKų>W&O<|=sĒ—úĸލ—’ĶŨŊ LriĨK¸eU÷”‡pPæBzãfŊå’wÄÃP¤—,ĒîZÔ#ĄupĀ7A÷îĶ={ē÷0íJfaØÜÍ5oŪåĸ‹.gŠÃ“eäX^:îúCų^ķ!k°ųÅ7Ÿ…Ģu‡dh×Vë=$CPKσ:ŌhIÄaųb j"‘ķŦp$šGt|ʖ¸b“RGhtƒ_|¤×¨įĄ•4‚ŸŊuy¯ŪŒDÉjäË#fsI¤ų˜œu˙^śPĢ+ŅMäŪ‘i;Šét/^˛ŽY%—°=ÍGœƒĘ‘Gî^Ļís8G3ŗ×eōøl|īÕĢg:š:§=6—NæBŽk}¤Üs߂2oΕåu¯ûũōzŪŅâĄnn?îØ“2‹´¯ŖākÖn€tj_9Ŋā4—ÅT–´yÚļ °ŒeqōGŋ‡ûš›n-“YF:røđōæ7ž>2|íu×Ķ9ØģœrōkĘ­wÜ]Î<í¸<ßrË­ČĮŽåčŖŽ¤|K˜XœŅōysįąįæ‘rō‰'đÆų˜Ąx”ËŨ,Š™R^˙ē×RĻĮYvz}9đ€ąkųŸ˙tŲyŌ„rúi§ÂŖEėU{¨,×qĮÎĪIgŧˇüūģßɚûįpŧH'a* ãĪ;˙‡,á؜Ëŧ‰eHŗČÛ‚Nû¸ącX6{ü؁ėáčéĶå›ß=Ÿ}E#‹ŦģyÁ˜´X†wŧõÍåcŸųkĪÆfļa>oÁ‹Č~īíoeĐíšrÍĩ×ŌYØ­œ~Ęɜ°Į;Nā›obˇÃa#u}ɀ2ĸ Î&`™mÉ|+BņEœĸŒ„ŸŅąx!°Æ[uČ]ˇŽÜXą*ž@V"q¤_&ę}VĐÅ!¨ŋUD¤xøG¸BÛ(T””Đæ_ʈ0p™Ę1°m i°JŸQZ M@m “§``”=ņųްidJi5H.‹—ŽaÕ!pDŽRĒÔYNîĶ‘§eҁŦŪNœ(-Oģ˙€ĮŽKÜÁÅFĶüH“´áĨ“•æSiŠŲ’?ddpë*KĢVã¤/,L4æußbŗŦ3đz&w}æ‰üjÉũ­8ŧËe´iL ¯2 ˜$"i…—6ŊLÉÍģā?ā䁖ËЧ,m‘īü% ¸k}’ËŪøē1ŖĮâČ:ÔŠhG2R?ö¸ė dŽŧĘŠĨlë8r”<ÁŧŖvĩĖ €ŽÖáąDĄ:ĀĀø‚œĒ 'IäßLƒŨëË˛qg~YēdŪE•.V0ÆaJG• CSįÂú‘ĮÚ0dM>aa˛e_Åâ™WbChdŋ āmšĢĘūåkœ›~1gęÎԁ8•Į‘…&ŋ4DqžšNŖ§•˜.L™v+Ŋû˛9očy×/ŽæČŊeK×ķ6ÅEå‚ ¯€ŠĄl°Ü)k#ßÖ!]M•-ЖÍôđÜpÅĐƒžÎÂ~ö Y<ŠÖĨņŸļĪxF$7sŒ(Į1’ÎōBTôˆG Žø„LËaĮÖãį”īI@jĒ#é[`°’Tš4?út¨S^r ™ųî­<…Ô”AũUˇŦa0%PŠmÄĒ÷ ˆ•ļ,ĮķŪJÍžXM9–.B-—˛Ąã,u:–‹[`=Ž×–r˜{(S÷¸må-XĶą$/;'——ÜļŽ¸ŖˇnކO:­Ž#÷Ĩ<ĘWĒÂĸā6]f:BŦ”zAŗÄÖTēLŧBĕAdŒĻvԈ‹o` ͍ehé…?–)ELZe0€MvĐ@ūšōëœZŋaąĻäuv­Ö[åWų"/27…|U~šĖ’eÛyôÁ6Ėø&ˇZNĶH/¤¸HŦä&aA+ĩ˛Æš„ä§Ä¸TČŽ–Đ,BŪcŖˆS']:|XFûŗ'fŲ2š‹Ŗ¤mŲČ~…ÕĢ7–ë¯[ĖhęRģ´ČށâŪ|āPŒáøĖŅ,ĮZōĸĸŅŖ‡ãX Ŗƒ0† ™ģ0B8įúYœ—ŌuÍ2;vN;íDœ›ŽeīŠģ–o}sfœßŨá&c/g ×3ƒą‘Ō–ąilw-gė›BŪ(J<+Ûäy4Ĩ'\ņOmÔø—ŠÖ:ę3uā ßōÖZz,ëĖ™W•}čŸpH¯bĶãQđx KpĀSëļÅY%ƒząļÅzņœüT¯Ų*ŗDęĐåāM:syKÅ=‚&m‹m’öŅzÖYôāW¯É˙y+•OÖĢÚ)āåG“Šú&O˛ô-…¨šĘ RY”EOIc[g›$™æ?y¸áŊ+õë‹ŌĖ@Y›?5â<į3šÎäÔōļˇÂ~­)8ĨŖ™u ūžYī›ĩŸâMëžYŲĩûˇßöKÛ<9K™ĢŗHûėãfõƒ˜A9ä­åŨTV­á¸O:ŗŅ—IÁøIa勀޸XÂÛAܧč¤Nß{,)úŅ–AŒŒ{4¯K\~~ĪŊå˙/eŲc‹ËG?ūžrÖkĪĖYüͧM+ŗgĪ.x˙Ŗ˗˙ūŖ,y63 K—.+—Îŧŧüđŧs˟üņŅ^-+īüŖ–Ņčŗ?÷ĖÜWŪņįŸ){Uxč‘ōá~ ė_úõéSœEøČŸ}¨L™<‘¯ū,í‘ˇ§ŋíÍgύåÃņŠĩ=ŠeLũŠŋ(Svž\œõXđŲás6ÅŲö*ƒ ŅúīđŋŊN`U#å;4ņ‚Ķeĩ^Ŧë˛V"éÉˆÃŖb KEfX s­ėÖ ×øäŠŽYŲV¸ÍÃä5E•ëæŠÍײÆ&ōV!„Gą(ËažËŦŗIĩXÁà a-pÁqä-kīåĢ™ō‰“0ͰĄ1HÍüŪ§}Ļži c”#U11ü5ߌjRA)—ĐGEyGŨ|ÁmR/n%C§uäč3é‰nęŌ‘ŗėÔ7ôÅQ!/Īz_ÍŅ Œ:÷â¨?GÍĖZ<ŠzosÉŌķ§UŠ„Ôœ¸oč14ŧ΍ÁâŦõTŌ&qoxÛäąĩpæäĨ|68ÚrÉw) ™*@…–váŊLŠķ˜ōĪ]“qM°—|Õ$Šb3¯ú$߯ķ’ĸ}ú•aœķôąĮĻŅīĪēŲ@šümyŪ„ÎZyŸ0āŧŗžuæĪYÅúÅeČđ>p‹‘xœÆwRēė0Š‘"^ÖÄTrÎ9 ‘ŽOČ,Ûũ {:O2Yŧd-įĨ÷)ã ×eQĄƒĢā°ûy´(3ߖQēŧ¤0åđą˛!ášL++Ömåwā%¯­nËn"â ĐâĘ/ȓ œ?IcPETåmkĻÜ$Ą¨Ag|‡Ŗ|m&æoŲÁŦĀÍ7<´Īš7ĻÆûø­d‰okÚ­8ŧUÎLed /\Ã˛‘ĩeė¸˞š_Āhp{2S›ųĩƒ‚!CƕĩlD“LŸÍŸ+Ķâđ;Ϙ_zxᚥrLčĻx$ǰiOˆ Ūx\ÜËßä[š$ΰ\ ­–ßu×Õv5´@ƒĩ‘äĘ0ĩr‚AĢU;r‹áūøčĨČ ?\íhZå|‰ëĩÅWm0x=vÂĩÍžÚnķˇÔĐC¸õæ{Dė¤šįM›7AĪ ,鑧nNŦíR<Ü!Cēŗ, UV„ĮdŒÎą‡†97<Ιķ$GgÎ'5æy|ŧvį”"ž8zŒ4Ž•}}ûö§ķį õvMGAČ÷ŋ˙=8Ęģßũå2c?:Đú<—E¯+ÃGŗüfbœ KZ¯6ųĻ51ü´qšáŲ2×Z﯎ōäSM)D‘údĘaŖâ}זO}ę[eī}†•ãO8‹cX=ļfŲĄžęe÷ÍO7Ī•ëŦĘUōn~›äD5ôˆ†/eĄŪđ  °ŌH¸ujŠúí]+ qū5ĄÉ3÷ ō‹xøIi|"ōVŲYŧ`Ÿ5Ų¨Ū‹uā–[xH;omîQˇ<¸ĒlØ|/ąž2ĩ oWžV~ī÷ŽaIʄ2‡ß“y\ĢŽ“¨Ã˙Ä ËėĮæ•ÛnŊŋ\~…k÷oį3–ĪŽeâøĄ,ķ™˜Ncˇ´ŅČ'‡@¸ŪŖ)ĨŨ?;¤ą.CĻĨYųļJda-úÃXõÁy‘ždCü‰ĮÍr°įķ&^˙tm˙=ŧU÷đC*ßūâ?Æ6Lž8‘Qõ5åzfΘ?Ÿ™‹}˛$h>Ŗų^zyy|Ņ“åm¯?“€žŒŽ/-§õFŽîÅž„Ÿ•'fŨS>ķßɨú7ž}nyÃ1‡ĻããÚzGõÍoô¨QœŌuošüšËq‡\FąĮÁÍÁŽĮĮi<‹/)“'Mʲĸ7“3,?¸ābÖꏤ,ždc™0aBœūŨé\¸lč!Ū,|֙g”w˛/ĀMžņ?—%NͧNå„KĘ÷ta9tߊÁĨãægmÂØ1c8&{eäfīŊöJĮâ/?ūįėčO줌+9]ĖļĨ7eėNŊlŲ˛tLā:üŌˇBvav7+CŖØ:Xé-PöîWŗËūĻnb7bÜ™pM™¯”wķŒuÕã”'§Nģp,)ː¸¨P ŸaË R<ßMCF~ÁįI—Ņ%zēö U*´øŸ6Œ6*ôœķGœJ%VāēÚ;ë;´1¨ŒFáÉnlbŨ„ ŌNËB88¯ “tnąCDŧS…Šké†!Sņ(‡Î̌r*ˇ´n‚wÂŗĪƒ{Ōn—k3i-S7âž%ŨÖ¯9Ũ›99Ē í,—›jîP¯âĀ8ÔL=ē^•ė(āëĪTįĀÁLyöfŠ˜%*‰ĶÉFRĘd%roš Š?ÁŸQEaĄĶ힪­­Ęg}H… ŌãĻɞВqvÄĮU@e#dKēxHũMKĮŧãâV~;c¤É&3T†Â{ōlRå‚ėnšđ^„ōxiHÄMŊ9˛ŗtéš2e×Áå$Ž×ĘÜēmÉ0ųöë—sā¤Sežuk'.Rį—õšá´F9žfYÁėGŸ(÷ßËēÜ!ũãā¸ļ9î€ÂĀU§‚Š]e€zt­|–u´ō žMy{ŗ=õĖ}hFfC—ĸŗũzå9°†cų-ZV~~Į*^^•͒ԝļk3ÍÚÕFkC”§Øgm$•œŽ:ēÃļ+^8Dņc7‚;Ļ ]ëˆkĪ43q’›´1 ‚VŽÔųÚ¨2­ņ4§ŒķųėÄĮÃ>X>ûŲ˙ÁRĄĨå–[î(÷Ü;+›õ7ÛŪŅÁpĪÁ‘ĮOdiČ¤ŌĨFv›Ô¯ôeŪ§qDøõ[ŲĩWšđ˙†üÔĶÅčéwĖĨ>ŸcÄžgf‘V,įE[ŧ‡Ĩž{o}?˛œrúîŧiųe"ŖŲŖG`6Փcēņĸ4F÷YvrĮ?Ī’žëŽš¯Ü{ŋ{Ræđ_†šˆ3=‚ĩû¯O{ėĻøĖ(#v6ŸÃ VM{÷AqĒÔōĢ´+qú ʸBl' R­ļ(†Č2_~Õ+m ΆĐzÕé×ĖėÕRX—´¸Ė­'ëúßÃō¯ĮĸĖÃáĪ[åqÖŗÔåĢßüN9 '}įɓʌéĶŗ6ū]ī˙ŗĖ"ø&ų[īy¨ŧî”ãĶ6šTόŲ={VŦāũāđˆĐY,o:õÄcé–ûwŊcKî-Ŗ˜ypėžYsËû˙ā=đo GY?]ąī?=§÷ÜvÛíåžYsĘ~SwĪļ!Ėļõ‡ļ9ŗį”‡8ŲëOūđŨeĪŨŲ<ˇ.G˛Ã`įÛ5ûGymëĶĖžŦgšĪNCü`yëŲgd-˙ˇŪUN=é58ôËËäI3āŪķļ^ÜŦ,ĪæÎ"˝<ņČƒn¸‘ÍÁ=S+ÖCė…ļĸNŸÂÉøZ8ąL¯¯gÍŨčQ}ËūN(ŊzwaĒ& ˇ ˇŊ¨ŽTģa/ŊoásÖÄW!¨qõŪtíÕâßfę*NÂÔ'ī€ā_Ŗ(ėļyûâkÛ<ią´á[s¨xÚԕŠúŨ†ĩŋ•Ōʙš>Ø*1ôũËĶKÅKâTđ턹îq.oF]<méĮi=¨<‰Œĸ ¨UņyĖč‘y&&MBĄî‘lkfÂ&­ÍŒĘįŗ$0šįÆ@ 3§„ &G’ŧ(sđŽß Q9Ė0#YęŊÚäR,œŠŋĻ ȏä›6°ā†€Xķš į˙Ã8>oÆ­W~ļ_/—xÜŋååĻ&0´10đárķ ŗË`:Õ)ŖŠ3÷fdãŊu¤ĻÚĢĖYŗū{ÚÄÚÕHÛŗ}ÜAegĒ Ļšũ긎|Sâ#F )W^~W6öf4LûģB Åš…ŧT:ŨļĘÔcŧe옘1!ĐgjTy@ąÕW7īf@XÍE5ĘN@ŌlĸėD$â 0 íFz“Į†h0Āx: ÉŖą2ą8DĮáÚį´y-|Jdv¤NXRGíŧ8*fJnæĀ8â›Ĩ9¤ąõGˇŨßęː4gÖ4ÂPÖJĢ6ÚN#Rđ´tVBqĩ áhîŖK†{ADôHüŽeŲō§ËŸ~č/ËEzbËl>ģs<ī0ŠĘˆ'˃N8ia&ĻŦĩn:ˆxÅo›ȇY˙öví/Āo‘a̧ÃÔĶ™w˛ÔfSŪē;yʰrƙņr­‹˙`6ËzT¤ƒ6ž$5gö|:‹ĘŊ÷7éōÖåˆĒzš ˛°É?š_ĶÆ!H¸ Ŗ,e‹4zéœÔÆGü#qüį ųT9l÷’9ģcŧkׯŨXÆîÔ¯ė5u—ČĨ§ŋũę,Ø0;FG`OędņbÎ,Ÿˇ†ŠäP%Iy´m°Ŋˇ‚Ąg(bŲAæxļ‘™qĀŪlŪ1g:wļSŅY}5åĢŪŽæíĢēk™yņŊĨ÷(:Î`bÜW îĨŅQ˙ĸ¸Zß~ûl<˙\mWypŠ‹ÂQuÍDŪ‡@Píp#d‹Ŧf^âhJfČ7vƒ|â8Į!!„tą9b6IœȖ2đčŧHĄep69ļˆ{íWĘ@œÎŒË_E ŽMf˙wysšļ 3+v˙#ßĀÕÁ™jëâ›'ēŌ™uĀa˄øKw€ é‘䔕æŠ.°[rĘ›ˇŅ/gŽ3“bįK^úgÛûoŌ#bĘÆŊņ‰mîņ­rĘΖ.očÖĐÆ 7ĶŦO,X^–.~ĻL›6gj:Ętté’5åē÷Œķߞ›.ÅÛ¯Îã€˛î[’;qzųÚWn`iĪÎåĪ?ōtėFŗAvuÖĻßr˝Œîŗv˙ö‡éwÆĮÍŪ,`Ŗī”)ƒņõØĖĘT'ŗCnZ]ĩ’åg҉Z6Íčō̍(Õõģę_d_&MÕ?͇įNšCƒ’/S;cÅFģÚéWo ã‘8ôÎ6„0?.}É öđG7°ôÔ}+ũû(ßúü_av üŖ9擆˜Õ°“sČÁ–oũûw’™'öÜqįŒâ/);â8`ūđãNΆfßÔŊ„‘ôŨ'Œ-ˇ0b˙žwŊŖ\ōí/"˙ ŗ‰xŨēĩåo?÷År:3ëÖ¯cūnå[ßūNī}™Y¸÷ŪûĘ%W^]öŪ}×ōāŖsāŨsđs—ōÉO˙UYÁ uo~ÃY9Ļ÷-øåĶ˙c:gō,‡ŗgā–ÛqėŅŠÃ;¤Lüú7sZøž\紜õώ…ũúö+ß˙Á×RÆŲYųâWžQNay’mŽŗs71kķŽwŦ+_ūü?–éXHߎ-uŸ‚3"…}bS÷Ū‹Ŋ÷”{[Ž>x?f{VĻdēîKø­°ęt=Åž3ĪŪ—×iO¤÷ĀYÆBjƚŲ~ũî9PuǃįVôÎģL@9ģ•Ë/ēƒÍqaTŦS=}—%6ÕJĸ6#@Æg-$‘6F6^9Å&w(& čh­œ"JķhŖbHŖąQxŅ'ŋÉ¯Â‹ˆ„ĩ!Îē~ƒ´Ě­lŒ‰XÅ nņ 4¸]hˆ"ˆÉSŖaŦßĘģĨßo˙Ĩ/ĶdÕųW@ļu *ķ]Û¨ąŲcΉåņ9÷dÉâŽĨ1ĐĀPįʑ›úuz„؁Ĩ O?õL™:}lŽ:tsc:¤ī”BmĪ´á@m€7ąwd,ëĢ—rŒä*–ÜõaŲ‰{ëUŊÄļÎf3´*%uéāž:Úu…ĀXŌėāšQ „:¯@p‹Ųāâ^ÖĨŽƒ7?âuzŌlą"…gn’H<<7 ·,)Õމ Č.ķģNHÛ9MŦøãėˆl™õąfLŧtøxm¸Dį’ŅØæ€C”3„ĩļĐåNîčŠ÷˛ĸޤ îę,5ŧ5ŗ”žZ0ōį$´đ”3kîä-ßõ^9ķ2*ÉċĢŌ^—~ZN“‘.KuëāOŊĐį]Ļ ĨŊá´w֏;Ŗ2aīķ˜46žK ĒĶÔÛŋ:‰­ž:sē˙#pÜז+~| {xž,ß?ß5˙ŽŨĪg06v`™6}ËFx‹uš%Î úēWKĮÚuæļĶ:ûY­EŠ"&Ÿ•TeŌņå7ęč EúˆŖÂ˞Û{%ĩĘp#Œ„-zķĢōkZķķ ?$ĘR7›Ī›˙x–ÕôĮÁīסKšō§?‹?áŲríĩוKü“rÜáĶxWÆeœäũ§í]ŽŋņĻø>n*Đŋš…ŊŽĻJįÁ÷<Æhû.ē´Ė]ļĒØ‰x”ÍģwpZ/ÕŌĻ]yÕUåû\V&qĒžīã¸ķą…I;yœsų5å´ÃH~nž>îĐŠeöœyåäČŌėAXV.¸č’t&Q†{īģ'ũ>föfĪĀ(и8{Ũ`ŧë”)9Ĩčŧ^ūž9āGė-xbáâœ÷˙¯_ųZ9éD^ Fš\÷Κį•Į9žtŲSĢË˙č÷ƒo~į{eƞģæŪē‹ũ†ŨÚ,ú[TvyՊõåĀCĮ׆˜i‚5Äęeûõģį@Ëk~Ÿį •n4Å2‹ŊĻ+wŪüD6’*k(Ž(cŖtŪ§q Qu#ąëasēH*Ķ Gš ĩéŽžŠ†¤÷˛1Ė­øĶÉ: 6 „å –Šŋ^éŊ7>ՈXŒLŽ‹Ūx[ îí„Hƒ¨BųI=ūÛ¨P6ļqu¯~ŧúēŸŦ Ô`TŖAÚíW§q@C¨œ(WöŖsÆŲ¤ä™ä:„õĸžŊ֐|ÚęŅ›!C¤Ņņ”¯íu6tú—m€ tÎ˜į´—j;ÚĘsãļõ‰&ŖĢiâŖëØ=ęŧu^ĩ)ęš*ûÄĪ ž¨ŽôŽ˙ĪH6˛āāƒÖÉ™¤éüjDÉGm‰nmĖ<ÚŌũ*˜>ŅIëœųH¤r•N8ĩMŲ{P‡ĮEØhz°(ÛÁ]–KûÅMlš÷Øģ‰Ÿ+ļU8CŽw9]Ļ)Qh /ü‘v šœ*Ë ų9gáUīŨ[Ĩ}NļŌÖä•NĪŌ×RŦ˕SÂėŦeĻD;ël ÛĨKXCė¨XŪ,[Ė7ézštÃ=zĢž~Ļėģ˙ŽČ/EĘéE˛#ˇũĢ“8@Ũ;XĢĶ;iō˜rŲÅ”;ūéâŌ§g_ۚŧ#{ŠÎˆŦx”ī[Ņ^{€CdyŠåƒ˙* Ś­ÎšaŠ$)Ô:–ÂU}¨áĩŨc•,nrįw”Wļ Ę>€ŗjé ƒ°Î)üEéŧŗôh8oÍž— ŋ‡|gíīSŽbîØQ#Ęį˙õYīNeG^4yûŨ÷‘ŽKNú_ßøÄŲĄąŗ3y¸dũƒ‹.++žun™Č1š8!9.cuä~8Įi^sũÍåŧ g˛\¨GŌīʋĖvįÍŨž=ØŲ€Oōãe˙Ïˋēzŗ${'%Éw_BöÍŋûXÚ˛¯~ãÛåØ{Ĩ\3¯ēū÷fT~`Ū“đ—œčã;|Ų×u,ÉųŪ…—g†oVĀžƒŸ^{#/ęœ ÃŌ~Å57AĄoßTvßy<3ƒË öĖáČÛÁė)đ=Ņą¸ü#Ÿ)ƒúõ.+׎/ģs\ĢO;qF:Aį˙đG)̃Éļ¯žPPÜ=Ø´­=á¸m7ö`L™.ØyʸŒøjåŒÄō…†­uÛÖņößF3~Gü­ĘãF˜¨Ŧ F—{īz‚]éŧžšõl^6cųĩq‹ŖŽ§eĐ˜Û T(´ØÆŖmTœūQŗë,A̍MÃ'ŦZŽBmaH Æāö7šļ_< ¸äT…ļiv|­â“ÉRYųoŌáčĩÁäYTâ!Äzo“kĶåt68ų`ĂĢ%aûo§p íˆ9S¨c0zėĀōøÜúfBąĩW—qĨō­ÕL;*Škd†_íMĮ+ĶSHļ_ÂZSUUh¸a#ŗgQF­=@ĪšęŽ:‹ŗZk\õ„~õŨuŊÚ ĨËéŦŸ3ƒÕ­öĢÅ!RĶE‚DĻąBXvđ…¤ŅY¨#‹b?4:€āŊí™Į¸:;!mŠaF×#€&œ6„‘/ž#oyИâŌQ†ŦCŊ›„Ú+á!I0Āų‡‰ģt„K¨ß)|ōq=tN‘‰|Ȍ¨e‹ĢŨyđšcŸíæR ōôĨO;œŪ`ˇyIøÔ÷t,Ŗm•é CėjÕ2/ũŠ'āĄ(uiZAķ.ląYÄ' ;†^Ûõ4lč´/ĢĶ6Yqŧ†ôíŨ­ėrČDÂ<öŅ—kŅ9FæĒŒ’ ‰ęO:{tî˛yÛ|Ŗmëí(lą ļŪ“LUž”„XāŲ6ˇ<Š3b¯ūˆo¸UĻņcøö)i×Í9R_ę ”<#R&UÆôGmœĨˆƒū؜ųåÎģîŽ-LJÔŧPv<ŪS›÷=m9 *}SÕ6âJ…1‘ølëQāI€ GĪĩ—ā€ü™5øhT…Ę=žŊ’eŌēä§6Ü9fĪP‹ĀGÁ ]„Ų¸ų*Ô"eđÍą+9!fĘCŲŅ;‚ĮAۘũöĢS9`x–ž PFNr:˜r„ŧéT(OļTąB؈õŅĐ#×}9s~ĐāœWŒũéÔRũߝšĩæBƒĶ“ë×z‹cˆ]¨ûtÚzvô]x-Îb\āčgœ t1 =$—ēn>ÁëoÕ{nĀB„‚c:dEZxJū‘#Ã|ÖFå†ô !oōŅņđšFÕ_ŗ4njú4[×Ī×xA%‰ãáÚ9ŋƒĶ`oŊāž,͏Wm"y¨}3L4‰ ą>WG[:â,%Ky0zŅáØ} ķ—ŸÎj<úĢ“Ū.ī4‡8H1Ŧâ_r‡ļ†€T@­GēGvWümé -9ņkųĖ“ņØæØŽhĶüæaûW§p "H=X/ņ ¨Ŋõë7V9‚ĸ*&ʘ:ÕĘ¨ÂæŊŗJĖÆ+Sļ˙[÷•ÂK9U§M­ ķNhî•ũšaĢÜʒņ´& ĒĒ7$ášš\=Mž†§?ē¯ÚQīžĖ$ģÂôsĪŋ §Ú ÅIŽįŲ3Hmšmd0ĪäēĨ?Uî%ú€ķōhÜö^ŧ^mīŸÅĄö2Ŧ恃ŒŨ;Ÿå7ž‰wGĨŽc#˛ō/Ž [Ęe?ž‚Šž*K9˛TĮ^‡_:ėĻęÎæÍî[lh!¯öž-CûœĨ÷IŲĐבxđÍ6o“v+ĐL'ÆĢŋ>eæ?Í{Fƍ=ŌažÎÚ˛ŧ‹c÷jįËų&ļa#úŅKĄ%¯ ąČļ_Ëô%SŽŊčŨ9¨<úĐrŽäâ t•%˛dCPˆzĪâĒBEųXa<‡š;Õ6…ĒĘgSk}ŖÅj"OŨ'Ā}”\€ˇö ĻWŌYÁ#Š4*ÄK¸_\Čã)s—ŽX†X“ĀÅy ķ@–Ā“gōÆIäfYöÎU˜Z†dBčöĢS9@}fdÛĄ#Ø^6Ę]\ë5ō‡yâ(QVôä-Ä}8Dy¨Ŗ6­L´Xļ˙vÔ.ë$/ŠĒQG,%°†l¨ŊėĖۘÄĶEscMŦk×}´ú›žĐMĶŌá ›>˛`:âĐæ 'ĩÎfî‰2'QÄĨÆ)ū|ÂōĐŖ5¸8˙įėSœ Ŋ¨CĀüDĨMjs0Xü”„e7:V–*!ĀKeō-đ”,°•F]ĸ -ųJĢČM ކ_Ōãŗ@â _Ŗ]֍^hĒųšBEŧÄņkQZįžI•`Mlx— soxÅNŊõÜŠwes_sEO“ų&I–$ö™đǘsģũĢĶ8PåĖŠČJFŸjôčpk¨vÚŦCUĨ•¨ˆšŅÖąäQ ÖjՕÜ*\MÃ]eSgÛō22@jõLéh?Fxm$ŋŠO\|ĸ[Ž¨Bäęé_‘{”!4"lÎTYs@đųŧāËY —;ĩWë8˙˛įmã~ÕŊéļ{)Kã˛™Į,æ,ū™¨¯-dũ}hÖė,åŲ‘%9Î\X–mņū:÷bÚŽÍaÛ°_u˙Ō´Ōė€ąīp‰męŪĒļYNŊû„˛Âƒözt-Ī=ŗš Ųe0‰z„Á*žÆeûõęā@ t­å¨˜ĘÚ5­+•J5e™…-†Üŋ#„*â1sTR‰°vmÚbÜ9üēŪķ­f”Y‰ ų‹Ž‹­hϐʼnƒ`Ū1"6ĀÍČB† sMŋWG™hUl›CGm+5 ū×YaÜTĒPkôöĢ9 8XU.jÔ}"­ėX§„kôS—ĻĀéjŌ$˜đöŧg,7†n7=0čUqÕFØĘŗ^SßV6§3oęxÛȡN¨u'¤uėå¨~j\ŊV ¯Ž‰j7Ž{ qńÆß†Ē*h‹ĀĨMj1 Ŧã:mŠ/O§‰´ ĸČ„¤ÁԈÅ9i¨R`§z,—ÉĨ’´ĸjmŒ)Lnp艱mԃ'6.ļ2@1rO‚ŒĒK1ų$kËÃsx“˛o„ļҤN§čŊ7iė\x’Ve™áĩl™—E×FŠĢHZō ĸúLTĔ\alė-‡\´—Ä”w‹vĐWŋĖ”)+­Âx¤h-ž´R/<[wĩūēũĢ“9}jęÅAmmūŦ2dĸĘ#U T×\ÔÔ30qú‘C†Ú(‰P\ųĒr•ũxĚÉɕ>7!ūŲö§Í÷^ū$Ŋ ĘIt˛5RyÕ΋Á{ņˇ˛Ę‹ŦpP×<ũū(ŗî'Ë tGî@vdĀũīō2Ī.Å%9uyQė`D“ĩ˔ŧę|[Ū&˛“~äĐĻŧŧĨ–A[´vÍŗå™ĩ,!īNyĒqáũU-zg*ĩŊ›jü¨ JMÛIE؞m8@Ä)į! ŪÔ õŠ++֖ˆĀęō89m=€øUÍxc.†"āĀs+ÂÄ[× ¸ĶŨ6´i´kģC¸*į\ ĶžŒlY ¯ ]Ũ;Ÿ’ 2z<ņÃ|ĄÃžŊįĪ[đ{į…üųĮŖé-NAÜ ō2]Û8‡”¤ŲūÕY°úkõYēü)7TNfxΞ­/;qíå`Ŗq†TĮ VņFÄ+Æí×+΁–÷ūZęœmƒēh¸¨ŠięëšŋĒ÷5u­kîq˛ÖÂÄĀT_W›ĨLˆBĖŒî‘ŗ:ˇuô™įØ ōDЄO–bK&<;Č îöâÖŧ ڎ{‹ĄæEļ(Ëõ^戃\ŸÔ8ÄÕÖÕ4m6ĄCÆü(_&°Vá“a|Œ”ē8a$§Pš—8é’gøAH&”˜†ÕĒëtȐʧäK>ŠG Ys‚Ä%-ö_ŧ6ų`IynyR7kËsIN@ĨÜŌeXģÕņ1s×īōļRFŧŗüÆD­ũ͆42ĒĖņņžĘ¨øHœLČC@â,Ĩ…°Ãat̆vDĩÁHžĐĀ iÁÅí4ęĮT ä }qü;ÂLŒų$-Ī$=)0‘!Ãz ZMx‚ÂCëAZ˛É“@íp:W&ĮæKRMôÁ/ͤNŪ0Sak8aŌ!¯ė¨‡•.QņŲ~uŦ— ŽPTSíˇÚ~*Tļūz'2Ái€ĘR´ÖM”õŦž¯L!’ųŽOU#3‚%9ØÁQgčø5Qĩū‰÷‰UõųVW’_ÎæÕôÎė›ˆgåBBũ]gĐ|ņé&ŪXŊ|ÉúŌPö—90Đā+¸’ŧŪōĸKÚR&TqöJŪmœ8˜¨Čs‹Ķ&IšGG0I_“ŋč;°ÛD֒ūjxˇdĩˆ^ôüĸ‡B^Õ2m›žĨ͞<ĪūŪuëž+ëVķ’¸ēįÅdŨÚ1:V¤bP•ÛƊ‚×ŨšĶöģNá@“Ļ.Tt7q(LqĢíÛDQtî2kir2†šmPų3H…C<6æ6`mc˜§‡ĶX¤<°uxs0•Ō˛õΆ,8 ä š¤IäŨđ4X‰Ĩáx;¤­FHqš´:"ŗąOFĨĩŽāÅā…‚ ÜūÕɈ# TY&jĩ}åČZÔĘxĩŋÍ‚ĸŒ(_q,Rŋ‘â@o˙ę\¨Ö‹CÚ!õUgY{ĸŊđļ.ƒ1Įxëŗ.Ų‘vF§‰Š^Ģ 8Ĩŗ’ ˛†€ ŌQīܛŪDQÂę{€!ĸē+ÂU;¤$ëi>Ōizå‡Ģ18uÍ<02č@ÄŪ%+r¯xM§ ÆaN¨%il“„ÄNäm%6ą03;ōŦëņ•ooķ"8P„$pĸ•Fí[,8Ф l“XØCË(]:ío哸Ē^uIÔąĀ<%ŒH'í|ōâ6F†Ũô)_Ō!"<#ũ!JĻ@wø`<é5âū‹‚g۝D“>õ—|ˇŊ8™ ^jûH}Sq9ÁĮēļ•/€”3Ûe`Ģ3Vįō2/厯‘2r_užŠi‘õP‹ wcđ16ÂLÔ÷üU°2] úĘy*z„'_žĨģ•=öǞ?•wÅ,ßĖČõff,#ˆĀå€n˙ÔË)jßļ-ÁJ eˇØÆ˛™Ō[˔;nĨ‰{ėËŽ %u- đ† ļMŧÁ•8ō¨…¨äŲāŠĖ Dũjq›W}]“WÂšŌ´×Q†Š¤Â›ļŊå$y–&ąģ{¯đ?ļ)0Ô3ŧĮ̃ų‘ ēCâ8XÖZCHƒûĪ+́Ļ"•/Ģ#‚Á0!åí*0z’ߨî6Đû(wŖô GzܤŽNš 'MC¨¨wmŧüÖØ{W߄iŒĒ_\‘ ĄHx)\fdIŅ:S‰Ī Û*MNrC9LHÅn§Âė*¤Ū´”=Jšŧ‰•\Š˗ōC”Ŧ‘ū—{ũĻé^n>˙mđÚ ‰näSTFŦēˆ'Čc›åąÉ,īĄ10Ûg˙¤ƒ_b“ŋœP(ũĀ[—j/5ēG*°Âl ]5ŧŗ ėLIvxQęqĮtjƒĢđĢüŠe63’ŦĄ–n˙U3¯ØĒž0ũËHpV<Đ Œza á­Ké-ĩÜá¯xšŋœz}6A —@Ë -ą¯ā‹zJŧ”ųÛÔeâ+ĩJ —ņU˙Jžø\ã"÷Mūę|OŽ<í m3hÅEZ;[ŧ ؄(5šÔJ Bl 2bŗËœŧzëÃ+øŨŲų˙wÕ2X‘M]¤^"TĢTQ&+Õ)ú*˜HU'ëų­WâKÆNmë¤wāų¯#NÆZaMžČw5Ha‹°ô Œ"RY\ …ŠX•žD}Ķ&ŋJ(*07äÁŊ˜ŪQ6#wÍS™‚šĻ‘DWųîUpYŒĐčQ_MϝĄŋŌųōh´ŽL'+äĪĢūJųk]ømCã§5N:9nް´)‡ņ×xxĮaŠ ÅĢžÔF ]œA™ũģ–ōČč¨}É zŦ82Žū6Ŋxi2XĶ“ēˇ>ÚlhĸíFō¯ É %wčv["‘ˆÆfԆ”ķ*"?&Žũa[3Œ“­]oĻM‹hၖ°Č\l‘úÅI)Äŋ'&˜ŅũEū´1„×ÎåilZėÎvÖō‚‘8˛No’ũO),(ųZ~ŸCƒt€_ÛRšx÷å ?tŠ2ûɯŗ$u֖ŧāimė%‚08ØøŠpâDį•ŧŒÉ­ļ?etƄÚŧøéĘx 5—ŧčuHrp[”,_¨v$ļæņjũÖ?ī;l,WFžaYx(ÅÖHå’ûc+A^i¤ ר'ūLë‰Øxãøõō[¨öāoZ'5‚xm‹ôĮŠ6\<ŅtątŪ%M–ĮS |iH¯ž=9Ö˛gYšz5o 둪¸4Ė1˙ ™Á#¸ÖnXŸ—tīÎéŋFÚ˙í+ÕAģb@wĄî4ĻLķí&3ÍL5NÖfģ´Kqņx7!Ķ`8zģ튐ũ˙‹LĒôoC*<[Ã)}e}W@>Ō 8r„ūĢnY:BųīŊN `­F_Rĩ I;bÚ:ĨÂŖÎM°ÂiŒtĖZGˇĩoæ­âˇËM´yړØē§- Wk§: oít†ļ8É)éLäWSŽųÕŪ5 öã4ÚĘk„šÜÛ@(ôĘ5h—é>367Âņ'ĪÉKÁ7ŊAū†~Â$ œ&Ķ {ų ÕwŪ•˛BĶfFšN;ų¤tf^qe9␃yßĒōāÃŗŌŠ ‘Uų44§h˜økŲ|9Éūûî[–.[V_¸°ôëĶ7e•‡ZnŌĘŽÖiJƒœWūËÕqņ‚BęËßX“Ļ.Ģ ZáŲ\]í‚/}+ŧŨÕ˛ÕO5bAö|é”FTä3ŸPÁoCH~Ú:} ¯(EļØd\‹T*Ōܛۍ4ŠeS/MĒ_ũŗMIˆFc}V.¤Õ Xņ›ÄrÔn*ōqĀ~û–åËW”šķį…/ũpiä:’™†D žßV^D+_Z™ø6´yx)ėļ@ŋ×āņ'8ųí §Eū›ū‚ĒĄSųĻ]Ÿ#bÜdZG"%Ŧ–#Î|ēü„žĒRŠˆõ‹0Â_Ž%GgŸ™¯{`ļĘBŒäÛ°lŪŧĨž€Ŧ ė(bŊÉ$Z2SŽâüseDŒ€ĩŗ‚TlX @ fa4`ĐFxˇŪ€E9¤ĢÃč"nā¤žŗ/×'.ZōdyÍņĮ– ãĮ—Ÿ]s /ÚØXțWņ&ĀgKŋž}yũöŗŧeđŲŌ‡s8r[O@RCē0:õL^Öaš†Vö›ąošę'ŧŏ7!žĐ녲jõšđD<VŸ„_×ŧņЙųŪYW;’ŖCˆĖXŸü)‹T#ÂĨ™÷ąŽ’Ôˇ‹ō¨zÆö4öGš|™E‘ōf#}ōŧw¯Ūyk¤¯O3jžĀø vߜh=žá™ ŧuōŲtÜz÷ęUyhŪÛō’t0] šŒļëÉĮ[?pųĒöļ^*Ô/˙Vî×BŖúsv´ļnõú5ÉΑJ/Ëb~k‘_âŌģw/f’zQŽ qø÷ŖƒøĶĢ–ˇ;*žu˛{ˇîՑĨ|ëŸy&xū;äŞz,`ę’úkË^ tJĢåĒwUk°Ö„ {T%ÁÔ\Új?ŅëŌÄĘâ&ƒz @G†Āš@ÛÆ+"F‹(ö„_—ˇT8ĀČ<8I`–1šūzKBm‹ôk‚´›6ė/8ŠoáĘ#—ī͛īȂĪf Ü‰‹2&ˆ“œĶ­ŒābƒMb "QÂ%ĸ:'â!_x~w]~Č8rÂ&? ûjø8 Œ¤BžÕũÜûŧÚRÔį°>e'…儞 ‡Ī<8äœäŸ|’–:Ä ęēášÔc;!ɃX’™ßŅ×Ú!ŗ Z>‰Ģ2 %Ĩ 4yŲ)ÔGlūdŽķô)€€V„• Ā[֗u§ĨÚ×%ŋë­o)Íž]Žģéæ2lĮĄņo‹_'Č{ naE•Q,VBOđŠ€=đ„ŲđĒ ;‚÷q.¯āæwŨZōÛÖü‡ī¸cFĪÚü5ĸ: ĩa“mÕ°‰C#"A9ķrÔ­5âOco9R!)4ôÕQ¸Žž•g—Šh™ōüryYQÔō¨/Ččŋ1zôt{ü–Ō]Ūúđ/NXîŦ#p ŋe’~ ’ĪäŪ4ŨCpŊ4šDuÁķVéŠėŪŖ{Yļl}šíļ'Iŗŧy šö⥠5<4>ō×K+˜aáR´ĻÆĨ"ž l¨gqG¸ˇžLUĨ ÖQ樤,bīœKúuĐXĻėŧs™7o^špæË´Ŋö,3y5¸2ëí*–>ŦŒļs™˙ònũúŧĄOšŌš7vL5rDyāĄ‡ë5e›Į莲ŗ~ũ3e÷]wIũ>ōØfęŪiÖ3jTäq؊҇ĘíW˜ĘÁ6uƒHļĶēī¨.nē2“ÔĢG76tmÂÁbíđžŸ—ĩ É[9YųÔĶeėčQeˇ)ģ”Å4ôO._^Ķkm‡üIÆ3Ã˛<ŖčãÆŒ.ģœÂ+ܗ•E‹Ÿ,ôĪË\,öEę´ōÛgÃĩ=ڛ ;í”NÆėšķR?é@û"šŦÅK…˜FųŪeō¤Đôđ#aëē!;“Ã9ȏ#‘žŪWŌ‹āĀeáĸÅqX¤aDįΛžjûėŸWίF–ėŦ[~íÁ‹ŧ€'ĘaQlčÚõԖĨu˜”ˆvc/˛~Wy00ūσ˜gdAcDCä_xE6D[ėéĢé‹Ŧ.mÄ€ĀŠs“Åž of؋Ė";hĩ,mž5šh˙ĪPĄ-‡6%Yâ,@íT€“:Œ8ƒPؤGčRF mCmđrÃ˛ņ)—ˆųô ^ēˆhŲažĻ6Ün‡‰āpĀ]/OjƒNtL‚ä ƒŅ]Š ‰‡˙œÂFÚā…nígm_kštĒm[Ą,A”S]UÎŊ”zÛéßTĻíõÆĪ•C:€ĸl)]z)|(™‰Õī4vlŲ §˙AlíĻMËΓ&ĸgã)ĪuŅī§ž^™ÛŠ{îÁ҉ëËôĖŽš6­Õ{qØIp™gl„BÔÉ|Pßŗ4&Ģ„UÉUVÕE3ōéMĒo•z6vˇõ‡ŦHäG0íjĀ‘ÅČ.ášhË1CU.›CdƒōĶŅ$Y…U´RdöļöüR/U7}PëoŊ؜ēĖĜ„_YŦėņu8XĄ‰Ōį´9kķķOû€bVúy”†čž~Ž:Bë5å#RÍ<2€Đž˛pã¯y€ņ'vx0v@z*”7‰žå”--œų…Įę/é ķČ\mųĨ!’HtŽBĒ{4ĐaâZ_¯MTŋÍĄÖü&C|¯‰|v ũRoc $‡?ŨÕn2ĸö.ˆ´Ö(A…–†—sY(/”ģūķō˛•bãÔ6lÆĸaļąÛČē</•IZŸF ‡ œĘq4K:á”&§ëtē2mKø;qōđŖ …]'kķĻēėHGÉüW4ų¯\ĩgcS×Ņ2ķzÍšĻą/4üBt¸–ÛQ/; CĄ%‚Āŗ ąŒéwGFĀ(ž/đĩËŌŋŠ|Öá´IkŸ>ŊË đzī'…éËŊHĢasZķō™7’zŸ]øˆĶjöˇ ģ_ī}nEKđNŽú5÷mzÌkaŧ7ŽŸËā!Į—ũđĶeáÂÅåīxCŽđ$¨ŗęâUEŦσ2Ŗˆí4Ÿ W1CN$$+Āw8U[`^Ŗĩ\ž’ŽA#‹I~†§ĀuÂE‡FG›_süqeœ¸~Œ:~Ёé”ÚČüû9ß-'ŋæÄ4 ęQ#3ú|Ņŗ–…‹‡ü3O;%im°:ādgō˙t™KGáˆC.GqDFĢ•Ĩ%Ė4œ÷ƒFœq6lGdshyøá‡ËÏ>VFŽžîN`Gš2ÔŨëŪcƒH)ÖmkIē1ķôĒgĘŦ›×”CŽ \7ZõÛŖqâŖQ͝sÅFa$íTÉËŨĻL!Ų–Ø”ĨK—EīÎ=˙,̚^ĻíŗúÚ?ôWŋų-–lŊĻė ŧ2꾍›nžĨĖŧō'åÔל}ûé5×e$Q[qčÁ•̝Ŋ–ÎŪ rÄa‡Æ9˙‡ ÂũēōŨķÎ/ĢUÔNéxĩ—ƒړÆŒ)GqxęĮĄC¨ˇ!åÎģīæLæîeŌÄ evâūû(įũčBfv,¯;ķŒ šhGm›yÅå*č9ū˜Ŗ3āņĀŦGË3ĻGöâ‰rÉåW”ũy>æČ#ˇĘ˓Ęˏ~ŠŧĖzė1ėå¯'/ŲĶũmÚÛŠ˜ŧ(ĒoœTŎ[|nS‡ĩyCÛŠP?:ĸĒļŗŽęžJ`mûĀA‡6ÔDļMĘ6Ū€vI õ;vū¤Íۘ´gÆ'9_„VûÛāŽÛԒ6„<ō‚DĶ6ô˜Æė ‰]Jz‘:„×Đĸ[3¤,:2Y›÷`€'U+ęš~ķj:•É]mzmžeUig2ĘŠÃf[n˛73ūÅ^ËÄ­õa`Øb¸dZ ĸ´jyMåe1j;íLœ÷ĸ†đM›ž/Ŋûô(ĶfŒ,?8˙!b7—įČ6¨COÛBˇŋ"îäKÚöj‹A_퐅ũŧãŽģË)'XnŋãŽ2męŪe<3ĩ+09˜ƒ<03qĶö™Ũ?œĨ›Î8Ų§wŸ2ëŅGĘ÷ŅIN:á8ėĮ€ø&×ßpS™Īė°ĄC㠄éXūÔ9ĢLD–¨ĪZ_Öm4úEįj˙•9EŽ0ë´ļĶčœĻúPÔŲææ^a"]d4™ kM+^qéÃČ —r†'Č7œTh€U•đŽ(´ŖûFi¤QŨŽ­ÆEÚä)Pūd@ąĘ-¨{pJŽŽx;tBÄlô҈æjĶÆųozī)Vh•’dZiâJ8_DÔr—#đČe–}‡đ]XĘgŖyWõˆH ņõxĶøāud\XÁoCŗYW$]EIšĪ}åIę‰ZX­‰5ÎˆuČŗyxg:gÜ-{•ļ–1/duˆā$NåK<ɒ€ā—q™…—ØėqÛ09|ĀūåÍgŸ]6mŦœŠ Ö[åx‹ß0¯mŸÛûķbø6ŦũŨšŪŧ3ú‰ŖrĐA3XŽp(ŋsR>;tö*ks.ÆĒ92L PrI„Úéĩˆ¤A„ŋ`ĪT-Wđ•NĶ"šuK(O„̤ŽPŲdĻWO2õLXäĶ<:é˛ílŒËIœbļŅšč’ËĘMˇßYūėī‹#hGq_dt1ΞâŌO|ô–Ãqėßøū—¯˙ũgËΓ'‘îŌr÷=÷•3N=šŧūŦו™3/ĪHô Į[f͚UūũÜīe¤˙Ãü@°‹gęėÍ`dî™ōísÎ-O¯\•ʎqG•ŋ‚|i •YúŌ‘%‹Ö•ūC7§ ‘ŌFi5–ķįŽ+§Ÿš{9æčËį?))z•é͆P¯Õöä÷eTĢOŽX^^‡ķŋ73/SˇŨy§ÃĘÛŪōærNö7ÜVŪūæ7UŪÁkü7ŋáõ8˙ģ”K.YnšãNôęŦ8 ˇrŋ3ušŒ=ŽvĨ“?5~Ü8f3įnę^{—K.ģ,õÖŨ~ßŧˇsԑå‹˙öõ˛ #Š/¸÷‰?ųĸŒ8˜`įoOÖ$_qÕOĘūõ+ågŊļœzōÉåún(ûÔ_–מ~j9ōđÃKō;îč#ąƒ=Ë?˙Ëé.,Ÿų؟ŗ„aßō˙ö­8í.k˜Čzæ7 + .(?įžíßŌhœá†ĩôļĪÂčN{ĩyžŸqōĀ ]}Q9Z~ĩxÅ%ÎKAš|nÃZzZ:ˇ}Ūö^ÜíŗiåĄ—ygĮLâÃh×>U>ô§Ī”/ų;å†+cF÷#7¤0;!ļÉq"HņjšėôØîΞ3/úģœĀo|įœrÂąGãŒ,K—.ÅoPƎSÎĮŠwPđƒ*ŗ™¸“ČŅųŋņƛĘy^\Ž>됨ņ[ŋĄ¸˙å:_˙ÖŋgĖÁ„›:Įæž”įļ‡Ž\k?uæĩ‹ ãŲÁ'ë×z‹ōe[lķÜÅQŽvIOjU p¤ŗ™XÛnÛkņ+u&ä\fˇxÖīW>63`!§p„yį•%ŸŪo,šĀf°8¤+ž%"ÔČÄrÛĻi‚SĶK7ŋ–­Âļ–PņWÜôJ?aIĶ8õ=oü‘. 8ˆÛĖMa§(~œN?Čä‰ūr:OüŠ.Ų^mgŸYL—!YGęMí9%_;k’ÔaÚ¤â7sųjGƒ´ÁŸ)(˜ecÂą2Hxîģ8’/ŪĘs#eÉŠ)ouv7{Ü0bn•@3—a„5iZēūË_Ņp­dÄ˙}ŋ˙ŪrëíˇĮI:ûu¯ÅÉš‡i÷åe"äYgœ^ūŋ/|9K'Î8íÔō0ŽÎ}Œ€éĀJ/Ü5ēųėߕ¯~îīY ũ\ų<Ļ§īO:ņ„ō˙ôĪQvi´ĶāeƒæčžJ°mūN§ŋéė×3#p=˙§ŌīÕLņ-yr K0ÆâđŋĻ|á+_-3 p´Ā‚5t`tā\Æņƒ‹.Ž"ŊåggÚđQFĐ |ë›Ū˜u‚ŸūėßÄ);î˜ŖŠ]ZNeS¨ŖÂ×bP† ­KBčËüR^´ß/¤ĩ‚dúļՆ5•‘4ÛŪĐ´ŋŊMG:…ÛôUÁŒVyBt42 ˆ…TFė*ŪLŅŖ3œ=}7ē(Ĩõˆ=Ŧ –ŁŠfJ M˛ė†…áÛvPáˆ@vŊĀĖÅŗĪ>—eëé ŨM‡oGFvŒđ`ųäßūS9é˜#ĘŦĮæ–{īģ?K¸”ÜēŒqĢŽz áFÖč;Đ1ë‘Gp4΅GŨ‘į.eΜ9åœķžĪ÷-Ü­åK_ûfqFáøce—ōsh‰ŧŦA^˜]ŧĐ9†sž{^šyŲwę^/K^tŦėŧ=ōĐęōÆˇîZžôåe_‚ŗą/ļ ŋĒĻ~1\kŌšÚû‹4ũŸō›ōÖēŦí)ģî:9ŗb3ĐĨË/ŋē|æ3_U+SiÂØÆvļũ}i}Š;.ÍéޙŽtŧ›=§,^ą’eŖ™5{ĻÜxû]åHfáė8ƒļöוŗy´Ŧc™ĶŅG‘Ų× žÍ œƒ}ÚT÷9Ķį ĀWžö˛}7fTåC§_TŒžmj~š×ôŌ–i—¤’[ôØe úv| œ^ü:gc+€M" h&ĘŽÄIKXÛ^›Œ*h…ĮˇėöŧKĢ Ē3+Nģ#ļ ~ĚuCޏÔÔųc xhrŨˆ!>ƒeNâô!LQI°sBaECÎxîÉX<ÆåŸįäīj! Nų[&6.zāĨ kšL2HŽÚ9’ÆoÃqfžÂ6'ëãTü"āŨ ĖŽČĶØ­ŌP&ņ>ŪV°đœr&‰’ĸúJúîņ›H’t t¯ŧ*DŊ§­wūÄRyo€tA G]_Öxm žböB5íšå]pQ™yũ-åmgžRŪũΡ—]&N a] ė åēëo(ßŊā’Œ|šaōčŖŽ(F ‡yNĪ÷Ȕû ;Ę˞!ÎĨ>702Äᇕk˜-˜3īņ4˛.ķą€ÛæßŗGĪ8Oį˙đGåë˙vN9īüof:ū3Ÿũë2ķē›ËŲ'_ŪūÖ7—ņŦvdĖŠøˇôĶe˂'Ęį>˙ŒÔT6â,ėĩûp4÷˛Ÿü,†æŗŸøhFå,÷!,˙¸…™„ß˙đ§ ë Ę×ūîeKncęŅĨ"ŨXīÛō7tP]6›ŅâŨwŨ•Qä§Ër–ĄššW™tŠĮĪŽš–YĢŲ$<ëŅĮĢ]Ķp92ĩ’ÎÁœŋÉt’ÕAG–í Ûš}†NÆ<Ū)i¨|葲/#ŧÎ?|Š |%BžĐéfhgöĻOÛŖŧãíoŒÎĒ×ÕHO­ĮįáYŋū}3í~Ė1‡•ŠS÷,Ÿ˙᝔yŪôm§ ˙Ÿ–ąŅ)ĩāŠ86üvĀ;`ߨ“Ņt ¤ĮŲ™ƒĻī ęQî‡wûļ/KĻföDgy4gPtōÉQĪ„xü‰Y.äž;úÎâ0y§ÔíĪŽšĨŧīÔlî8 _DüZė˜NŧrŅvĤĪ"gû3Zø3›ŽēlĐŊ‹Āš‘%c؃íąsč,‘3?#y&Î1ėk¸”a;ÉFķE‹ĨC"ž5žŒą¤×É?'ĻC^˜ũŧ˙ÁF^č\<8ë‘2~ŋ,y‰zV{:jl/–rŽ-?ŋį8N–skũn­-’Ä?xą~V9‰|*§y)˛/šĸķ Ģ‘qFô1ț áÛ+*đː °mÚ&AlT˛iqđđ pÛlMđ‹`-ío‹RČJ¸_rߖË4‰ķFûÖ°&ŧj${Žšscĸ\ōĘ+õâmGūb¸ņUym\ĩËvęt¤'Mš=xđÁYĖB˛WĨ4Ŋ€~m°Ÿ\ŋm{%ŋ)ĢúĻīāē~ucƒã™y€ĖęÕĢʲUkD‘δļIŊęŲŗWybÁÂr 2.;vŖŊiĶk¯—.[Z›3‡ŲŲS˛2a%ō?yâ„ė ¯_ åיmęEš˛˙~D°iUf›:8§đÄÅ&]ģ,Ĩ ,<ŒãʈuXEHLļËāŽhYŋ„)ÂūØ čÖĶ@:~U"ĢÃß8ĒDg•ôQp ūY2Žļ•N‚Ģ{V’{,PčŦŲ5ųšˆģáôÛái2Á§2ũ ‰åR„)\˛ 5ooCqCHŠŧ'„;; ĐčFũ˜ŸndPáÔ%Ķ0#EžÆšT~ęį›fŨđn¸ļR ģ’;ļOvšŦWģ >É1T ͗T|,eú3Íŗ´ūVXáĀÆR,^f‰’XBĖo*ÜŠ@pi T UĻ'Y.3E;éˆCnŽ}Y×ėNô•,š˙Á‡Ę!4än´ąņu*o/—đ´ë˙[ÃãŌN×čŠôîøw ¯qۑ‰Œ&väß5Ŋ~OkyÃ[_&Ų ĪĻĶpÂaÄŽIīŌĸī_xI9ęĐË9˙đŲ„MŋSđ/]ļĸŧö´ÉYī{Û]?g”q:K€–f]ĄŖ:eŽëuIŌ…˙ņ¯ĄßrīČō ‘ÇĨ냨,Ã)ŗŊŦģ_įÎ5Ē}q_sōAeõĶ›xŗ›'–PõÖĘOzĩâkđgŗ˜uk>„EŪšŗ­W8Œ°â“F‘ĒøĒV âļė‘‘Ũ?ũĶĸsô4ãŌ ã&eČ;ģŌĩĮšŲ(¸Y{G`ä< “4rMƒu§’ TE^ZėÛúŠ8U&I|ZŽNēd]F; .,‹–?]N`]ĩŖO2Ēt衇°æŠėqmĒNM7GpœPž…K–æÄ ;î{°|ä˙92ÎYuû—¯ŗNũF˛‡ī84NãžtîdŠĐ=5žF!ė qeú+Ė‘Z î‰ņ¤—É|ķ›ßkô‘úŖĀŠ+j­G3Ŧ€GGsH9ëŦ‘q,ŽŧōÚrËm÷•cú˛–ļڋą_ŖÚĒõ4øÎŽ9Í=köŧŦ‹ŋ^žį­ufî!Fú\ęŖ´-]ž‡šwFÅŨĢķ䊧ËN,íšëÁ‡™U<įaPėbļå :ē:ŌîķNūœŲËŨv™œ‘@ā܌ŦęŦ;ã¨#a§Î‘}ij7PNuHė@8˙$đí,N–Í]{į@‰—>žéõ¯‹ķå>7 : đŪwŊŊ<„ķnGgŽĖ§?û˙–Ŗø8ãôSÁ=€zdōœsŋˌĐđcČ6ōr9đä…NĨŧRĮ^ŽŧtiĘ4xPī˛`áSå}ô%KÆĮ‡ö’s*¯Ĩv`Âûv ĄĘˆuP­éęԃ ßĻÕBĩ8——ņÛĻ5ŧ ‹Áāš…7ÜxÃ[‹ĮmžÛ4m¸ô9ČŅÂ ×æšÕqx1q´å2­¸˜Ņɯxü´e3Îg/ĶøÜæmX›×s܋Ŗ-WŪūļyŠ×<]Ēc\›ŋ<3ų.Ŧ÷Âo˜¸…iá_Z6áM'ÜJ>ŖŸD° |ä#Ÿãy'lŲ°˛Ņ*ĮÆ8‚ŦÎÕüԇÎũļmŨĀ@€Kr]5ā ā 6ŅÆąŸ3{N™ŸWEų!ēr u͊QëÔö”ā4žČ6Y§Sŋ ĩĪiĪô%…ÖImÖĪđŦtXĐv,^ļÉüđ§ÍSä<¨;v¸•Ú* ´ë&õō7Ž1ŠÂU WäŌ%Â>ķTí2Í ķŽš‰"Ąŋõ%ę^!‹ßKÜú ĸ€´¤Ö/!JÉŋ>j@¨I 2ŠíUƒČgCķ,Á†'pk™¤Ģ=‰HĪ$éS•ŋ){Ķa°S|éHdķ¸S|y[žČÛL}Ļøjæ-\|+ĶPĄC`K•’áwFŪ5O邞0Á|Ą9FH„äfRķ)ŗ˛”!˙Ģ ėĪå÷'š 0 û"‚„.*ÜЗš6K>ōVi†VēĘJūĘ"JüÔŅ ļ›Íî­XØČ‡įaHxŽAņŸm0ŧU"ŠÔI€›9O†ĪØŲ$`xļđƒâ#ŧĀŲ ƒ'jzA‹\¤  ”{Û,uušqŅ7 }ˡü0×/}č—C,h\Īíd]íE†r0:œQöqn`.ôJĒwŧûO˙|yÔÃūVãŧWZü€û‘WpåíũËŅž%ŧ=o ŧš×ų[>Ė­ÁrĢŨ#šEčųW—¯øōv{ī[š‚ö§ävOâ_ÅÕũmâ6WŋÎ %ķ0*Ĩœšz×øÂ63\į[ôLĢŋ…}}ŋ7Û<ĐØ&L&.ˇ$ˆZõļŸŅfŒÕįģm(6ÔÉo¯]ü ú°‚mũTâAöaī1ļV:Е?å‰c ëČí<°ÚĒŖ‰ØŸ™…qo•ÆÚĪ9hå\$ŠĐŽ-š{ČGĒ) ^}ˇ>’€ļƒĢė’P0*Ē“4†Ÿ z4ˇ;?íT˙$wˇ%ÉĢū蕐‰=ō™OŊԁ$-%ĐãŖ•Qūæt´xķKũ×VčH߅%ĸI´^öŦ´ā†iŅĀÅLÎû“”‰#ęëô˛Í˜7Æ,5ųÆĄÂÉĖ"ņøąmUIŪŗuŌH¤Ā€Å% ĢĶNd˙Ž‹ė RP—‰Ę:Žë"-åVÅUũÄįgnIŠŽ6NŠ]šõŪm_Ûy§Kn[ōx×ģßÍm—đĒw Ў•\ÃÉĨ'Â]Ɗū+_ũšåŪŦč?é<‘Ü?ÄIÃ5<ŧöų+ĨWŧęUŊĮú'˙í“YY?Ú-&?û´§/w€îG¸böÎwŊkšÍ­...ĮįŸ+Ŋ>K:ú‰øöÄ÷o=ä.Ũ*ŖãļxĪÅ´8x‡å-oŊryúĪ_ž<ôa_H_ž”[ž>ǃˇ}s˜ėÄČMÉãJÜŠåÖÜß˙Üüzoķų_ŋíâJãZĄķi¯$Ūņ–ˇ ŽNsKÖģšûÖŸĩ¸+ˇØ|×w<ą+)^A|ų+^ąŧøw^έ-<”Ėۂžō¯(›;qķ[Ū˛üņ{˙œ Áņā;ųAĄ;/Oū˙ŦĢžú,†ų÷ÁÛĢž-ö°_yËĀ#XhđŲ'_3ęĸƒW+˙ėĪŽėeˇāØ÷üûļ´+ŪųŽånwšëōPŽŊōeĪm!Á[›Lėßû?|s“•̏Ų“Nųžöĩ¯K¯Xüō?ėĄ_ąü‡ũáŨxáŲ“â…ÛĘŧZ[™K?ŗxÁ'čŅāb'¤CcÂQ%Õf>š>:ų…:īÜõkžņR8'wöūÖoÖÁ ķÍ \ÍũŽ xÕ;q°ŨüEū Ÿ¸¸`ŒˆÚĄi ŦÆØIÄÉ%>ÄĮąqč+Ž…j‚o¯ŽžfäjšRüžᕤ†´ĩĶ6$âÔÄ_ķÍ žÔ 9›9 Eũ°ĄƒÜrrá‹n@ ĢLž/\ä&eáKqyQBoė€ÉČgĻ)Ĩ Õ˜M ÷7 ’ƒŖžŖy*ÔF˜úĸÃîä5¯S…Œ'á<°øđG\â­œĀōĘ[ÚĘ˙Ž€;ŽR>#6õõ*šûĶôÁ÷ō’ûގßųܟˇŪíŽw­o<žÅŸŨņ÷Wžäž÷[ž÷ûļåŲĪ˙Õ”üã×ÛÂĶßË-Ā?ķôg.ƒ+oŪŽį‰€‹'éCÆtģ3B{üĮ_ũ´0Ό'âgĪ}ōŨ„ĄĮ Åh‘ÂW}­˜˛•‚†Ájŧ/NqD}HÔ˛on1į‡m›a,~zëqÔɰJƒĮиÆ,1†meĐÄ]2ęП:L_×Æ{æFNøi†žzÉĩØv^5iʓ6ĩ§ ]'č˛@mŅ X’xvר@ØvO„§Ÿ)Û0|U¨-Øa5zL}ųšŧDkōœ˜Åv`Ę×Čĸ>Ōĩ9~ęC)Zkvšcí 0tü“žū{u0\˜— –Ų[–ãļEUd>Vīß GŨö‹ˆŽ„õã48Ä3;_wįq­úįårë=vĨüËüāåÉLnœ„ßũîw[^Ė›-N4ßÍep_Á÷#ß÷¤&ûaî+đŒN¤qo”ķŽļ9QÕ`ūāWl7Q{˙Ŋü}JŨÄ eyæ‡OÄņ^^üg˙ü_r_îų9Šų ŪđÛŋûōž9øîīú˯ņĻ–¯ãļŋÜ>Hôē7ŧiyĖW_šüÃīüÎîĶõÄäŋ}ü×vkČû˙âĒîŅ5qfû=fũkvŧQģîú"ŋ#yĪ:ĄlÁŅjb”´­P/€uÆVŅ„#jBÅÖĄėQž¸`BŠ„Íŧæk(oß3¨¯€Jí;ƒ¤t9F ř Ū%*팓håœ× ]áŊėči°Ãg4’Éø”Ž_7Īæ {ßūô´§˙B'ŧ>öĸ—üN—Ÿ/`āyÖ/=¯8õūķÃˇ<ܛ¯œ¸ įŗ?úc˙ž{˛/Csæ=Õ\%p‚xī{~1“Ú_ãaĶwpEáŽMø_Įmg^E:JŦũü3ŸU?õūsOBtâxöæą…>^Š8šū8‰ņ€Nc2A›€cĮOqĩėČōUūâ&ä×\Ã;ƍ ü: “*Ÿb3r}Úôˊ˙Sæi\qš4Ÿũš¯îž˙ûŨãnËË^ņĒž!r,/îûņ˙đSLôī×muNޝāäë^_ōÅËyūĮÛļžøîwåjÆ—?|ËÛx ų(÷ė_ŌۀŪÄsū€ĐŖņ°nō­AĻLß æJžŋ¨W Ŋ]č o|SsŸĮDߡyĸ÷6^z`^ōw>đ.Īāšĸcøōä•?~÷ģ{™'‹īä&ŧ˙ũ˜¸Ÿ\~í7_Ž9Ō‰ˆz<đÚån<ūËŋúBNßšÆËGyõátĸqô|ãåŲåķ‹8YũŒã%ߚˇĖ“/ōŽ}ˇu |ŌÁuŨä"qÕéĻNzÍaęë-]ŖļŊn ÎÁß|ád‘ŠzÚȩ™ã{mg4ŠaÂ~ 6&ĻØÎŅöˇ Iģlsû‚Mæķ ķÚō9gō*˜ō¯ÕoĮi)Įč‘~‚´):B¯ö')‘ÅŊü$>NÔĮö8Lx:! nÚÄÔļ[.Ė&H2˛zI×ZD˜ ēÉŽ>Úzņ\eGNi:FGĢoém~g› ŊĪØ Ųy‚Š‘ĻŽ;'➠›ļāãÉôsž˙+ŧ)īÄrúŗ÷÷?íĪė-~W˛ō˙‹<Ëįx}ņ­.Z~ëˇ_Ús9Wq{īĨ<ôԟûĪŊ¸áļ,Đŧīũī_.㛚ÚzIŋĘūtrŽyÅW—Ûŗ™ ĪÍø5NwoúƒWārb}üqd?ņ8Į"ŧūO|Ē'> (ũ`´;qŽe¯ČĪ ģqeĮbJ•U¤ļmŏ¸ÂSé‹?zØxˆcęŌ!ZkŦkünĪH¨a ;ûO›ōšŸ¯Hö`ŨęÛWĨŪIČ.™ G•0ŗ9xöJŗŨdŠ’ŨiÆ6Ņ•Á!.ûÂ@8û¸ŧ×^ôoČ€Æ^X™ū$W!xmyŽã§UĢPßS΃æÄŦéü (|Ĩ9ÆoÂĘÚ4•ĶĐÃ̰ú v¤Tã…6ŽnĢ;2kN$Ō€ũļYįvS{šDąŲûÕũ n'đMū™\;š9ĖDÜ'ō_ūōWôŖHwŋëŨhŸ˙‚_é;2Īd0|Åk^ģüíG?œI÷ī5>úQ_Ų ˜ožđžÜÛđūsŪķÁžË.ģw÷Īzų˙b.Õ9Pøö—ķöá?“erÖ§ū? ?ōjr ė{¸÷#Čá?NŪ}'û ųA'ŧWū€{‹ŸúĶ?×+ûžîņkįëC}ũā]ît‡åi?˙Lîáåu‚ŧZĖį |ë‡uˇæĸ‚›Iã@˜hwģ){îŠwđØ‡~ŒFOöáĢĀšĀ“°ƒLƒ&æŧc(4šÚœ„“ DC"ÚDĸÁäXāIEBģ,ę ōđĶˆãžã#įOŲŒۊ#ÕÍk?‚PÃ_oĢ›XןmeĨûø42ŖæâSy“ßf“æ˙Ûoĩöĩ—Žú"Ž•ÉÁAß{b9ŋĐ:õ ę­aMlđ‰“yīõˇŽ¸ų{į3™õ¤Ô•N'†ī`EørnõđDÖ˸÷Ä×ÍÉe“*nN#Č&yæžZÕ|9ás˛6Ģ9&áöēkųQ,–õM4¨ž[ (`ØOĨ’IS H͡†yĸū{ôá[s˙ûũ¸į=ōžŪôEwžSˇjCiz˙ģWŊųģÜNå‰ēĢ„ˇį>ÛÎį¤ÎĢ{Čɗ'ūsŌāī”Ü:ūƒ¯#ū3žÛx዇“žS䛋ŌŅ7Ü’ûņ]aījč'~ūFĀ_q…Ãˇ÷ø2„NrhëÄÚmbáøŠãÕoí¯|íë99:^~ōũãđ‡oéÄf~'Eb3âKŒ7 Đø1^ŧ5H:Ƌ1b jOO}Å<ŧ”OŊ;ÁÅ_­ũ[/}ysėËEßg!Īۂ]ÜņĮCõŗ'ÍÚÁö3ëŪ˙ąˇzÛ?O7ÛŗŸâKã¯ĀÆã5ÖNjîŨ‰#pëĶ.íE6íæå°ĪˇũpN’ĄŋXBĖØ1—{ŧAKÉŖōgáĐlĖXøÆpėjė“ŪŪãĸ‘m`ql|o<=ļAIhP'ƒņ-)×BģũËM0dķdh&ÁÃĒ@ĘVŊL.Ûm˛ō¨IŊĢqÅŗŖOt”MyÖ2`2ЍL›Œ U“ëšC1ÛĐ[J¤@˜ü'?2J:8žÖž]ˆGāķ—hĀõzõyō76”°Uʖũr~É$~<ŧįrˆƒÄā•ąb}Ļ›ƒįķ~å×[Š’ūåŦœ]B‡R?Ôį°Âéí?ū.€Ģš=˙EŋŊ\Č@z+Üš#¯îr˛į ~æ?˙Bĩ9¸ú:DžÛÜúV ÜO{Æ/6ā9sŌīĀéæ ŋÃí/Ķ›YĄ›íĒžŧ}¸Ry\|æs~š‡įūōŖ[~đ_˙h¨—ÖM ¯á~_íōĨŦĖ>î1_Ŋŧčˇ^ÂCËīčy‚Á;ē])|÷æúc?OaeņļÜĪĢą¯b˛āÛH\qđŖļĪŌĻ3Ų%õ!ĢË8 Ø fûRÃ@oOíį|ueLÛôëĐŠŽ•fVâˆä|Ņy L~ą ė&€‡įxŲO1ė˅’܈#i!čČ4(CXwģ,DÆ.ƒ[öPDčŲyM,8ĄÁ§“!ŽÕęæÜųŒ=Ĩ1^ŨœĖ:a7†mØÛæ âG<'žn•<ĐĘËë/ŧú››đÛ=˙FŸyjŋ9ŋÔ­ÁF= Đqڈ„˙])2"ÔÉÖĮSžcËęŗ}!pãß%O’Ąt}ŊåŖųŪ•˙lwĒ÷x?˙×Ŗ7íx<Ģ-ØPģBÎ>í$Ņ|āĻĖæ–Ur>ņ֜üpî}ĀųŠwūQ ž6ÔńŊˇ-ũú‹^œũUt7:ū@āEˇ¸°~™ĢdQû6ˆC‡Ēæã éA2v…÷Cc2I,@ÔsO[“ˆę§,ų77MNŖ@m0y“€Yx“•õŌW2éųE]ƒũjcå/ہQh+"'øÄ´@&‰%TŪNŠ6~Ą$YÉ_zŖëؖ”7ū‚ˇĪ“žČéLڐÉĮô å´īīôúŒōûĢínŪ=āI‚/čPGûŠcÜmģˇM|ûU'ë oļØÚĪ”ũώū×/†ÍŦëOŽqŦv°l|lķ=åWįOĖĨ°üŠžˇØĒØ"0ôŗļ3H&—Jk*A'îÖūÆĄ#4Û&ūÛ$Ąp×r­NU#_?Ã^RŽ^_Ф$;Qˇö YmĪĶ ŧ}\yíđq.$U6ma°ĩ͏‚­ ´ÛŸŊ]ēÄĸ}Ų:u–ĻŠMYZ„ÍHrtÛ‘ŽŖ1ÚØ]}ēšņģ}HĐ#ŸIw°øų´•%H61âz@ kI]ô;ā wšˇú Ęąōu2ä%ŠļīŽ ĒĖWúš§4€Ī1Tčˌ0bŦ˜ŸņŽķîžÛ&Fˇŋäčŧ}BJįåō>Ōküüą-īÃ}åyâ`įœ÷i įĐIyǟ•.Wļ.rB„đÛûļ]55¨ ”U×ŊüeéI†+|6ŸĪ%tĪø{†Čs'Ŧt’~đčÁ.#š|ģ‹IĸdB'ņvLĮ_öšƒ‹žā"Q{Īģ—וÁW‰ÎdâŊ¯]ũÕGķÆ\~ŸÅĻOf#ČāãDķdəĀĸÉO~†‘˜ŧ3áOQá¯-c6+hvg…8Sׄ4‹vā6$él•U ū'íT6‰vvčō1ĐŨ¸k‘–´=˛ŽČĨ˜sd§>Ūrŗsņ°,LÂÆjrJøfŪ”i›X"*žŪąP˛îoˇíTXmŗUhšmÛKײ[ö*ؚäfÛÚÖÛm§tŦ~)ÎÖ¤i­÷9šĶR˛ÚžÚԉ„”mÔF¨‘ŧÔÄtķšĪÄÍžjЍė7ÖGl6;OÖcūė{¯Œō܊ąÁUy&}!˜žĘ=&F‹pæÆ%²īŠņ4ŲÖ" Ü9ą„[ îú—ũ7yËÃ?išÅŦę•_ABõÎ*`<8húČ9§ĻæĘ´ ´%2ŅG›´ëŲ2ŗlŗ>Uˆ‘q{ķĪÄŦą Ŧcáúā=ÜīGŲ•c响„UgÚv6ķ tļ[Ųíį>ŋ#<āÁ{uOQd§U”ÕĖ’jĘ-NÖ7éOø8!kˇPIDĖ&÷öįš[C{鍭䧚IßÍįsŠÎd“veR?ö-R(>Öe­ŋČpĩg×Wĩ"š¯UT|ųá!<3č, žœ?Ë­ËĪāĘ̉‘7dđ*ĀLÄ˙øŨ˛üØ?/oōaIˇˇt;“×@;4ĨĨ.uöIJĮLl;°”ŊJ´Ņ<Ém{Åķ˛_$M”ēuÚ%Ž[]âdāĮō§—sOî¸ßXú—s¯2{Š~ã7m šĢŌĶîŸõ–^I7tŒJū•i6ôi Ĩ“c/kį¤ƒ•y{…V>ƒg:á€HOÄNŦ‰ž:ĄŅáW(?b)EĖC Hãšŧ#ÚŊbŲîÅįx¨´ík§°ß„†XQƒū >ĀY^Š‹ã‰6tŨ) ˙ÉL›¤í”gʆ8ŖÛîî3-û°öÖŠķļí)nU7ß^atž;>%Ë&Šøž*ĮųßUĐĶöEqô7ũ­•ÅĪNđŊ•ī^÷¸E÷9ų8ˇįŠ" ­OfĢ­­ũv <ĘŋnÅeßßoC9rkd/ėiNėÅ€T!žŨöˇî?Œ~Ļ˙¨F\@IDAT0{á?'ņAĖÉ­?fi?Ėŋ/˙čw|;™aËĀQoˇÆ{c\Œ°Ķâ❴Ä. TFđe.{}ŒŠĨŦæ€*Ģš(”ú }€ŋV6gôž´„ĨšĢe;Aes^ų”dāŊËüįm5ŧĢô3Ÿ ÍĀc+}Į qåĨ!ĘbÚÅĸ ÂHĩÉ8ë•)jĩ‰ $õt5÷*”i“Š6c­Uܔkō‚!:ņ A´°âHatËV˛ cŠ+ ´ûo]•đ Ę: [_ŪŊŸ)ŅmN8¤vfnĒŖ^Û紐ˇēæŊmVlĮÛūÆhlugÄ^Aų8i-Ό!ū&f,ĶlÍ5а΁=˙õĢÖ<.€\co‚Æø~|‹-+üpõáuä×´€›`Ŧ˛Ž Ō\ãNÉė+đŗĄˇžXŋŦqŲYoĐã+)ķ‹ķšĮT!öŲ+;(ĄÄą‡Mbßz¤!^uÃCCUe‡Ü‘]<ęŗâK`t|`!ēåe“ß|Ļ~äI° ũŒŖc‡™ė‡‘ØÉŋĖ72Ifėę‚dt{úBŖT˜,øÆ/ úOāĐhpK>s‚jĮ1é Ī9‘ň\;‹đ7 B s•‚Jƒ Ô)ŖRŒf” ~¸žĘ@,ÂŲíæļĀŦXę1ũ:ņQÜ3Æ!læsīÆÎ„¸­ū;PÍ Đ§ë\pšZˇž<‚ȰCŪ€úté$Í'ų2Ĩ}SôV}öQš¸ũ¯zs%f\[ûĸ.Ļ.[—1\YN“ĢlvZ}sˇ{|pYlH=Āŧ҈\´°[Ļ۝,ũ0A!'Ptō\2‘k؍ Ęa@ķfÛĶd,Ÿk7åۂ| îŒßŧ<ˆŧˇš˜ˇ‰,žQdÆÉŋ[6Ģôš˙*ˆđVA„ˇ”ŖäLYgōO$ RĖČŽĀš>šv›hۂģĀĩŠ:ãĀą3wųČjč+-yZldļ2gQ;Q ĮÎjƒr­ņäˆĸŒâ ‘“Ÿvå^ĢĒķXü†Ū•_gˇ žuĐpG$8ûuķ[`wrĖ„ÄDĨL¤fR&†Ļŧ B‚l‘´M*\€˜Ŧúéę´7vËÆĘņéŌųTpģ´oōSĩß8ÖĩôĮ&‚ôãų§šsôįēw ˇ?÷7“ /ŗ ZČ[{Ÿŧš b†‡8­Bâ´îO-L|Žá ičQ4CXŸ âÅÄ+‡BČĸLq5Ī ŗ\JČuĨŠĘ1°J#ÕŸæ°$9ûÖIˇ9¸@å%Øh–ôĻ­‚>˛t%Eû1ÔÕ ­rVFЄ—$ö–žũ§ˇ+íĸčâ&BFzåÁ‰Q-—q’4'J ŨÎ ā|Ö …->Š-ęŨģYoß=ã™9a.ϧĮõÅđÆ_ šÄ¯ąQ¤ ŲüW6Îí[ŨögÃÎFDs(÷â’Øk%ZÅÕŗU: 4žâŲĮŲ’‡īM&÷öyķģÚ;Yã¸îL¯ûe Á™RFmWĶČ>žŧÃŽSĪĪņĪÍy¸HSîgŒ˙y`ļC˛ VG´;ü "KīNŲÉ] ķØáŋp1ŽˆŦõCS‰ßŽgˇĻsøgoô8wCĶŗĮĩ\ÁviæĪËđNęŒ!ī÷ļc7É#PgĨ`ŗr†“XķEį8DâąĖ MJš¸ļÁĢ,ß"4SÁ V€ŗÛacŽ ƒ_fâ†?%ļ<ĩÆ ĩ´[á­ z›XŦ‘wFčõ˙w!ĻŋâúcŒĻ><@ŋuڙ€Û}moËQë-€įĖCđķ™]¯6‘0O„3šGšB˜ĖCŽqą2gQĢ[y¸§–bš#|sžcIÕŅ+åYŠžōrĨ™ŋčQį`č­ō˛bH](áøØ†LJW~ķ*'L]ПÕMÕļMX)ˇroĸá¸ōE×B%˜™¨o:Ą €æĄ3ÆŦ<üÍíÖ;)°zîųĨžĢbĢŨ9”‡°ā W•y™¸76“„q Œ˛ŧ(FdŪž$ ėY1@ēöĶ‘WāŗÛ™`ũnĖu‚‡O O,ęÎZķsũ 'nÂĐq`Üķą_{r_ N°Ī8>1b­ŗ¸Cī^ã*†LATAsžŠhj!~ėßM¨ƒžV?ĪXK.Š´;7qs…Ü­îeŦ c [gLRô¨+ŒuúŅM•"á|ČšR´é_ 9´WČląwîąđėW†˛°ČáD•—‹€9Ĩ+}blųMūb— ĐŲ:LûČ7xŖŋR #eÆÄ:֍Ú'š$+ÜpŨüĄ¯Nē`í DYg˜vPBĀē{OFčĖį{ŋIūųæķšĸŋWŪŊåĪũ]:úgķEžâĀc?=-¸`gĐ÷ļ%Õ8—cƒ@/E“Ė mË^ėĩu+ŦĖ¤0ƒ„CĢ‚r‰ÂŨē{`OômķKxymŠ$­ÆYŋö'Į.yœĻĮUE9$-vŠ`y*ŸcŠ4}@säâX~gˇ3Ãkėė¸DįŽûņ(1Ģ?‹'4f#Aø"Šį÷frFųŦ_Ī —&…îÉŠÜĻcĶįá>}h/usĸ9‰Õ‡÷ĀĄNwq`\Ŋöi1Œ ÚüŸĀ Č/MęlpÜ3?LL8$*'áÉ"…õƒÉ‹üË;>]žƒ˜*“@ŗ+LėCĨv&"N3ĖŠMM"JĒėp\s™íLގ@…žôÕĄĶÅSęU‘“Yz PĒ”}hx[ĪÉ&Nā ĨđkĐGĐrwøŌ•u\1oĸ€ž#ĪȰ˛ŠN[y Áđ”•2ƒĢÚJûžú7ųÉ0ãŅø‚¯uW '˜ŽūĮSĩÎng†ô‹ž5^ōb™b'}\<:LÛ7¤˙ä}qŠiëø'&f|ĩ˙oÆ(´üP1+íĶŋ=>å ĸ4h7váāÛL ŒĨy^FXĨ=$‰§!`üY4kDĐ>'Œô îƒĩɓĶ -å¨Û×)_z ;õ ÜɊÉeíģö˙ô• ŌcˇÚ~ˆ2OŋN:ĀõÍI>4}D<L˛ÎšøRŸ>Q5á “ˆ"đĨíæ_9ėgÔZæīÔ\Nš6*ę0ļWūtĸŪ?eöx+GĨ'iļ"•BúÍ +_Øx&#ŖNgˇ3Ãö™ ũä'īzRÛæn;ãˇBˇÛ™DZÕĮĶú|6 ‰ëoÍæ~¤>¯D1^ }š"2:DZzæĀ€”ŦtčūŌĻôM nĄ2ҚœÆšÃށ^§ąĨVaˆ¸ŽĐĄŽ_wœŲ:ą>wēk•6Pgˇ›ŲÆäŦāo&jãa’]ĢŦăÉvęũ˙ žĖ–Ņ‘÷¸­ĮāzÛ‚žcÎHÚ79ė‡rĻīZ”w˛vwž¤<.[¨7GÎĶ䏌öąNBQŸú2ēĘkēÜh aTĄâõm'į ŪĖ›¤/¯ÄˆnwgxâB}Ãô —â<~?ŖWc8ČyĸŌėō’ßŲíf´nœĀ)ÄØûCÖį_žf@4Œ }ۀl`ĐÕãĖ™€Ņ9 ÄĻ2`ėâ°&üčB_Jō‰ĸĖWZ–¤wä¤?™dRh!ÔA괃‚åŲ$>ĮMū¨\s@ d#ˆĀŽėÆc}žũŲKĐcÉ3éە†ãĮNō œūŧéj’ĶDĢ—pÉ/æƒĢX3úŒö&2&g'g’GG–`NķúcĒCųĒŽJŗĢ\]=ôų­|Kr l“o{n“|;2ŽwP6§”ƒˆ#!˙Įʉ:*'ķÁn“`ŠņÖŲÂ䔠v?fŖÄ¸fâ‘ĻôZô&­+éŗō^ ōŦkX6ÅMÚ,B“#Ų¨wŨd­=: Î-~Äēäa߀­ŧE4_ͧ0Ūm›<|MáLŒjĸlŪU*2i9zāšDi_IPđ!ĀĮÁÜ?ÕÆÅ4ONõ˛rô´J]™Ž8‘ž-ė‹BMĨt܄•@ō ģ8ŊÚ{9°ŗ_g€ôí‰ūž'jzĐØ`‡ W•ŋÛ asüjߜčĐÛÄÁY˙˛SÔ6“p[‹ãšĪt”húāt"9­t# UÁ­…ž“˙9ҟ1Ā<1TÖüp2Ę;> ĐiÄVœ¸Ø$ƒ Œƒđė_ŗĨžč .ŠŽt tPžČũlÛÖGėsĸ1{Å'ÆŠo%}9”Ateô´^Ú] rz–ēŽŦ„ pNÂbÍ•ŧ]Ywc °Ņ•oioĄÎJ6ÄiĮPÛ+G;ŅOYŅŖį€î5 V]ĖÄđ—L1°<ģ1Đy'ųyvWâ8RpéhŊdėZ˜ÁÔ¨2`‹Žú§á'@!A`sņd™;"ÁI՝t€› ōxę(T>ÍŲ°ĸĐ6ŠŅ†fgÄOø†Y`zЈ*-ČzûŽĖF†i͎ÛLŧ{•‰Ø4ų4ŅgīU@it%Ę86劒 OŖÅZÜ~QėĢ—Á‰—ĀÛO„ĢÖŽŌ°Ö’ˆ+xų@ūļ Áí9–S„šđA ‹~›\đĐ~"ÉZ§œÃHAĄØĮqElj|lëĩC—öNRh"=WŅ’E?ĩ%ü”)Å´5eņ3€æĢiËؚęƒT{.#Áš”O­Â…‹ų:{œ­v,ōųļŒę xõ÷IŸ"ō¸@“ãtÎí˛sN_ƒ×YGr_Ģ 9ËėŊŒmŲUĩĸŖ 6w9Šę§áĨBĐ6 ™ Š0Ÿ­‡o}+ļ đ]uPp\ °Ĩ:WCĶf\’D̤NW:ŧ\{õõ >@ ļkĮ¤y@?Å^8ˇ•úÜČņÖ°ÁíßßTûVŋSøŸ n?Ūūã ĢßŧÕoû›jß_ŋīßīĨcŲMŋ'Ž<]5'§Ī#Žŧ¸šļZ0‰âWãÍéFŽuG6ōAžá8?˙qíÕ'–ķ/ô6 ‰AQĨŋíĨčöŠŽj÷{?üÖ˛ˇūÆĘ{ëÄŲ{lŲí“ɡÁßÔ~(ܐîVį~ÃÛęnęøĻę÷ãŨÜVŋíwđŦ@ŠkŽ>ÎÉŲšü˛÷ĸÚWíŲhÎ÷ē’7,חĢ':ēž=įč“R|Íđį` qPļŪĒKÚ˛§…*ąį„‚$b=”p‡iŪ’!ŧ-!i0WfsĸÍ4Ũ<ũU#y#HųH%"ŧÖAԅ OƒdhŠ•ˆ“ ”%p™ŲÜûAݜĀY‚TÄĮcč–^)ÍĘáØ‰eđáÁ6øōZŅŠˆVÂ@š”ŖĢj  '2’ȝčtˁ‹˜+Ž{Į–æ/Õ䝎ŸāŲëŽå×Ė`Īelt5îŪĩ>0†ˆcøDü-Æ2Žžņ~|ÕX Uũæü"<_ÕΟėfŒ_8¨āī$!,ÚÚ|99đЈĄßĖčtbi´ĨƟ‡8ôØŧáM2=Ė Tp}Û%•^ëņ,Đ¯ÅãĶ HtĄ:L˙1ĖE7'ė ûFÁmŽÃąÜ•‰*ģ唭CîŪ ŊŽžŦBë&ÍŲ7ŌĶ™VŠĢ&Ā)‘|ˇ9™:—¨‘ųGI€˛"{Ä2Ķ`›…؆ôÔŅA FļQoū|öRûēœÁ0Éížcמäĩœƒ¸ŨÖaķ ļŗĮ70ĮįÕ>ÚŸžf%ęØu'™0blĄcį#_šã؀H'\ Ų Ču¸ƒĄĀ‡ĐĸÍP˜€˜Āˇ#‹šŅßcköÔDuÖļBcåH0’€x˛ŗÔÁ¨s ã{¨5ŌŅd@F˙ˆŌēĒ!˙mĸīĘÆ)î3vĸx”ßd:Đę× .ëĩxSû rkŋŠãũõüļŋŠö­~˙~ÃÛöûÛˇã­ũĶŨīĮÛŧŸÎMĩī¯ßŽ÷ī7zÖī”ņĪ ŽL]Ëä9įšÃÉn ™‚Aa0āõV:= ūJp4š éÄîĐĄ#Ä÷qb›_üîA2#eļmŋîÔßÔņVŋí÷ãßXũ^˜­ŧí?]øũpūMí÷ÃoĮÛ~ÃûTĮ˙ĨpūļßáGÅõŒĮŽ˙t뉾ãŖ?û_'žMÎũuEšfĸp‚:‘t+Įl>§Ūņž<$Cķƒ1˛Nd×*kÆß$š=Æ,a@-m˙ ãæyHƒ¨t‰)oĩqâėÄF"{ņ­rSž™wLá\r–!;9 €N^4€ ?aäM‰odR?™øm{ŗ-h#Tō‚7,q€ čt‚^úˌ-Į_';čÅq‹‹ĘĸÛN?Ã&\Ģ_5˛œl@ęI"x§ĀHAųCÛI°AnnARŸžå^ēįņkÛĮŽwœ8ļœwáÜjÕOē}ĒöũČûá?ŨãũpŨ­~ÛßTũÖží7¸mŋŋ~˙ņwSû ~Ûop7uŧÕĒŊūŗŸ^ûqŽŌąHf?Ķsž8†žfҰ(äĀŋm’ŽĮƒŋ[€ōDÕx;‡“ä™6BHŒCc†¯ĩĘŨ!4dBYÚžŌŌŌîŦĮõŦą4yz,­NÃĮĢŽĘ蔐éĢ’–ˇqŌĒŦčæ™Ŋũ ŧN\„W€bĐj.dm:@Ÿ6iĘRŠYđ`ä¤?ö‚„Vû‡xŗŠ7Įލ'éJ¯öƒÁ4|Đ_×f]/úe|0ĮBs˛Īd\Ŋ…Ī­%"ĪÉ ˜T–ķVPSĮÚŖŲ̈ráEŤ¤˙ĘS2ã^ĀÁÔ_Ŗé&WãŽķ0ŪĩN,įīÃ+Wvgˇ›ĪvīÁ=Æ*é!Ū”b‡+`ņ›Éŧ į6Á˛Žmœn§ĸןVãÁ†ë$}tnÕYƒCšâGBnhS\šģ"Q4š8ĸIpÂ,øÕl‰4Ųhh‚8ŧ×J8ž¤GÍZXÛę<#ŋáŗ*W”‰âyœŊRÆ]ų$ŗÉ:%k,Åahī9ļÍ–ŅÆ#ˇ] Ŋ<bíÅęBo”ļš ū†ûŊ¸[ËūēÂFq/œåŠß/ë&ŲÖž 9øÕęŊË{?ŊŊđĶļAO'ųõ[ãr9Íä˙Čˆjė$N…Ô?{ySZķēCrĪ!Nlyøē]ˇœ8+<‡™Ā™(7ÔöįTvpCų­ÚazāÛ¨nXãK7{÷âūļũõCgƒR’ŨōF˚ŲļļŲīĘwÃøÚ…ĩ´q´ŧáß°ŧŅŲ43.÷–Ĩ10âũõíˇœ:ÁUĮcÜúqđ0÷ÍÚ‚ÉŽSÍ Ŧ 0Ö3h[šm:XŲ÷Ŋ5ÆÔā|´A´ °zÚ[¯Õ0P7ƒ6rBā$u`.WĖÇ2 Ā™˜Gœ„8ÉæÄWZ•ļH{Ļčh Rá$Ą•EÚ< éä:æM‡ŅM._GčČØŨā•ŊüŦõ‰EÍHĄé Š Zē*/˙šŨ8Š OuLv•>Ģ‚wzgb¨˜ŌāĪöíÁCV”îŦöK‘cč ?/‚ˆã ŗĘ€‚4EI94€j -åXĨŖ h;D_Ŋūú>ˇŖ>/ $Ü|ôIˇ•O0ûËiˇŌ@ËŊžGŲ{jj"ˇÂN9U*n\6û[öJ<0…mCŽģø[ģûËŪēŨōH9ú|29ôél{ĨŽû[NŅ1NĮĮĪ]ŗˆrā ĢôLßÜ:kŸd2o ĪØĮČqÎЋŽ ÅsĨL)dĩØOķĒ%7āÔ[P`[đ8ámÆK6÷Š.=6tDHw@T§ËįĐĐ;ö!ÉÍäd’Ęr+”‡īčŽøcX°á@ÂP¯ŧD1˙ŲÎ÷ķ%2­t•ĢMx É|ˇ~íņQ6Čf”´7ōô€•ygé˛I¤õ%‚øÁœqĀ+ŊĮ8SOöķ*M€đ:ÅS˜žÚlä×2“¤D“´Ģļ^ 8Éŗ>@Š\y˙Â`Mæ&ā,!ōpä8äN°-ĘÛ4‚„ô3 6fyÚfĒ65Tŗ u ųßčX›ėĢÜ˙+õՊâĖę–\‡§2ÍÄ.!vÚå­ømęD{“/öũÂe˛N ›ą}Á­_Yփ‡EŒlĮëûFa]—bĄŅ>ĖXŊÚx˜É^cƒˆ­č ņ€¨÷ɏîŦØ‘âš\Iö!6YŽWâ“ŗØ6Ŧb Pƒ_Œ×ŋŽ3Öz55@ę)ģžæV‰"Ã3ŽZ!F,äAŲžeqt°mĩ][üŒ$ûVĘPQ§^bc• 39 <ā@_†X :Ņ_éä×ĀW8ųJ•+PÖžčßôgsFˆJCY™Ā¤NYŊBą3A“âšĢmßd|a'w’ QĨŊ~ÄOöŗÆakDūhäÃāÜvšÆŧū¯účķUNQÅßžĩ)ÎuWđ;õRųŖė-ŧc ŧō Šé¸ļ8‡íĘd¤ Ŧ6WĩŗÅ^XĄ/Œī€'z͈6›ĨoŅv>ķ<Ķ2ÜŅM*žh/ĨŅCŨ(eXåąMču dĢ^[ą)(h!Žƒ“0@H‡ ø!1ĮBiđĖž<ÄB˜Ī^ÍdjũŪEŅ:tå'9čųc|–[€cîÖ9õķ°ž´€‚ųČUKŧ|ĶĐ>û⑊­6âLEiŸš€uâķíX!ÃelŖ=W]l×į|FUåZ}ēö…ĻWÂĸ9m#Æjkę':o íø‚kę0vLü“1cÜ$ėØXŋ3ށW= Vŋ+”öt¨•„_đČzĘßåjÔ"=wāULOūĄĶŦ~@ú‰˛™[”ׇ€K^g0]™đ™\]œA˜[NxāR9vO‰?wXOQvļ‘t§]”ŨցہĨ ĢļåÆåá9nŒÖŠļb—RƒgG[Ŋ ĨëF!V1âhÂ4@Ļ ÉڋPW瑉Rgũ­&„Z†*(Cä|ÆŽ;œg“×@pâå ë)îģ<Č œĢßLđ+¸VÆHĐ&(­g[›)­:´•ÆæSߡ“9 s‹†ļŌŪ&‚Ą5Ŗŗ%Éj­rc/¯ øR˙”Ī•4ë}øÅ7ĀČ×Ô3gÅr°•Ęö´e#pŖ#ŧ‚Ĩ˛Uë}ˆ<ˇBҁĨaâX“åæyų„Kî˛YVn&:l;Ž虹ĸh<ËN<´NŽÕI‹ā’ØúBzNEõęąs™ŨIFvĩMąŠs¯q¤ũlŠTT&üƒ8IsæÃ<„w‚Ë'YÍT )ОÍ*å–Å…Āš‹ ãŦôMDÔŦĖĶŠ$:ôLŠĘ^ūk˜Z“l+$Čfœ˜ÄŦvSŋsûã×3§ęúã\UüøņåVˇ:ē=z$Ųőž†ŗæá÷ éøĐ˜Ģ>ڃ„O(¯đ[)KæĀ˜Đ€WŋĮGĶfūÔŖ 9UŸ„žÍķĀ֍o…CĐ,‚=Ô×~é…\›,ŗĻZũqĀŽ‘.ÖĮĸE¤¤YœZ˜-ßØŽ ‡pøĶ^Іžq̜cŋ¤Ŗ~Ĩ.3E Æ\‘7’{do2ŠSāKQ˛¯2¯ˆõsũ걀%ûĮ@Ú4‰ĩå61ä”oC1ã1Ā6ĸ?’§îø‘˛œš$ ˆ‡N ÛÅ~Ģ]:Áˆxęg!ģŽē™ĒâŲĖ0.ŸZ€ÉHŸAA™ũ•âžfžÄØė4}XšR@{X&;‰LNJ;vË܂Aí Đ$ŋ|3†ĄmļNŦ@ŪVHmßÔ)‡˛ tŪYm‹ĨfBDīüĪ|ņZ!Č"~ėėSŌ¤J9¤˜ŒûO$­>P†UO(eĒ2´2‘t!æ” üķž¯yÅ÷ÄÅ# ŌTiRŠ)Cs;׸ö6[4áįxÚĐÃBC˜6pûÂfÁ ¯0~æĒĸđh­/ŊiÎë§b>4&_L.Ür†D•ŗūŠŊˆŗ kߑĪČ?˛5&`V°mT.2ĐҞō/hĀÅöÔû%QėëŖWÕ5wÕ ĐŽŠ’€@ĘÜ)Ũ9äC_ēPĖÔĻgáߒˇÔʲlpŠ1q•x˙r“CænAãĄąá=‘n^ûi×ũ›ą%–>ŸĖŠHĢßöđuž-šÆF…~Ä éFũ,@  åhOiâvxI_bŽ ’Ę'ŸmęÉ1[ģÆĢŠ—Š•|¸(ÍNXBPûoDE_Am_hŗčVäŦūü‹.ŠŖ<Ų–ĸ2×uņhVwLО¸ēS¨ņī*ôˆŖĪ†mļ’)ŧ{ûQ>ķP]h`×/zPž[–”Ÿzlā9¨z \FjÕôÔI.#*P‰ĸ į ‹ōĄãËîģå žāčrŒÂ=PĮ|b@ ÕbuW7ļԒæúWįĮČŲB˙8HH\QĢ᩟rōQtl`Ú(J?{< žŠ$oj"‡8ā]wõąåÎ_xëåŽwētšæšĢ—÷ŋīƒô'ŲBOáV9Ãbą’DXއŲTT–ĘJ¤j~­n+ŽĖŦųF[Žl‚(ŋßŅ…ŸũÃ>Ė6Qcž6ēŲŠZŋ¨ĶnZp&¸ ?Ė2°ĐĨáŨm¤ūĖ>Ō—Æ ŒŊ•K{ÛĻĖōadlHØámá ĢyŌpNŌÕĩÂØ@øÎÎI~‘Q}'Ŧ_k4z@yļ‘Ëœ;q&w) a•nQ-Btěh*ä–é@ÚĖņ",ö퐭ŗŋ•#ÆqŽÚÜÛˇcšČ7Œy倉3ĨƒlļÜŊÚã>ā”Kî☐ \ČÕĮ|Ú0R_3­zf1( Îļå.垊*ZŽđÛ/öõ<ÆÂ¤'s;–@0PL ›Ä/ĘÔz‚kĻL,aT2xq€…ŽŧWËjIYULDãŅi…C1} é Īm2=1!˚›‡I´˛Eô5“Mm.ŗ°Ķ“‚Áļ úęe ‚5?I$šû,ốz p…¯52“ŽüųĖx)%€øŸIáPuķAôŒedŅ7]A‹žTE⛓iĮ.šļYdĶžī>ž˛W]‘ŌE_q˧16T夝<Îáˇ\ØJ*¤7bŖ—ôĒSfm%S?č‘ĖÉQAhšŦHģĄe sŠnĢ ÕEØ!FJ÷|Ž Ŗēh‚?öQ>tĻTŌ"~šĀüŗĸKĘPü[R6¸šFʁŨPu4€äĒsëdF eE ŦŋŦËô*GeĢ"’TH3NP4@4ŪEY8Č[G ĢžļObĐ, Ņ6?˛›]2PŽš†â€ģÅį7q—@Fn{߲&îÁ*õæŖhŖ;Ą\áõUđ'DŅ„ŦT‹3)nbÁuŖ[pę=‘ -ŒĒ&[Ô÷ UhâX8ũO2rĶ žÖ ’ŽŠũO‘ ĸĢāōČ&Øyˇ¤Ē,c'&– 9Ą˛#š$ ÅtkąŅŠ:8-d"t_0‡!ŒŌŒ¯´ymę§ĖÚ;ÕŠúĶÄX"rlā%ÓĀG=­*OĮHhMģl +˜<ÜTĖÍcmÃq×:ÎÔæęģÉĻdĀĩrŖN€i'Ĩ g|ũōūŅŋ<ėa^Žđl€ÉæÉ?üËĪũÜ3–Į?ūņ ā0â€OČĒviwPė3‘#?bGĻøT9(e¸dJđVÅn+töÅßbS<Ú5Îv+’ƒ›ōë$qĩŊ¸îl€¯ĘXBLųBfîQõ‰ēEévUÔ+FÆ][ļiŽÍ Ģ?<÷^r'Z‡û°;S4hôjT ´Ļáōb<ōÍP‰ĩ´•[OĩbVtR׊ãNŨĮ^}Íąđ‰–ƒ9ü”ļę€9%Đ~g“Ú´ ĮToÁ}7_ëʓúD+:2ÆSę$-jƒÄ*Ęcŋ(N—|ėo°â’°MXĸĨl#÷¸rOĖČCz#NÜܧ7ĩ•%Ā'î¤čˇ{ŗneHŲÔd]ũÔ CmŊĨäŽũjĄÖxd5PđēPWHŋ5ÍLÍ,.QĨm’™<ĐŠ û$hĐį#LģöPŽ­Mēāé×M‡émJäAˆ.ĘVްÚVũdöŽ* úQg.˜v>âĮ; ß××´‘c€oú_qÅ3^}íąåÅ/yī wjšį=oš|Ņ]LßāŠüc l~ČŪŌ bm+^2>Ú^š00ūÍES¸'†5ųFŸ-^bBŊęY'Œ<šdEpũ§}ĨSŸã(;"l|VvĄū44ŧ5Gšūå_øL´MádÜâ¨Lˆ!!„‰Š|Ũäe3_U nęk/˙$؄[Ā͆Á„zU–Žtļ/ęW˙öš]'pĀĪI$R Z“hjÛDf3ÖėëŽ%ŗ>c" ĒGfëŨzŗÅņŖ&y9™˙ĐøæŠ" ^ 0ŪīTL'¨lQÅGĀûQŨԖ<Ä#Ž—Š!Íú ¨3Đ3đGųG)8äŅ`lĘÛĚÃŦG{īÛ׈Ú.yG×l-mjg\If&ę€sœ]V‰Į”FŽą™4iR=áāĄŦÆ%ĩæ[”–Ŋō•‡œĮ<ߎ{Elĸād7&nqņkD•p1.A˜*Œ[ƒOĘÆŽÚøž3LéĢ‚>Ģ;ę)M1‡fč `lCũŧųEŒŒŽ“ŽĢ?~ŨrŸ{ß%¤+ŪņŪå‚ķĪ_ŽsŖU älõ OōÔH‡Ŧ3y^MĨķŗi2ũ}Y*õÅ!ŧ•˛û_•ÕPČNÎ$5ÍīŲzT5~úOqõ´ÕA1DíiÍ„&—ˇŠãJi˙$€Ė%…|“dLJ\T’mĢØKŒmt<ßÉ.–Z„EŖ6Ēc$ŗíÆË ë‰ÃÂꂜãeT.kĨ%wĘė :cĨĀ24ĩW*å(ÕėĪ—ļ3r4-ž ȀtEÜ8Ņ+UęŗMæ›@Û´Ō–āøD&C+Íh×ļ’™b—wIîĀxKĀ$MķƒRŠÕĒŗåNžĩ4ę\€„¯ß­•”ļ ÉkCõāČøĄGLx)Œ^Ú\ú +_—ßũŨ÷-˙äûŋtyøÃ˛|ķ7=~yÛÛŽX~ųyŋŊüÉ{ūbšāÜ3Ī•€bZūōWžfģē#­lŠOPä œëÉ4čʤOVôã_i›0RÚVFh–e]uÃo&^}¨ŊDļ/.įœ\Ūūö?Zûد\.žø–ËûŪ˙åŪ÷šĮōoūÍ?_~ã7_ŊŧūĩXnq‹˗ŪûÖĒüFĶܲ32Kž“ xĪę,õøCšų[šĀešHƒÃJVĘĒ؎ŋņ+ž&‹šs`pĘí‰é@LAšÆ$DgW% õ*ÍīŌ¯Ũ~LålŠGj+2JŠ}kpËnė'’¤gŒØîäGiS^Y8ĨŖ´„Ũ¸ËɡRœZ.šô–Ëūā¯.˙øûūĪåĒĢ>´ŧéoåAĩƒËƒüeË'¸­đ_üĢo^žôŨ_Ë€ĶËÛßņĄåÍoŊ*ÜŖG-įķœ€„Đį7eM WąŠRŪ$Ėō*›q¨@&pŠ KŲ¤×É:åyüÜv01įĀƒ¤ž_ĢNō–œ7ߨĄ“¤'}ÛķĩÉÅžNą?H@„›Ā !åųä,^ĨƒvÜîôįīûØōŋ|ûã—gũâ÷.ųš˙f9zū‘YÁŽ“*ũW|Ębûƒ‡yQ9\‘ŽiFûČȈĢÎa‚ĒŽ Y°X‹~Ō]ŋō7*Ą€ ĒքxukÁ‚ļúĮ3ē:¤y×p‹åĻÖIUšŌœžŦü‹1 ,kŠ~ÜÄîĮs„ō@(m1€Ŗ‡˛Ųˇ\ŋ€Đėŗˇ”Œj*-ĩÏĢŋŖūÁK39Æ(b‰Ā÷ȑKŠĩB|˙‘Š$dĪĻPôĨÉiCé$„\kÜ@Č?+ĩ§WcĩImĘfsyƒØ›6ە‰väë7Ž~”ķqėÆôÉ.op( ×ŗNÖË[ÖQš%)ÍuŦ <ÛđöūqåslŽg4đØJo\y %/*éÕ´ãÉ/UTMU¸ęyôüÃË{˙ôŖËWüųō•üŠå›Xüx$û“Ŧüŋá ī[.ā č@e,ŌÜYŽP^dRŸZŲ•+Š͓™™ģā§ä’’q,œ:ŒĪŧD›),.–ˇyƒ8;cĐ:Čl1.BpęžßÖčVL•Ļ=˙ÖĻ܀eŗÕž€4F ĮŸŧ“Üäà ։We¯pžĐ+Sôõ§F”<ŲæX”å|“OÄ‚Íâ*\„e"uąú(‹*ŸĶå”Ļļ’ō 5ôÔKŌMP]éXVV7s†lļÆ-ę•NÄčÕ‰lāÚŽŽÎ 7ŋ+Äå –Ļė"¸p Ļ´åĀΈzĖŗ^‡>–ŠSw…å;1*bįhr ×-¸>ŸŠĢöÔūÔĪÜ[-Õ_oC?*íä[öЍÎļ×*–AŨ8ļ/¸(9q%Ŧ­@į Œ¤Āæ„ģ=ŒíáV˃˛"9šdāąA9s q…(*&ÛfÄāIŌωMŧĮËbĶČÚdĘŌ—É@]ÛTĖā?zôđōö+ŽZūá“Ŋ<øÁ÷īIOzäō’ŋišĮ—Ünš†Ȑ’û­]5…ãš˛Ö×P•ÍSšV'>˛¸D¨ –§É‚:3í¸´9PÚā ãI…"š"Åõ–ŲF´ŅĘĐŅIjvŅŲҚøTs†ĻÎ4Im<”QüħlŊ_Ž0%˛Qge°í^’2™¨ ļ.$w@ØTŽÁ(7ēI˜Q %$āÁˇ ÁKœ6k ‰$Ąƒ°~¤ =E„š­ Îõë„į@ÛxÂŌ@Ž‚>°79˛Øō‚‡ļVēü™ÜŊĀžßd"™VgđƒĨM’‘ęĘĀĐßAA4`Ô%܁ŗœŊėņ´‰ÉdK’ÁBמąÃ?YĄ`ÉÚvâŅĐõĨjL‹jĮŸũ Ŋī÷eˇ]^õęˇ-z%ƒŪųG—ŸũŲg/ŋō‚×.yėj—R´âŖēEmqQßI3y¯›zhNXˇĸŧUCZ­˜Õâąúx°ŲB ü6Îæ8 ‘Í_ ũÚ¯ŊĮōʗŋcųanũąí™Ī|õrßË.YūŠW}ŪõŽwæ$á6\Ũ¸|ųÎīxƒú)úëë—7ŋķ*~lčôrŲ}nË̈9@ČcĮ8Z­"?7mk)ŋ3ĩBũRØtĸ‚}ËKĀ{‹†0NÜēĨ øt/pzÂ8UöiĻ-āąÚMž>änĖ{qĶ•j0$}<Á5:Į>ÉdôpíH =đđ~õwŧũãė.Oø;ß°|Ã7>ާŸ˙ųį-Ī}Îī-ˇŊŨsĨÚ͇ ‹^Ōʑ×DãÄũĮŋšŋžz@•ŋâ28ūÕĮ[ū@KĸCq!<0Ž,ĒԜ|Pđ˜ upœpE[-į ^vI­ÛW4­P>p‡ī*ŋ„ŲZ]Öv´Ģ‘ƎŌ~Ôō¯ ÕŌˇ”Ģo‹ˇ‘ņšR!s’ōĘŅō+ēU} $ū6€o jœÄå[isl)Xîx*5Įčâ`"ląU’˜øL˛Ą'-āŠR [%jžŒ›DŨJŦ–Õ˜ļxŽŪŪ.ršsfEpņōēĖĨ˙͚ō•:Ņæßä@G)ˇ<4ū™ …ŠÍ/l7Wm)›$&5õWž"? “ĻI9UĀÉĨPY€:e”DĘq E¯ žâÖÆ÷žįÃË7|×>đ2ōßëģúųÖˇ^ą|û˙=yÄ[€|Ļlå“rĘd<Ŗ]2Ģ%Uđq"¨ũ{ũx›ˆv+G•tO8å€Āøh4˛ß¨™tReœnr‚Ģ>Bf“‰ë`ĀMke°´ōL…¤Â8•ļ&›üĸ¯´ģ:šNf €V3Ķfŧ­xÚR ›ŽÚbŪāĻ>ŌÜŦĸŽ‚Ę/ØĖĻ8å y æÍk:Z3ßŅ[ĀāSũ+l'zēMJnËox$ĮÅ>°â—ēņĄŌÔ&Œ9Áŋ”ĩ.>5SW#ĀâÄr• ‹lqiŦ]8éŅ*maĖũÆôô÷ąšĖ…Kw:€ŋ‹¸kŲųÁøËfėāø2VáXëĘ%+ɄƒU6 ¤0#*kˇe``ü7gÅ/rT)āâ)3SŌhČk3šUŠT9.h#e ö͇HđŒŒ(§]í 5Œ2,3G ÆĶÄ>$ë!ŽĐŽÚ zЛŋŦ&,ĘsÆ,Ždũĸēŋ<÷„'|Ũō‹ĪũƒåŽ´y)ŗ €Œ@3?I‹ÍücĀénA mE4ÚDąķДÁt¨0ĸpūAĨąē›ú•|x]ŊČ>&Ąœ8 #u(K\Œ’’rUčÆÔI‚=Ž–ßA((„i[4‡eĀēAģRиá;đl… ZčĻ{ Ą¤$ˇt8aw‘ģ*ŧōŌÍ´Qy*8FÅQ–]ŌmÅ;ëPpM­7L€7‰r:Ū6qX%“GÆ˛´Šo>ę<Ũ!{¨ŖāĩOÛĻ’Pc˛Ņydi„‚G:`Ĩ*z*iߌŠ|•†eģ)ØgÂãÁÄbųĖ _ v1!É Č_Ø=šŊāĐríĩ×-Ī~Ö¯,o¸üm˕W~dųšŸũ'Ë̏*đû¯ûãåŌ;\´;áIîf‹-ˆKõ܉1 „,z9ģMK,`L(ÔōœŪÄgēI/9ÄsŗVŲØ7zL4§G œ“€ģ}ņm–Wžâ-ŦšZžėž—2™?ą\tņŅåūåS—Kn{áōá_ŗÜį>÷\n}당ĘņÖåū6Ž`ģW€ķēמØË˙Āã@IDAT]žčV\ÍãnøķÄ"Įå\…]ŌļĘë dŠØÉM ö“ÄĨ Hņ•—M‹X¯´—´ėnĩZAŠwe¯ôޟ¸§nú&–á`ŦfąqÖYy u‘Áãnü÷đ‡]˛ü§˙ôÂåŅ_õ°åîwŋ Ī3]ģŧā¯\>ÆƒÔˇĮĮ§€Ņöéē–gBP[ړ™=ņUZāSÂMJK `lžŦ”#‰ íf,lvņ ūcL[/] c{Œ65†fßĀØŌļŊô†_ĸŗāSŸ Ë6 oņ:V\M/ŸĄ˜øw7ĮÉ_ ā%OÛb [ uߘÅä6m_āię šĐ!\0Qī&”´ųƒJŌ‰ŧō¯Jöōĩí;xKū4ö9Æ&MŌu›Ö-?¨MļIĶN]tÄKddĶ~ؒ‰#͐+ m­%BR@ĘĘ6ĩƒßâ™Aŗ“ŠôԗŊŽGļÍGšné[aô“‰ŪŽƒ`lę8Ļ"JŸ-AWy”U]ĘqėédaĶėí:‡8Y?—āŧ˙cËyG9i„æîŸåŪ÷ēįō˙Ī3¸%îĸ啯|=ī)\xūéhš ۏB2k˛Ÿļø#ŨWQôįĄũØ ¯žøÖp{Ņņøģا˜Å†đČŽĪģBĢņ/Žī÷>øĢ“nwé-¸BĢ °ĸą°_ĪiHLë Ãj;ÕõÉV͇_nПAaˆc/™ëķY|œS6#B^´Ņ+h''ēĶ#ęå¨ ĘdΡ`‰ÕūŪE­|åZ&—äfķ‰+l€\)Œ=\ 2<×oáDęŌ=$Īá'+ŠÄĢŌȐöSû’0X(¸ĸ:BŗŧIyļ‰a[Įŋæ,M¯Ō Zę'=ķT T)kcuR–‚ûƒČŅUaE†yũÂ`P6 K4úꅤu -Ēl›<1ķ_o™Ęs˛&}ŒÁKđ”EÔĄĢuŅ­@1-VãɯŦrÅãˇ_úËš]Ęßûøūī˙ˇËßũ{_ŗŧûßËÉĐLZ.ėj&ęÚ@å ŗånõ„tũŲ…jR3åURäl@ãėcŖGŽ(cڊ›Âd'ãhŌ°Ätũĩ.Û0¯/ÅÆĐĶ Ķ šņ†sLhŌ.öMũŖĖĘã.ĶQ’F–†d§ŅÛüo’-4üUû@ũbSÄxYĐp9žb4|ßé7š“7lĘč¨ūr°ĮfŲ„vea íų˜C}A›~@ųĘö$¯ōpķĨjŽBˆĩJeË Pž5ŸdëĄ?…”p P´ŗt–}ÄckWË&â֍BŖ8õÅĻ^“9Öđ“Zr‹ˇé|Ķd›–HžõHQÍĨ.Š¯iĻüá^Ŋŧî Yžã;ž|yâŋuyÍk._ūŨŋ{.WøŊ÷ûÔō¯~đ§8 [–/{āí—ã,$čīnV+´Ģ)§ré#ĖOUk˜aāÖĄÛŪîĸn÷xֺ߯3U—.GĪ;ĖXQäîę ĸĨRS/>RÆëŠ+>´<ņÛK;ÂUØ.wŧũ-—“L^fÄVĮ›ąĄ ÄdŊą•õZ•:ĮŌÕ?]åÎFÄ8R)Į˛wĶ~ęæ‚˜`| d?š°Jī€í+(اŧˇb čņŒ~ŗnķ…]üŠ´­žžąIÆ´§ŪĒ<ņ]uōS3}K{Vlõmž…RÚZ>ã)ãĖ’ũHéž,%#jėeQ/.ÁķäÍ=æŽņ6LĢŅ˜~Ä… õĄÛiXŗšŅîsĻĘ>9j´0/¨@<õ eŦ/Ȇ–Ųņ íĘÔnķ0¨)‚@ĮEVK•É:mŽō6[lķ¸:$G:Cę˛Ō‡O>ˆôã\ÛīÅ(Sãđ@;SsY<Ŋr ˇĨpuĖN…ėžd h'8dĸ^Ŧõļ‡I‚˛Ūē‰ŅĒ9öjCÆÖX)°ĮX8F^3ÉP Ëļ[F’€˛i†­%ö븧—ø˙cyĀ~S(Īōõßđí ¤& i‚ž‘=hWîĩuÎ܆‹zĘ|ŗHĩâ$Ÿô Qí”O7“ÉĀM°0č Ķŋô ,v;ƒÎXŗrŲŨ‡XļaÚØĻ‚¨Č–?ĮFDnlä@}Š…Rz<KÚ& ŽítĀ69–6Ēmö@•ÚúÁŗÕM=i ĀĨ( 24_vėąém˛ps*)iŠØ.˙*ÜŖ‹JĮ‚ĸž/é]'ˆ ´=aŠŦŪȎbĶM’†:ä€V‰ļr‰k ąëŒŌ€_b:ĶD1/UĄŅÄĢŪāW@eōŋ€:ézū†ZሧļŸ`Õü—ˆ„˛û8lŖû Yæ•čîpKxĐ5 ?žüĶúß-yĖC—'˙āĶøŅžÃË­ns!+Éž‹[JúN1äā&÷ÔC0›ik-KCĶ%äU&§-' ÔnôŒĖújnCČ€ës@kØƒĪĄ'e'œ¤k `šhIįžĪũūúä‚ ,WžīÃËųZ~ō?ūŌrģÛŨ’Wž~lyЃž /Yūč]W.÷Ŋī]Ą{îōöˇũÉōÁŋü84Ž đ,I+é1÷“ņ˛'riō4Ž*;R›ßĐh.í32k?mæ) H…q¨^ €Ô ŗ6ĨSÄiĪųļ­ nųąˆ‹žvÜi§˜ĖūĒōŠå¸”Ûģ~gųõ_}ŲōęWžgųžīųû˗ÜķîËSžōRö-wžķ­V:āĻ—ũOÛ'u:ĘM(ŖQ¤võoëø+&WM•ŅĢ%n 6Ģbc˛5 5œę‰‰đĒį‘–Ür”=1õŠÛbĻ/…cŧ ü•›ĘSĶaÄĘŪ{öč(Ÿtũäŧ'ĪčԍķÍ&ƒ~RØšē5ŲÃĐlp§ŖËģÍ Jn÷Á˛qTū”•ę­!„Ą­‡b)KeĀ%˛éb‰ŋ ŽŨô ‚ =R@ ÛM ‚=$GgtɘÂؐĒūsŸ ŖŊ”qAnˆíŌ„E9]úúH^ŠĐi$nî%Nú5WC^ęV€ū´j‹2šęÅXoßŅūŲ^Ÿ–vĒūĸÉ;JҘãęŠUéčGßxåüûŖŊvyČÃîŗ<âŅüĘđõ×/ī|įģ—Ë/kz^tŅŅÆĶã<|€ūîíACŪ>*Ÿ‰h×uÖt˛k3ŒđLÁ‡>rŨryä[¸Sā[ŋõ/—đlŌsžû’ú‘ˇöā´•. €c?‚dķ Û/Ŋä‹ Ÿ„˙s—´WūČWۈN“ŽĒΧlĶ÷E‘úlöŠ‘ß[4´ĸ:{7ž­ÄZ°Ģ…Và ;}f-‹Wąž3Ÿ"6ˆ¯˛ˇuÂëX÷)Á(Yp)§zLũæ'éWž$ŲEFú‰īDŽ HÎ8‡l´ũ6čae0NU@°ä ÔmDڜŠl ;()ËÄļPa.ĢČ"§Ėųüiˇíļ?aũSN1\õ•fũ\d˙y­sšēŅ–Ž|øÛ¨§€Ã_4Į‚ą‰$āŖiH5ābĨšüšõÖIå†nw~H$évy­œ‚U†ˆ˛ūŋėŊô§UyīģiL/Ė }˜aATF”&F@š¯Äs"ąXˆŅ$æxĸFMĖ1š¨č˛$’˜HD4Š *bEz“ÎĀĐë0Ãô ÷ķų>ûũĪ,ĪÍ:÷ŪuÖ2g-Ū_ywyĘ÷yöŗ÷ģß.ié×NcŪ~XėŲ‘Ĩ´rIĪڟÚî]í+, œĘ Q”ŽôHáŠŌ’xŌCä^ĢÁeY&˛a 1á¤!+čT i@šÎāR7ØŌŸé2¯Ë• >¯Ķ`†:…€—Áȉ^NįcĐjŪ{ôŅol_>įĸP›^ŗ†Sæ,i˜ŪxØÅŖz~e_ūƒ5a)āîüĀDīȆZĀæƒYÄ/<Ŋ“C§TÆĒņ›ūaR<´ #yf͔+šŸkÁɟ=MK{…`“VÖ:(ŦU%Āú.XŅ‘`āOũ‡O,č0j Œu-P#\zJĩ¤{Ŋ jđŠ N;A˙CSGū b)ËEšHLoę…`Ē@ސÍÄJžLü‰ĶęĖW$ā‡=Á0~ÛBKŒģa§HúĪĒ;Œ"d91]$QEi‰čᤠ'úo ū:•î=UŽ/;°Ŧ˛—Į‡Šę´‹–¤}ĖĀĢŪx™ôČd™}ļ!īļ`đáÉ/{ŲžíĄoë8}ĀKd’{˙Oärtՙœ˛#}OoTGĸęĮ…Bxö ÛVøa¤ÉÄĪ–&ļ͍x†>š#ōŌ<($Y.đ$ÆMŠ úQ?ã0ß0}j=į{åŠĩíąGīk“§ŒmŸûėšmGÎ ,^ŧ¤ũÁ)'˛3°Cû›ŋų\[°ĮΑščŽÛC=[=3°57rk={-ę×Ãę-[…¨]õW}UÚPQ.]!Ô'ˇŦ×ãëęēZĮĘ%ĒF aú<áKœ*Ô3nƒbq 'ūL*übP‹G;ßûh{†Ë öŪgģöąũs›:ebûĢŋ:ž?Ž}īĸËÛžd,ä1ˆ˛9†Ō€W ĀԒ ÆæŖ¯;™¤Ąŋ:ęīNĖō+ģ˛Ņ` ŠO´!}ÔPp\’†4Û/ߨ{FÜ>”neŖÔ‡ģd\…ŨĘ׏‘¯>ä9˙ĘØĻL5ēF–uŌgŒĸPŧ…2yęBÚŋLrƒ¸œ‘K{ŦĒ eS/cLÄÆ. ŸÕÁ tēGyčP˛¨ŠmžĄ˜hØøĘA´ĪÄĪvE"û`s„ÎlP!ŒēĄÍĨ­Åꁡ°”2Į|uŗ”GyÅ'ŽR›XaŒŒÅŦ#’?}?2qJŦ ú­ā;ė4k Ølߚ!Šô/lv˛V‚jŒÍļ83"#Å:eČÂ_?HiĘ™ž%\Üw.zĸŨqۚöÚ×ėŅŪôĻ×ļ{ŖėŸn_ųÚUmÁüŠm'.}3Ö}ŌŲhôųڇ’ü8¯üĸ!ĒĢļIĪšö rsvŅžûíëÛQŋsjßūö%풋¯m¯<öEŒßŧü=#/…ÔŊ¤øŠĢjĮķœö‡§ž†û˜.j?ûé]í…ûs–ŗÚęuëŸaLT§ÍcLÚ|(ČģŨ6īS‘QŦP•ûĶŽ#ÛũjŲĐuÕvÔUuč˛oSBcŋ6ƒ ō`QIl“ˇHĘu yaųãË_a23ŌI-f€•<õq’é’%Ÿ:"7đ%´ŋéũtąøÂļĖí“@&~î,˜pE NllgãÛ#™‰™ČÔ÷Đë+˙=+ĻL“끤Ŗ!ō% qb×r`D_ôĘÄ’[ã* dŲÎĒąĪ(5ũ,éĖNŨũPûÄ'Ō^ú˛ÚÂöi—üāĘŲÃĒņ<ÖtÖö“sŲŪÚõëÚÍŋfg€˛ûŌ7ÚŲÜûČ#Ë8ëqh{ÛÛūk›ąŨd6ÄķÚ~ĪߍË7ĩ§–­áŨęØ<?qI1žsäu ũLˆ‘Í@Ŧ#âJƒÚbåxĪƌ3ú ųqĸO´"g߈MëĮøÂ /ÔØ&-u œJŽ>ĄŋͨA>ꂉBôyĪËDžn2yÚøėyVāËį^Ûļßaf;á„ŖÚž{íĘÄå&vB¸ˇi„e0Ī$\ātœQGáQŽųA/ú„LžxõG9ĨڐJXŧÜÅļ1†46;Ņ]Ž>K.꓋žŧפXĸ?žT—ž§ŧv΀ŧöøę^~âļ–1 S|†LÛ'm¤(Į mQ’=ĸl5ãŦbJAĀOŪ>.yŠÄCRéŲ!ЍĄŧ4‡ārÛ^ĸäKIéÎCČSŋël}UVj‹Kųx˜HĢ/2-FŽęãēŒMErYĘĸ\öÕÚ&‹ }()9ķūՆ^YĻ-Ĩ^ãÎE~]P;ÖIáRtú#Ė) ¸â/‚ŋäjúڔøøOģhŸr<āŲ†ÕĢÖqãŠööˇמõÍ?#–ˇĻŸ×Î8ãëíyĪ›Õ|ņÎmÚŪåÃÅâ.x6ãĘTVü;jzĸEz9ÄŅ †ŽËžčA†ģîzŦŨŊøQCüŗvá…ßmsįÎAßkßŊhQ{øŅĨí‰%Ëņƒr”âûL菌MĮ˙Üė|˙‚{Ž6pô¯Ŋgål&•ÃáH›Ôz U¯?ŗ¤¤ī8ãŽĒL›aذM´/ öUŋ`Ž[˜Ŧ1 įãÆ”oī ÁŦžˇÂû,Ŧ8€VQēj;-á•.ķ/茺Ü/¯?Íĸ,íj“6@¨eU›‰dæd‰j(Ģë"R§˜Ę˧(Ô^ŸđĨ¤ÜVŽ~4HĮ/ũZüņQá)ƒ&OYæZ/‚tGž-Ēž–4JŲ—F€ōËbŗ$įĩȏÄp‰A=Ų @x¤ąŋ žl49Ôg›c–˛ÚŦqÕ~ŠĖœyJlČ/ĄqģyŪlIâZÛũ+ũuē“Pú¤ŠBM$Ë_ųĻúˇļUŪzofvÁŋEí@eĘl ‰"?\DĨôĘkxûZĄ0éd–“]XB놞åĘĖx}ü)k ]dŠ.JSuÔgŖe^#ņ…C{8cšÎ˙škīi‡~ ŋƒÚu×.nc8M—~‚SķËyĨ_ąMÜbŗ`X”` „ngđ‰"ĀS vh”‚ŒH`=lxãiY˛ņQÁˇ\ÚÍÁ …ĀÁ }ęÅ%/ÎĶWŅhБI¸"ŖNáj†2 —3mA>ķAtP•¤Ū .;ĻļzŗcüDēlŌNqH[4nå¯?¯zũW~QĻįzrÚ@ƒ A8Ĩ(ioϤ×u]ž‘ÕUEYöĸĶ`Á…U<sBŒM4Fá҅“ȇNVX>äl`7ÜYbŧ5úœ_ü†>‰¨iJ—žęp5ˆäēú *p“Å7æČ•ˇ|\ųøĨ–šĒIíæ§Õ˜(N˜8Ž'íĖiß䝗^zU;ũôwĩ9ģmĪôiwc€I×`†oôlĻRœ¤ŖG%,hČbÎXËžØŖzĘåq)OPQÜ]ĒŦ!Xfí‡ōj;jHģ™<’‰Į}R mä‘>/cRæØŅ<÷{Ú$ŽĶŨ–.]͍Áˇrsā˜vūųßoįŸ÷vß=ˇc_ų2žūõmĮĻsŗđv\>4%]ÍŲŋȔN˜ĪhŪ3āSvbœø ‘ā—Ú-2^ĩĪëÁÉiŗū&W¤‚–oÉī@f t88I ŦČeU,Ŗ*mŽ(lNŧ™f1ŖOÆRÄÉáDG°c‰O=ÛsÔ1ķÛWŋrIûėgŋuų˙…G$Žã ÷ ä2NJQmâÛöŽÄTå&C Í(”6öØJĨ–YYg5ąĶZŒœ € ú m­ŦđĀd›ŗhNô)&'7C\+ÄIŌ0~ɛKIģâ\˜õŊ☂7YŠ5Âę’ĢŦȃ8]ŧįĨ‰ô5ą(\éĪmÃt™ļ)ՎnP#1ō‘ãŒSmp;“Ę`T>ą (¸LU&HæŖųWĻļčO։5ų%đOņŌáO'h)0KJ-pzÞŅIUaFXAĸ<ūe;]U ™~‡<5Ejb0ëųė˜‘‰‰üévqh‘æ†8H†déŗĒÆHŦßî`‰æ$~ĄG•áÛ|ŲgmKžXŨfΚŪvÜaJÛ}ۜāž{Ņ#íß쁪ę9Ō?š5}€Éļzĩ'aäį=@‰Ëk’ĢF”ųĮģĄu(rįâÎģžh¯yÍĄÜOô9Ž8˜3j_lgžųõv䑇ļŗÎ:­=į9;ˇ#~[Í=G.cē|ųÚöũ‹īc>ņâļ×^ķÚ?üũˇÚZęķ”"úh؈FtDFÚļÕč‡@J[ÛF9 Tũ]”ÃŲđ`Æ7ŽSiŖxœx‹cĩjcõ6îÔŌ0)Sa{‚#rËWÚ§Ÿb2mŠÍX—|ü,A•öQ—1œ9˛˛T-ßč ĘŖšŲŽCī'÷ŗšüę´ÍÅ-"áSn;ŗfLĄČI'Y§ƒ§Ń1ŋĄ‘2ÁÚ\!AŽíæĸåųņ¤åˇ‘ęĶ đÎDŅ•Są`ŗáeŸÁ•§Í˜Æ?‘†­åtÜKYĀÄāģá9ôĐmڍ™Đē!ÕđZÔĄ\ū• üTF/üäÉĀo–Ü‰IčŗęD:ąec&Ÿ„Ŧ Õ $oÃXī†ĶđĨŠēŌM• %Ä$ .ÂO”ĸ#Đ rņgđf% š›>ģÎøL™jĩšĩ¸jgŒDä•ģZÎFHŖltVÃUđŒ…¤^U.fŊåMYH‚,œ >1YaPŠUė4VŌÖšWŸ2Šd×KĻ ņš6ę+ŒHĀâôRĨĻTá eōUž `ēøĨŒ'­ūŠAcNĪW<éKĩēVŋ.ÂŌħž⭉ŒSŒŌ 7Ļ‘ú d’ Hû‘qš7 JĖ"4mË*de™üü“í¸L§ÍÁNÚöjŠŦ#8^ŠĸĢīãԟŠS&´iS§ä5oöĘû/8‚囄•S¤Ô¯õ‹äÕtšŌö‰LĖëtŠ B~‰š—ũ¸v@‹íbĩ<1¯~ Šŗ-ÃcZƒl{ÛŊ‘߯q–ŪZËôÔFŽĀ™ōfŊ‰ŧŨÛ#ߋ™ø_~ųĸļëΓŲųųA›ŋĮŽíÁŸlīxĮÉmÁ‚yíc˙đĪm OMRür9ęcDõɄ c˛ÃæĄ˜WÚc[Nš{i‡ ņĨnûV§Ķ_Ē=ôÉĐËĩĄ€SKyõmHøS§-’ÔÍ’ĶŗŦ’F›("ĩîą3÷ÄIã8ōC{ū~;´ˇŸvJ›ŋ`n{ík˙ļrđŧ6‘A#Ս—7?@ØĨ3ÕM˛tsT“Û,öD“DE#~Û'haRœ>Čĸrö›O Ųȑö`’øÕ=y•-ã–눠GÉŒ$BŪc 7ĮI‡0õ $Û"hõ‡ėÕė• ,Zc“OĮŒŪ)ŗ-ĨáåÅCąAƒģyś qĢCŨ|ĨąŨ=:\ú„åTtR[aRœ>Ší‚9üŌË刑2y+xČŗā4à “ā¤ĖzžÁ­~SøÄ>™ļHtö;xˇfųe?2˛ĸ@Ė%ÚĄN–v&ũË´‰2đQĻ*'ię3.P— R$–Ĩb˛€Ķ¸†ÁIyö{ķ{7—ļŨqëōöųΟ’—~ņ_ŋÖūÛ{ūąãxÛņD°Ų;Loëx3xŪ~R,Dĸ ¯ŽfM‘íWW¨ORč{[§cĮŽj7Ūü0—īŧŧxÂŅy‰âS˗sād˙vŪšŋʛČ˙đÔßãŪ›Ûŋ˙û%í .o{ī=Š-Yēœ+ æ´Wu@ûÎE?ÉøqĖ1ûæū9}^í]>4-žDFM ‹Ą_´TyMaĩ:‚Hđ>íEâæ•ąÛJė¨=~ŠŠ€ŦdéMĶü§A:ģŖʍŗ´uœNžö‰YԀc”ëŠ/âW¯%CĶåI‰tƒ~台;uļ{ ‹°’›ÕWTņ™ą™>FteŽ<Ôę7YĢ=Ô >q§ËßFÖŗfMf ÛĻŊ˙gĩãŽ}^{˙ûßČŠío´G™īĘu¯”í‡uûb$:ÅĒ´’MBė0ūŅ[Í[>Õ ÕVā1Ļ(°•2ąčuƛūĪĀ>y¤ąŠˇVķŗ ˇN§čXâ; ÕĪp”RŲnĄÚČ5ˇ0OŸ>ž§yLĖ#Rīēën\ÜļãŊ‹žķNĪßß}di;õͯâ¨Ũ‚öŠOŸŲžxbĸ¸Ohõ:lĒŲ֜ (8s¤Gpä“ÖpœŽ.}ŸņĨˆstɝk_đ†ÚÁ' ízʎp3øƒŖaˆî"¤¨t6ôiŸÖ’IĢ~ĐrˆŨŲ۟뎗.]Ų>˙ggGīĪß};:kÛ-7ŪĮ}ãxlŨž˛üJé-•ą$qŠ F`-ĨClû1\ō¸ƒÃ‡Ôá ]cn`ˆ3&bsd„Ãøąß E‘Û™ūHßtc›ŗ„‘)•1ČwHĒŋŗĄÉD!qCZ]4Ž—āÕXgZųüŸŦj!Ũ'MąŨKÖ$bƒ’66Â0,㴘•#3íƒ=…@ŌXŋôb„´lŖ‰ŌRMEžņ•jJ Ė”h?å$^ /‘ä%¯ :i¯Ī×Dúķ…uFŠ1ēGޏB ĄũF89‹:"„˛OÄ)Ž’Č?di/Ö6;|…IÎÂŦ¯ë ‹ļ({đs<@áĄÎ—yæHú:oŪåˆõ[N=ĻŨwßÜĄ\Ō~úĶËÛ¯oŧģí0‹3|ŧđkŽõë֕OJltj’ō2)+Hƒ+@Ôí4ú4Æ]Ûôcmãž.“4ļfĪß|ÉÂvķ-ˇĩß;ųŊÔíÔ^÷_žÛ>wÆģÚY_ē°}ôŖ_h/دvÉ%W´šķfĨ~ūķ‡ÚË^öQŧgûÁŽĨ˙mj“ŲŅV/AË­Ũ‰Ķx°F+GÖ!Fõ]bSC´>Û/.NTYÍĐĨ–Hcxç@Ą ×Ğ-āxš:cŲN^Ģĸ‰Ōb•VR}EGL˜ËÆId˛ę:īđبėÅäģgˌ5Øâ7éEæqIPVE¨„1ãaĮOyú‚ôųUšúd*ŗč؁ˆœĢbõņ œÆeÆnņÛrlĶŒÚžŠĢ–ˆv|'Šd°Ô•TÆųØb)t ‰>Ķu;EŋĖĪ•V-^ˇ ŅZ´ŽÁāöX'oúâē‚9bPhs’ļŦŒ‹¨ú¤SO×X¤_r¯†ė”Į&ŌŲŽŠWŅ7IRˇÉ3ßAQ&é6uĩ*ø^—ęā›é …ŦŪ2'a € AĖĀ“#wĘŅDx°'˛ËˆS@Q.Ŧ%•Á%ĘÕGšN’¨ī¸ŗAĢ_ŗŲËį¸N7Ž}˙{ŋnúëˇFča‡Ŋ“}^ĐÖ¯^Ÿ‰ž ­.)X'N50k¯uVĀJyHÄAii1˜Cˇ)Ÿ÷iRW9 )3JŠŽUĨ#jc튟Ŗ×ŠIÂbšOíė F//ņŦ‡õ â„[é54%Û”ŗƒöd ‡X $‘•#ĻmõXĶëĢm"ŦĒŖã čô]RØFŋš$°‡ũ.ŌØEŖė!čÖÛ”ĘŽ–™XāceEnŨžĨĄUĩ?Ę ĶÆ`öŽÕ-^•eŅ—Č€ÜęvŅŽ ÆXc“ēÕ*ū瘚"—ô|h!Ō Ú–‰}™Ęe‘fdQ‚xũv~J˛Ë6+í;dŽ€ąũėcŧЍ J{Á.žäã˙'J‘×:š„šä yéCm2"Ų5<Ú9ŦQŧķ3yJ—˛ôKˇ1~Jmđ"åwŊTeėtPÜĐč1ƒ%ō°×vī¨âĶ4ˆ:yä×\hÄjÛÆđ×ûģāT†€{ŊkāąČA9ߤzļ"FŲÕ‰aŲ#Hģ÷ē’Đl`¯|ĖXŽŋâ{/gČ@‘ˆN#-S’\:On.–Ú,¤uSÂŪŽk Ø1wk} °QdÉ$Æ­6—¨+'ë4ŋvˆ5̟nģ͝֎ ĶRŌæÍÎŋÕŧ<Į'‚ä˜Ixåņ3˛wœPTiׅBDÂŖBž$tnŒ÷íÜ1Ŗ—YŌAJN}Ÿ=ĐŠ•nŽMöŽØLŌŗ ņ'üi+xŌ¸$t.ŸŸžŽ'yÃDžîäĖ=;÷8ƒ-š€-ޤY#3)Ë ŌÖKR–ífpgI†ŋ¨I,mŅmA† žrPČY‹ž‰ûjŌn!šŧũÍš_o˙‡7đ‚°YíÜ3¯hķ÷œĖ5ō3r)LâPČtī?ąGēÖ€';ĩ@ŗaĸ"d}C›ŌvZ Ŧ–EnĮ”Æ­ƒļĸdĨ ŨļøŠZ%ąĘh—–Z…!LĸD—ĒŖé Ōņȉ›&MßĻNŸČ„bcģņÆÅí\ßv›;™Ë…ŽoËWŦâčųŠvōëŽjûėŗ'GŅŋĖ›wâIaëÛäÉc8ĸ>&voÙûC6ōÁP8uįč(úw™üÛ~ļX8ļdĮúĉũ˙i—Ŋ.1kĘ@§¨§thcŸŧ"ÃOÜe6ŠvCŽū)ÜųŠWėÕžōå‹Úū ÷j;í:ŗí2g7L/nSđĸ}Tž5ŽõeRĸiŠĩ=č3ĘĢö.›:Û&dÔŦxjæiēÚÎĪŧ5šöË7ÚÔ“ÕžƒFŖŌéM}Uä¤Ĩ(öA  æĢ8ÚČģÎՎ“Ōd‰üüĨ/)kdÂ4JĄ‹ÎBéuQa”ã“ė˜K¤âÍÆ›jBT8Ę Ÿ˜Å ØĩĻ–ˆ”‡Ŧ31lTžpâ)ŖNžŗrŠ´Ũ5č†a¸d#öC9ūexC)éD “Š`!īö8žŌņąAšōÂ'6sás-jûgĩŨ`ģ8ƌbЧiûĐ+ŨPTŒ ĶļĮV,ēsūčŖĢō˛­Oœ~Z&ŪįœũöđC_lˇßú`;ö¸=8CĪ6Žmŗ/Ķʏŧ= [ją˸™ņ8¯ÜZfíŅíFÅ'ątĶŧ,v “˙Į[ÍËķĻļ .x7/|˛]uõí„ã§ũíßūgHĪáƝáũ$3xŠØcí…/œÕvß}6—mĖũ7ÆŦgÖ1mú„\ūŖ3rÃ&‰l9‰ûl-Į'âOúøwīPd`č÷Ÿũ.;ĩøĢ&u\”/Ũ–ÉĮ*˙jˆčX+OoĄĶf°7e;f)yå8N8čĖÔø¨Ką”\ã03ŒO]h*cģæXęP‹ôâ§ÎŧnRcoĄŠ ^_Ōi‹/RqŽėƈū 3)äeN>Ų‰ÄĢ…§ûĐ8×6cÔí]âFjŌÚãRh¤3ãUeiĘí×ŲÁ$›K|û„)ŒČLß)Ą°?Qš?įé ‘QsĨš_čLa%Ã%ãŋm&uå4 ãsņŌŲĻŦĩŨO s!ĐČ_ÆEOļĢČ5*Ķ6‘Ÿ&ĨLĀp"8<8#:”ĻãĄMŸT]\Ëuqî-īžĮLŪ¸7†GōMā†Ãm9"ĸS‚ÄŋZԚ2ŗC‚˛¤‡u‘áĀž-é;|ąDCũPįZ™Ã˛E=<ļu[˛ėҍ>å͇a\Zv ūšä˛3¯ƌ¸dKL’÷–|Ō[.ī“–Žrf=Më[@ŸzjŅz^‰ūOEq’26m÷(=5ĄŗÁ A‚Ąw8Û8ō-N:†ã(âdTŸ× €Xŧ–ķ7 ΆjĆĄ´:›ƒ`‚…šV\Å|-̀9¤ŖŦôFéŦh'aĄc”ĪxLS­} ˛:hyņüyËY;N‰Ė2¨dFąīęE*YWŸ ‚*÷ŸLŨĐĖôíáŗjãĸ<ôf`!Ģ%%§|— Ĩķk‡%_ôzŒ3õ!„¸|r[ōäŌvŪųŋlsŲAvÂŠĖ’.&ōn5,ŅÖÁA‰XëHŠ3ÆÜI‡dďĸu‘7׉ʤ}Ŧ"@›€íā¯ĨʎņŊ%qÄē6,ü˛,ˆmUAš~p°Į¤$ŊŋãSÅä2!^šuđėɌe›ÚOv[;ûŦ+9’7šŨpÃmkKŸ\ÉģNāzß=Úŋūëųí†ëīΙ”‰“ÆäčĄxFgį-ÁĒĨũ˛;1ø‰‘Ŧ5ČP˛M2¨ëŖS_ŌFļ]6ž”ā‡\ļÂD Î˜˛!ĶöLi}Æ/OéüŦOÖs´/7\ÆŲž÷ü÷sÚ;ßyt{ûÛ_Ī% ?á>ˆ/ĩŖ^ą{îĨ‹¸”į„!‹“-mĐīdŖšö7¤~Āíå'Úā˜â"m8U#/?˜Œc3ĐĄK^[åÕĀčSN˙Y–4ˇĐ ¨č_}õĻRˤö#62čüoÚ +lúŗúDd…¸8EŠčHîöÃlUdģáÍ8ô“Hׯ8‡ú>†)͸v)ßĘéĸM—ū ^øâQBg…ŧū¤a ͐ą 1dbXĐį8ūō^Ą…3>ąMĸO¨ĪOpHĄÍ”™WĨ4ęu 4‰q#éL¤ĢmåCøåSDxd§L–ŅÄŖ—ŨŽZĩ†ƒVcØ1ĪŦuôĨqíŽ;īm‹ī~,}nân^ßÄuūĖė3Ļ$l/pčD;ē´Lšļ—ŠnMl˛"ĐŠwōīŪ›9÷ -]ēĒí6wFûķwŋ™ƒë9Ͱ ×ļk¯ŊŠČã?_vØAmÚ4ƂoáÉi×ĩyÔčŌ'WĩūėvÖŋŧ…†Ĩíßņ/ėdīŲfL›˜mŽ;ĶÕTÚ_ė…töąøÄúHlØTc|rEĢņŧq8™$‡q"ŒF>õ"ÚLâ,ÂØlŌ&EÚŦ")Ŋ+ãSŋ’éܡ,¨ĐøŅŨV[O].Į#)ƚ“Č‚MĐHo›į Ž‰Åa<€&ÛˁGF°’ÍA-ĢS˙eŦDqđB•¸Ui8\ķCž;énw˛R¯Å|jg Yôí­ū¸,Æ琟’ˆ-Ęsņ €c€ž¨ų@Š#Û>áņā3(øjzÄ ķĩŨō‚ŠUü´Í"éh7Œ*ŌŌ3ĢiŠq‚˜øĮ˝† %^ÚŽšMšŠĻLáäã,}B:x¨’FĻ$ķz}])ô$n‘[÷īšk Fę=š‡(bƒ¯›į搅îÆÛ9ggC[­ü?`™Ė3ŗ]ė4˙'/“&OČŖ˙´cööĶÚÕWÜÁŅJžúÁ€ęÆÛč0¸ ͚ĢÅäTgĸ܊DD‚T¤BžpŖ _Ē*¨BքT:’újI$k§T RęCP‰ĮKÂ]1UÁáÆÄ;ŠP†pSž~}M LP~ą‘LQá˛ŗēaFcˆėCf† Ø*đ­ĩķ8 z¤u°V'ô˛f01yü*ŋeN‚„(?đf`Ng üäŖ†zQØ.Ųpʧ|DĨ"_ė“ Ē°ōuĨeöœč°4ßĸŠ\ °;X‘k3K$ŋĪģļMWŦ[Ûzx9ũwäąm&oŪããA!Ęe"N$˜*/ū0§ÄB딈*í0oģÂ`[×ĀFÂvë€+k˙ ĖJ›Á(]Åu‘Ģ}ÚËĪ} 2~Ü`6Ā Yę­ŖÜ´mh§ˆ9Å: Āø Џæ?Īé~Ļí˛Ë4ž>57^qåmí›ßēēí4{bģåæ;Ú¤Ix|čĒöÆ7ž’Į‰îĶÎøį¯´[nŊŋ­XļžÍâŨ'ŽAļGí¸!ˇŸ˛Ö+҇#ô‡¸lK ųnžTŌ|ŧ˙Ā.ĩÅņŦ’pL Č6ĩ.~HLCmãB›I|â‰~O[¸ŗ3†I×ĢNxg;q9Đ÷˜|­mozã‹ÛãO<ÕÖ`ûxžē˛ ũig\Đw´„6ˆ¯6 ârƒ"|Ģ_‰×ä%ÃŽ´<6<&¤í4I(Mû]ĄËčöŋôiŖuŗõ3=,°P_ÔŠWÂĨPäČĸė-øLũ'z;Ą“žāQÆHLP‰ŦXmŊūg&ˇĻK72åèlņŗŨ[žĄ}ÍeŅÁŗČ;Ŧ“Ō„`U5úĶ22æëž…8 *û2ŠP—„Œ82•\ĶuĒÎäF9•o#ëk˛9Sf}dkŗšX7ėČČoÁ YFaĘRwa7IIƒ–"/r¨÷ /˘t_wŨ“lĢ|Į;ÛvÛMoüĐįÚi§Îd{t›;fämā†üI°ģĐŖÅXėŽĩ*“ëŒbt‘ht‰¸c‘i€åō1Déå?{ŧũ•×dōČ!īå~ ĩ?zËÉ\.7§]đõīļwŲĄŨ{īÜTeÛy'Æbr×ŨfˇwėŊkģęŠy Čúvô+öČ̓6p6҃Kƅp’ŊäS?P'F|oSˆ_˙eB—´ ÃÎ_­ŲŸˆŠŨé;čâEČ•%OŸĶŠņi&å*J[ôؑĶ2Q*7; ä7zÍŋå”ÍāĖĀL~’ūâįˇđæá_āŸI%ܡ͞=+GX˙¯“oûŋpßöĩ¯]Ô~ūËÛÚŖ÷­i{ė3•›'"›K8|9lË#č#Ŋļ‰Āř ąõPg6eMoJĪQ=påÂęŊaSZ"-m§rņjbPũį%vŒoĸüō9?j7ũzEģė˛ŋ‰ūĶūøãm{žŦ2qÂØėøPĖúļ6:AKnë>ŠŗOˆÛr{ ˙Ē×0ĻĀ>ščHĒۛKUl3~ņŊq}v¸ač­Ō…*5KõWøČÅ8”s1hx‚JėŌ¨8PĖ+Q)Ȑ9yQŸ5m­ ĩũŗOB+¯tĘĸŌIIYL6ʞQķzrĪüČoČ 7´ŦĪbĪŽEmĘU‡ ‡ŗąŪEáÁÄîĖčt—=ąĄ(?9’ —BÅŊ:Ĩ)ËO]|­F•wZéJiâÛbŗ_M;~ąÎMÅ0NQ‘…Yėˆ„O}–šäŸŋ`HĻâ"•ĐŽö2 ʗ0ųߗwīŧõ­'ĩŸũėÚvũõˇōfßemåōu<^s6¸gg= øĶ>¨Ī$y+ÎK—ũĀË"mŲR.cÄĶ$mn[™8’DūhŽ=ąŨ—˛3?uę(žū36“ücĮ%s“Ú[ßöw\øņö{¯;Ą}ö3gĩo}ãšļđ%ģä‘ÃßãņŖųČyņ؜öww&ĪüőÎŌĪŨļÔgÛßwũ¨Ė\¤ô1ķ§˙õˇČŨ‰ļ*Ûkmčg­ŒA6ļ §?Tc‹ ´9Öö6.cX[Ŗ'ĶfæČíoéßʇ&—g+‡zˇ•ĨSJ×wÖØūe_ɍ0˙ é—–…ڎ/¸ŌM¨#å‘#`Íĸ^dPaą>.÷Ɂ €Æŗ+´ĨXœzxlڞi W˙ B QVë6˕­ÕÅTøĨÃ7BRŲķÛlcß&é3*FޜˆvÍ*LæTB(K“ČÁÛŖ|€•ōKŋÃ^[Ņū>ŠíiZkō/6ę ’h÷_lRĄ!yĮA|✭Ûa<Ą BõčuÚđšSŖnÆ¯Đø‡õ HØ˙jėŒbâ?Ο˙‚9YͤįLeo}§Lūc„ŧRÖ˙ôŗü7[Ō[÷˙5ŋ%Ī–é˙•œ-iˇL˙¯ø~ŗ^Ū˙'ū˙¨ü7ų˙wäŅÉąb6ôžŲ؆-ÉÃ.åÆŠ1é(F’më¸ô´‘”˜0 ,4L)K@æŋĸÁU@IDATŽí}ã,ŧ¨ CJ>vÂG=Ø ôÉCwÜž˛ũÁå 3;ˇ˙øōļhŅÃm*/.ōúKˆŌčÃõ´*M˙°,ęŠAPBtˆ<áĨ˜P$HĢKŸrW”…´,ļ““<7ļŲ°Kč ô(ĶG Tš8HæÚH:L œōÛ)¨ė=%rÍÚņÃ]Ėbt ËĀr2đd’D…¸YGL´K͖ũ&A5îäE˙YpdÖ"cį“.ŋ˛Ë;`*ŋL6eáfŊąöRĄšf͚\:rÄĪi?ž4—ģ(3K÷ĩŌ‰9zjŊöą.W–ĻüSUž—F)+Á Å:—ĶHC˜` žûŌÁPēP'ąY2>¨Äf+@˜ĄŅ6â[ÂâëCbĩâđŖ\.ŠÚˍ˛âühƒ‰ĮrTpZ›8yÛvŨ ÷đ…Kšėg#;ÜĶøÍj7ŨtG{å1/iō§¯áÉ%2Á~°-ēsi›Â=^gė%ĘNsąÎ† zcTá×ôjTŒ͋äËNhm \ĄSW<Õ>n˜bu *‘ŗĻbۊŗ“؁ÃY^fDášÕ+s™…Gf'đ"5}Tžˆ†R6čRJt Ō¨°aČÖäŋúHjTzĨA–v&M>;xŠ×ЖĐäÉnĢŨ~­˜āNڊjã)õ*æģ‡>¤2)•¯ī)Sy&ų q'î´; ō‰ôŠŖ&ÍåOrÁZī’IŠĖ~Ŗ j‚ĘåSî_)…!ũ>Jáƒ1öw…’éŗœø†h›ŧō˜°Đ$Âã2ą+ūŖ? ã™>*rū;† “iiũWô29ū˜/>ë Ÿļ(ž@Š=Š?/ŪäYũŪSŗœ—ī=šb];ųĩG´}÷ŨĢ]}Õ íī˙ū2v’—3‰žPÄíO‘ŖÜō:Mģ¨ĮņŲ%gŖŌ<ÖYcīˇĀc7E>ųLq^~´ä‰•ŦGs9ÜåjĘLūĘcƒ}ŠŪwŋ{÷@Mi/Z¸}ú6ÎTÜŌfΞĐVđÖņ3&ˇĶūøwÛM7ÜŅ~Åe“Sš‡Æž ŠpŅĻvÁl˛Û1lŸŦOŧØf~h÷´grÖɘHč.0] JŽ<)ILĨ[ zÃŦ˙Ô­Žâ‹ī’äÅíUūe5´!<ŲÆĨņŠA„6IS˜‡|ņQœx“ĮÉkfđs1|‡cÚ(ÎĀŦ æĪßąqÄAíūûl_?ķįmîž\zęŊA?”'ĒXYÃļŦ€+UtbŒwĢžƒF>ž< ¨Ÿ† ãgډōa‡¸úĄRj‘|ŗ¯‘­ŠRS>WųēCŊĨ|TDž>ŅúRtĻ#‡tPLUv¸u– üöƑÔ|ū+ōÛļúĨĒâwú>ÉÄzœŸr”]ĖOJ't‘Šęx˜rŌ`Ôą—ėžÎ*­ gŒžæÉ>3՛sd„­dCüėōÛņ &r6âTŽG´ä9É^Îá„Û ÚÚ;oH+€˛áHšHL#œ[ OÔBoÔ w¨˛Š…Ü@›Ų–VB CŪ=ŪēĀ”†HūtJ)Œį  ` :wīCÎ_đš'-‹”(O!ėôÎŌnÁÖ~ņē+ČNĢ—ųįCĻËĪäÁbUû‡\L¨?;dĘĨĪę:5åiôÔÆâ‡ė+ zäent‚GÁųáŸt|˛ŨŽėļXMũZĩ‘Œyr5#-˜ĐøB~ôâöį~x;øāũšŪũÎv˙ũ˨}j 7âA¯ü DĘV–v‹‘úŲöŒob_)u€ą>˜ņ%RĮÅĢ7¯üy؅Ɔ#AJČ™†|Lá¯ŽÄÆbJ-×OÕÎ9*×yŦŗÜUÅå>ä•į˜–ø„ā7ūéŽ]˜ôĪũŨ<4ā™öã^ßÎ>ûį<]h[.ƒœ•ūæäâä“k îÛÎã]#×ņŌÁeO­įæÛŠŧŠxÛÜÔīåžœ+M§nœģéŗnˇ’ĄÜã3Ú]Ø*ūDžã6ø' ĐWül›ō+ õcĨøĮ.ŠÖņâŗÉܧuࡡü›íc{s;ˇŗÎūzûÆyWļįî;ģqåuēö–“;ŠōÅbÔ*\ŦĘņg”ú§A@…!i ÆÖm4H"š|üær7eâ{=Rã“`!ž;/ȎQt—„0#ĪO×V.‰ šíäâqšBÎņ€T‹ íŦvÔ}ŅŸJe]iÎhCšÜĄXē֎ߡ„„†ģ*$M?§N)Ã,ömĪ&Š3—|2—›…LցÍĨđˇ\îŗøíøŖ÷å@ĐIÜ`kų~ûƅ—ļ•LOzõ<Îĸ>“ĮŌqŊ­‚†ņ.ƒ0ú–ŸãC.=‰/ŅËˇÆ Ī …,HŒWī75˙pÄsúv“Ú›O=‰Ëfgņđ’UíG—Ũ–68á¸ÃyGČ#ÔŊļ­ß°žŊūõŸæ™˙ģōčäŠ\*ˇ2ļNžÄuū|ä-ã;ī0-~Lģ!V{„Ívî>ĶÆČ–q oŒëLPņĩüøwpŅģÆŠ\ĻÃ:/ĢŌ š`­,‘¯ˇw‹ŊÆ 2öŋ´ēĒmƒąīŒšČĄLÚŽ#2d¤LĒŌĶ đĨoVUl•R^ãBÖQøųiŽ2ģxE1’KŖ}Õ§ŦŨŧŦLˆŠ8´a,ĨĪT›3ĀĒ.‘é:û…K;D\ųŌ´ķ"íN…u>\ūYĨlu E™Õŋ)ã‹@\ęĮōĩŒfį>šQY:6c¨$ÖD~Ų#tšCÕ˙¤ōɗņS–ÕÄÜļĶĪ#­"ū—’¤ŽĢĨwKHaŖnđŅæ eŅķlhbcˇÛOŸ˜š5/Ũ”6cÎΕslˇØsމf6ĻxĒûR<Ī.ŋ5TpäĻtĖ8Ļ œ0ˆ<X{šļ0e^WÍƍļ“O™”t‚)ō F˙‰Éęā\ä'L$d _N‹'‘ Ũ ĪN›Á>}XæFŽtöd Čt ;KįDFaŗ¤d&žÅkĮ3ĨąN ˆŊŽŅū”ëäeĸRÄŠ6nĶ[$MŠÅéŦŌ‘6ÅOĻ3$€ŠĐÔā u×'}„ĸ@ۊėéJíĘĶŠŠ–*ŲŠs’åMę9ƒ3¸_ˆĀcqw0đô­~Ņ.:9‚Ŧ׏ÕĒ1’brą,^ôˆ„Æ:V@ K j ˙ęWíÕîŧķ6Ö˙֎>ę@ŽËŌžpÆˇÛöÛOĘŊ(Vĸb>“.éJ˜J:´ĘdđPĒõ‰M $ øO˛÷ë 9…ÂL>5&YĮnc.8‡xAZ‡ehfL•K<đ—Å)¸§Üēš Šķŧė53Įķl<ĮM( ×Á)-KÔ[M]á6ÚYÔũ—‰šĨ’PĘC™üį@™Ô+€ŸVę?Í)ûJvڄŦžoũĀ`=ļAė[|…ôoä~Á æĩ…ûo“ËlyôņvËMwĩÕ+ÖđĖ˙­ÚÎĻ­áš˙>å./#Ė °§‹eQĻX3¸™d‰‹)7îŌžčÔĄØoŨ—ƒ î¸īʓ¯Öq‰ōEßš“ŨôĮŊ8ëĩ‰GŪ×^H_|äá%š!ųƒzg=—´äÉ_/ųÖŠ,úM‹™l_ ™ŽI_Ų(úBÍúcR–˙č,œĘr‡.[yņšŽĪŠāŪŊa|ËÜOųĘ{tŐāÕ)3Ą t‡GrŠŒ7ņŒ˛~#G¯&pŊ¨G'íD5PAøėōŸÂ>ZͧM:Ą=ņčšļ0÷äœ_×NjȸÔBĖĀ… Īņ0¤%XÜ6䒡 Ύcē7P]ÉRqeÆ`Q$uŖKWĸœŪ’ų ^U[5LäŦę“Ú3„"ˇ‹Gļ†Ne^ ÉGįæÁß2m”ąS€võ:´ˆ$ŋ˛aŖÜZ´CíÔtüvŠ=tWŌ‘ŗCĨŒ ŗP“.¸Đ8y˛Ü[ƒ¤¸ÔKt‹˙=I'˜WVd7Đ/q¸)¨á!Č#ĩøÄU:2đH§ŅY(G‘噞ƒŸ¯ĐÕëŊ—öXŗĘŖÔöīgÚŗ}sđčÜ÷´e"P&|:4H1:G ÕŖ|ët„>äg>ļĮīú#-‹$,uRŽgrŗ2 m6`&U€,ũ¯{2IîéäҧĨÚÄŽBā>}­ŨЁ[Jhå1įR 3č…ōȐČLĮ&}?õÕۇ˛ÆÍŒ“ō8CĨ}˙ûWr¤ķ'Ü 0Ļ{ė‹9ЏūÉíčc^Ô~āyíĸ‹~Ún¸fīaXŪxéĖ6ƒžęÆÕ›ŽŨaÍö×ÎWSkօŋ`âGŨĘۈ$Ų`h‹Ā˛ĄAĩ…§­iÃÄĀĐ õņÁNžŧ‘ņíoú§ļī‹fr]ķ;Û>Ü>ôáŗÚs¸dSöŊ ¸Z ^˙ģcŒÕn=Ģ_Ŧŗ­ŖOLÃOåXDŊí?ôį\sÜw(J“2ŠrÍé툝ƒlI’E,؁ėHSĸõvƒĻ¸Š^,ļ]7$JÅ ē* sXžr‹~åØ>Ö@OA&i4ŒGáâ/õŖj$øCY]k Md[NĄü€ˇų¤×gĨ:õäu@UBuÕ/ub!oŧƟdŒ‰ėÄÚ9øŲnÚÄĐ ’ÔáØ`ŠÉR¨ČÎ'õrų”ãM)Ē™ŪŦ$”Ŗ(O<ŨsĪ^ÜĩŧŊú¤í ox-Ī|¤Ŋã´Ī´ŗŋúËļįnĶxäđ”ô)ß˙“ %âŸøJ+2: Û$'ÄĄ¸tlĘ@ŖšĄ3$æ°_ßJ?nܨöĐũOļ—šįø/äĖÜ.KŊģũÕ{?ÉË˙˜{wūĸŊų Ÿä2žmÛg>ķåļįģļkŽģŗ­į=>Ös"gʎ>z¯v÷Ũ÷æI{/X8+;ąŪ k˛gÕ=;…žŒđöKŊŨũâøY, T(\ĘH‘įö(LEŲPu&Ĩ[īÔÆRí”KŦnWRX¤éƒQBã+4ÃX/*N0āY€QŖíácRÚ$” yÆōØjŪžäŨ?_"žr%87dU|Š‘^9|cdK;zlˇė–ú5}Zŋ6u\¤Û+‘R\Đ@”O‚Jž˛E,×/ņåļĸeÎÚxĪQtå…ŋë‘PšqŽm+jėSĩ\[g|r„ÜڔU­$R•=´EųJõ8ØŗVD°"ĮöËPîšOĩņ¯Æ9°ßŨDX:, #ˆđ—.@]50…F‡ĐȆÎ(ŦĶF5ņ­ŖģtÁāác *<ĄĨž0IoÅ}á.Š2r="ŸPŋ\1– ÂÁ¨væ„ĘV‰yKčЄœ?×ū{Y 7Iz ũrÂn’v.ë”æQͤc28)sLį“‹ŌXØ;BÜÖå[ ŠLøQuĐjŗü 5¸—ũÕ Lf­_ āīābã谐SqŦc€kdĨ­B§.‹PDĄúœėZ˜ō"EQ*‘újG%uoŽg‚Q9úm§§ˇī_|EûÅ/—´ Îgģ—ë4˙ėĪÎcB;7Gė2Q€¸ÎXÚúĨ;uė×ŸÆ +~|l}ƒÚ`^…ę$íĨ%NtĖJãĮĶļƒėL¨ô‰nú&tĐF'ē”Ą.W&ڕ8RoŽÂŲ€›Ą wÚ=ē'2uŠËö‡Fh#—įȁÅĢXžLČg ÕwÄ ÕÛŗÃ4{{_:ö /ŧĻŪå܀;ĒŊęÄ9z¸;SÚáŋķüöšoûÁ%ŋāąƒwķÖumÁîĶxšīōĀ–a䌑8lˇB, đöKúXa‰@dŸ{ž"īč;dŧđ"€ÆÆûroĐ<’ Ÿäq§×ßp3—NiģđēZŪĻ1ΏįÆá ė˜dÚ~Ņņ R_ကŽ2GRßQÉG—Ļč#käˇ7ZĪ?Ģ`3f> ōFP ∴9tŲaPõÆHhÉÆû 1eŊvĸÁŸtŠIÊCDkI‡WOJ]wp[Ĩßc+ãÆiĒöût ÍVļrjĮ8™đɚŗ B!cž¨ņų2Ų`­ĒÚ2|úqd‘88ås×Wc—v( Ãļ!øõ‘Vˆ4qK2ú ņ „1æ8X&—ŊEˆhŌįzŊ¸Jš}I%ü\QŽnˇÍ_žlm;å”#ۜ9;ˇo}ëGí›ßŧ˜ŗ‰÷ˇįė=“ƒ†ãroŅúū<˙Lbą‰DņŪ^~įpÕĐlT¤?ĸÃö’N_ēÅh¸č ųĢzˆ7q0l÷Ũgĩoķ—í˜WÁe/âÍį_nį|åLΐiīúŗ7ļŗĪ}O;ũgq9Кvéo īNn“xĸׅŪÆ%r¯ËŖAßôGŸiĪÛ{:gCšˆž âŠv|Š_]˛1Ļp`Üã=8)ČÎĄeVēĐøąXküĨŌD蠌@Ŧ'P•XCēũ"G{ŨvGŒÄ@ØŠĪ$˙f|‚&8?‘§FWƒ\ukSŊCf*ĢVķ2îąÎ‚Û¨ü¯ T”bÖ¤áuĢ娐˜ĩ)+öĒW΄Éļ{ŊŦ2š"í`ˇžę'AâčÔbˆú?”hdP¯!8?„ū˙Ũ$Ф÷™âT„!Zû„¸äķūƜŸ@ŽqYJn6܎u2”ŗ¨ŽēôáH(eEp“ã›!Ų>õVšmqÍOËR…nũmžæiƆūáߘPŧ•īÛŨYˈ>Ą~¯Ōŋ$aTXÉRmkoíY]š!î}xÊ iŖ”ąR‡įŲŋߒlįtVZ/pĐ:ŊĖČüŦO]5Y(–šÚ(HaûRlj/‘`™‘Č€â†°ÚœČ1ČZVAˇUŸ9äVĀ〒šYe( žęŧLAéĩŋF D â’#&eTgĸ Ųą;`H‡‘ŌIC‡č˛Ķ’‰„pL[ŧøĄöč#Or€7sx#0ĒA 5&˜PĄū2->RŦ†Ŗ^–gg‚AÕ8ĐĨ.úĮ“&E†>¸ÄƜė"ĻáXyŊbãzEMLąĸš?eĒ~‰ ã…ˆ/jP” äYŦâ;U‰ŲrŌŽúXĶæĘr\$/ÉÆáĘw؞ŲļåĶíÎ \|ņU9Ōô1đÖášíæ›īj>Ęwŋũöl?žôĘvĮø÷ņÕmۜ)m"÷ lCÚ¸ĶîLf’ņw+ &õģĘO˙˜ĒöQÎ_Åyŧ_~,r•ũĪpÍ3/‡›8žsöÅíWW<ĐūųŒˇĩŊŸģGûėgĪi?ŧ´Íāhč.ĨP›;æ™t(#ZąĩúĨĸ)ĄŪöŠįķCm$ÁÖÛC9J@¨Ã˙ËkC,āsˇ;9ō 7d¤m‹lÜ(‘{Ę+ۜ Œã¯âžéå Ū‹ā%L.Ų!íW¯ˆ­‰Œ'dÅvĘĩ&ãžÖ¨Y RBâ•Db+hË,h?cÚ¸¯ĶįåWŧLI›BN"XZĒüObôU†@ĘãˇŪĄģj§¸”XėXįŲ7ņÖ¤P+l%ūU”tƒ=UĻâķqÁž ËgãOŸÉ%‚0íŊ×ĩÅ÷<΍ž÷ryŲä6fÛ1<ëCd:yxúMšmú*ļ TœbĶŨ‹đĀUcV/ĶģyŒckŽ­}í—^ú3Š—õ=šluûŲ勸į;ÜĪô`;蠅í#÷áöŪŋübģíöûÛ§>ũžöû§×>üá3۞<‰håSkšy|ûÄ'^×nŋm1īšŊvđnāb‡Ų—æ(Ũ'ZJ:˜?1_ æH}úŖôE–1ÂöŽÆVˆuÖēŽ3°¯.Ŗ„-‹žWĄã‹’“ĨÆžR|ōŗ„ϰ ö›טHŪ‡ZØŽÃx­ÎšĢLéŌĮņÁ¨ī;ŧxE4tŊXĩ.÷ŦŅW¸ÅY˜-ͯˤä)Ŧ“Ŋ zƎ5ĻĐ]PJvˆaØ&‹=c.>Ė<‡qFĶ×LčŖRH!ö+KŨ€2V'>ûؐÖWŌjįĶž(.ü%Ą<Čb ō3GFPüC™˛õÚĐīķđ8_5JŦ:}FĻueo…„5,ūuRb^_ °Ķ›TVų¸Č;#ŦŌ…ē|#1í.Hņ:6hƒM‘34Šį—zm$Ję ą_„ā;” ķ§äēô§ƒčF–$ ž]~ęŌM€č5Obm`d1ā ‚ z›.3!F‡bƒAĮ|…āƒuxcĄé ‚’–NÖE'6˜ t\B]ŠiĪAÖģ"X|<›]3Á\â Ĩ#Ę XybB—•d$`Ņ/ģɰ‡Ÿ@!5 ķāƒ‰Gį‘9bŅSļ—,{†eéėd7á6 ËĖđŋ–eč3rĘSvÖđ›Ãßņ]gčŧĻ=5ÕŖújÃ:ō‘kÜÁīę"â4[š0šęĐ6ĨŖPé]XĢÕCĮlkúF¸î ņˆũS&äÚ(njŊôG7ō˛ŋŲ\?{$7ö=Õ~ôƒ[ÚÎÍÎŗ€ęĐtëw‡FQĻ]‚Ēüf˛ŋÂßVjĄĨžV‰A¨Ã-î‘ØîmÍnĢmGĘE~ĩ?“Jbē–.œ _âDšaÆ# g-ĩūt´Bi;ķ GôAŊRmûė_fNZZū|D Ië§Î˜ÔÖķødīIųÉe×ņ(ÄëÛžŋÔ˲°OŪ6ęË‘Ū°poÎŧ\ËŅĶG¸$ge[0oZ›ÄŅx1oäũ+šîUWÆ>ƒ‹ēāiAļã”×RöũP\áĨŪYüD`í˛ëŒ<ôÆoĪQÜ]xŋË 7<˜w˘1ĘÚ(ĻPë؟ô%¤Nm´BúGZÛsüŒÂ,ūôÍ´Ö0åCĩMúPœ`)”V zģ/Ôę1^]ÄđĪ`@QÆ %ÆŲ ?ŠâËøÍÜI‹ö՘T#Xí™–TNÔŽRŠf÷™2éRwŌ0(tûnų1($Æ@wfŊtą˜#.i}āŅ{°øemŅ­+Ú_ô5ípžær֙0‘ū—ŧoŌäqđųHD‡•øų™,`w6ČĘE˙ŨNyũsÛ//ŋĄ-ēëáļlÉēļķŽÜąí¨ô_j”pAŽMYqˆŋü4Q$öšH™§}ĘņÁ‘>k;1ÉXģjSžTtįí´“>ûŅvÂë÷j˙ôOīnWņČÆķÎŋŒĨMoë}A“‚ŅÛ͔rRåÄLz Pûf=Ãß6P7$˛‘V†eŠ(°],$拠úyĻnĀ W2ĢN6ôŠ0ĒÚÜOĄFÚԘV§ŋČcÂNä“oĩcû!užmq- }čŲ3ՌŦ‡h-4âÆ(ŗhG:ˇbö›˜%ļ”. Î%dĒ.TXĪä(mOdƒ%ōã¤.BōßĮ&ęņR&Š5œH㸠7ߨJ>>čܕV~ÉKöūۋ‚aˍQyŽxŨëŽ`õŅļzÍÚvÍÕ7´;nģ?—Œã˛1qŽå ŋâŽ˙5‡Bņdamųuō…¨Ē²Oz0)CbWä”?SŽīgŲĐözÎ.mÅĒÕí’oŨÚŪúŽ#ÛÉLö‡û¯ēj!O8û$; Ëšā.gÜÔžôoįķÂŋkÚ>ûĖĻ|M{īû^ĪYÎÚŠoųL;ø ÛŽŧĐ˙ĢwôĢmĸ§ŗĩ—Ú۟ÜNx‰fÁ‰KH‡¸ŅFíŠņ ļ*Õ,‘,‚lœë,͜ņ˘oÉ Ũq˜­6ĸ>ô8fÕØkœ[Á¤Ņ(@NF7ņD`ŅxšĻ€ŗ}ĸ.ŧ ˆÍČ´XĮō!9ú`“՝YJDČZ:fŅ÷"Šs&HĸŪL>āĐqW|É!œzmé‘IJÄ)­đ%‰`U¤ũ11L&ūU Ęâ4Ķ&Á%TđĻ}Đ̘^KˆŲGŠG6qeߞ"íÕúÁī^ė3@%PĄ|åõ[ö(ˇė‚Kyú6zčkÄ̎ą!˜Ģ=’t„ØHKޏ}_ļƒv¨ymūYhš°P$zs(ôpŪ,ßfÛ (ķŽĶ˛*sąĩхnPSŌ]kÄZ 7WÃjƒ‡ÉzŸeb]ãôƒˆÄF:yúž/ˇ?q\;ōÄ9<hĪpĒí¸ãlîāũkãĶ2vx¸âēäD„žB¤iį&~čgdųÕŋ:msž fâNËm„Ūj ‘‹×o:7ũÍb—2˛‚‚Ŧ2sųmsS†×ÅĶĶËŊĮ…ũÁvŌ'.Æŋ}ĄÁdY—QGuĨTbí¨ËąĘ¸Ë$„u&;ō)“˙LFäAQų¤ø­ĩ6yé#÷DÁ#.ŖF!˛ 2OÆ>õšĻNī‰3 e9zĮ:ƒ<|N]ÄbÃ1šj‘ĩ~GĒĶ@ę†ŲÎ[ķ ĀõkÖˇû/m÷>ŧēũÉiGļãOøžiûĶ?=¯íķŧImˇ9ƒDėä-ãÄĩ1ė‹÷‚ŋt Ē”¯lķÆ&ˆc <–ƒ-ũ‰DY•ŸøŽĻˆ]ųđn3jt{pņrŽōÜ{ŲK8Â6ëÛŨwß׎?ūoIĪiũĄˇōîwĩ—ŧäŋķ.Ŗ5yģ÷Õ×ŪÎ͊vDØ6Ė›ƒ5kÖņrÄ)iWīYĐîtŠĶ31NFÅk܈^LnZLŽ C"'ĨJĸŦĀį­Á’ŸŪ‰]m3 āķ_ĄHDT4'Ŧ ‚äģ\uZÚ0Qoc"zÄŦ ¤M–5Ę(=ļCâ'üŊ)ĸ#ø}JäūmQnĶ–h°Ÿô—”JĢxå‡Qs’OôɗŸnKĨp€,FįÅe#l¤ÉiZų†>–­8ēƒ$ũYōFpdЉŠX?øŗ ŖŦPĶ’ģ-Ŗ2.‰˙‰O›å÷į”Y]–•OŖ}ÄŠļđąļļŊÕŪæã@ÖO; S/Ŋ’GIt‡],Ú_ㅁõō ŋÚļTģČS6˜H&tņ"û0$úCUüĨ°ŗÉœûLŠ:’‰ŅĄ–ãŲå?ƒj¯&ĸa ÜaP&Ņb´ámIBĨ=ŨŠØŽˆäŖBąB.uФŊˆe­×5()O|‘ĩí˜ôÜȐfØ9å¨;Än1Õ5ąī@Ĩz+¨Ž–ŲáŲNlŨC÷ČpŽ_gm,”GGé˛ĶŨs82c¯x­Wzub‰ŗQŅ/ęS& e¨Sˆ9ÕĢz[7"’”NYzÍiÛŪąrÃ~ÍIˆÂ(Ÿ&ētĪ‘ ^Jd]ÃUŠO˙8}ÍuĸßlHČÆ("\i+rķĄ -ĸ ęą I)­éˇĒPšAķ:ôŲ3§ļģŧnöôËÚ§?õûm§ļoīū‹3ÚÜšĶōԌMOstØsĩú 1Cųüō§xu÷%0’ʈ=7„Đ• †v1/ôŨS,Š ņJüä蕭&­vˆÅ)_ĩ¸ŲÖ.c5ĩ(ŗ*k-¯ĘuQ†€ ˛œSKáWی%wĘw ļlÉŠWˇ˛ÕA"qMډ„ņkƒēĄÍ˛r'NĒ'ų”ĩ+Žŧ™ Ėœ XŅ^üâŊÛąÜČčŖ /ģėÚvøá/hŋžy—6,k+–ķÔ.yØvÛŅšA{#ÍSBÄĐ7ŌŲ k,Ö.})¤Ímœ)ĸ\ņR0ų’7ȝļL™ÂŖ[pŖo¤ũR†Œ`Nό€ßąĀčÉ$wę^’Ō]÷#`U°@g—yÅŠĪ39pÎDđ ›8I›” ÔŽ38tÂZíĘ*Iō:)vŒ(>J F÷_´•z؍¯đ ¸Øļt .;ŠÚėƒʉCŸļ„ĮÃN7ŽŊī¯NÉ˙īņ$Ģ3žpNûõM÷ĩ“xâ×ŧoXĮŊ*tU,:̝¨ē|Z} bI'×2w‚Ä'¯|Ú% 6ÅøŅÆ?´ģIë?“S/EœŋûŒöÕ¯^ÂŊKSÛûŪZ[ž|e{ßû>Վ;v~[ĩzCûÔ§ÎâÆŪ÷đ;‰7ž‰3mķō´ŧ .¸[Nm3-ŸnGž|GÆ:._ÂîœųAMôŖ-S+!ēąIžqĝaá1éŗ•Ä”˜§ #•ÚÔ9 T EPëo…´ŖmŠ Šßjī!„loÛM9Ž-i7åČÁŊŒx2ī¸ßK¯IÚL›Į‘ß‘•ę@‡L ŨĻĀžËK–Áô’ĸičŒm4ŽŲ>Ĩ÷Ā“Ž-&đD.„ÅöEŸØĪcĢíMMIĸŒŧ7ˇëeTM×ĘJ›ũåR!€*3ũĸĶ*.rŖŒŋ Ē<}+ã7tÁÆÚ°Ô&ŋ–egŲĩå:!ÚI¨>9“ķ/ūN?—N[%´ĸ¨Rün  ą=3TË+Pûu9Zη•+!Ĩ[H+=ÛēĐéu*Õ´¨’…5í ŸĨî`ENūÕJi‚ŗęlŗēüRœÜl ĖߖgFxä{vų­yĀØÉ€’ÆdōdøfÆAķ’´Ģ­Ō*Đl[Ę3Á’pq‚@ęųšåæũã[´†ŠĢBüœ0“AōŨĪ€WŖduxGĢ…‡_QpéˆũւWnõLŕ>ę\"ÁŅ.Zēļt$ĄhS ĻbÆĄĻ”G„č.ÄŧÄ$GŨc•~ĸŒ^ŖUØ1ÄWGû‘NmŒí%9TĄO;JČĢ9k}€8M˛ĶR‘ŧ´Ŗ;hĒN_YÁŽ5ˆSęVšŠ`sQoƒŽĶÖ$õāGŠŨ~ŲķäØS6iŠ­&kÉ&ÁâĀáÁž 8ņ„ŧāęnōģšŊímGşį}í§mĮ&ņæO_<§m(:%ŨœëœÁcfđĪÆ š.×Ī ¸š”•Qũ¤qÅæd(cM]âD¤‚Ĩ¨vHK+IVĘōÉ l¨Ã§­”Qg Úb"NžķŲ.%°ĸ¸,ė˛.Ž#/eVČkƒNulаôÁŠė­ŒÖ­”GĨo%ÕGNâxpI.úÕ¯nn<đH{ėņeíyû.āō‹Û%?¸Ŧ]|É¯ÚĄ?ŋŨzË=ė(Ŧâ­Ĩkڌ™ŧĨt,OB˛““ę6ø2ēP,āāwƒŠÃ)ü¨…9Úę–9\qīâ'¸øąv(—&ųLõsŋrc{é!;´ņļÅ}ŲäÄwzŲÆrƒV^ˆ{uÅ(ĨÎŧt ¤Y #öŊĮ94ļ’ÜÉw#gtŖô)uÕ }ĸĻ û,ŗNßék9JŨ6OķpĒ7™V„~ žfJ]Ļū Ė~8HN_¸mHĒũŒō€%H OÖÎļÍ ƒTؘm§HagPĢÖvĄ`˜h˜U–fĒĖžR&[aÂ6ëXTЎ-Μ4<ÖHëQ ,D>ÄÁ`UŋŖĪøŒÂáQžpÔĶnčôë}\,‚įÍÛ!oН={&oēæÚø[➕'ÛdÎ^mpōÆãƒãkĄ¸DĨūˇœŧå‡ÄĄJŅŧš?JfleėĢ= č…¯ÃãķúBĪõ˛QĻĪV¯\ĮŅüŨ9‹v%o¸^ĮdūXãų’vÚi_jļÚļŊųOˆžåO-o´;/3šˇđƒ'ämŪŽ'œČSÎXģ#S“Ÿō™^)?é1)šąAxŒ-ÛĐ֖æĖ%iō›]Rū(Ÿ‹A÷2›(ļåhCįŅN„ÄUÄjĘ cX,d‰‰˙`‘’EŲ/‰9ę{3XZXĨQO*Ę.wB†ö˛ũb7ŧ6ēdl#Š?ūoöŪüīĒÎķ=FzŠH§„^- "Ŧâ\Ŋt•ĩŽkĢÎčŖãĖ<ęÎ8ãØÅ†ÎX ¨ĢĸH”"M^RH ûzŊ?įûOÜĢģ÷yÖrīÃ÷Wž§|úųœĪ9ßķmÆûI—Rƒ°ŽĐuPP˛‹ ¯Š7lŖš ؗže‰Šō[åųíÛŅĘl1 ‹č“$ˆ7čˆ,bw J@‰2ƒ'|ē–7õåjāaQ}QH•å›ļ3X˜Ļ¤âBTų‚#‹o¯^Čw5šånÚNšlĶ~”!2—Nēt†…ØÅOnĀWČ´a Oņdžá<͂ˆXú}ūáį u%6-ę9°{>÷Ii?õ„gâ'<đn0Á§<īiVH=Ē‹R%¤|ūŋž ƒnŖŒNí7l›ōí1YŠdļ­æ§uôĒĶerY"XN#ëČ&r‰UæŗįöŽ)ôŌîâéžę5ÉĻí2Ņ„Įfy$¨˙LĒĸk*CŽLo뎚‚OXZķ4™ÆK4Û'~Ĩ)ŨdŲŪų%č*‡b¸1Ēčm%’‡ęúg´Ė@!KuŽ@ĸĀvj÷ŅĸĖTúF(5Rš2ąčLV¨—ļå’(;”ā‘Á>› z1(å:W:āÁOyę‰$%‚!ĒIéY‹üŲ“ é 4FËn‘ fžŦ)ÁIäĀ›•ŦʰЉčĘUëÚaÛ77 Οŋs^|õā&ŸũĘaEbԕ“ú h™KģXŒ´3nv=J#ШSúLj$#œ052‘B.åĖŽĒZKŅÆ5A€ŧ6’×`‹Ō‰2Øæ\_šĻūęí&Ё¯&^ęĪ–:Ĩ“f >BF-õô#=e—eN>Ü:: ÚÖ+~h<ÍÖál[aåŋŗ—1PYtÃMwr֎<ĢüZžŧōyîÁX×öŲk~;ƒŗ~v^ûá.h‡ž„§—Ü’=¸a—dŒ N‚:ļĶ|š%)äĀČĘj™îũyÖ˧Šė>o:—z\Đžô•Ÿĩ§ŗ?/<û+^ĸt~ûíU7ˇIS'†—ö‹ŌL[$ŋ ÍR¸ė2З‡ĐØiK ŠTx?„—EĀÁƒcKė7’AĀĢäčœŧ|”ÁJ„J?Ļ ’8Ņbĩ¸Ô9žZ°€‹ãĪ{‰l'\ ͙RåĖf‚öSTŠöļî•ŲABĀéÄP0›3ų+ģžĘW8ųÕfAųŽFŠkģĨ2PŌB7 øqPįåu"Ē#‰ÜĐĪŪIltëŋŌĮx%0pîēüOí& ØûR\Z6–ŗRŖķüûwŋû•ŧĮb:lŽĄÍAūBŪî3é=[TWH7_Č Ã°ĒbČÛ]öö5Û$0æKQ$‚üg Ÿā Ŧņ5<íGg~<7ĪßÉYŠÉS&ˇWžō…í{gü¤}ō“_l/}éņmÚÔ)íÚënj/~ņsxĘ֍íģ§_ØöÜkNûÅš×ST;ôиā´vÛíws‰Û ünsņ,eß.]´9[d;Á;÷žuß™äaKuŲČŒØ‰BĮúõ4ĩ†×Ö[fŸđāÛX–ØBą īžē^ 3DuAø+_ ãĻ/đŅ_Ë/ôŠĄ˙@=*žCMŧ yŌ?Å´ũÂHšÄCt°ČibXk ŦԘŊ›¸ŌĢ‹ŠkÆuÁ•Õ-q›ôī[MĢ}ˆŒöįabíŗ„Ŗt"H›­ē`F†”–BqÄáĀ[¸e‚NÍpÆÅ žōá}oŠ .oŌYú¨Ģš†Õr6û…Į’VĄ˙”Õ2č\ŗyF€4ļČ)éØFA b§d0=x"JU™œXÆŋ,.ĢXĐŦ@ŅÁ{[ho[Äą8´ėxŌĄÜ’øœ:)[UĀT&éģŅŽŒ &VI+IŊ€”:|ŖtlK2Ø'¨YÍ ĀCŪŊK(“7Ÿúb°%Kfˇī|ëėļ|Åũ\[ûšÜ<÷–ˇœÂ5ˇõMīĐƜ@ádâBž ŽÂ$0Ŗr™ĮV™4$gĄ6âßļ÷šiÔū捝Åņ0ēú¨¯ŦÔŊ_ųvÂĘZŗÆr¤Ũď)OÛUiÚĖĨ`G>~×6kŽī<¨ÚvĢf‘(rcÛ\î¤m(°}ŖŖl‰&,ņ réÅæ6‹7OáB'ļ)€ė%JĸČ'‘~ŅûR*čä Đ˙)J Qj:lbØī”˛o$ëB­ZD¨‚$aŊ °ã%oЉbÚéUM9 uüØ´ĮÖW¤r!؁Š`ŦžÎ@K›Ō@ؤB2ÄÂH˜jg⑝8ŦōHĪú{×lČĨ)ͯ͝ok]x9—ŅlĖqė—Ž{ɚ'>ôüÄģ"˙ô(}ĄûSú‰Ũ”OÉ_}Bg)—Jƒx–ēŽ—k™Ē­čĄ;r&Ō>áއ83yíĩwļų vnœ{u›8á{í%/}Üũ9oõũr{ûÛ_ߎ9vSģā‚‹Û—ŋōŨ6jléø‘žĄŪĨíŸ˙éË<ąh2ī蘞˝´†›Ļ_˛ONšueĩL†ĸ2YÜũ@üPčíŸJuīyWē€…J߇Ēmœ6u3~Š´ŧEĩo‰”bāL¯ĸe‘uށ˜Ll‹‘ a#l@"2HōĖ ŒLÚŒ•ļ\H"E‰ŗÔ…ŋíāá¸QTĐUj2v†i'Ōą¨úŗŲJ>Ā*k ß‘Vnb ūi RP —tyõ 7Ų&ž3žx0ÛY!#`Œi“ÔĪĐ.ĩ`SĻēĨgK‹,č XqQZŅSB)†Žr(UėUcöNšeÖ*2Ų'–›ēÎĖ—Ž ö¯žpe?dî|ƒË_É NOŠĒ<Üd‰l%'ü”ŗs•Ší'¨’T0öÄ7g %RÆ3Û`.ÉARÛ^$éíkĶŨāĻļiˆęęƒņûDŌgü>đāƒ\÷:–ëlš9Īëú€qōŋaÃɏŋĢœĻre*ÆÔÛ×ĮAßAgY[dE& ]­ŲŒ6Ņf5`KGgˇĸ{u¨<&Ɗ€ąøÕK+ģ wT%Hȕđy„1¯ķ:ü‹l PΛv3OŊ“†ščÄüԗōTëŨl Ĩ&8:uqĸƙ­ŠŠUI’î˛ˆSEvČH8ų}K‘ •]zrĖž–I×ÍÕ)$iˆ'oŊHĒÃf'LĢw)„ePR]ĘL‰“ƒ ęĸ ˙éÂ*ää-6ŅetfA!›U*i;zk/ vŌÉGzû*’}›‡x毌YœÄ ô/Y-ŪÔ>ü‘ņĚ[Û%ŧéÖgāGOÕ0đ9i—ŖęņÉdQiŌŽÉS9Ø+ōQ§%5Âō‘’[p"ž>Eræ@Ęö—R…‡éøuƒÜЈW€ÚŖÚW`یUAá\ŠĢoI„tŸųÄ˙;ĩxN§—v'í ŠK×âMzöĄJ{;‘ĢƒhëË&‘MŊx {Ĩož ĘX=÷ŧßpũ˙¸ļ–§2}å+§ļģ§š,j¯yõ íŧ_\ØN>ųĮš„cÅōģ°ÍCÜTŧšMš0.g“Ō—Jšģ#&ĸņÉA ,š”ĮNáŅŽw¯š¯Ŋûo>Ö&s™Ō>˙ēļîūûÛéß=ˇÍ™QOƒō&b7–m zĢmĮ"€:ÉĪÁ™ŧĸ'ųûī[ßvŲe\[´į|Ūĸ<%~fõcÛŋˇvāĀrCģíÖUí֛îj˙čˇyOÄx.™™ŽĢæFq=Ä­úpų˛uņéGg1ŒŸ‹Bēj-:ÎnŌ„ĪXĪ_ŪÍdõYqôāØ;Ē­įÉCp†k§ącÛÎøŪË^q8+žˆ{hÆĩoũŧ\˙˙æŋ|%—ũ‚G~ŠíŗĪ’öŽwžÔ–.Ųš~ؒŧ”lŊÕíū Úh\€ĐußEPeHFFōĩĐU2̃UæâãŠĨ|üÄVĻQ'@™¨A?uĐ5žJĀ1#7k Û`Į=mBŪĸ( ė0Ū 2elTAWL˛W†ēÉÖØb+­č#EyŲ:‰;āf|PŠS­|„w(“øÚB’æá'7ãąō:ątSÎäE3¯$ĐJRᘟÆeoŌĮkĪT$v”•ŲĐ™õ“U<åČØfIÁ ™”[|ū¤qIz –kāÉ+}&āė= pĖÎ%ŒÄ}yÅ˙ˆƒ5`[Ęõ_‰Ë22ƒgãĻ/¤JÆčīåÄĀ—Gįęų~1Ŧ&Lŧ—šQŌTÖØ1ē÷ƒ+xքŋđ œBDŠ+öŌd+ކĐņ(HāŲ÷˙mÛIŊęʆ\<Žœâ"YŒ 5J cgŲ~Np|öđúLÜĶÄíÆŊ<ŊDßQ÷7nĘĪ íÚu÷vöĖ™ļč€8údĸī`ē7?ÍØe—ĀŨ˙úÔyđ°}Ú ä˛xŅā@IDATBČčŌÛmø¤ũPo6 uHY_Ũ8ūŦk {­(ūŦ`Ķĸ9į°Ŗ¤žrÍ+”vŪĖ5ˇÉĨ’Ō:Üpž¤ŒŽ'LŌ‹ž.ūæIģRîė㏙LÚauręä|ŌU—DÚ]…ŽžJX2†žė,;ĘPč>xŌN†”)ãUä†';t&¯a(1YWāäp’ĨÚŠˆU ĨŊ!šŽGŊÖR7ˇąžR õËě}ē2z‡ž€vë¨Ø˜đ2 ņ"Š…Ë.ÁQ~ôâĀ6|Ā5ÂĻtčÄĘĒ´ÆCmîd€zņ¯Žcux ÷ĖfžČ ŖÜĘ*ŨŽ ÖkĶæ‡zņ‘ˇršī{Žb Ëæ´ądŠäļ;8…^ú6â“VéßĨĨžhw}„é`8›éMŽÂŲ6Ĩ3ûnɑËŦ u=í^[ĻOÁW,ôéLĀĘF‰;FđmúR *Ë÷Ģmƒ§‚âKŦķÍ`k:úFĨÜ čĶVä?ˇ6ī2}BĘÎ<ķ×Ŧ†ŪÎĒíŠöÍoŸˇ ˙ļvøž\ēņšvüņĮļõë6ļ… fgw7 ?°qcD+Ŋđ¨đ ã˛ É´ūãå۟0a§Ņí†ß­ĘSTŽäÅJ>ōœ_ÜČ;xQûi}“oė„i4ĨĻĸLé^õ8“1sÖøvĐaKyqÚÎāhÃōß˙õ=|˙d´”éOMīOĨ៊Î#\:6ĄíŗlaÛkŲnmīĄ˜ŋĐ6ąáʧMf•_M;ÚŽĐwøÚ_„Öî•Â*֘­ˆ+Ŧõ%ˇ†ļú¸éV‰9`65–ˏîX~/ø‡Ûü=fáŋ›ÛęÕ÷đ‚¯CÛ;ßõÚvO%[¸tFûô‰įĩķÎûU;á„įeü~û_Ž'ûLį%†ëÚqO‹Û{ŪũvįĒ{ڌé“kQäÄ"åRÖōåLČZá1CIŌ€Šʼnøĸ/^7VŌÚ ym•ū€oK˙œĄîiŲÛgōë("ČCc8WS{[ä.2*§B„ŋD"fʲJN‘ķœaŌúĪ”"ՎÜČÁá€"˛Ú6ĖyäéPŽq“BļÄF*„3>X2–ä”o‘7ņ!HĸâKāKŠS•lÛĀH*\øSײ™ØÉ9€eÆ*hkoļŌWšæŒŠR–7eIĨ´×U›„Ž`iĢŽ+9úā?ŌÆ!Wŧ*Ž);Ÿîûåāy:zÁģ"GŌB)› ÚJžfƒôp/ uųģTF,qbhÄë|Ŗ~§—Ë;3Ŧĸļ×įŌÛāųЉ˛‹ûŌÃ4}Ŋƒåą RĘoÛ3Āo7›J¸zątÉĸļ`ūüöķsÎ㊴'>îđļ`ÁüvęégpÍęÆvČA˛Ú´s;í{?hîŋo{ęŅOfpķq|[Ú¯/Ŋ”ínž×ūãū×Ųîe{ņˆ°{ۏ~ōĶļúŽģYqãqgdlOēWœą1*ÚhNrÕ@5¸ÛT¸vĘĨĀØ¤ēœAˎå‘M-­8:đ,­Æ !W)ūM÷|Q߀Æ*Æyķ nJPG”ō­Ëœ¨Š¯\ôödäžb”ŖĢ ™ß)[HB§×(ĢĒJK“Ją#ĄmëÄŌų S2y‘#´Ø;­Ž›CHÂĀQ\W!¤æĪ2­ 7 B Jvt]ŖJÚâC‚1â@-×Öąe&š´yY °ŽŦKĶ-dŤc—^ĐėD§–—ÖE,JL7ôT{ËEHxšpUÂ@ĄÁ•ADžu WĸōÖ¯KĄÔĪ` z¨ ä[¨šüc7nã}īũˇö„'8á|EûÆˇ~ĐÎ>÷wíį…_ށ…ˇq´Ö Räą]ŗ äļNbá€RYō(#}Ο“õÚØG.ö|ã ú—°üröCPõ†l ÕŽÚÛrũĖË$dKÅú@Đâԅ>ƒZŽ/Ĩi€ČĀ/hÁsûyŦ%¤cw ° zFJi+FdP5ؓ×Ķō>”ę[ížÍ¯OŲi/ķÚ9ąŲŗ1gœņĢŦôO›>ž}˙g2ŲšĢtĐâöęלĐ.øå%íä¯ũ˜§MbadC›Âã}/ÁXØ\̧zĄŗmŖ~CĖĮËá/žŨžpŌéŒkGą÷!×~ôÃķr?Āü|‘Ŋ)͉Üâęķš":dmķČ?~ŽmŲū ā?–įĮoęöVsÛĮļ=Ęô§˛Léæp\p˜ŋ`Ė|°Ũ|Ã=<1g’Ũ>›îiSÚmJÛÉĮ~ÚŗõŖ}zyV5m?{ŧ}6=’v/>Æ[ĪXŠ3ŠĀ ģ Į_ļŠ=ā<ęķŽ{ÚŅGОwü38ķ8•—åŨÔ>öą¯ĩ|āsí]īz}ûčĮßÎËČ>ÕîÛ°–ņ}:ÍŊŒ|¯o 9ˆyō“Éü¯ŸōCÎ"ŒiĮŗ[xÎŋ=Km”ĪކVƒ?dŠ~ô}'gV8Ž*\ÅŌ%gšÁ9Å…Ģ­˛Ž>Ĩi< 0ĸ#đ“äáŅĮÃËĒá¸pĪDXč˛ŋ|Ģ\y|ėgōŠŌeQĻÄ1(Ŧ´6w+ËÛž5&˜@/ƒĄ…ū” āázũč%~ÆÕÂT 2ĘŽlZĩ&—d@Ǐ%4{{û”ÎŒK>ĘS1NHķ)Ũ?‡8\äĄ*ē ō§ˆ&l+vūÅ83Ø)…Ēš…)ŋËeA”íČ"NÎŪ‡'íĨ.’á/ņ[ß_ūÃAEÚ¨,ŒY'‚í ’ŧ…wQ§le–ļĒ’ž)~đJNØĐŅč” n2[Į\zŽĢâčĢ5žiģŽ¨ĐHŅŅ-€[Xņ'\ Ŗ}M@#Â×>ķDQ´Ą•%ŖÄ î6:{Ø^~ ęAž„dŸŊ÷jŗfîŌn_ąĒíˇīžíāƒjĶš!hŝĢÛ!Ä5´cbˆãŸķlĨw7š}kk¯nĮķÔļdᎥŨĢ~č!LúŌN>å<al{æŸ=ƒŗ2ų×ۋŪqt¯34dÚY§ĐŅiW–Ķi_Ō:˜wļûŠ ”)aĒķ•Ãđ(¤=Â4č×ē“NåWzq¯p™¸Â ė°e"!Ŧ3@œ-¯ĖžųāOÕV$Ũ8áƒB'#ĩ… €ŅUļ| “•U|Ō:å–t­Ē;ĩ2ķ“g W9€…rĨ8rDoŽF‘ƒGÉ)Ī.W ‹nz,éLČlxęŸ"*K2Ÿ`M%ŧŦMKĄ'P¤@˛ôģđĀô[ķ*uÖļ\â=b[mėŖÜÔŗˇ ŊŖSRATŒđ†Gx)!?âÜáà ­éäeÃ'āöL€AsÁĸ™^ċĻN™Ø^÷ę§~#+ua'ŸVi(sqUļ0ļa ž–ĄHKHÁ&h˟e(Īö䔭ĸDBÛ7֓ZäƒûüĨũrY'"mĄ~ä…!īĮņĮÁQ¨´ đŧę˙DQ&ķÂû•¯˛å \?<#<õÚ‰č't“5ͯ÷CíZIT]ėĨūōâŠZ)˛WĪp3Ÿ™Ø„K0ĸîÎsŲā{´7đžwÎUÜ(šĻũŒË"nŊy9 {´÷ŧį Ü7đ Ūđŧ>bŦšëūčņđ&ˆid‹Ū(‰D2æĖž‚ˇ6•ëÃo䑍œM}Üķ|õũÚŠ§]ŨÖß˙@äs@ Qlf˛č5´Ę¯y×ũĪž3…•Úqš4CƒÚOę§ĘCz{Ųo2ũŠlSēŲÔŪ„íYĻ]w›ÁŸ|X/ ˜ÉmšĮÍÚļøh„ãĀx;÷ØČú¤ãH’2üˆĨ?ÄŖpĨĀĨO“ūeWąĸgIŽá sõj:Āĸŋøšmų+Úwŋûž’ĩ"7+_rūōöŒ§ŋ+ãū{ßû—íėŗ˙…×ÍkßūöÚe—ÜĖĨBc¸'m\›ČŦß]ģŨž˛ŠČnd(Đß !Ģžb˛”: !2°ŠÜ‰¤ëZmú :SqWĐjŖ(¯žÍȟÄmåLōOqaaŸKß Ŋp(åŖÚXŅ.9ĨW’fĖR6x—ŧ˛Ĩž|îIGę)—•<¤g}H€GE°=k4Ģx>Ā [1‰§Õ)PŅ;2j'õŠP¤+v×D“2‹3ØĒ tĀ ˛ō–ɧb#e¸ĄH–EŌ]ģØ"¯¸ü„Q§`XÂG´ŨRj:`ĄbŒ•–%–Ë,–‚āSúl”‘Ë´ĐČPŌ2i"ķ#ė–šN.ī‰Š‚p0°ąDs%t27čxâgõ^ŽdW7SļyéīíL.UXöĐ. “´ē’rļ}d“"ŒZb9ĪÃOÄõ#Ī(,¤›Ī!dxËÍw8á,+ ĶîĻâãXšŋîúy‚ÁēļpÁ‚¤-ŋņÆšv!7Æ­äFĄ íĮgū´=÷YĪäÚ˙Ú?øãœöœÔŽēæēܰ™ŦÉäŊæ˙ƛoi]ö›v͡´#;¤M›2%o?ĩ.öčĒüG¸Û>´ ?›'ÎLڕM›$0ëHVRî–ĀîžöÕię4 NŦãH,]BŌl:YÁˇã3*G’8¤øÛ‰îOV:ĸŽ&|V"Ŧã#‚“?{R ` ŽHŠķÚ)+PÖ5¤@vAŌY; tūŦ8¤c ŌoZų%^›ō¸’ô.‹ÂxSäÁ#¯\ąEȕ,…  Džü¤Ŗ”ĩō/ŽŲĸ[ubnāČl_&ëƒOНEöĩč+m;%8š†3Ä$ČÆÁVNÆtUĄ ti ųÉÛâeP ŒBYø(íX$˛Ô-—j™94(‹ŧĐQˇ:s@čŌ÷ruL?ųé3Ûk^ųäļ÷ŪKڅ^M{(/˜r’úH(!hBÅö¯—(™Ķ7mčÁļ‚•h]JpÛ×%‘JŅÕ œĒgCí˜ō”|R)v0õH´ƒˆĢNÆ56å¨˙ ’œĨVZļu/kĄü ¨V`Š“L0Āšhĸ_ę%#Å ¸Ô)UÂPš€ėScĸ=rZįá\Ŗ­mļURÛČ .ũ œl`–-™֖v?/@:és?lSYņߕ›/ŧđԜØ˙€ųí5¯{qûõ%W´/˙Û÷3ßČ™ĶŠS&dą#7kžN΍‘‹—Ėl—_öģöü48cÛĮ?örŽ_ŅŽ¸ō>&įR-NŨĸ¤ēAÄļ×ԍŊ…÷HLÎÂMųēĀJūØöh[Ā39ãš_ě×Üĩ)qcTB<íXžVū62ÁĨéJÄüŸÕīŒËņlûxqŅ Ņô ũ*ʖĪģ𐠟såÛîp/š=î¸ŖÚ-ˇÜ֞ķœwR=Šũ¤í/<ĻŊí¯öi7Ũtk{õĢū‘ˇųū'Zæ´Ī}öäöĄŸÖ>ôĄ×s5Āníc=ší0f‡ļ„'=ĖŲŽŧŧڙLuŪĩë=*r•ī:™×ũÕÉ“ųûč!ååŗEĪreޏ­üåīv`HačáûÆP 4 °Ļĩ“ücĩ*‹a(5gŒ ŧä“ģ…Šč2n•EŌĻģP–w’üÕBb8tBĄĶNÔĒ/l¨ā¯ˇQÆ ķĐsgu­‚ gYI.@(!`ĨbŒhĢĪx†Q۔ĨY,t°–„ I5;ë•FXėAdˇLŪQ‹´›#eTtē€ ßäT:ÁWÆbPģä¨>J+_ŪKŅéĒēō ~ęcRō1@ˆ—iĪÄr™īYÔA7‹JŽžWwgōĘu0ĻF÷[j™6#šÕ˜2ë#i ļö?åĸ(æŗJt´ !‰Ž_})é@#Bņ-•ģh”é´šÄ,Ë@<1uë%@°Ũü#šÕzW­ZÅõ¤ŗÚû.ã€x–õ ž`1ŗzđŠŋäŠĢÚ+^öŪvy×+i įīN#?ŌN?ã‡4īXą’į´7ũųëšū×8¯l?øŅ™ j›XIp`48hČígĶ2ĀŌp:ֈC(g}ãņHEȝ™­VTqąL:ĒK ×fûd'”9z–< !vtyJAG3īĨ5;Ž&Āude§HįŖ}tŦęˆ%hpģˇ%RŦ åĢbK[%ŦJĻrå‘ãČŠË4¨u"Mi&wĒ~,3°…"%žåÔŌöÉĮû#1­ÁnGŠ:iøožžĀô`Ũ s Ė˞ Bäĩƒv'rL b:2E6d4láCz8P mĘk¤ä„Č™b:9{i(gtĮn™\H‰"™b\W|äŸdī:/ĩ᪠Н­h#rļh i™Ošyꓡß][ûÁ/n/{ŲĶō6Ų}čä\:7s–/Ųq,eācIä‚Aĩ¯œ*Ėi%Ÿá}bse1ĄÔŌ`éDÁO.ÄļŪ¤FLũĀS›Ž7čÆīÉįŒ‰†”˛m˛!$BØbĩ:$W=˛& Áã/“YXXãā•6vžė*šL„QI]­ËäIūō:Í-˙Ŧļz9ž8Âä*ũä&7SC›a/{tđKœÛwß]ņ-Ŧ–nnŸúĖwÛ&xģíĘå]ÎŖEīm Īm¯y͋Úe—]ÕNųÆOÛXfsÂΠŨr†ÉŗS›ĩã–ļ‘ĮžNåQĨ+VÜÃ;l/{ųķÚ!:ĐMX“y|!~–øctđIį%ÕČ8dú Y>ÕMQŒŋč‰;ú8™)SÆqiŲæÜËwđAK¸§åHŽå_Ęã;—ˇ_rIÛsŸûgí˙z˃í¯ø$gøgŗø0ˇŊ˙}/o7Ũx[[ąr57 OÍŧˇ@ááģgüÜâC˙T4$ÉåĄĘa=e>ÉŽâ,ē Ûũ˜Ēl’˛HX7Į¸ęßöGi˛Qf?v…7´Bßc {™aØT&iQ9ø×ō5Նކ+ĘŠ7—–”mãfŦĘĩ¨bģ×äŦ Ø~lí.ąœy@ۊŽęāA¯ÆUiųĒŨˇrŗÔOÁFq…`‹¨Đ^h5Į"ÅĄoĘXŲ)t>ˇ…ģvŊqˇO„ÔNė1dėQ䘊å„=ųhŋĒvqRé{ž ž%‹Û69;ą:{ļØJ^É#18zB7ãĢÔđ­‘1(}ŧV$ĒËėąD$Ôļ*G6ĒŌ~gœg¨ Ŧ6×ügS†\)ŪqĖ#ņÎŗ`ũ/^Âļˇĩtb§ĐTš’Qķãû1‚ŦákĨķB¯ÛÔĘG}3Ny7ڕû›næÔôaŧ8g?žYž*ųŖŽ|bÛwŲ>Ŧ"ÜŌ–ßŊ6+ųęā Ã6Ä=k×ļŊ–.ÉY„UĢîl˙ö•“ÛîģÍ垂Å\´w{íĢ^Ņžđůä€Â3 Ûã}6Bu'š˜Ū‘&go—ōũ/ jŨ ĒÅ{#ę0ņ$pHÚą’7ÃĻ]-r˄QÚö6ƒ•Ŋĸ‚U‡ëˇD–mœI7vâ6Đ >æ¤äō 3ŠžĘËyK"ũ^°ŋ§ gÎ%FPŒÖQ;ĀĢt†rëU-ö °ú9đĘJåd§cU!ųT’íüå…|DŽĐ–~đ•QîJ#‚)ų›)=”A>’ÉžäC—}kŧÃAŨČBFč”ͤ–ú‡ÂRųĨ;+­’PF2$ĨE™r:.–ŖP‡-•§ Å¨y…v­R) 6š$h›†št‹føYDũēļp†§åđbyË5rgč.æĸVVecĒMSāŪųB8('yjúlš)ļá/šŌAÚJž­Œ$¸j{Īę$Ö*O§K€h[¤=5YQ@'k-€ū ,åkč-u™—@Cۊ­{āæ^y\™÷Ÿ+¯úmNYĖõū7sΝ/˙ 72Mc€[Ô~}ŲåmŅnŗšŦįÆ\+xČûļs.¸ˆSäÚņĪ}v›Ë™ƒE‹æˇ7ŧöUíÂ__Ū^ûęWĩ¯}ũ›ÜS0ĢÍÛun웕˛\ǰÍĀõhęŊ•7īĘÍÆŽ”}Øl mX;ŒNë—ĒÔÃ(įGëĮųÅÖYØŲ× ŌxNfq_¯G;U9{ÔjŧxÂwš0đô_˛Ģ#uķJÁ&KčíĀä1ZYaã%:wVž#°âu #°đõ—O§Hāryp~ÔŨ•…ҁ [h mwîį´  ŅĮ}dK§ĸ\ÛXfáLĮĸ@Ûĸ÷¤ä´LąôSí“0Ĩ ÖåĶõ!]֔é0!MŠ´RZ\öę]ļú(JR?„Söš[ÛR¯ZáĖU@Ĩ fúH¨S •đ bQZåĪ]B#ˆ@<ô›ÉdđĘ+nlŸøÔ™<šrOVŸ—´Ë¯X‘‰¤7|fb,ž<Ą"ųe ‰ĪDķäÕqø‰Ū1\@E&}õņ͉vų;‡ÛÚ. Ę M”đú`æF㞏„Ú1 ˙j‹ākq"{6!gX”[™ØĮą=ßĀŧ2wXËåŽ!ä ¨õŅŲHgĀVēIŖĖÚn8%%(˜ė{&6„†ķ×ŧá™ën÷Ū{1o&7}nhī˙û/ĩo~ķg<)ms[ÉãŊ)tŅĸŲíŸ?ø–ö’—<Ŋ­áq ˇÜ˛ēŨqûšŦŪ{ŠČÎ$xĐđĩ¯^Ę}vi/|áŗÚ!‡,mįŸ‹ĮčÕßGš ŠAĢ˙N$2Ļā¤u9ÛcûGÕ‰öc= _"_1ĸŧ––K[ę›ö›`˙*muú?íū Ķ;3A6¸Ú_ũPNk'm?!Q“™ŠL9Ȝ:u|ûÍonn§žúžtuC;…ĮÛ~ūį0ųŋŊũũßũgŪQ0ˇyæy<šx|{ËÛ^BŒÕūå_žÂb„ž;§mæāC&h+“{“|Š'Lí@ üЈŪĩXCyK;qUEaggUŨ2ĻĨNæå ˙[8āBbx”ũĒÛ7ėûĩĒ^v”F"°ŒÂÃ~ŽŌ5V‘ė˜2 ”ŧËPRE2’ĨƒtB 2“ˆéČꄟöT^ōi{“áTņ(UbjtˇNiJDĶęÅ[ác䡆raQG•Ô3›˛ƒ¨Ũb;ʋNo 2™—Æ.PšËVq>ցHhɏ?éxyŠųĖxw8Ģ3č@õV—ž•ĪĘFܒĮ*!†ŧ´ŨڕîtÔÜŗŦahtõߔÍ_t.J6œ/čŌWŗ šē>æĀM™(Ę8Ąđw~4Œ5‘ÃIÛŋÆílëX,ˇüÎļ‘?+… ­üąxœ#^Q“ĖÔvôįĩu>1āæÛîāώÕmŪnģĩ[oģŊ-į)÷ķÜjõģōĒkÚ^‹ļŸ}^ÛséRVö_ŲŽ~ʓÚķæåæ§ķ.ø%×´ŽoG>á íŋūŨģۚ7ŋąÍįĄëo¸!ģđfč\ ›`ģíg+alār>:€mË'‘ °ˇ0ûʋå“ĒE Ų å Ŧ W˙fæ: ŠtL:¤$ŋœúŌË@ÃĒ[uOJ8’æa;:rø‰Ã…ÎŦy¯ŋķ¯Šƒ”ΞF힔’Ę%7×XŒ“‡lėŌq”‹Š´QEˇ“ĘŽd@.&"éđČ?tåjņEšüËPNVÔ×@žjå F:Wā˞Ĩ›+¤A¨:˛¸@:Ø&ŧl§Pę|ĀOа3“.Ę ĸ ņŅ#¸Ęh}é¯H…é iV7éh͈ÜuĘuđŊHēúKx Œö2X81[tž‘Ö60IWšN'1p{­î|>gÎ6ĩ˙úūWpßĒöå/˙,אņōĨō“ö°a ƒ>0š(vŖ^˜2™:Rš¤Q0Â[Žŧî%/>‹lß`…Ŋ•$øS9í4˜.1AŊZšH:opëžPļ!íuÖ뛑Į˛č >{m—AŅĘ(V~Tęė[NĀF38į5öđÍĶ>ØĮ§¨S-5”‡ŸÔČ×-“o„u_–/ál+;ß8bįĪc^ž…g­olķžĪˇŠSĮļÅ gs#æ]mOĩÚuםÛū/ĮŗēzCûΊgķæŪMáąëŧiíéĮ-lįüüâÜ[°ü˙ōŠŖÛ7-ĪKËÆEÜėí†LyĄŸí‚CĒ“ãJü+’>ö÷čZ ÷„čރwéĢņĻøŽi§&åAĻË˙ãiē$0åsꤧf28ĢB.IįĖLė^q;…XrīÚûYT¸ŊũśžÛūúsš„wm;üđƒx÷Če,ô­lËö›ßnžå^lãy™œ¸n™ĐJˇw>ûŠ’˜ĪdĢrÄŠøáū)!ŋ4€¤o§\ęė›'^$Ž5núą‹gԊ>š-—ÜŒÔY#€Æ~?d-‘ŠûT叜ô,e‚WgJ‹Ožî–jjm`JĻ@§,ãUy‹0L"2e´fŠqÁąÃÁ=”E†[ ¤¯í2žAÄØœą s…n ęs˙Xhv” ’†6÷îjs1NÁČņsęjėuūĄô`Ŗ/"ČÛŧŠŒü­+}3ŪĻGūūYû€¤F;ąP†ĸ{ö%“]ėĮŪEēŒ;Ôe `´Ô´ņZ|ŌúJâŠ:RC”–– púqt"´įS—'É!ŠLd :1]jĶ€—ОgŽ#_T!œXÄĮ~–ąŌrŌz™mžÚYŋƒœĐâW˛œU5ö2î¨h~@e°¸0ÅŪŽļM\ƒ°;+õß˙o?f…ā,Žųߨ–횸ōÍoស§OC—Z ũä§OjG=ņq jsÛeW\ŅÎ>įyNļ=é_ŋ؞ôÄ'pũád^ĒsAûÅŋʍÁ:ZŨ°]ŠMcVGH+ęlY›!i› í<4ļÍlÆGĶڅŸ›€ ’čtp™|FŪRßΎLųl#KöÁ2xJAgãą…–IŪ#2uū™€âĀ:ļôSÂÕ,L‡•ZwúHŦuéBIKžčĮáŠÁ׋.Dŗ %Ũ|éÎ\58*ž.Pɔ9ŗ3 +ŪJãģ\ƒŠÉbؓ6ē‰ČųJ֐ ĀđųęG ¤Ô0ÕÃrq†Uú4‚° ´Šgōꩉ‚ƒéNÅdōîPmĪØ.j˙Ȗ´@ÂņŗR_a›H‡Ę …ĄHUŪĐC.q¤KZx­č•›—ŠläR“ģV¯i÷ņ>§ŗ/ääĨ=9Ų sņmŗŦō$ZĻO*¯THK›”ūdJ˛Šb­€VšNccŨ/4l3ä3x(˙Ĩ ˙ęDöčPŧĒ?HS˛Ņ ēšD‡Rw)ŋFlƒØč ēäÕĻ8ä+ąČÅŽĶVÁU9ē|#’ES2:ž<Ōĸū ]!8;')E„ķ ŊđŨI…Ž”rXĸ=}O Ŗōøô•öŸG™īUy°ũķ?|ąí4ql›3gg&ũr‰×T^Ú5‰' =ģŨrķííûgœËSÖÖrv\앺 ¸āU=‰a”…Š%"j‡a2+lDƝ?IAĻlF= "¯…Ø•Ŗy’‰'ág:B“H?ŗV”Ō%ŅBBa MzyŠ•mS°iv%L)ŒÄ¤hŽvŒm‘ÍÅÛ9cƒæá7ˆ!˙hY‹g89Á2Đgd^Đ[ Ĩ°ä4i‰[ėNĒÔüÆ:ōĀ3õ%í$ŨčĪ^ˆ´|á 1LMļqĸa|Bû„D•9øĀå F ĘbCm ŋčLal!ŊBÉxg O>… [GH ).c_|VŠōP­~ãäXËŠå‹Ũĩ9ˆžqBĩpV7{Fę Kņē^۔qų¸„’ÖVjļ‘‘šígS>'ú Ā&đÜ~ r/ûTK¯ßßĖ E>ÉgܸÚ÷ôĶŧņw ×ÅΙ5#oÔ<>âîĢßøNVûĮķąŲ3gg3×´–ˇĢ-—¤ĨŌž´Ū‡#‘+ôĸ‘N­Āz íœĨũ¸6õ‹ŧŸ`N{鋎ČBĖõŋģƒ•Üzˆ‚ōÅß`jĖöWB#„‚lå˙}íáiû¸ŪíĮ¨b‹ *mŠŽÕ}ąˇÕđtŦ§XFĶĨįŒYâ ąÃJ}ŲŊ-œ>M˜lŧœ<„éžáB‚/ģčÂëڏθ¤]|ų]ío˙ö™íãyûÆ)˙-÷ĸ<ûųKãjŌŨ…(6r§Ā˙ŪOBۘŪc‚|­#Ÿ0Lڍh،ÁÂú¨T{‘Äžĸ.āLj EĨ‘ĸl9}ČdųģEd(ȘęŲw:¸Ģķöq™Kw 7Į&°“Ÿ‘Î7÷>M†Ēâ!9ķōbK¤r›bJiØaHcÕÄR7+HĐ&Gō”bĮœ-—iE+ĨĪ€rŅéLĨ3wēö„AxÆÍ=ÅæŦJ'îĐQÄüŒ<@ŧg)(… ŨG•w zōäúDåíGć—׍ž%âY\ļ1oįr+ÛXW‡ Td͆Iƒ‡tžĘ›ÎD=õßOõ*3Žd>HŊIŽé°ÂĨ1)؟ģ"uWä䏌ų¨k§ļœ_ĘŲ™t+ūØ Ø‡B˜"‡-= ‘– Lí˙üE&ƒU1bN:<”ĐÄ ņeÄîōeS ÷˜Ée$;´¯~õtV§ĩĪ|éuíŦŗ.äĨ>wđ<øŠ9c0ÂV*%~ iÖÁ‹lŅŽnTT{n…ĨšMÛu¸¤HŖ´ņO_'m~M Ģ-äãĪJyĢ‚DOÍš‘Ër ōĩ=! Mƒg뤪5´™d4ø––.ĸPGĄõd›čŋx|”ŅI…īí Mi•ŸœųméH2~0TZ€z…°ŧĨʓĒbį~ŠĘˇsRUü:ĪZÕĢ8ęSV<ČßÄ"Éßžįŗŧˆlb匯‹YģņcŅÎÅģÛnžŗÍâ‰VŽ×}'¸vúōqķŪ$/i1‡ģeo7ŗŋĘģ äŸŧåęDŠ$T.Û—ĖK•7Ŗ…tíyĖËOđAūOęÍÛwŠĘTÚŠ4˛+?—g+Ÿ'đĘÁŧŒ)Éx$™"¤áįb#s Öø$'č˛ OÁʲ⡍ĸÄI­ĸ$-'ĶâuüX•Âtv`cf øL„C@ŪaG%[ô 1(…Y/×N0„ru–§ļUæĘ—jŽEëw)›ÖWņŗh°îā§)ĄžĘųīčɧ>!X~ĸŦ1´2Öu^]ĖØĒl ÚŊč„qް…ŌŲĨ’3RŠ•‰ísķzĢmˇmķå80črž¨ÕëLw{…Ü€ģ-íí#­”:–A;ŲfJu“–Ģ‘PÔ €Éå)6Xā4Á‘9ŌéâŪ$ÔH3Ģ9xO|ÔŊ¸vRvūmŠ‡é’Ą‚ŧÎU˛TũāąC°ĘdG"‰NĐ´¸Õ%ëu۞vÎu”ä Ēéf€uŖwu*hPž &˛ŨAáÄ:S¯pNä“ĸķ+BŠųË$›‘/ÎXHŲŽ^ ĄØ˜Ž’AF*Ų´Áf2í$)“?Á˙˜RģXČP2 ;@™4Ō’~‘ąÚÕ &w$ ŊßŖš@îXšŅŌöļ(oĸ5!Sl‰Ė¨”`äĘJĩŽíNĩvæŖũ”KũmŋØ_Ū´7bt˙Â>ڏú I—pE+â(7t|iX{hĮļ|å΍ĪŲĩqãĮļĨ{îÖÖŨˇ>´#¯ŧåĨČl8 ¤¤‡32ŅŊ$O+õ°ĨŨŨ, ÅĒUp)kHJ˛ē”j8¸×î”+Ģûč ŧnUZvKđŦ4RU$ę ØØ1ôą‡ōûĩŨPØųĨˆ´Ô…Ą<‡~¤ĩwĀÂļÃôņØ åą­lw!ž=ÔēŅR­‘lOv9 Åū‘IŒ8Ÿŧ =\Đß&­ä´ßhA,‘ōîÜT)ãQŧųw7ذšũũ{>Ã}ã˛đâĨ“Ë–íŲŽūĄmō”IíöÛW䒌ën\Õv;­M8>4†ŗĘĩ°Ô•øŊëÂ˙Qø˙YũEüßXą=Ęôûę–Įm•S‹§á ņwķú•NĒ”´ ũĨĮ”8§ÅIČCĘnúp÷qV_ÂĻŦ ;6ä^čÕ;I$]2ũ“ëۛŪögmŲžKÛ'>ņ\^¸;O°ōŒ•,<ĢN^WO_ļŋ ´ôîaâėsõ‡:/]QDĐŲėöUŒ<\\T$uŌr–žÅü"ˆHCšÂč8 Ŗl Ÿ vä+Q•ÕIŧķĶߊĐFŌōc?Ī4u˛(\Pę <øUǵŒ˙t>ōË=cIōėË¨ŽņÄq[%´‚ Ž‚Z"8t„6?eCūrŋ  <ėÄû&os r†#N2œ (ž’ĢâSlCŪØåņ‹“ŨČ/rØÎ %uŋŌHB~pLLüŖF˜h ž,: EÍčDEh¯Ø6DshžLGNą'%Ę‚āvyĨ/IK9āY˛Qũ¤¨ėUĒä¨ųŪ]›})íiQoˇ0$Ÿ˜/ī".Wˆ(Oĩ?NLÚŠų/{ņ$. hH;ļĪqĩķ†-ļ˙˙ūīöÚž•¤MŌrė PĻã;Ã^ééhŠđČž7ŧA!Ž=8†NčFyõ@ˤG ¯?Ä9â&:iĒrŊŸ+=.Õϝč…80‘K§Ŗ#—˙Ô^vČDŌÁ ü:ޏt˜Đ@„Ž+´‡ËdŌŠJP|xH‡d’Ļ]ĶĀĻĸX"ŸX‰Ŋ[&ŧԁA%—ô×6•÷g&ŖØ <ö >´‡zi*+ĨJ"3ĀÅZĘgü ūH&đ?œƒ;g]h@ÔVš™JÃȃO„¸Yaˆâ#čĪĖ>5•nlCƒ“yÅĢ ÷æę`'eaÁ„6¤&+EÁPˇ*Ŗ¸đČ5—UĒ!ĀÎeĨnŨēûÛûŪr{ŲKŸÂ›dnŸüÄÉmíŊëšļw—ĀzŠËBi/c˛E‡Đ– ÄĨ›Ö‚ˇ:SÖyՍ`ĘQx Đₑ˛MV`€Š>c]ÔmŋÁ‚dX&ĀĘ ŗÄZ˛MĨ“°…Ú6$_ĶĄGZbîŌ'õI˜8PX&]uŠĶ…S~žMZžŌT¸tTqũQhyla‰i”KOâÂÆOd.Uę+V *iqKß!)ž“*R'æöĨFā;ĒÍbb˙0y}ō˙ø…ŧTĖK~æĖšÉģîoĢW­koۋÛōåwļsĪŊ*đB–čĒG@åúŸoōL[!ĮÚĸ4í-bǧcÃíĸķŸJŽ˙žūŸŠn& Šœ†f§ė~ôMO[é¯Dāô"Á+ŽˆÁFĨ)Œ=‹å6øĻą)4Ē4ļŠŧÂn&GĐņ ŧ×öŋņ-OĪæÆéĶ'´-SvĘä_?2^Kßn™x %eė~ZÂFˆČ•“^ÄÔpÃĮ#+ÕéR]†ę´”™§Â>#dN¤+Ŗ:‹c:ƀ_/4o “FęeÍG*e'qR"û¨s3&eqH|€|rc‰đ’,bÉSN_ŌŋŒ%Z6Ū/ŧ –‡Ŧ0”%í¸Q0Ž'Fh8čyŲĄû’UE‡ŌlęOu—]”@oņ‰9Ó}׊푟ōI3ˆ!Ÿ1a bÜČę9ŧõĩœq0Đ(āĮq˜ãÔøD-+‚‘âBG|˜)§´"?øĄHĨļ‘ƒD!ĮÆØōŒM"ŋtá#’R‘nÛĄŨs(Öļ‘S9ČØFúM5I”ĘÕ Ęnj Ė1“s‹aŌ­€ËSk˜’_`•ŸvąOū­ļLæĘepTIM/gëĻ>‚–´EhÄūōãSg$Šá‡K€Âh+ĨĮR˛â|iüdÄQhˇ4]ÜĨ7?n™ŽÎĸHßĒ9ŗą.=p;˜ĀJZĮ ˆÔë Ã‘ĨÅB×ĀĀ‹ŊpTĘ/ķÉt ŨÎōúyŗ‘Č]ŠžEęÉAōIq–Ū’ÎâDŦsŠœÃ$ĸ÷Sʨ:Ôc˜•Ũ@â]]Įnîí8Ę Oká#Ŋt$ xíTm ŠA?āraƒ‡rD> Ÿo: *šĮĐQéJMô‹NÉ:21S ĸR’‰‘­C&Ķ™Œ–ōˆžâ˛’ÍÚŦđ¤žv/rX]´;¨Å#›6‹c7teé4HĨĶN›YʒīAÍãŌåųzØŪmîܙí´SĪb5¨ĩ霿w2iÄNĸl–üüØëÔ× P‚*ĨemDāũŦ’(_ÁÆē•¤´<>A;Pƒz]chÚūŽZÅÁÃFŌ˛MųØhĐĄŨjâo‰›õā#‹*é[Ęeā÷ØNĖ*‚_UĢŽVf˛ĒĸlŠ÷OF†0Bņ€ÚÁAŌuũŦō+›ä-eë6N‰zôōāG#uĩ”_˜ÂM[æSå]ŪÃRÃɴ铿éOļ|ā LÜ&´‰<Ŗ}éŌ…<~yW.ũē„ö*zŽ)YYī°úãĄĪû¤ŸQŋ—yFĻm0”ËB įSáĘÆÛüģ%ą{tãQzŖęŌÃû˜´ú¨ęQ<~õU.õŪŖā%̞M—Ō?ķ)ĒōrKĢ$ņ„É#3—dõ~ļĨž|ōqXü ̚4xú$žDÛĶ´IC˙¨Ĩ +çäqįí÷ōŒ˙éížņÚŠ?æ%`×ĩų fÔÛÄŖ æ AĖo:Y|ÁﰟYÄ]°™“>åVæ‘úE—Gž’ãšč§č’ž(}Ëé;Ö×Áēų‚O°Ō å‰öËā)xū™˛°v9x°Œĸ¨‘“Ö"ā…šļ(Z™ŪŖÉŊęãĻûäfÕ¯Ū‰Oŋ–ļ¸ŌËŪeČYī~qÁ¤ä †/wic\mí7‚ëf_Oë$VWYʍ™(ņ8;ä@ŦôVÛÕ‰ôáh›ų6ÉCĀ ģ™čÔíãN;dVÖ0‚r_m?EA"XĨr@Ļ:bģhŠ.ÁĨ°(&|CŖøJ ū.Œ•b%ū’Á—•Ŗö B‘ŋ؂DæčĩÄõįæ~PŌ¤2Z.fÎdĄ_îC‘—åüELöā ¯Ž9(ļĀž”ŧŨDÆĨAxĄÄÕ1•Ųqm´6Ž0T ”<ļmG¨A°Z×FĶu‡ŖÉ”VĢ–tšË­kâ¤3xŲH&[:›Ž+%ŨÅŖsŨ#õR§N§Ęd ¯Š“Cpp<ĀŖ˜2˙KÆėAĘJ”p¤í¨ë`’vÅBė´AôĩƒwŨČkíaFŊ”ÃÁĮÕƒ•(á cŖPåO™Ų“ī'ĶŠė°&´U0N:-&ŧ‚(I­cyР߄õšHÆūĘxåą^RH!M3Vš!ēÅO”LRđSæ1ĀŽ_˙@ģņæģx,芛7J'Oāq~+9q¤J \"@VâÛlƒ –Ĩ[\,¸|ŅØ° °ÕūfĨŗÕŽ‘Īv dĒ"OnŽŖ0ū ‡ Vņ‹L—i¤ŋAē|Bû)JØĀR‚]ŧ %šŖDã`yŊ/;‹z.>eyh Úm­‰Ŗģ~ãÛ.|rÄœŧ$$ö2Í/ŧÁŠŗfō‘™õaųZ¤ųƒ"Ŧ<sT˙ÕÍ+ĩHâQ΄ÔiĀ(&‰c¸qķA,üûŪ÷YŽážŌĻMŨ‰Ëž6Į/ę 3) ôß3Œžũƒl;ķHæķÎŋ€IūØč”Ë9‘íūõëÛSŽ:2on?§ˇMŸ65>T—¯¸@‡¨ëõ%­,˙C}3ũ$ŪĨ™| )†u&´jô–1Ÿ)x#Ŋūņĸįß.ŋōĘvŨ 7ļɓ&Ö$YÄŅzé?ԐW%s§oy=Đ7~\îĮFîÊnbŅäŪčĮ'1Géz˙Ŗ­&KÕĸĨwTFÚ_›é úUM"/äņ9íS6Ôŋ˛ÂDüČõ÷Öc .„~¤ŊL [Ŋ•4hy¤1ũߡŽZyo ßü–ÚW\Ķ>ōĄ¯q 0ĨíÁäߛqĮnü¤ŅAĮę'‘"e[ gģ…§>.ĻōØO°§5Fi"ßBޤņwhj  I[ČG{—.ō„;“=š8<Ô-ĀSÛŌĪÖø>yŋÕ6Ôj[ąbcą‘h›2)Æô&ˆÂ0ÍXi“ŦōęKđ6tŲkƒČ‡ÚÖ!_¤Ŗ°bTHt^ЃWÆ{ۛ|3Įčtĸ2ļ›ÕŌ’žŲø/m ŗOEĨiá<äËF&°áŖü0/BŠŽÅĀF9ŠÔØūŠ|ŌļÃÖ1­ôĘ"ã@,"):äæ´Uĩ—VŦŗ€(—VOũäeÜKœ×ų´2¤>†P\í$5ö¨Y=SĄÍ% ];-rŨ~Ļ´‡´” nĐ uÉr ‹ˇÅČŪ&~U{IŠÚZ’ĄžĻ͍Ėڍ­`ŊoķvLR¨Ņ aĄŠë0ƒ“éąŋíÆļΈsĨ­*Nč #~N9Ë1q&“:aöĻɸ‹ßéĶÜX´>å•/$•ŽĻešÄ@;8•@vęüJЕ÷ÉōWP2-Z™T.c;I'Äĸöî]q TĸtFÉPVÁ”ŒÔŨ…™; -č~ŦÍLہŒ!%ÕV­+püËŖ‹T‚0EÔ>dA…(ö(öPWV*‡@’6 Ž’žøöąęŒÂz>!‚$ R–bŠW2‘*œ‹iMę8å6‡Œ˛HĪ€Ļí2ņ‰JfEŽā†6âK˜˛œ MmXŌ&H9ŲÁ¯Äsh “ ‘W˜Rt%ĸē.]:—gy_ÍāžĸŊõ-ĪÍãũ.bße—IÜX:6mŪH 1ËKÃ2ų´ĩmŠaĩk1'ī— č›7Ũã[íĸlÔĨ]ˇ…n*C¤šŦl†ôŦŧÚNŊ,\ˆ]ÍČ"ŧŠž˙æŊÁļÚ™‚D+§<ĄÕ)RÖÉPn€§–˛*:jå„+/" ß̌˙Yĩ !pËŅĩ˜ŲR”SŸ˜žfC'lhY¸)‡ølļõŽcQĶ&ë¤Ĩ.ū°ŗ\úŒeãxŦ¨ô&NÚąŨˇnCžvåd7_üÁ R xÅëũëF÷ņN—ĨK–¤m¯¸úš<Ŧa'žî6eōäLúŊŦaOŪę~íĩ×åLĀ”‡'å `üøņySüLÆ×ACžSšWÁÕs'w>ú´:~ŸģvEŪČ#Ĩ×q)ĶCœÁšYCšęJ ž¸n ZŅ;)ąÔ2™¤­ĢĮÂRĒŽāhų›rđŠ‘´Ķ°h zŒĐ'ÄVÖ8^‘:ŠbdT&EQBõŠ ōuéœÍmác]Ũd¯‚Æ^1Ę…ĐÆbiŠ‘:ˆŖØ2=Č>“¤<ԃĄŪļlęÄŖ*ĸĨ ›ZQßĸ!->ļQ*ÜŖ~6ZÁ;>@ß-Š”§ė˜rā”QüĖH9 >ģŸƒå$QĒ7˜—]4Nųāģ‚f~¯^vō€ũ÷ogüā‡í\.1zÖqOkG~7:ßĮJūÎmßeËÚĮ?õévä>o{ė>¯]/Üŋŧ|ë´īļ˙xüķrā؈yķvk‹sõ]wsYĶ4ĻqVlIû×/ĩŨÍäū¸cßžš‘z:õNú?sŌŋļe{īÕvŸˇ{{ûÄķĨ—'ŧđ´WĩÃđķ€ã[§¯=ûĪŽk?âđ´ÃZH.ūõĨÕb}Ā˙˜=íE)mbû§)ÉØD‰‡–ë/ĀĨŋŗˇŽ"É§ÕŠĢ…„¸PūŌ÷¤kl„¸ãũUŧ{ÍũmíŨÚ˙ōíŽģîi'}á;´ų¤öø', Ũ­ĢÕČ"ŨÉ{ČĖÄYĨ MuSN%QÎ.oŦ•Â(IUĩˇBÉÁŧDŠËÆŽĸ)ũ‡7íWŸ—‚ļ]Ŋ–Ėāââ“ÍOŠn^qjGâØåáŽ{ô(ąTˆP’Dfu€Ģ—ŽŧŠ^ZJQ dFÎxh1ßŪ,ĖP-%Û¯'„Ô^5ÆČÉļ TvŅÔôeĢW7ŲÖËhąqÆ0+Ųš•Ŗ7MUäŋĮĶNK‚NV•ŋô<OäcylC™ī&ŅöĩjīøRæ‚BĩĨ¨ĮĖ~ų€ęÔíHč/Û=Ųøˆüģņœ å/Õ&1n'@z–œ2—<ílĢ”›¤.°‚ĢöŅ6’‰EČû>ãpø—é°UĶQ'}ú 8ÕāîĨjļėV@┠*ԅzÆÕM§ąŧ †ËéhĪj äĀäûmo6⍸Ąjār\˖Ļ1uB$íJķIĩ5ˆWŖĮ9h|ëĘĄ’*b HĄƒ#toŌŨœ˜IWĻŽÔ&ī”&›F‹DŲüŲŅÅĖdC|‰<*pSBœņ*|({Ą§Ž(eNÎj•‰ĢØ]ų§Ú•8ąŗâ˛ ä™Ŧŋ€å%ļō@O7RŽa-JāBoŅüĪ QBŊ¸|+UđPˇ_0”Į\ĄÛÕ|6v’ČY‚NBŌJģÆčb\$ĄÎÁĸøIC çHW›–žÖ‹É)ÕŲ@ä“Ô9ē‘.uĨzĻŽÃ‰.kÚĘAXųŖģKYÜōÔAÄ&pqaîėií⋯i_ü×ŗÛ‚ģļųķwhTۉ7 -0…†[J”^iÃĐīõÚĮ'Ķē—cų ōdĐ'ĸ‘°Ō„ošCPáų/íPĢO!ĶÛĖX™Ķ¯6¸z†Š\Éöˆ-žųŦč[oŽŊöËjVJ„ņŖPôɌÆN{[žkŽm°ËEũ0Ļ „ (âšl^ōÛ_āÆč"9E/€ĩ‰˛÷ļVqdY÷ųöę7ŊĨ]}ÍoÛ˙øÜ„ė$y ĢæsfĪj'ŧāxŽ!_ÕžųĶÚŽsfˇg>ã8VƯoīũ‡jo}įß°z~OÛkĪ=Yaž•įŅ™”ĢĢã›ú{íūT&īO=úÉíĒ̝n/Ũ_´ƒ}÷¨ÜŌöÛwY.ĶyâžĐ~ÁŊGydûo÷úüY3f´ß\uUāžrō×9ø¸2+øk×­…Ūdn„žĮKĶnnõîŋË[į÷ØcØmioúëwļO|šĮŠréÎ^>9ŗžY8ũ{g´ŖūãËÛģŪķ÷™ü,Y´ ]zųíÖ[om_øˇ/5Wú_r‹ÚÎ|đ#k¯zĶ[›g ?ôĐ8,\°{Ėi§}÷{ícŸúlbŖxĪBüĄą|Ä6ģöļĄ-íŸū3Ŧ:•MŸŗT—O_´Á{iīī^Wœū@M] Ōũâ9˜fqņ>ŪūÄ#÷kĮwpģūú[ÚjŪ.ũ‡6Õ"N.Ņ‚´}vČä„Ģo$”ˇâgŠ‘aØŧ”Ø^ôĄØxŖ„Õ΁"… V,ŲZW}*ڇ_ų:ŅšęRė ^2Ĩ§ ŗw$ō…8]™œFŦū(-#lb,ļ‚ U‰jė‡ŧāŠ‰œĻ"Mdí}OqĐđ/|ķvhAËyŗ~M^ZrŠ–āJ˜Ÿ¤2A“tĐÂSE~æ;*ÆĘ)(ŒN č0âf…×@Žr…^@ĀÕ°TäÉę$´t„g‹  !œí“”U %žÉ&ážGŠ fÖđ“–$M§)ĄŌY¤Ē+ۑ.ģ‚’r(…—äØD¤ Í•r€Œ× `đRļ ʤ<°R—jßŌSå‘j؁ŧRĘøÚ€2‹^ŗZėĨ “'o{Âüö“ŸüĒ­Zuo{õ̟“}đƒ§´=öؙ‰ŅÄļ ‡ßúdbē‘Qžø”i>LE’Ö—”Œ|ôVjû r ‚¤Ŋé'˜ōģ71`}gĀˆzōõ,ė+Ū”+K&ģô…ōuíoÁ4mhkG%cBļ¨+ƒõŅGŗé(Š žč9p}Ü-“kĀS_vlÖ[āWMj+ kõ›ŌgPCΘ¯\… ŊZɔ€í6B'6éũŌéÆĀĒ˙áíTgāēíä3íŅkūĐîîIZŋáæå<+V,o˙ōņÛŌE‹Úâ{´kŽšŽ-]ŧ¤Ífâíõų;ŗ˙Ö7˙Eģō7WĩŸø™\rsô“ŽâėŌÚöÁØöcõ|'î ¸öwסĐģcųŠ\nãŗäãĪéˆ[ryÍąG?;7ģrūގŊ9×ŅO25+ūŪāéK&÷]ļO{ß?ü#—!ŨÚ>ÁÛ{žũĖgä€å6ہ°7gŧ įznūŨkĪ%Čŋĸ}ék_oķįíĘ*ūnŧ'áööõo~›Kyvá@enøŪ ŋ;WßŨžsÚ随āĶ÷ÎPĖ͓ŽžæÚŦčoälÅŲŋŧ¸Ŋįoe|Ūą}ä'ļ9sæ´Cö[Ö~wũ m!÷,]8?÷ œõퟎ¯Ÿöũöô§ÉXÁÕõôŸ?dsŊĸĘmUÛÍm(#ŲÛxhs=aˆ#- ÚHŒéž*]]7n2ÅÉ0 ėh&TōrššsgáÖ[Úūé[dÍæŠņˁ Ã.ų$ņWdtŧ(?ˆī uöGøå/}Ë\Áë“éŋŗ)Į K&h€ÖY>,B]ē°ōļUY¨S’ƒíz)pö´­›õ–ģ,—†[õJė+ė,M}ߛŽŽđuž_TJŽ„ø„ļPÎʸ—ޞĸ™Dák)āz&Ø´}b•–HŦīrtšČĐp‰%ĒX!ßō“üĮ>J>Hõ+ZÃ˙Öh@Rb­ČĖΒą y3ąpžŗAžY”‚žĶutSÅĀ iJčNŠŠM{Ō"}(ĄJ)†Ø*ļøũMn‘4vM“Œ ĮzËFtacčAˆúZڄŊĀäŠna•Yčøk„Ļžp=ŧ,#jøÁąĮåÜw@lĢũB—EJZ]+Ĩ ›-áÆō)Å >ēlĶÆŅČšÍK:šéąũŋˇzƒyNžë­G—É€A%'ĢŋāŌyq ë˛Ę…¸ÃŠĸuēo9F”očĐ&“:ŠTuK+ėty´ôG6`< ŅE]É\Ũ{ĢŖ¤†É lūՔĘ5Á‰.ÖuŨ“`˜ŪWø°‹LåĐWGæ ĄH&™ŪÜ T:eū•Kēlv#‚ʸi+’ąM×7åéHÚC+`fõT:Āis'ÄąøáE™ÕR6 ™Sˇ:-+-’–ÉGõ“Īô¨O'¯,~Š.`i†Á]:Á—V%‹ 0ÚŅBfCĮAAymG‰B#dõ“ĀA(ŊD7Đ fe¸J[Æų Ÿ!'*uļ?ÍløUá2x¸:Ė"ņÃÜP§ ˇ´iĶ&´Ë.ģš7ļWžō¸Ü€{Ę)gņ|ųŠmŌÄq4 ø>g\vĘT2ø¯T<É!SFũāJ•A$ë]YŅĖļĩvąXPNl+ GĒ1pōŅÆČ MA“Šö”ģtĩ•<Ü2Ëŗ˛ü“HŊ+¤Sû—6Ę&=ôÍđ‡j˙°É_úLlŗTv†ĘޜecdäR‡=e%é—ŌM_G¸áŠâ†›xÅļ8î‹ĩŠZq­2íRčBđÃøĘcrŌáįmú‡ĪÍä=×ßxSŽÉwĩ˙N/aÂ7Ģū^7ŋháÂ<&ôŠ+Çf0^Đîã]¯}ŋ™ssĀĒūj`Å_uįmÍ=k™xÎΤ82 ĪfF3š_¸`A[ĪĩŗfÍʍÆŪHģråĘ\cË+ÚˇN=­ũäŖÚŗŸõ,ty¸ũôgg1Ņ>#qŸH´rõ]íЃ Ų[˙oöŪ<čēėĒĪ;ęūēŋūzž¤¤VkBh@hĀH&`1ccLŠ2WH\e\ĄĘv9UޤœĘŽ˙H\qÅĀ ‰)TÄ!#4B2ÂQRĢŠįnĩ¤ūzÎķ\~ŲeŨĘä­F^pūvŋN/ŽŸ|3'/{ ˇîđįo)ø=ĮÆ{>đĄíÛŋõ[¸UŽ/ ŋ̝ēĒ“ lĪī@|€›+øÄÃEūYĮĪâ7Î"f×mĪÎŗéĢ͈åÜæôšb^ŽŌ7û<ā ĻšV.gĐdĨŲųlϜ[5hæĸ9Ņ?uá†Įŧ%7h:ƒ[9îųÔÉíŊīšiû˙đ¯q2uÆö?ņúí‚ ĪŲ^ū˛§đŊ/líŋT­=(ĀoŅŽZĘīŨxjRgãŋVŽĒ2jãaŽÉÎeąķ™Ž]7û:ŲđÕÄFyŽ ļ+a˙zŲ?1"íØVŸČŒÆAzO“!Æũ厀IhØø0ˆãåæ^‰ŠÖ[Ļæ¯7`G‰æí1&˛M_JĻīPeؚûl;”ÁĐNåĢßŌ7qę:>Ž[ŒČtôāˆ×&›ōܡÃÖŅhTœņ¨ÍvhlÚ čuŪS|ŌWĮ2¤ŌaÜdāĨÅ{¸’3Ļõ8š-—Œ‘E îéÃC ƒö5,áˆ[>ĄL{}NR Û1Ž`œyŒį.? ņ\)ÁŽháåö˙Šb`<ÄG¤~ėsŦ3ęúÍÎ5Áœ0Mô^tdų@ƒÆŋŨm‘\c|Ųč`šgĩ/†’Á–ŖûûH”퇄1L˛3™Mü›M#|QÃÛŠ­IDū‘‰[Ũf{ŧ4˜ŒČø×ؒ—6“7ŋÅM6\s*:•Û'ō^˙øķ$eԌ\ ĶŖ‡šš ØĄVĩFÛs´Yĩ{Bøé‚°QIŒØDÖg csEŪ\蝂ŗ)§˙ėĨøJÎÎĸŅéKîwmŦޟs0UH9O<ڌ/qϝˆm8?:ĻMR˛fíÁ2T?:/&L2c•Ų¸°Ë›¨ģšĀQl7ېGL+E’ŋiųRIX&øū,ôÜôåœsĪÚŽŊöĻí=īšĄĢ¨~Ų‹Ÿž=íéWđ…ʰ‘?U6“DEÅ\OėuëeÚãl€īdŦYžZ´č›lí,Ė˝Lž@ėũ‘d7}ZCĸvAŒŋbņWļÆfdxÂ×âĨļÉs텡õü5ÕØ6ā5ΝĖĐæ–­ģ {ķ’Xéŋ>ĀW*SJƒūĻŽü‡‡—ˆÉŒujûԃnØÅQŨaƒE™ŧTD¸zWW„ŽŽ!ô‘õĪķōé;ęyâ¯dŧ=´}đÃ7r…üļíÖ;îâËļĪŪ>rÃGú˛­Oŧų‘ũņíû?~¤“…<˙yÛ57ÜØI‚÷Ëß𱛷ŗŋ›õ—r/ŋ÷îßÍŋī— Ā9CKũ1ēcÜt!};_ŌũëßņWˇō?˙ŗp˙īŧ/įˇKŸüüü@ÕuōĒW~%yy' ŪZôĄŨĘÉĀe}rāÉĘ%|a÷N:NōEhų…`ëŪÃī•yŋĀ{+~]uåÛWŊę•Û;~÷wˇ˙ūüŸļ¯}õ÷l×sÛĐGų´āSā_ȉĐu×]Į'Į{Ē_žé–Û8!š˜<—O%ŽģūzĢËo2 čųŌ˛Oō;Ÿ/Îĩyl'_,ģ™͙ģāM/Ō›õŗ{svxõËŧPŽ2õšå)h~ZĄ¯ˇÜz×öâ=kûøÆí=˙áC<šé:ž}ĸÛjüž!LRĄÂ ēš)EeØŖrsÖ?Ú{nAķD4ūlą C˛Úã0ĪKbæŠäÅãĨꎝ!Ę4:ÍcĮŸOÚęi[ÅJ[26G â1ue7ûp­Ol'^GvĢÉFu!ŸĮč]pĻrpŅwęüæü™ÍƟí˜ãĄ2ZŖŪŪ-ĀËū1ûH˙šK&H"?ų’1áØJŧ|`AŸ"Ēžŋ $—øoSr6ZÃņūķbdØôqœqē ĨY~l?K…ÂōÁĄ5IAÔwŧÛŨL1 ™¨/;­-ē “k ÎzÉ~hŨ_ō´ÆŖ ŧQÚ×4ÃdŖ4* 넆EĻÂ~ŸJČk`ØęOĢb¤olWĄŠÁ?¯jUF— ŌØF SŌ’ĐÖy h‡oŸÉdķČy|‹Į¨=ŽĢÁ'!ԟ|ĶĘŦy ˆSųN-?†ØŸÛæĪåŖô6‚öđąíĖĶÎá*ĪŲ=ÅB˛ 8I1åÕũ­›¨%‰nĮ ąÄT°ŗÎ}â3­اZËbzÂ!ÎL>ŗX+´,ķæŪũrpu˛™sūŒõÎ.ÕųÁEŒØ.Ųō!^ŗĖVĐUhž:¨ÁōËÅ—xÅö ‹ ä&ņÁ”ÖB9qOsRF‘ÃĶŗę•÷€z°Y–ĶäΤdēŲ›ÚŗĒæ {ŽęŦ8ætä(˛9 ņ'Ÿ.8žŒš˛áŠW|‡Ēėú4úäiqĮ[cšĸ…?ąĖŅc@ÛĶN= uCÛu\ę~0HB>ۇ žDĢ‹g4Â.°Í{öëšU§–ŗ–Uū´äˇ¸B Y:ZÁsšo\a%ßŊÂâŠĶ}øĀŲ<ëüŧ§œŗũÂ/ūÆv×'îŨ~čīˇs|b{Û[?°]ũäKˇãĮųr°ˇ5b\ė´27y3DâųĪ‘h&;*EÉ6JTĄázH hqôś.<´uIC 7"|ÕŗËŅ%9`‰Ük:Ė6§:bėn%ģĢq9#)Ģ.”iįĨDž %œ•}~ŲnrŋXä‹CĒFŠôŲĶ9Ņ“wō‚öˇ!¤O`tUh÷K1Úõu2Ij‹Pâāî|ÆGũĘ÷•50¸…Ļ‚ŸcsĄåâũ îëŋô’Kšæ‘í¯|Õ_č9đ˙Ã?ú!> :gûé×ū\_˜ŊÅņÜę#Üû?đí•_ų•Û˙ūã?ÅJoß^öŌ—nīā °įĀ˙ŨÜ3˙dÍųëo|ãvnWÖįĸ֘í÷´íNp\X˙ÅŋøĒíûāŋí$âÕßđ_ßĮm¯ũ…_Úū›ŋķũ\ŨaWâ/ÃļsĪ9wģūúoĪŧúI}rˇË„¤_Ī'gą÷‘ž÷Š=ˇôhīq~¸ėĒ'=‰Įâž3Û|té\síöŦ§?}ûŽoũæíkūŌWoozĶonĪâļ§ķø.AŖe|ÜÆĸ˙…/xÁöu_ųržjôāöčīe˙OüÔkļīúkßҧ×`×S¯~2W˙}’ßôķįwyVoėI^ߨŋæ6{ꄠĐ†ĶœÜčß.:Ŧ6ų­O˙"Ģq}˜ĶųÜæwÖņ3ļŗĪ9ą]réEÛĪ˙Â[8:Á ĀŲ]PlÎ7Ÿmkž1ufdæAú˛đX]ōô‰Ō˛Õī‰naėq!^gIMûŖÚĸ_ŌÁČgöę SØĮĖJ°z0ŧIšŧdDEÆe­m^¤hūŒĪˇ‘˜•1öP_mîØĄRųœ—(Ou<Č>!âž]zЖŧkü‹ĄÍ~rbŋ9 åÉ#ã'0 Úo{ū‰ƒÜ Ą_yk؁-ļ|ē(BŋĘí_4ájNÅ)ķ`Ä7ũĩ`ƒOIæä´‘ˇö…Ķ#Ę!”„h HöYļĪŊ3ÂļɓąŽš€ÜōŋĩEHFŽ—ųP?„kŒ&Tmžūbī'ĸã ,ūĄˇ~—iaPX:á7ĨÕYöˆ ärĶ˙Ķ` _qčü™ŖķŊ‘‘nÉ\RnNXíKÆ}>~L˙Ų÷°ˇM>š˜ˆĨã&ųĢŽĶŸ÷ü—˙“œ Ps5ųX‹ž˜B@ēõĄ@ÃĀ×ß}ĘÃ#~ ūđBÛĄL{2ļKGi“Ė”wÚÎ˙(œøc†õã°˙ ŨōfĪŖm8ā0v{‰Ë˛Uŋöōgßīíŗ?ōëûŸ †ŧKÎļƒūÆŪdÎfÚMą…ąÛ],ršû”~ā„ėØ9Ûq>âõKlûÂÆŽ+!€ĢsŲí[géfŊml})¨Ōdš@2`5x4ČëŽro‰š(M–ėíū(Y}ķ­ŸÜžūë_ĘŨž¸ŊåÍīāĘÜÍ|t}ƒqđˆÎü!sHL02%=ĨąqÚˆČ„ÚĮŽ,ĸ.ڔ÷íģīŲ64qˆeíšäôsíjbC§˛vŅ™v9ãS˜íËÆ%¨ņâo&CÁdœØ8ĒĩUėņYä7nj’ĪRËvÅt0”´Í7KāĪë”Įŗēt\›k˛>ÚÄäA‰Ÿ0ŪՓ …ĩEĨÉaŋ{uŲ(™×Lđƒ_‚Ŋ%'˙ŧMėÕ, 8K°ūō ĮôÍ\U™ōŪėĸáâ ĪåK›įūîëļīũŪWo—>ū‚í—^˙ŽíÂ‹ÎæD€gš§PD|ĐVą}7.öĒút`éjQĄ–Ē’˛Z)/ęšEŗMLäô!ŦŅ1ÂŖĩk>„$Øčhâ9|aúßú¤5!cŨúŌŽ¤ÅFe—ŧUÉ;ŋũ-͍Aį:8wkD>ļ%#/•CūŠ_ڀĐ0~ģpC][Ēd‘p°}ÅXW›6 _ŊE˙ĐE|āû¸ßī–š ԀĪū*†čō~÷+¸]æagé—|]ØŋčKŸök¸ūC×d{îŗŸŅbøC×x%ųŧŽŦûƒaŸāËž>ųæË^üBž+˙åœ4^ՕpyïŋqûWÎŨ>ÍcC}Lį§ųށ,Î<ãĖíã7ßÜ…ŋœ/Ô~ŅŗŸŲb˙G˙õOvÅŪû%â/˙˛õôo zÍë~Ž'WāVŸįõØÍ[oū8'°'øBđ‡ûTá ,øßö[ŋCŒOĪÆŗš‚˙Žß{w1đËŋ—r;Ķ{ß˙~nãšv{ßø _ņŠí‹Ÿķœ~gāSŸâ—„9yy˙?¸} _B~ ú;nģ•{/v<3ßüÄÃÛf~ęßū ˇ9Ũš=ũiOáSŠ›˜‡oLv~aûŗĮēžŽėžēt/ˇû>Âq†O˙%›Ž÷fŦ•ËÆ`Í+—IĒM$2æ?›9įã[Ŋ˛˙{īžiûÆWŋ|{Å+^¸Ŋîg߸Ŋī}×qËŌ…œq‹1ãÜE™‰˜ä™tV÷äüäšeį$ņmÛsKē$ä1 ]|Ø iŌėLĻšō{đõi0¨ƒŌ›@üWˇĀkė"āgŗ™*ŌŽÂđ$DTŌÍ:ėúÔsjaN“Ŗ„H`ä9lĄPÛf\BÕ6vÖÛ˛WũŧčŧņĮņ& â7‡ŲčŋļVFF,ˆzęģą˛ė/``ˆŒœ˛8¯‚lō˜Cúgųt(˙Inã|ōU—o¯|åKˇōh×_ũÕßæ ũį•[ÆŌ-¯ĀÆrúPĢ eßÁ6ĩYģæ"ÜÄ@÷ķI#™_Z¸€BLĮĒĶܧŦ*WsšaôGg8ĩȧ_ę–`,ŧh0€ƒŒ°mōY8t‹6đ*tP•ųĨåĩNÖåŠXŗŪ"_h訁>ûR6Ŋ+U×WãēËéŖ8+"6Wˇ0ôŅf9oûßųƒYĸķQmá ‡ŧ ˆSŸų5>ŨaƒEYˇq‘Ā5sĖÕÖÎzlÃqŅp‡PÉFF5øĨÁąDc#>l0ˆ'ĐÛ:`d„Îâžē’Aš€ŲQÎ-–…Ŧ žšÚæ5ÄBÖžĀ)ĢąD%›SæBK{lB–}g ę˛–Ū‰˜Bu–LŲs¯]Œ”đÂāĢĶĐf˙čąŨ+ęũRęiŪßLšėtN]Pg˜žíöƒŗĮq>NZ1CEáģžDī\]W52ÚČĢ~ĶūĘuíĢN; ܁ÄUˇ›ˇūĪū.“ûKļúO˙Åööˇžg{üetđë E(čėXÕüuU˛ä¯6ú ĪíH&=ÜËã¨^9uR?9bXK˙>Hķ[úyU(fŲÄ&Lí/Ís‰€åe7¸ÆfĻxãf­ItoW\ Ō€Ž0đkøõÁĒlĖåK:ĀĮÚÆÉaŌ”ÂÂhNö`đČĨ“ÛBW‡¤‰Ø1}l_‰?:ha3ZĘCëß7ûíēí€ĸŠÆFxT‚E}\ÆęcW°Â”8u4~õģx˛ēôî'rjƒęœâ˛'’<ũĻ"f ´ėŌūīģīApp yAŸœāĶ_~į;˙€ûēĪ-ÖĘ Įƒ Ë1qV?›–ŊÅÉfėņÄO•mĩéįô´™`§Ų÷ €ūÛŋĒۇúŠí2Ë0“;čkņB#KāĨ[Ŗä¤‡“ĨȑVÁ#z h”Ų¤>1lj=Õ‘Ī ēFëd›“|pÁĢh/čöŖëÚęgH‹ ”—.jĩģߡąPŠq˜8*j ä‹Ū÷~úží܋Oįâ'môŅŠ˛;ÆgÛûKĀsžūÔ'wĪøšBîķûũ1-ËÁōGÁŒŒˇ}đēëˇoúú¯Ũžxå•ۏũäOoĪāKą÷ōiÂw˙ŋNž=ŧŊõíŋÅ'_ŪsŠĪq᭔>ąįMo~ëvŋāŕ§pŸũÉûNrEūēîÕw!îī œĮUũ'í—v¯åKĀ~‘טÁml>ūķ.îí÷v#Otü/0ė,ũĮ¸˙]ûü‘/TĖ“ ķɓŋ0lœ<éy2ˇađ;Ūâä÷'´Å9ég˜Ôy÷í÷ą¨áĚøFü™C§Ã ŧüˇŅœŦ=:$Ž Đüî 7Üž}ÕWŋ`{ú͟ŧũöo˙úūLnēƒ~ܙ $šˆŲ4šŦõëžėe‡1”nSW;-Õ¯!Ōø ]ÜŗIN6­BŌW7}“XŨ oČØ$ƇפOz’Zžr؁"&Ö¸œĢņꆞŋĘȒ‚ėQYsŒĸ6ņ:uÜŨŖŽN7âë&ŋFąõIhuŪ{Žmj§”ņh§Ød[1PŋÅfsöÚ6:?NHđ˛ĮSí76ÔDX;m㏉ĮO‡lĩüéÍŋsûНxūöūņßŨŪöļwl˙Ũß˙_ˇg?įĘNX›āS›ķ°-ā‹3âlV Œ#ã|æúR]ķ§°ļ7ī`‡–Úâ~ŠÍßÄߒüĶ,1ȀúŌydÅ%ō—\?x1‰Á35™Á†(ļė°ä“Œr{ˆI§<ö׎€´Éß%c߯ėNZl)ŠŽŧ\ą0’đcŨQšc‚Ę•áĪãGKČedąs­t?–@ E<“W_åķŨųGiøß‡Zd)Å"N‡:[Ņ TƒˇûGŊEl:tŸMžÚy#@% éA5=ĸėWŲėJŸY>@ée‡ĄJ‰v¯ (ĮĻÃÁ¯vBRŲˇt§Žélũ™„VNãFS•8- Z [ŲęÆtJĘԐú‰Aīú&Rv¨×t˛ˇJxĘDcEÔí^ŧ‚š2ŒÎ°ĶvŲ)˛‚ĒĪ Š„Ūū)ú"KņRNąąģ…ŲŖîdjmúÁ'=‚ ēÛôĶCåLļĘ~%,H~1TģĩĘ–ĻžlՃé$ĢN"Ædü@}čˆe0eņ2‹„IlaīīíÍĪ=Ë͘ā÷ĀĸąJŪĸ5äq÷ûcĨö€ģ€ķ=›¤Õįy?y̓˨ɘúũÆ|ŨøGÃ×=í´‰`Ÿ˜áū=LC:÷ ZCÉ=i"sú7ëã]*ęMsë‡tÔæ?ģÆxxøš=ú“' 褠dI|Ķs؀C•1Cė °,v}OEôšŒá†vO{cՊÜđĖĸ}då;Á-?įņ+ÁoøĩßîDéûžīŋÜnøČMĐNl'øá°û9AØį#'yŅ@ ŗžŗ¸â(Ņj_ö•QõĀ~ܓ^sU“ũ2_ˇ™…,4û¯Oéōii KdŖb^Âč'lÎŖĘŲߎíú‚0ÚWnĀßũ´°”÷Ų%<…ąZÔdŗwúČõ™gm ˇųR8?˛YĪՉŧ^h^P…ž”¯TZrėļÁœOČzō  /œķÁjo^Z1ą}NēĐSėČ8&Kķæ`Ģ6|ŽM{üĨ]ˇš§]Ų Šûe]ĀĮšEÕ/Ŋ† ¯ˇÔ\ÎwÛ^ņ˛—vÅüÖ[š'Ÿ§éx‹Đ|<Ûŧ“OŸÉÉÁõ\å—G^mŊĮãΚŘ6–ĮŪęŒáÜcâ^ģœzĪČ#4Ģæĩž” `Ä͐ .8o{é+O^)ŧîŧķ|ÂđĀv÷ü÷ŸPIšŲ§TĘh!ëXnŸēҞĪyšĢŽ6åe^@Z‘ŦH1EĄÕœSĐúŒ#sĶ‹.ķ` *˙ę[šėė[B}ĶG3#ĘĩĪoēc(Ē[!ĮvŦm;ĩGU$oú(ĖL AûŒ…æ`ŦšPȨ.Žĸ'@ Ņ=^‹é­Ļę”ĪÖæcpk8@q&ĪČĢfhCOy>%Đ Čæ ¸ä’Įđâ`îjKĮeķōĐâútĪkeà —˛Į;ž9Ędų‘ˆb§‘9­`-ŊĶJF‰˛•'TÉ2ų~AÁĄ9į‡ßē1|nÂLIfëĶĒŧtųÃ▙c$€É$ļN'Č76u– o†ŊyÚ0 †ēĨÛhĘsĢĖüQ?9=G8cãēēôÅ&?Žšˆ‰3 Ķí´ëؒۆaŠKJL'­wĄdÜgâhš¯Q0Fž€zֆi?<Ķ_†4“ÎÁ\PŸ‰$j‹ũ%ÜŊtؓ¨ē(īāĪô…¨Znī<¨Ī˙â‚D#œUÖrÛqœ€ÕĪbĢAĸ˜ũ ãL‚”TŒ!öŊ[“5ą™Ē>™ú#ßáŖ>sL Ø ŨâęąļØRŪ@ĢËw|+I)cލPŪÁh›ĀÄŲæ}B0ŽŽsß`96ڐ5eÆ?í•ĸ˜üu| œ ‹ŧ-(Ãgŗf;L™ ĀڌĀ r?ĐÉH*/nŽw|<× [¤ÁlÜüÃpãåĢūaqTãΡ*õ–”°Wà —˙bņ§Œ6ˆcy?éTΐ‰­ŗ—ũKĀÜŧ˙žˇ~ųÅ`<ŧũ›Ÿz}†ûÛß˙mÜNđĄíGø ÛķŋôJNŽs̉OPLđ*/Ã!éĢ~j…ũĸ=MšŌú×^}W˙`ė÷Ø35@ßCMSCÁŅ9ž+â5“ķô+ĸmŽmqĖį${Ą…"Ž…æT9åq üÖŨÖ.CdķĮ˜EgįbVÔW?†1ũoJāál#b0ĨSdÍ~ûÔÃīaūaäôÍÃŖ>u‰ËŪÜØë =Öļ/”ĪņĒ4ÛäÂũ”ėŸ ?@yeü‚ķĪí™{îų‘íË^ôn;‹yßökoøwßÁú˙įMomÁŦUÚéŽĪåĒü•Üן—ėt}čœ|=nž(ė•~t¨úĄî¯×Žzŗ÷Ą^õ–œąĖ–ą™]›§c{›#Ų<ÃŪXvŨ“…?3ĖSņxГxų„Á͘ø8]ĢQ˙8oZ4VĩØÖ9 oo„ČsHæ¸ëYÍņ$2ĶüįÅåCũŋîúÛļŋüuWm/ŋļķ“ŋ´ũÆoŧ{{ĘS/ĮˆĄY "üúÎÛ¨ĄŦ:&¯^~Z íĐ9&X1īz4ŗŧb01Ęâ[ķ-°ŗ¯3’Ÿ“ķ“ąâ9ļ\ØĐq)ęÔ„fSQĻûø™÷ Ÿí‚d— ×ļÖ¤Õ'f͓õ9ö)ËÛ!öǤ.^ö}‰˙0/]?î[zŗŧö›ôņŲ0¤2ĨkÉ̇ĸØ~?`d#dŗm.Â=Ž”#rbÔҚK⟎äŽbļŒ5Ōi6ĀŌá•}īcŊķĪO¸mé˜>ĨĘļMáÆsqwÔ 6-môāž@aŒ7Ų!W¯3 `™-Í9fųíD0ĸČŒvy$Ū8ė B“-mļĪbg´™Č$åĨ ~mŌļŨ)‰ÚkĮ)=“ \đ¨/÷–9šĸN7;4Đ%ˇ_™2Á€Ŧ­Åē>ķŸm‚ąšn‘IuA×ņ.æÜ:ĸM™ņvėŒeúS<f›äWPũĘYOfq`Lf+Éás§… čbp7Q_C~0—=Y…ĶļÛO?âŗƒ>fĄ˜gŒ%6ô˛˛oЌ›vx[NáĒđye|ísŗe,bP‘Ąlh; cß÷ Č0ōų]-;J]l4–û}–ĘĪxœŖŒ:hūMĖĤ¤žЊ4Ģ~;_÷ģ Ÿ’‹˛ŌÃo_i˜ `€co„“æH|´94Üęˆšä‰Œvī\9{E¸ō—Bņ[㠔l…Úļúe4ōN*Ėæ¨ž°‰tķ}ú ;i›‘;V—˛nmĮ›v›ËČ\Ļ’ÚŸļ@’§øžŲėû“n<5ŽÍ°Í¤9ÆCdŗē@ę`īBMĸ qŪööwmŸ¸ûSÛũÃŋē}äú›ļ7ūúŋߎēzŽØöq´X*¨_-°‰'ULÁP>ū }N~iĄŸôøtš4Ŧãŗ0CCûgs‰=rÖlwŅĐØDˇy¯& Đ:•Ņ”SÂI™>sîi^QH)ã‡øåę‘<ĐÄÖˇ=-Ä*§Ÿü4iˇY-ęĪš •“ϰ5j_sMō΁UŊ؆žÉˇ S5R&õîqpÔℎæéĖ­ãÉŖd>;†OŦķV™]{ũöfž™¯eŪõ“Žā֛ãŲũ%_ôŦG!YŅ?Øõ˜jŪFß>v?ŗm éG“˙Ø5N6>‡Ė˙˙ŗņ~ö˜|ČŲ.*ķėļ.N?OrŌTŠĮyÄcĐ,žėUR…yŪc懝ģu{ņKžš}ķ7˙ĨíÍoyĮöē×ūrˇF=õ)O脯yŧåÕ˙Ž-‹Í{ĒĄŨüÕ<[|&˜žMē­@SˇoŲną1Kqcž:ƒ¨C>Ę[Juk#4[€įČŊāįv¯´z^ōŌgņû'ˇÛnŊ{;“û5ˇƒ,ēÍũĸĄŠaŧԟÉɉĩšēâĪ>ƒĀčĒŽČ9ÖõsƨLâ šĶ“?’í}ļ‰7mQĻOH–ĀjĪÉ¨EîŠLĸʎ¨0–÷…J°Å|×hÚj>Ŧ"ĒØsĮp „@ڄÍŅÔį€k¤hs"§} Ÿø€DûĢoæ819ۘ >؃AFšĢeFÎųŽ§ųOu{€“ī‡ŋ˜įūī}åÉŖ |7Ÿœŗoã͸ZŸî ˙íKS:eTvųôŅä\Ų@4V—–ųFô(ûxŪķ/8gûÚŋü’žŖs?hvī§îßnšųn_zNˇ“Č×ÚČ7Äŧ`Õŧi.8ÄŲ΍ĮŽÆtĮé)›9Õ¨%WĢīĖ5ķښDé5hšmCŗPN:#Lķģ‚Đû´™ļÉmųNŧTū‘s܍žÚR-nö,vsĄąV]O \üé×ĸ¨Ģ9´đ4_FUŒŧ˜1§ThcNíōÎ\•Ĩ™­†Ž˙ÚCq`¯lsĶphÎK`ø#‡Zt„BBŸĘ¨—~ŨãEu>Ņ68íEŗ|OšßPSĘfk<åĮ&'Œč´dĮ(›wĘ-^Ķĩ#/īv€Mķ¯Đ •HÃ]Ŗūį”{˜{õ ƒŗ>™¸å—Lü‹ŨkOp&åíhڐ÷ūoe\ ¯A_}°žäc}ņ)ᙞ§Ę6^ā• ´ĩ‰™´cŲÅ´8VZ 'öŒŧOŒNö0R[öŽÎÜAî@Ų)SŋØ iYĒ [›X &H뉧NƒsÅ?yĘļÎ"?+Lo $q튒\4{ĶŪYX‹•âe_V #Qō@Ōęy ĀâDšėĩŽm\úk€*§ÍâĐięķmvƟŌŅ„ĪXSžm˛6ķiUnYNgˇ“ĩÛ|úĨe‹E›AŨ1Æn]?DīdĩėƊ1}8;>Ö¯/­e+|" ÛĢĄĐ@wNI cīRÎ@×]ĮdqZ>MÛę'sm2ęŌLrc“ ęRëžų3rúˇĮōpŋ%:Š˜ķ ųe—_€ƒl?ö#ŋ¸]våEÛ÷|Ύlŋũöŋ}ėīØNœ31čã~xĘÔ8Ö;Ҟë}FCŒõlpQ§˙ä¯,4ļlŨÜ'Fz'ž1šXĐĩĀPļīŠ*ēŒ­5.Ā{ĸDœđ ÂVÔdlÛąĸ‚ŗ[3ēÍO%šČҧ-ZĄîāŠ1”‹¨ôDœĸ1šEÉĩË( ĩÚOŌ&/Åĸ"î˛WîŨˇĘĘŽ PÂDFf^–åOmģĪįîŗj^!ø žl˜U@IDATģ%î;FûUœßŗŠü G§īMŒ÷숚ĐšWį ĮĐũ'ÚΞėøöe/yūö+ŋō›ÛOūäļ§=í <îķ‚žĀmžßökœU¯CЌ23ĖMR:…úD[&˙ĻŨZ6´sÜ)kæRĻØ1ęƒ`ĩyņšÍ'éUKØÜ-š•ŽųˆÚáԎ° ä‹cŊ!Ø&Ũ0)āąÁ=›ãy†Č0I'tõHŪ5ÆlX­ ´Ąúh*ÕĮ\‹3.ÅĄ•—œ 5įėâé‚wĀ(`hĮ¸åK8Š!Į\ÖņęÄPáfüŪŠ,>öé36ęƒėœ¸Ú—ĶÖâĖBUčw[†[ÆžĻ ‚œš+ECD_´Åø;įJŪoŨt>ˇ‚§œkcMļ(ŖÖn_ŗ,¤Lj˜Ļ7'†6úįĻ=p;u¤<Đ#^ q)04å,ĪúT¤Ēķ>IŊ=Žûīƒ‘OuËĒą.`a‡uˇöŧaAö՟+ŽÚ?ą˜ã˜ŧôcÖĖJŦŊ.m?鯝F>`È9 Ā€hđÖsJaŠ3ց€į2K‘%ĪîÄ zx NuŪķÛwhCŧ4˛qch÷ŧA-¨;‡6ÁZ"˜ķŽ>x•{ļŅ3D‹& aåĢd´•W ´i%¯žËŸhAŗ>gĶæBØEIŒ@ŲŽ@zÄđŧŌgË:ŲNXmō:ŖÆ­ ŨĘą(wo2n]M„>5Zt ˆ@fŊ\x÷ûæ[ø ×âLųü7fŠIsÛífļđsD*-öŽ$'Šƒ)Ũ FđĒĄ¯„ƒZÂŅ4[Ķ4}<ʇâØ0>ÖßyĨWN  --„´U_Ų<oŖå_+Ÿ>ˆ¤ŊÄŖĨō~F^?V”ÜôŖäüƒ” ã'M-¨ĀAÃ}­+ģ\ÚN9~č5ųÜôYk˛›úøŽąÃ7…‡f{×4țē.ięë$’}÷Z_´ũ>ÁF—~ŦœĶ‰üKŅāxâžovÂÚ˛VVŪ$Ÿ›1ĖöüCü…ĄÍđdå*4æ Cu›_~:7ļÉÛ,˛åwÁái•yî Ų‡OzŌ%ÔOÛ^ûÚ_%îlßûˇŋûĀöڟyëöė/ē|;~gõéáļAę{?í&(;‘ą]{čm-FæQ(°EģdDĀūĶÂjú+(6–C9”pņ+Ķ=úȏ—ú¯û3¯8V]hí÷ΛĶ2¨fΉũ ʃX ¨ËmP͘îČV8FãØ’™ƒŊ|éĸŌ1ɃIôyëØš1’iōm?.hˇēėËųõåŨVŦTÕĀ Ļ ļÅ:ļ+)ŅiË7X{p‰Ā´9K:6õŖÛp4!Å_€¸DɄb ˆ˛S~¸´\Vģb€Ö}e'ĩ%ã@Š•ĘĪnÚWGŠĮÁę“NŠI˜ũįeRt`§Ŗļ:˜ÄØiĻē'{PáĶFp-uĐ$­@ÉnķĘvš÷¤ėŠņ(Sĸö†’ēŠÎ`7ķ'1Āl té}ęî´1NËūUW÷ °šJIÛ–¤¨…­-VÂė'Ņ1• ģã lĻĻ(6[uŠXB"Ëūa‘ϤqĩēâjHrČ7Ŗg2Å~W~ŧU¯ė‹‘ō.CûŠíä›}Ž-ŖSÛK§Ô­ü˛o§ŧOļÂ,ŖØ´ ŸšWΊl˙=æĮšąEË&gØŋqí*Ī@ĪōRÖ —süNVš8ŧaĸšXČíęÄđKˆ$ąŋˆŽãŸ/aČĄ—:xÍOČ<"|Îíû‰Œ\_ØūlD`úÚŪf‘g^P*ĢídúŊ[ÄČ ú _˛ŋ…qvéãĪŨūÖßúÆíCüđöž÷~G•Ū/Ŗ˜/ųē(ô‚˜8õšcWøĄÄwʈ°Í Ōį)ī ™.ØėĢŽ~# îōJ~”%Ģ|đ¸"Ĩŋt́ĖˍŪs~ÚāYla͸wdO7ŖB:Ø{Ä ÜöĖ—ÛšaŲ cĮ#ÛaUÜ=ŌÖvl/f3O ŌĒ™IZ¯€Ų- 8ŌÅvYäŽÍÆgņ`ŽÎ!Ū˛Q 2k“ēŨ[‚dđŦ +ÚhŖė˜ˇ­•sχÆXáVē ›m oÜĨˇUG Úĩi@(GĄã´kPˇLŊÜËĩSož•Ė_FYnmĨō8QN`ĮáäIUĮ{å&îúŅŋø[ū#`ŦÂą¤éŧ= ¨-€pĻ †ĨаT*¤Iģ/Ŧû?Ę5ĤFUWžļŲXØš&Ģ’vĖFÆÃŊB šrÚEV,‘Ã)ŲkQ”ŠíõT/ ˆW En—oŠ‘íÄ.Ø-Ëǘ­ŦÛ&‘-ŋ+ Ŗį~šš-$ÂĘ4‹ĖZW—u6ē€ĸĘDhMä<’ÆníB7tī,ēų` ŠƒˆiŠvŌhÄ5ŽûÉMÎIזĨeŖrûϜI9XZ\[ŧ,ëĀɒ:`Ēoß´Ũ-[)[Í3 Ëīö2ęBXŧŲ. …ųŖ"OrąNČ!(ĸĘ1ŸzĘ´˜[ė$ēâ&°ÛĘ9û/õĒ¯ÆôÅë¨/öü›iiĐ#Ÿņ`hÉŪ{LŠ 4쯤Üxx[K[ČŐĩÁĻÁÔËũ“‡ąo&Ŋ"FÅtčd\ŋÂĀLj"˛ŠĮĀŠ§H¯ÄÍîø‘=ÅSA{ ˛“ÁdįJķj&>0á7Į;o :TžÍ>˙`šØ)ąG,^xÂsŠí­oy7W<Ŗō9ėßôM¯āyë'ˇ7ŊņŨÛŗœˇ{"„qæ!-›RWnIY6UŨwSeûfuũ@˛Á!’ĸâĀ0m–§9g2n\‘Ž7ķŨ*¯^Ž4ī(˛EJ¨bLI8Z|5Đ÷+"Ú ŊOm×ŽØ7bŽliå=ŒÛĩ č#5ØúW¸Ņe~t[b4į@¯–Ą˜zzSn4yÂšČ\ÚeüÂög!Íu rOVž‘'-dJD3ááí~•ûü ÎŨ^öōįōlwöÃfū>Í˙ũúßáKÖoįķŖ}~×bĪãÉm=œœš\›,îÎķ×üã?^Ôø ž Ø'ŦŒQ'ƒ mŦ…ˇė$ņö–âˆpw|zÜëĸ˜'ĐÔi*į$`ō[r ē¨~ũĪĻąƒ*ds׈Ÿ¸ÃN>ĶJÅünáIģōųŗ&­>U›5×Pō8_ fšx­ÖĩA Kŧƒi ,Ęt‘ËÂn#í…Mfļb°ķŖWũBÔ& ¯l´<‡4Ûą¯f¸%HĒļŠ DÚG`W„ūĶ‹Į`É×ĸ¨Ú_6iƒąTā°ĨđG‡:Îĸvâ;zG×Ŧ]ÉŽņ?+Ž"Oœ&āö×LhēĄ.Ūڍ—}­žr ęô“|ZŲnÅB]Ō°Ãž2šdG06Ižŋ†í{ˇ|A‰ēy36,‘Õ¤MKN0øž"d–&'/q–LėŠîū Âŋs:ŸŦāApæ 1Ī ÷įŠÖđQ2Ę!ä° ØZ€RŽĀt^œ˛îl° h?ØÂᤋ ĸēzmŠKkîlKˇRŦl…tŠÛÉf‚ÁÉÉHˆ"“Ž|žîUvö>1ĐVd,…ßížO(v}Ë>Ŧ!`%M–}šĩ×ūÕÖíH-ôĒ\‰HŲ}ģĐ×pÔ+īŊb„“ˆž†*oą+“Č@J‡Š"­ÉI$°éįōzüDˇŅ/&MĢWŊbg‰njËlŪdQŋΐ=ÚÔ3ĩ‘Yo#ēūą‰Ĩåąj(›īæAIŖŦØnŠ‹ŌIҘ{$ˇėŖ]ũŠŲsžĶÖAL›÷ŦŅtːīŠSFécÕ´īũ> `Ú(–âĀ?]§Ŧ}ΟbŅŽÍsU#iøcüĩ)’8oՓėâˆ_ŗŗ -?ÖíÄĶė>î?.†&$A>!=ĸąFaæŒ]ņ„¯  ãC^äņŠĮŽĨ}”ĩ ņčNŽd •ˇ ~, /íÉSnąkŽŲM ĩØmjâŅ3v4UÕneô5›‹ßøúƒQ˛\rņyÅ÷g_ûÆí Ŗø7˙æ7lŊáĻí…/~úv&?˜ôü(?vtŦįŋ/ t#ɂ ×Hā„žŒ?φŋûæ Lũ‡­}ܞa™ųÚ;ũ†ÍōŠ‘•éŸHōĶ`UrīcO'Hb dfFã>\öWye›2đi(ĖēF1ŒŠŅÎtÛ÷3¸•ĒļÀW— ×f ˛$fíļmđ⋞ėWm”ëŗÍ Jšˇt@‡ÅÎH M&Rˆ8cŦ’a7‹v ‰ÃŽ%& BfB]šķ´›˙´ŠŗŲ Ŧvāéę§.fzin‘Ũ-î]ŋ6@îŲđđk“W>K ¨3 Ü$|õŊ•‘ģū‹ŋIôŅ[b`Ŗ°ĩÆLIÚŪįČ՟I;\Äĩ4•ÅiŗØ¯Įĸ;ę÷ŗķÚNSōęUNĢ ášÔ¨•Üâa‡}j˜ÜīũŖĪv\^Ö>x„å­œX4wŌĶëđY°je Į–šbÛvürĒ:ë)´ËmŗM†ŊûÔƒZgī{ ķļŗŦ•7XÚāWVJ ŋåķÜ)ˇ6ĒtĒ7^ߥKs[Ķ“…‘‘f99˜,O E´’¨6yâa—;-;QÛ˙BöōŌ„ĐSpø2˜ōN˛‡“Ĩ”AVŸ[ė ,Žļ›"É6Zš@ÁÉ/›^qå%=~đßžæWļOŨsrû¯āÛˇ›oš}ģöš›ļ Î;‡útúíK¸BģS5Ú ;Ę#ZĶžėŨ0.;'žT“ įņËĢ3{īÉ\Û&ĀŽ‰sC×ÅĐfųfž‘}ŸûbáM;ÍĪtS×´|ˆNYLō>ķšnæ’9åünš-ÁÔ㉍ēFN.siü…Ļ# ö˛=ŊŅw?ļõßé;DČ}aûŗĮō1nėö‘žvũē/bnŊå.žKsÕöW^ũÕÛ¯ũÚ[ļ˙ņ_äÎÎØžõŦËâ{ˆÛ‚ü%ķĶ8vÍÜŽėø×m*`y2ëŸYâœ?ãŲ<œüŒĨå4Jæ˙+kË̞Īd\š•5ķŽŊåãâ6;žd|{^Į%Q æģZ°IÅåp…Ęoj{Úį‰y<:•Õ—Y€Sc ĖœĻmüekĨƎËÁY‰cųš>ŊÍ,¤´n6}jŧKĀI/Ę͜8‘+:ÂÂâqNŪ™K'F˛x­nėí=ČØ1o}ú’§`¯bŠŒ{å‹ĨæĩÔŧĸ…rč;Ø0šF{Ôā›ß0åø ÛÍIŲɛĀėĒb?Ą…aúX°Ė" ž2ˆ6#j&IŅO×BÆeyŨ˛œÂ+îæÚÆžVvėPōМˇ´Á9Xf]§ŋdl4Úå†0ŸØkÍđĨ ĶÕ?Xã$ļŦ`&+õrpįöĘĸŦųÛRĸâo-ė´š˛9îqBiõČ"doČãąŅČrXN]įå/müx˜Á”…ŋ cđRt”=‘ÛĪ8TOo1Ļ^͹˚ †Ãāmq0x9!€­ČŗëĒ}dôŊ–īÔĩ)Āä4Cá EÅbŸ,ĻC<ˆĄĸÁŠükÜō ŧšJ)Ž^€`{zF—ōéĩZ3\Ģ\Āõ!™´ CŪ#á:ĮŦķl;ĩŋ6‚ĶÕZš60.¸šÅ2.Ļ;h’grSyT 'š…=˜ Ļ =WF ͏ƒåá`ú>˙rG›§ļåÎ wá§á†Ė‘‚aØYUģ¤įˆ~ę} CœŠŽ˛=>ę†88gCÆNdsPų‰ŠjÜö)ĪzĸāTļ"Í /Mŗ08{¸yˇrü Iƒ]mģVø×}”ŋØŦLTJWDprî „ųHrl/gÁIvå0n勖uÆÎ^=eė&‹ ߌ+ōHŪcIŧ;Ÿc°<9!vbī’o&:IxeĘįDŌ6 Í‚lß}¤ĢĖSÎvxE˜1>nĖÉĨ:á_c &bĢŊk"h&Ú>ŦvũnWbĐk7€Æ˜Ũũü"ŠĐįœsœ_ >žũĘëßŧŨrÛŨÛw~į×o÷ŧûąûĨžpÆq~ßũĶģčAhŦ)4/QŒaÎūŠĶø&\ãE_h(V坅†vmŗxĘR÷ojšN͆…žĐfŽX1V7rö[ōÅâ]ܔ'vŗše<,ŊSoŽZīôŠO `4ļ؃ŽT (&RƒÚ°h.Ÿ˙ÎQމ1Ģ24m§;c;íî!č~|.ÛG‡Ēž°ũ'‹Āä—ķŲÛéüēđ1ÆŲ1ö÷Ũ˙āv?čõâ—ÚCá]ģ×ωbVbāæ;ņ .]Ũ‡ņęÂRpļôįâ¤x9vŒ˛UہÔžlšņˇ˙gVv&œųWĒ7ļĒ0ĻčĐūŠĒ\ĖU˛ 뙨õĮĪÆŸØé{;ō`mĶwķ<´T7er<­ bc?Ķ%ëî–ėVÚÆƒŊ,žqP¤‘uqŽ6ėrĸLW”ëKĸ8Ņ$Ņ 1žôÁF\K~âPJŸ>“Ö¤'Š <;s—ÎĒöÁÄ5čh.–@ģûîO˛¸9}{×;ßģŨ~û]Ûw˙WßĐM~ø_ũâvéÎåūæŗ‹Ãƒœ8 L@ėũø\ŸĩKüNdŌ ´Ž@-!åô­+6Bô ĮšŋĨ‰Í|đđąŗų%Øû‹<ĸÜL×ĩė ÛDŠåCžč›ˇü™gg;Á÷fÎâKōl7ÜtķöÂ>m{ÕĢ^´Ŋ˙×møą[ļO~ōŪí†ßŧ]zŲųÜtfŸŦÕuŽ1re˙}‹rŸ7íņÍ\l1EŽĩ>`.—§|qgę0–CԏōÚĖqŦ9¯ųr8 øųkūoá§ÖaPŠ:Í[ë6jK:å2Ōyh%o>в_ŒĐr›$ͧĢ4Ėé Kpt뀹Ėx]"ō*ŋ•b’؇ÛQK gˇķģëcäÖ?q;xAO¯ãpׁHǞ:ų&Á^6ÛėČĖØKza…Cūâč~ˇĮ؅´*ékLÍF%ÆvęGę Š‹-šBG›ØiFiCÍãXöä­ŽÁ ˜øZ †FØŋxhl‘ļx”‰&^œļ˛‰9:ĒåHŽ„9ēŒßΧ°qzų7>jƒķ˛æLgIŧFn7JŦ‘žuAqJƒ(‹÷āyŋžœíčíųZŲ]ļqrŽŪĢ?Ip `MÔ÷x 2yŊøŠ›ú3ų\ Zđ>?1wMyŦ… ē3ƒÎ4ÍŪäô`č1=7×AJsôËPŋåIÕ:@;‚ 2FÅž`,#­f‰üKĩhD> œß ļ~ā¯qdr [ŗK–Ĩa|Š0$ŽŊ˙ 6‹ūiĮ”w˙: ˇÄf& !JUŪF–ljdËę8ûcīŒÎÜØ;ŠļŨZŅgo—RŦäße”Į^˙ü"›+#/ĢHđK#VzFQ(‡~^N˜ļ…%qŖE_ îŠemÅ#ę\2Ö„V@ęUŽoáŠŨ>Ķūë cGž ļ˛3y˜€ŌËsqÅXŅڐدLūéBķr‚ķ¯ēŠyĨ7cˇ@c„T ēCŧ†T‹?ArB1ŽÚ°Ē×øDōĩ÷žFPĪvJēUß@vÂՏ„ęĐŨʑ‰Ž>kMNå/50;Y‹~tÂ(ļm]ŊąhÔ!Fv(`ĀÁhĮ~ׯl›(ÆW/ÜÆ>Ë{Ėᓺ]?ũS‚2üX 7ņR&ôeÃĒË%~ąa͗GwyŅĩËwƊ…Gc¨ęŗėzãļ…ã´]ïę“÷mˇŨ~ĮvōŪûļoxõ˸ŋųüíįūM|Gā´íâ‹ĪŸÅķŠMßģXĶÂŦƯ4_ëĶŽ‰ņâōÆtōaČÆG>ķȰf°ŪoūĨ5kö ēaáæá“_‘e“ũĩ§RDÆô,k­dÃÂĄˆÆW5sŠ!<1CÖĖ\åTŅđ‰9'AŠO‡?IdK1FĮ¸4TŸ#ėøņíøvîvō{čû1`—ŠøįūHMl?‹'Ÿ¯MvÛŨęcöŸįM┎]öŌ÷TÚA›ã›ū;~ÆyۙĮÎÚîá6šĢ¯žœG{^ŊM SoĻU^_ۆ=érMÍęĐgF_ūą]Ą‚XXՇ_㋒ē”ô}x".?m™úĖŔ#Á)ŗRįœvØP:ĻÈĸ†O<äõ֕„ך$­ņAˇ‰9´yĮ2Ûž0ÖOįŧaBĄzBoV ׿ááæl°[!QaÚj›“ ĪÄ\ŊYjø´ÕŽÖî|éUÃŋ"Å-+e^ų&Á™cø+ę\ēŊį"šÕŪĒVŒˆlûJēâ ^[}ΈU’Æ–ÚZŽÉBSk}QÕNbAkŽe/Ü0€\ĖÆÃÃņĸ¸¨vÆcˆ•-jvÃkąĒi'(¯†<ĮÔe$Ų;Qā!Â8Ša⠐$ ā%ŗg)vîčž@ļËĄŌD†KYęžjžˇœívD¸ę6Á\`ĐŲÜz9$­ƒ0´ž€ĻĪĻà CdãŦŗ7 õvÕÜ{åPšŖûģĻš ĢsŲŪ•Ä|AÆ? fëE=e Š­Øî†/pÔBž|ëÉ.øēĮÛÖîv4åî͍0M‡˙Ļqĩ_{ĐÛÂēžˇHƒėžw.(O} ˛¨ķŋúĀsŌÁ-Ųč‹p ôaY‰VldS_™ÄŽ›šV üÚøب]üŲĻŊ(ĩ¨›¤ą{ō*ŗĨ80И ņņÖVMCČąāBŧvĄ¨Ëi>ķŋ´}ZŪĻpąOÜšÉuø4HëÜÔ3ú×{´o—ÖlG˙ú¤ėے‡$ËnĶŽG9u:øgŧ! ũŲ„O`vÅ(åŒū\QĀ;W?•jĶ2rŗ5‰ƒgÉ?šä÷¯+PÔf“œ‹É}î~$ėCÕČežÄ|Ø-›íåŅ]čŲ´%ër`´eÆĸLˇ yķ\“„LÆũĖļ“3c;°ŠsƑX=ûėŗļķÎ=ąŊåÍīæi&ŸÜžëģžĻž˙âįĮAč _™6wû`t°Žvŧą(+ĢĮūR—>BGû´×zsŒÅcôŠŅUë5÷ˇúN)6ËK×#~rĘvô8ĖŅî,Ŗ!SĶŽD§<ÁCŲôŒcal’cŋúnš û:ĨGļ:áęŗčŖ›ŧšŨF››MžęüaiMÎcŽ<+ë—\˛h•{—(6Čtœ€ÚÅ@11Bã¨x2)F},bĐv8%?)j^譛điÖvŗo–g îü[}^ÚÕęrĮvEĻMžLž4č‡<Æbǰ>y€—v\L`/<;.øō|ÅUl{TæäĶ“ĸâ0'ęQƒ<ėØĖEësÅÔâž+ô=<ûbŧ|ĸÍVMōŠ€6;™ØGÆÔÖĘYĻ=ú"F*€V™ēÆûÚ§Š~pPĮŠĀŠ}Y>ćüÂëdWw0ø;XhN•GZčÅl'"mj N}ǘ“ ; ßĻlęL%åŅ mšÚw…•×&›ˇŊDŖ ÅęļíTžhŖ8ÉÆ %éw ŖKûFģ}闌WMmõG=bį,-û|aŸŲiÍ?°AÚ%—œŋ]ĘëM˙î]ünĀÜôuÛÅ]¸Ŋ˙Ŋ×r"pņöņŨŪÕNŋ# ņy¸fPsBJv§ÆoIš8XlĀ?ksŧ¨ŋ§Ũ>rlėÁŅ×ÂIØęG˛ãģ ú‡X€ŧīb }XūėŽ[—¤­ü¯øY¯ļe˜mâ)ķD´÷Ā?ņž9lņc cŲÜņHVį\ˇÉ'|ãû1sœÛį}N×f)„?œy<ôĐéøŗËB/Pƒ7īö°fĪ˜Ķ Įã8)‡2ą\] EËÄļÉâōU)ˆŽõ[œĢGc%y…uŗ<`Н¨ øī­z§skŲY|ÂqzWĄ$ī,aljƒŽ?Ŗ{ ĩvl_<ČļS͛)§i1†˛sÚâXÛû&u͈ ą'ÕĮxĻ˙éü —O š‘_Î~ņ‹ŋ˜Û~žxû™Ÿųåíú˙rņyŌÕŅUü _ō ,˜bwÜ'á2•ūÜe~‘Ô§×ÕOčusžsŧš™.úŽ- ¯xč}P>É Š,ųŪí7ûŅĪ´-~5õ˛ŽQā*<ōs,ąVÜŀĮļYLĢĄZØâ$šÛ‚ú,Į}—Q/-ņƏãâtÄâîļÎ\Š~cŒš°Ī M*ĮļėÄŲf;ŗ Ô”„HŒėwPÖ ¸ã‰}íFM<ãMį=ŽĐäņĮί1ĐiĄˆÉŊ6ĘÚŨmeØQ, >5Ų›ÎUÕĶŨžŅS/Ļ“ĻÚ‡čŲwčõ‰æi€vhOB:A!;#,œZksĮ~¯ãL_,ėŒ‡•ÅRûw.””č5oc´Ų1ķĩcFų•Ķå#5eWæ ÅtíoĘų%_ĐK{?|íÛ7Û+ļo˙ö§ōcyoé žū×É{īßNįŠMāÛ¸ŌjĮ’šŦßč(ŋÉl' ”Ėąyåü°Ÿbƒūh›ųžŒk.ôĀËļÛ;ņ"Ãq;ž%Ēîâb|L>Ŗŋ¸4–´ÛF„Ō3cKU-h/īÍ ŽŊœSęRFÉq{Tut)9/ƒŖNũŲÅ&öЂ°ŒĮāÖW;c@ƒŨ‰tÃâØ”fÛĮÎY8îXEaÚ4šd)׊â'Ņü“qŊ'™~AsöBOUø'qbĪ–‡8‘°ÛxÔgkP~ĒĮ2ŗŋˇõķč3üĘā ˜‰ØÔwģ PË K9›õũzi]Aakŗ‰šqŌ1ĮŊ8å^ũ32ę×_™Ü¯HƧŦ[žR)Ē098ö“Xĩ•bHŗiâŅ .šÛÄuôΰ!kÍØĸFã@]lEäD6=š•zŋb Ÿ6››}Ú#_Hî÷Ģ3y •Ŋ6ûíTŖtōļú\ĨœąĘ:ŸaPÂõ¨Ŗ ߊ˜Ū›/oāąōf~Glp§†ēķ āąßaŠsíœ/4ĀȊ#ß rR”N4ĮZYė îUĀ›˛éĸÖIĸ+6õ0aŅęø6–ĩK›šaā_›‹nLÛfŗŽŨŅââįâ‹Î嗄¯ŨŪųŽ÷m/xáŗļį>÷™Ûŋūą˙k;y˙ũÛE—œģÁÉė\wąâ„¯¤}Ę?ÛØ1Ú&öé§Ŧ¯ĨSŠqķĸ‚˛-@,ĐnH4 O˛øįyFÛŗž}9ŋžzQ/dØo_ØūlDā˛Ë/ádėĄíÎ;îŲnēņŽíŪ“°€ædÍ>ĮÄzŠūÚķSĢī,Ļ8Ō9ĸËŅž6BMĘc¤&wķ#åĢczōžĪĻÚD÷8éH=ŒiĘ3įÛĘ|Aۜŧīäö‘nŪžîk^ŧ}ÕW_´]wũÛÍ7ßÚåŨrë]ü¸×9ÛŲ<1˓ËFĒŽxâĸ4ŠÛ(uc¯cĸ˙Ž×xíąĢ1žAĶFqNˆÅ”Ū˙*8ŠXīȈY@]zŦņĮē|–/Îņļƒm™Ē“ōEŠĸžČĻBđÕŠ9nķÉšqÕįJ{t-3Š2ƒĩųkD>8ļĶÍõ‹ķL¨ÁdŌ\lĐ$ųa–Ş~øDđ1W ĩŅDãĐ$!^įƒ†-`äQ6;—hˇ­*ąEJĩA•˜KŀŠj”’8Õd\ČF€oJÍd”aZ[%q‚ömԝvŒxQ|–JA-ŅāׄĿÖ|Ë`OĄŅŪ‡A‰+û˧ø;1ŸĻ>ķķTQėģá55D¯aĢ/Āí¸.˜Ũ*%›1¤nWz/›°ųŨXmŲ헀ĖŦ“ oĻjIžZ€ß]EŪ6(ú5ŠeŦŪØ–ŖąĸíĶĪÚĐōŗģ~GÄc'e1õš˜¨M_Géü˜Š_míÕŊo)^õÎ8i0™•kaˇębę’9dgöCbÁjD°ŗˇ]o¯q&lÉ?&Ķ.N[I-Ŗ”a˛˛­@hŧõÛh1yü“}kÚ Åė( ŦĢ•Mŋ-ËģbP%Ææ "ŧdA÷Ŗ7ĸƒ¯ˇ“tBšöBšÉ.”ūÉãßnŸ:õ…]5ˇA)‚…Úöû,‹+`bø/nųĘžcËúØ"˙‘nōN`Ę` ŋŠ2cbM‚ú71™8ëÆ`/ųĪø!°™ŒĀÄaĪ\Ue?ēåįP jˆĩĪUĸUģĸŽã›ąZũK›ŋ,h_ŸēŅ lũļa[ƒNķô8+ÄŊ82ąÖ‡’Wš…›ø‘īØÖ CnúQL7q&å•G-{/lYvˁ`lō #ŠŠ9“ˇš¨žALģE ūJö!’Ņ$‰MÅbcŌ+ŧflĮށ=Ō´ĸÆ0a”?đ:{ŦN~Č9qĒkwEP-*hNĨ’7‡Ķ&˜Ōĸ:rˆ!Á¨ĢžūGؔРëÆVy•&Æã“tãîĖ‘ęõâŗhãŠM‰‰ßYgßîēëSÛ§?ųéíö[īÜ~÷ŋŋ=īyOŨžûŧgō‹§ŋš}ôÆÛˇË/ŋ°x4ÂÜX‹Rƒžīũ‚ĩ~ĸĨFû]ë#ģ i¯kŋc÷Čö'?|ßöÜ/šzģč"ž˜Ė'~2æbķ˙w[ųzõG‘},žĮj˙<Ļ5¯ŅyÂEüĘôYÛûŪsãv?ˇkāŠ:]ö¤_ũôČ.6-čsäQ=ŧĸGˆŠÖøOŖ6ˇQđ˜WŊÜAz*süŖâÛOũōî§y’Ī3ŸõÄíiOŊ’û¸Ocąūvũ5Û~˙Ũ×nW^qņvņ…į•[ CtvX;Rį ‘LÛi.@åÉĘčSN ‰đ7î<QŪgËhpˇNęÂÂ0ŧãj<€˛E†ŋXŖ{ƒڋZ<áMŠS¨1Qŧ‘į}ø×؋ŋqRvļA´ŪloķõŽQp–MāyŒĶļœėÕ=ˇCQĻŖDuķŊĩ’č‹_;ܜ÷cĨœę%i$ÅŪ–+”›īp`˙tņpŒ˛°u| hí8˜ĢíÁ§"€ķ)Ŗj\ø¯o”éve¤C˜ū´7áÅ yē8X÷ĸˇųÃa‘WrM”E`Ã1f‰eÕĨiËŅļĐî6f?F-pÜ\ŸqĄ­)€.„åčžO)šŽļYĪę_jØ×Ā\X6ÆFy üņž*‹aŗ2$nœ.#Øi)øģÕåÂrŧ8ōđŽÛÃģ4Ŗ~z@2cŽ,mU!VgŸ‹ĢҘ?÷ũ@j ”‰ŪQc¤Î–g¨@ę˛7JANŠNͤK%OŊ ŸXcŧÜ;öđjšFĢÅ˙6ë€&7GžáY‹…<†Ô@É ņ€’‚áŊ…Ņ#HxĩËßŅ”sĐË*īq%ĄXæB ē@įĀîâu Î"^øĮ8P Nĸvŧšl(Đ˙šō˜ņ:!k¯Bū‹žŽõKíū5IÄ5:xŸÚB0•åmÃ1ļČř]Č%xČų‰ĀžIŪmYø@ G˛VíJܲĸ1NĄ"ũ6“š*ĄÃė_˙1H׆uņZ`yÆ'Ŗ:Ā(J}xjŊR;[¤-‘ąMŪ‰áŠ]&ųfà đ\Éhh 1īy5$˜†ę××ÁWyi‚ÜÁ\˙Ō”ōā:Đpy!˙ÎWîļĮkĮĢ͐īŲO{ČûIßt!vË:ƃ}(ˆœ.ĶÉ$Ū< >˙M„xôwōEiMŪí´|ĐJmĐ:Y×?Ęđ sė4žSžuė@ [ôUÃ;jĩi0ÆŦÁŌG/@ÄŽ^øęé:‘͏¸ĪđWOšÍæĻoᖠģļ—ŊâšÛíˇŨš]“‚žîk_žŊũíŋˇ]sÍōũŸžrŦÛ G|*QŋyŦŸÚY;o-V4Vôząkúũá‡dy˙võĶĪãIĪŨîŖėĻšŗPĨîDöjúŠŸõY‹…˙™BÅJģÅ?ŠũÉ>†ücá?V{ŽÉôčø6ßB~€3iúĒ'_˛]÷Ą[ų~Ù=fvø ›÷ûøcōĩO ČagyQzöļÚ(C.LÍ-æ4įéĸzáčôZlŸæ)Wļ›Īļ=ņ‰—cߕÛ˙Ÿŋ°ũî;?°]tÁ9ۅŨ}ūđEÖFüsaHLs¸‘‚ôā&+ š¸W15ķ܌qķŋ‹ZM`ōΘÖÖėõš9*ŒŸ`@– KФ_=ü7]Ķ|PIÁĻ€ØYÉRĢbšq•ŖDį!ŪÄĄbQŠŅ‹m`ĘĢŽ˛bœÄąLqîŖ ĪˇæĨS2ŠO­RÃ>'7Ë&}X}¨EnÎyŒéŅî”Č dÛ/\6×@ęŅŅā•G9qæ cģ˜ÁķFYŗĮX{ĸ";ü;žÖ脇ĸėBÆã’XûœĢŒĢ͆žø´Ō•‘*؛ŗô(Įmõ×'ŌŨdāß^A.—P‡ÅŪs3ÚPōąļ5Ãî•wukÂn%¤-váë[Ū“Ymėö>–âgt~FļēÁV^ã›ĨŲĻ˙…ßiķĐąĖM¨6wÂKy¸šOtžvÅiG{3­ĻeU\ũŒe.@U^{ĖĖĩđ!s’>n֖}z:Ž•AXö5Úl™āY‡(°A˛ŖŨįÔ2moD€Ņ;ģ%Ÿ KCĄԜXIl˙Ôa™Š[pP.á1ŖŽæ‹MÕĄËaē,TįCs;āS:´é‰Úŗ™šŪ6öŒáÆ Bvp¸ŠĢIĒÚ´‹Qâŗ7g€ôĢ|( 3`Ŋø-‹œpr˜‰2y8­ƒÆē>¤™ŊĢ*u(ԃ°<ũ"OŧöƒAú׊á79\Xš°ũ ôŲ°ĀY\AŠ â@îŠ’,bKŖßf’bOpTeŒ\ NžČ¤ú‰KØÔ›hŗÁvô)ˆŽú‘Ešũ\Ŧië65š—Ę"§īã 1Fļ €AĢLĨņwôˆ?ų*Č´ņNŅŪq‚š˜'gvLˇüŋėŊgĖ­×yώLžÃzČÃNąS")ŠfEuŲ’eYĻ<–=—1AL)ƒg`؎'™üJ&ķ'2@<ąF˛å:ļ%KcY”DQ”ØEą‹E4{¯‡Ŋäēî{­ũ}ĸe#ąh}ßŪīZOšŸ˛Ę[öģßũŨcb ŧēĻĻÃãj°VdfĢ\ĸ HRŠņ*Ã[hđŌ'ĄøFl ›ŧd09’Ë“oÜΧĸØoČĸģæjT4#ĶÚsÆ]–šŲggÆģđé{đĨIįÆlĻ?m)ĪŋWlwė388;ˆī*ŧČNšcĐô´l* a1ū´ˇA}ĪęvÛ ŧēũjĨWķ˙˙Önŧöß+ŲuĐūãĀ]ûđ(Múp'ƒ–ņˇæYÖs:8c.ķĖa`ŸŖLßg“…ÍqčÚez3r˛§>ĮHÆLÖQôS˜ŖŽMԟyš&Č1Į>îģīŅqæoGyøøO~°˙ ãnuĶ—W/b=MŠíVW cĘÚ$‘—ØĨ3§ŗ:Ė#eÁîMDÄĀ÷ŋ˛ž+čšõQÂ,ÎķÜ"A۔d.løą\ãTģæ›GķëŒ]Ø0ąŨë(ë‡Vĩ“\ŗ1਋}nMĨëÂáZÛÔĶÆwåŒŊĘSŲDîÂMs¤‚C™`¤:<1H—;Nā+Ø(ØŽé2åUoĖŠ`Xũ4\`uļd Xš–x#Núĸû8$+{ C˛ԍŸņX{Itú=ãŽ/Ļ$`q%ę‚īā¤4*JfBÄÂęǰļŗNač_ŒGŠúëT*SáhÖ ĨOŗ˙ã…æģōĶéĢi+đÅÔW÷—3Ė´Č›ą8~yĄYožųÕ×Ĩå¸Ũ›1hÉ|ėúfŪ­ži#B´ņÖV9ŅrÂj˙Éĸjõ ´Ũ" \s€oSž}RŨūđq%:Á”_UäoųĻî&ĀšØ \v ÉđŅ…:čËf+¨ÖfôODÁåÆNĘ Õ¨lŪ$-|u‘ŅßpËN‡ˆcđZÎ$BV;É­UUĘr§,†ō‚ĮĄvr\PÅt2bďd“˙9ŖUncƒ”¯QäŲJ63€â—VĻÃúh=Û.õC…Æáļ6ÊåO:8ŽķFŽz WÛąĪ Y}­ŅŠ+¸ZĮÅøg}*$'ëKx1Œ€¨ŗLīá™Ŗ`3ĐķŖFÜ8ßX¤C‹#,~@4¤œÚ‡ÂKôcTˇI¯BŦœáSßqYŸĒSŋ!ņįâæ_°ÚcĄëģgđÁO<ĶĐ¤ķŌlĶÖžæ]Vō=…Ë9Ŗ‡’[Šz9y‘/´ęMđôDפŠāÖEĒō/J›§ØĒjt:îĩÕ|•mvĖwüOųākŊôg|‚*™đsÄaÖ$•˜IémɃĐáL]g§ŧŖLŽ/߃&ÛĸrÂl_Jŗ›-ĢīgŲô0ō5tä×§ ÅW͘˜c AÛNueVnĖS˜;<Ĩ!\›úKĀôéĨį_;x4ÂņĮž{ŧã7ūdwüá|9xߘ˙ĖĪ~/TîŸũĖ7ÆáG4vØĮ\žĖ`R"vāņĪĎŖĒÚņ@IDATÊŊ~\­mȈ<˙ <’qlsJ헕)ŲYĨöũˇŋ­đî,OÜĩ/áĪ0ÛßérÖäÎEiÛ"čË8čė(?ëUeŗŽ b;4G’•Bšôö‹gųß<ŨįP~ËâžœüîwÍũũΌ?˙âĨã¸cā‘Ÿ{ņ Ų•Q=č:g ßĀøįxŎØĻŪ}Ø´fæRŧ^á4¨EĪēŽžĀa5ēė“ņ;ŋĪ ?ĸ98ržĶHŗŽtYĢúÄ Ī~[÷_~_-šW†Ēã§ģ˙ŽRîĨÉ÷BW‹‚ÚĻ,˙‘P2đŊûÎ[%§ázŧ|1ŠžžhÁ;‡ãMcbâęD÷ SŽDĒŖßP„đ?%žČ´°]Øõ/¤X­@ßãĶ–ŠVëškž.&Ÿ1Žæo#Á\đ—,iSâô-ëļŒõŒ'ųĶž‚tuĢ Ė)ŋŨWãĀ§Í˜Đ•æŗīK}ú˛đÕŲ’ëI§ŸÍģn˜¯ÜŪJ\~˙‚PÕdwÁžÖēq$–âÉĩŠ‹3ŌÖ]ŅōņÆö‘ÖĸĀÆQ]]Ô0ÖãęÚW8ā•>~H3–5˛oÔvä-¸ķ.Í ãģ!,c•°|ĩ/$hĸØĘ7‡™ßŗî…BŋúŸĻĀšmÎļq$ˇ,ŅLH;:äĻaŧ „霃X!Ū4fâm7í;AįdäjĨhŠf@F+dkvšfL­ūŲ¨Ĩ„Ÿz4xÛt0ūļcŧ|K— ļ|Œ›ēô8:“TiZĀiS+ĸĒCŽæę0r‘*ŧ° >2´…0ŠuĪlĨRųvl^T%Îՙú,„Љ´i™gå´­ÁĨ“DLšĘĸ+.obÛ–Ø sˇËcâ 2)æ[QlĮ+‰í‹ēW3ŊāB’ŽŖb8˛#€ž1aū¨[2Pˇ9­uŧĘęYväpøåSeņ“T2}%ÄNÆŠÁ΁'Cc)ú:Ų¨ūGD‹­Û•ÆÔÜR—aYYÜŌZã­ęĒŖ°t‘1OHį~J“$ŸŌ/Ĩ%ˆtJžXsd{Mō+b?H×ĻčIûŊôĖexü'ÖđiųE4ņmû‰`†É´Ŗľ‘™ŗŽ‡ ämŽųŋPˆčf‹ÁKŋûņģ Jˆž‘5cōO’Øš–øEģá7ĻėŠ?SOėęv, cŋÅwÆNÖ@¤õĶ ĐĩÛûAå^ä Â{q`uü Gä€üĶŋũųáãAßúÖ7GųUáų‘sså÷O?w WvvŒ#š=ȸ_ō„¤|â%¨¸ŒGS›]jËŦõW‹-yú´ī—ŋ`’8ĻŌģ  ôsÆâf%/cĐZn_dP0˛ö8`Rēvo­˙×O ’—ĀfHŽ—}rß9Øgŋœ€î3žŲķÜøČGŪËmjwŒ /ŧtħSĪp˙˙íˇÜ/ø 1ÅvŗŊ=Ģúæ#9ū{/{\2͌G×!¯y-}íū3_ignfnŠŋ5˙TÜēē‰\’š†a#ųŅ`yķUŦ,GəëœÃđô×ŋė3Ÿ’õĖ5ÅS"oÚFZ@xęx°%=š†mEEÆ`•5'ŗ/ ¯o:—ßļžšp”dęsĄô›‚fč u1’Oø„ǝzU\ës<ļ(ĄÄāŽcĘBå-~ЊĐēÖÆúąmœÆĒ:bĄ\íˇSŌļŸE×Į '˜_$Ė+Ĩ&BQŊös”ˇņ1ëá&oŅ…Ŗn…DS ”>YęUëkŋ´‡Ž4īŌōĨŒáx‹tÁDŒk39ą][”C&BP‡ ¨y‘!Ž kŸãLĪņ‡II°ø!ž1(į6uhĮCņ5Ō~WŧŖFĮĸڠĔ•ˆŠŗ *ąY{×ɓžU6øTŗÚmĖ~˛˛ÆlNjASŠn’Ģ()äģÎ÷ŽŊŲéėč,W—*_龛ōâUFtF'–Sō­ÛŅžąÉI’úģ•+ÜČ´ŗĐ4ÁËĢd&6֐‰“0’)E[WĩÆ@ĨWm‹oŌ„Š)yŪÜւĩæEY_ĨPĄ$n(J$F™KĶXÁClã7ŲŽ,:6ŗ“°‚žūo-~՗ĨĪęx›„Aæ^\Ī‹š3/Yŧ‰ˇnlS´ü^YŦXœ¤gĐ‡MĄžō¯Ž¨ÚŞQ, lcS‰ŗ?"g[:z]ČPLŦ’ÍaąÅΰ”°üÍ‚š§æy#“‰ŸAR/ōĶŧi'J\Ōg1ĐŌ‰Ōž.îō rså^Ä8AõŠÜÚÉ֊2S›U“ûÆ/F™Ü̧`ˆ1­Å¸ %‡§íܚŨ_YÕŽĒëSøĄŧzĢZΉW)Qī!ī›<@SÎ7ûAûųGX˜ŒŊ%@ŋ‰C ˜ ‰3Ô1‘-ŨŲÔNn sėÅ˙æJ9Oø*­\w;ąUē¯úĶ[rĻaBO1*^mKĻ_n,ũtĻkJ)2ĐgMú$ˇOL=\H.Ŗ‹D=e§â•}ūŽ>úÄų‡ŋa›øŅŊg<Ã{Î{ûŠã¤Ÿų㯌8ÜÍÁ—^ôDČä+sŽ]Ļc9ĐjŒ4+č铷!åd™ļĘ3]ú~ų[šûØU&÷Q;RŒviú3“úęKĮ|:uŽū6æøScĢĶ×aXI~‘Ō /úÅ^ž8t _6ŪšŋtũãûáqÉ׎äGî.Θģ랇Æ~íā{$f-õ–/š¸&įČ LĖé{=õ^ķÎGįY$ĄoĘv]°Ē¯ÎuãQÚYâ\ÎÁĄâŽûŽuĘZVŧΛUDÎ/÷đ"ĪÛË跀=ú°âŠ"˜ŗ+' j/yŠãC&đ=r„\ŋĸßk)ôŨ@¤OašÔ7â~y™p”ÅÜé€ôæĒ #L^mâ@ä”ņEqß[ÍhSīÚŪ˛ß]ōĶøÆ× ’ũwĐvKÉūŒmüŒ\ûRRØLŅéŗąuljöx3%š×<<=fj_˜æ¸6ĄÅ׌k¸ōY§ČIÅv@#7ū:ŠížÖ1DŽŅ1K9!SÅŋäÍš˛ÕWžhiP›ų§XŗHK p׹¤ch c;GsļU;ŲÁRß<¤2Įgöu2xõ“ˆĘ¨ž—'2(ÔŋÚŅÆęŸŪå2}Ÿ aSÍ[oķŲ<Éžq`ʅbÍPį‚v.FĪŒ3SĮžp› ­ļušOՋE7 °]°ĻCt"DU93a+ÁQ™9Ā2”‹^˜ EÖ71ĻõÔTÉaÛ.0úŠíĨf¸~4’ŗ1 Å+Ŧææ`ŠNT“œ‚ĸãB5w˛‰sE啙pIˇŠ%ÆcCgԏĮ&^Žö¨¯äčM­Ę§^ŖkC†î@YPA3ø)_ôäÚimN¸ØwÆņëžxz°æt@ú„é+ėimĸ!„ÛŦŦČ/ûĻJ™ŧ6z•­î’T&H2ãƒÛ,æaĖžÎä‘c)MoÉdĶ9Š!ˆĐ’“ŧĄ‚Ā”ņ‹ž Ś˰"]ÁV%}ÛŧyČž:æ ŊčږC#PM Íæ.,ddF.¸dX?§ŒXb‹įã#QW ÎÂWH”…‹ĘŌĪÖą]ųÚÎN˛ų;;Ĩ•‹Ai Rgا"ûŠS îKžEŧÄÚą’ņ„%贃Ž> !¯ž6vÃÛ{%›D-š˛áÜáĪ<ŠÜūOK‹xPá3îšļČ+Ō õ´§ĪîŸeįYųě1ĮDĶ^ü„iŪ˛đšŨFü] k7RluRĻŨŊŧĀ<čāũĮîŨŒ¯~ųŠņČãOņI€†?á(žô†ņĩ¯]=îæ‡–Ž8ōāáī x¸ŋFėdtÁ_ķ/áN#Ų€áÖ€žxĩ=Ŋøūæoq<¸Iŋ:ÚVŸZĄOs°Ë8ËÚÍ9›å`Ę–c<ŗZɎZN˛#藍Áįā˙@n7{üąÆqŒĩwžÖøSžRõå/c<ųäžqĪŨø°ī8ōˆƒ3֜ޟÎUךÆz0)ō3™i{ĩ€æšs6ĸÍ~q­5PUßǐŖ(e]ŗ;Įĩ¨˜o9–b327‘ÍzŖFĸ­°u6ÎëđęC…ÜqBNLæ[š¸ūWöbН ߑ¯íŽ5ÖËP‚U‚$ŒGĻqEZ?ÍS ŽFÆš>ĨHVwâÆ/ėĶČAˇžüytŌ7 „SŒibJĄĄūr°(uŽié[5—*Ļ3ŒąŲ7‡YW'hĮÉ_\ž¸ÆŌÜo tdõ Œô‹¤ė7"c\ę,Ö$x̌ö}g…zĸÜ\tŦ]ŠųŖ‚ū7ö *˛Ķ–ũ6zæ5í(+ˆBčęu œpgLŽƒTĄËiŽzâPŪÄRwá˜Sė8Ļ_Ļ4™~;RįģĮ⛡  WÉlxk‰÷Ë vÜGy,ĮŒÃšXĐãIč)OŅzļ­R¯#@ÃDē˙OV‘Žķ$?'ŊëÔ‚ĄÜŖ°é2|éŪÛ¯ž$ųĐ^Ŋ˛ŅÔ0E‹Ų"ŋĒáŖ*ŧø šX …HũÜ!¨ ;Vōä@ŧ@ÆøbU•mhEŅD}ž@øœá‘f–`5ã‹•BÆ^ÆQ¯ čīĮN)̉ۖũM(đ§GŨę_rHSûļ§loM¨XōŨ͝ãą(0ĩ䖭gĄŧ:l‘MIąaŋæy“&us­}u‰ =_…ĩīâāĶ’kxˌƒ,A灍†ųÉvæ6ŠĢŖäŦ=yڋŖŲ†Ēb3íÜÂwŦâ' ĒŽëŅVj âŠĩIcŗž~°lh:gīđ⓱įŋš3ĻÄ_ēũĻĪ9û7?É˙t]ŗ(ŽÅŧÚŋƝ4čQv‚ú‚LÍ ÜąåGŽīú¯QMJˆ|Î Wĸ቉HķfmĒȔ=•m´ë'ˆúl€6.Ú^/b1 ųDūō=ã@ūĖéÚáģ‡ô„Ā"Ũ<$Ņ”: Éã•1ˆŒy5‹ņK1‚§™Žƒæ2ū'WōĨAņjŠ:T/qA_ļĩŖtY™ËEWkSô b͕Œœd äĸę†á ė•—_?îáÉAī}ī[ķƒPsrđž÷Ÿ3žuÍÍãę+nG{ČØŸŗWøâe.LāSûŠöõĶžÍAũšO4đũōw(Ž?gôiG#¨ŧΊ­zFrŽgõ˛6TœÔgžtĒáĀ=úøžqæ™'7ŊņĩãŗŸûʸüōkĮ>ēķîrbpŋpíīZŧįø:ž€äW>y[ž@ĖsæõL˛ŧŒEĶėz…ĨšŽÂɌ˜“#|ë⎥Qũ† |\ÆVŠx1/s Ģ­€˜‡ŽŊæ"Šõoâö`Y ŖŸĖ"§ŋÁīō’ēfÄKA&ŌQ~ÉuM‚ÖÕIô=t×5ķ’“ą”Cuųd|öŠtT›ļģÆāĄąYdô?@!›syēxhȅcĐõƒzBC8Į–ĘÆ×Ę›@dĢ–ĩ3Č´50T|ĸ9Åk6 ?‘îZŠ-uĸ9ĐTgÚfę§rüĄĄ ˙‘ĶŽL77ōJ7T×u‰Ũ÷ÛŽ{ķ=ģ—‹[=]Ī~G }CLūö…GjĘbn ŽiZķ@ÆwôĄM=€†Ÿ‰ÉK|åĻßuQ-+ŋbÕž•;‰¯.VÛųÍ éßēĐ,ĶŧV“k=f1Rm€mÅ\şš/“u2"h_§˙&Vį°vĀ4ˇU!­[T4%čRŨ‘$CVv”Ll3(ôSpĪdČKĐlSSSší™kK+ZŒ\Ėk:‘!2X9PČDŗ'Ę`ĢîLR´ĩĶ$ęWäÔz°’æ$2rĶŽ*úW;h­§ËHZIOa+̉¤nÛÖŅ0´‘C¸Y˃đō(KÂĖq K”‘ĄbâŸÂčĖĐj[^:Ø|JJtôG¨Xš9ËÕ#ˆŌŊ°# §r´‘ļ^ā)§ŋæ’ļ‹]žĸŗÔģESmpļSGIŧZƒžv­DMyŸr‰3 2KDŠkŦ)'_¯Ŋ•ÆŜE. ;…ņš–Ĩ!NT@\=1lRS{ąå@ˇĀëdiî$TË- Q Ôb ė,Iõø8}b-Úr-Ã9ít•Z2§žIÔW1ŊĘŽÂčs3E›č`ÉûŽNNVmG)ɑŽEŠčíųƌOĖlx‹*­ÆÛrŖZæ&ŽIVcQ6qÄ<;åņaÎ^÷úŖĮíˇß;žqÉõü€×ņã€ũöOžæį>ČcEķä ÃųŽĀŽök˙Кų…}€´x͆¯ī—ŋC°ģčĪ dĒ™Ssœfŧō–“SF›]ųŽOú˜žgŒe#ķ~œ'čøŠ‘r>Úķá˙ŠĘ8ûʗ.ëm@O>3Žä‘žrŋŅgųŋȡ‘];æÉ&ãuŽk÷—úå$čPŨuô;ôŗŖnë Ą•Č;Ј/˛5’PõÛŅģLh>sXÂö!9 #íB4´;̊›>øŅwŽëP°Íū! „o/Ģõ[åm`Ņ,I<‘ĸ“ųŒÕé÷ę(ŅĸīÅĒÎípęڛ5OĻē¯ĸ[K,ÚąŽÍ…™uZÖQ¸"Úæ<1æáŦÄ|ÂĢ&6Š'˛ÂėšÍ?Ę3-Ą+&ąŸb>ũ+/ŽÎuF›Ņ¯åŒĮžÆ°ŊņĶũ¨V˝tãJüt%ā’5)[E¯äNŒüõ—s‘ mÖÁ3iŸŋW8Su:™Ž;BĄ@ ˛Ņąm“āū9D7åką¯x.ĒŲŽ^e‚MY…“Ŗ Ų# V!lD”Rē|[ŠņˇžhŦfræēnÛ` ũЊŌ:–jsfājĪļĻûƒ¸ bVOƒø!Dm¯*~P_cNôâČQÅySŨŌ[€hy ė§e*ŧvx‰†ƒI5§ņLN¨šĒ*CēÅÍ%õr ž–Î;:åLûFĀŠPh“ąęšpasáԔCŗ…v#ƒS VŧˆÚ¤ĩʼnËfNƒČû‹Ēt`e Ŗá@[匏ø îVâŖ,ĸÚ&CžU–§ôą%ÁØÆOhJå|ÄC°šÉFÃûĘeDÕ.`¯TPwĀ=Næ =æpVųų˜Ķ@ <žiØõÚMLPúä„xĨ\ūô‰—g¯küHŸĩnŠ…Îv2' š2ødŧŠ™ŠÍŨ¨īDíU_ S/3Āøčƒö–YjžÄʧ+bQ2ÔŦĀđ*ŦčÉĒ&)Ž m ?¸Đ\0zŌĨĸ8-G~Ō d‚!O; ŊiĮqîHŒG>Lx7StZŗß]ĒįܕŠJüÔĮú˛x&L“¸|V6Í(F#ŌĖ7aKž"mB–q¤&ŽũéœŅ–ŸÁÍáĨS‘Ģ?v\˃[ų9–ŒDsB§ˆ]žÔÍw†æēá ÷åJ‹!ļr‚MÛÅt ŦĪ‘#†Íô—ü36W˙K1|›C–+ĩ/r°Æ†?b<ΕÚO|ęOĮ!<ōíī8ƒß¸oüØGÎ/đ$–/ũų•š…čxŦ Z$Čēļũ€­¸ëŠfˇĶžßū›ĪG{ÆŠ/NîtĐ×Ķģkí˚ąŽ8Û\`a'āī@ėÍIĀ>;vއ~rüü/|d<ūēãŠĢŽGq(?X÷Ô¸ô×ņ´¨ƒĮášŊėÅüW×8|XcÛAÎxrr Ų\lÁŸî##¸KŽí EoŠ…•ũs2¸ōŒNz6ĶmQ\B6ûęœy0'âE[§T÷'6 ģÅßⰘĩ4MpR"y›˛ĀƒÁCKQ"YyiáķÖ6Ž‹ęÄ+Ւ‹]úĀO36J°dÉc›ŠxÖÎ\B.BN’Ė•FāëMã“o KĮ<ČĨÄ7čVëđâ°ÖŠÁŸ€ūÃī Íä|sŧĻLœ)°čˇ&%žL ŠÄËvŸ%¯Zrhƒ4ŗ2[üÆ$q­Ņj4Gā@ĪK"%v9ĄŌ†'}ąbåĖ‚ũž.€¨c ´į{ꞥiˇ˜ŗåËō;'Z@ūåĢÍČJ7žŊ=ĨūōÜņ{ˊņčŦûđĩJĐqĀtnœahšÛÚ¯%|ZÎ"\ŋˆ32íŨœ|Į(Åž5Ĩíëô+uĮ‘Xū9Bcš7ÃŌđ:žÎ9¸Úø¨iãJĶW}œÚÁ*@=“cÉđ֖4\wøžNĀ7Š Iøvšm¯p ãģÎ:,:]qfák`*g@āYŌ°øâaĐĄž[…ĀK"ÔŗČ—"püĄÎ6÷ÃÂôĀJũH#+l$Rĩ‰&ĸĪb#,ŪôFē:ë:;BéˆwĐ)mlĸĨÍuEHļ|íX 3ú:5UsĀĪîļ̌/ōÁÁäԎôÍĶĒÖ>Đßė´Øf1ҘNgGîļõ´cĒ>iMŸ_´!m>}€fŸéŸŧúĘôž>$Ņ š§ũläcĢŖ›Ņ—4Įú,Ļō•djaÔęâiËyJY¤ŲŽyjŋY”ĐŽÍȚk*`ø!S3ÆH­•Ú ĮædĒÛ| ãUũ9}ĪF‹sLŽķWY‹rÁĻ’ž$Ļ0V¨éŗrÕ¨ģļä֓ābû’WĢ(Sëâ ULBŒÃ pĩŗ XÆUŊ­ŲË.#‡ņĖu/¨|úĶ˙ž{ī^ûÉŒûî};Oög—fôģî'ŲIĩN4ŋÍ ­–WĶžß^™ų›Ėc€žpÃĢc‚öoŽÃîCsČ9šėoŌü•į—8Üš“kq >u/¸āũãÆoã*˙u<~vŸqםxltã…ąų"cĘĮÎ:e˛3'tĮË0YĶfÃąĨOŽ÷Ôëf4¨Ļô ]įiú˛:åÖ-|a8öki TĮ{ŧ3}ĩ‚ŋD‰Aį“ļ7EŒ9×qŪÔ¤$å¨ĖŲ69ņČ<‘Œ'ūĖwsĐĩËúOnÍŖØ=€&_ÎiËV`]¯Å• ļ–^ō( Yõu5š$ĩ]ĶĨĨdg¤fû{ÁöøĻ~šāKĮŗÎ`Cõ–ÚRT_k;īā‡bņM?5˜tŗ$áģúÚË>Ņ~ęĢãMļîīķÉä¸!+EIœÉ€™2ĢØ˙qĸŧč"”csÃkk~j›Ėeá%2Hö=V—Aė/_MŋQ{qĮĩßRTôZ‰ŦyHĀ×÷ÅSC?#šÆcä !Iũ„† RŅÅNÂÕ?ębyE_ IAË0t|u}ĖqŽõ= zÄ#ņ93įŖcœĀņĘ-÷đÚļÍō@G‰ßyĮH]ŠŽuŠ4s-TŋĐßxŖ;˜ãOģÚ<´6 ÜČ2 Luåë0<˙q„M’ĨƒÖiŦĀą%?Ívh?h0“OUDĐļe&¤iᝃM‹MxąČ’0ķ,ŌÎR¸¸ &‰đΏ”×/]j‡iÛŨļ;đÆÚ;ĖA­A8$8B•u0•ļ旃,ærúzĩĻí&…ÆĖĄÉH1OđɉL|ŌŦ7¨Čæœ Ā^5@ß˙ä]t1hcKė,Ėlcŧ¸öĖ#˛Ę¤čģ›Îƒ´a‹Ŧ(ā›ƒWh30°…y…ĪZ; ÄT­ļ×â…Dčk“ĩ‚õtļÉSrA€YCØM‡~'åB1žÚÆŦŊÆo6WĘēÕ6ŌD2ꈗņÔz@*ę;ĨXŲ ÅËŅ—“Â՞8ų¤kųŧŅ­Žv,Iˇ˜ú`{ŌmDĮÚīõË'9>ķŲ‹Æcė˙é÷įĀī-o9qœzęÆžđõņ,p<đ ũÆķ<Ī}ŋš! ŒSŒūz[žŠŸ4žzņåãšoŨ”öēūúīŒ]ģöGđÅ^O ũbī:ęÉōnöW.j eŸįēĻ=vāŽØÆbGn›’ëm*ũTš9āúĐ˜Ē—ažÆz&čJ)Ÿ"T˜3įO!øú‡›ą“ã 0ô2EŧÄSž fĩ;mŪgí‚>ÕŊŦ2.ŦęĶöÍØÛŋī×,kōrÍXĶ}õy–ÚnßR7ßĘ ¯o=^XōnĩĮ#šU-męŊč"ˇC#ķ0ōJÍŪ¤ë˛6âŠČÅ pí4l âˆl6ë­z‚-}jļ,bęoŽBC,™´ys í۞ŦȨOúŨ‹ŠXÄ]\é­'…ČdPG](ōĄ{ĸbÛzž4ŨˆÖUņS uûÁ:˙‹eĨ‹6’đÔž;=ëé<|‚×Ģ,Ж]U”§•7‘ŲÍĢXoĘŦ˜|”ąâA`:^ƒZEš],žyv X¨;˙(ŌĖ‘–ôԓļŽęKõĩ’oú_ĸšÜIÛ{{š‘ȕ´ČĸŒXvļIP}é˜ŅF—Æ|ĸb‘ÕËŠ–RŅ÷ĶđÄĸĸß V?SˇŖziTæĄņh"nKKÅÜJ¯öÕNŠ A‚Á`ÛĩYspe|Å1į×Ė%Ö,ÁGeí2ŽdƒĄé„Mū’wņ5‘ôú”ŸÕĮŽ'ĄW,öہ| Øš¯|ųĘņĀCOŒ÷ŋ÷Œ<˛ņāŨģÆĮ?ūĄņõ¯_5žÜķO Úw>vTõoÚļųëxÛÄļU͉?4Ļoūh”ˇšØ¯ë7Pļ‰ū TˇĮcî^áW‘_āŅY$õĶ_vlģŪ_æū?ĨŦÜ(ŋ›ĩŊŋ‘‚s3;îŧ‹Ę­ ×>į†ÃB}⩌IŸõė3ĪcŽ>|üāY§ķŠĐÅãúnO?õėøÖ7oĪ“ĻŽâŅŗ>NÖûûՍ"¸"Ų—ŌŧXÔáËyWO”Ķ/K‡%UOn3¯ôyé‰=fę@‰į Ē›úÄ Ļ~Ā#JRGW0×jĒņWčÄw ÆbÁôÖŖ^œš~ ŖcĘĢ,EģŽFk_a.ēÖ÷vĐ;ú“•ą>Dk˙†‹"vnĮ¯ÚŒ)y“=ߐu)ĩ(S_\ļAÂŨ˜9IÅox‘ÁTK“eU_ƒEy/`ĘKĄ˙ëØĒzÅpŨRo ‰Š!u0BH+ž(™õ”m|ķym)ĒÎĐĸ6”9ĪQĨKnÃÄvūŠŲ˜ęÖSä ^‹ęŐ[Jđ5LIėØīŨõ‰\‰n%ëŪ ZÆWĨŅĐÁŽš>đD lš;…ŽM×˙fY™bXÍÎ8ZV!ęø˛Y1äĒ-üpĄN<9 Ã}ŠšĘŲÆ~ôk_\i™?Ô"ĪV?ÛĮ ķōbx C—$¤ōÔCëø'ļô‰ųûŊ’ƒô/ÔąĶ‘áwį"Ģ DČ[´-9 ŅÚĻhŪųŒqČļrVęĈ¸÷ jœFf8žIēC× ʃęųŌĒtūs_zXTų&ĀûœČjn™DEQɸØtQĀft `’õÛ¨ŗž3úítŅŦDlRĨW§Õ‰Ą.ĸͤƒ#7rŧ՞~Īi ]ËōĻQĨĢL&^„’ņÄ%rė%—2Ģ㠒ūÚˇųbL°Ŗ’šÖ|9ŧ/ŽīfpĻP˙ōÄų‘Á! )Ŧˆ|č9\Œbyę&gæúĘĢņHˇ"â`M­—’AīÕä5ŊĻnyÔ-•ŽŠoЅ”­Īv!j´d)ÕŪÎæü°˙ĢÅfę‰#ü:hv'düē*¯H̟AÍJŠL’—ą–q¨3ļ~‡";ÅxI[ĢéŸņM\K1Įž|.¨8Į<ϝadöL§Ņ‰Ļ|t_;č¤ Ŧ§íԕÃļ}MŽ‹8[šYž1ŠéGõÁ—œĀ‚~Ÿ†jLR‹Iߚ"sĩt€0Zv,ĩ&J"ANA×lķßšDÅO”víJEЂīŠÂĩjō(~\?%ÎEĩy‡ OÜhą =âõ!ãž-ë9ĄĀŲ䊐5ąâĘpvŧáąžŋĖIÂ>ûė'ņ=ëo¸}\zé ãØcqįŨü‚ëãÃ~×xøŅĮíwÜ>Ø˙.öh˙Ĩ’ƒIq ÃØZ×į–ÕŽ@lûÅR1-O?ķ ?ĩ+ŋjėUču‹‡Zߍ‚ÁņoĖMŅJúˆí÷ÂųĢyŒpbHŽđĶŧēûžw˙Ėxáųrĸō=m˙•yˆßí÷ö<ŊĘ׹yāÜZÃßS{öÄū_í+ž|¯|8Pĝ“uÆŽ'ōHwŨxōɧĮžûî3Ūuūã;ßš{œö–7ŽC=x|îķ_Åūmã™gŸ_žđĒqøaģ‡zįXüIŲg‰chņÔūĐBŒØØÆįŠFœ)üz‡œļZÛ{seûĖņĢė F0ģ$<ˆũūšÄęē8•#¯îœ—qĖuøÎoį™ąOsO'%S~ŋƒVßĸ>ņÖŠĶšhŋObÄ9ĮR?ŲĶ a™*ūōCĀ’/A)Kw6ã ĸQtm§¸´mÖQyé{ą°mŨ ˛ˆ6˛jIÖíØ?+Ōˇ|õ…H?O>qPIíY6ŲgHFÆõĐ O/ql•õĘú#ŽąŠŒšėɕ0ʲÕ^m´ĞVjΜúą>wŨKŦJ’c1ŦjÎ\~ ͆Dר8RyqQŗŠZ̓üÕ/•­mķĸčšOŽ=íG—ŦHžŗTļō3˜t‹‚bi7WgĐËGB–1ZËZ0uÄŅŪĘsNJõƒ˛ōéø˛äŲčĶŪ‹ū‘§ûO,{OŊĘ7[dšyIūå"Ĩü‰áđĒ[č;ōŠûœ#  ›O ļÕ`@}CÆīR˛#“X””$4YF 9—Nƒg„°§)‘M ĮĀõO)ëā+ pä-e Ō+a2Ŗ¯]đeËÁnĸ!ĻØ“K:)œŠ*oėôs•A ^=¸sgN3¸Đ]4D×ĸ¸æôĪ'ˇxëO¯^nÅĄŒE ¯°)ĢŽ}OM# †F§/4§aímėDbļÅáe”¤ŗvėŠj‰Ū<ČI!ÛP—ŧlč8Q”‹ _œhĮ\ÛgxDÃHsĀOZĻ;V‘qO—Úa8áâߓĀ*9ė}å_˙P¨ĩúŲė([zN1ė:ŦÎ2 éî@ Â+ƒ¤ņ8錯÷Ô´5•õ5“^5éĶZhGLíŠŗÆJĐ˜Ŧŋe'Jŗ”ĶŦ@mÚLļ–YûA1ÚO–ØkZ5V ö´Ä%Ī˙øĄŖ6jÔM›”XNK@2ōitĖ4Č 9}ΛZ‹žo]tēČJ2[’q‡7#ĮkÖKŨL˜æCœcvÜi?SGn–JŒg~ŗMÆÜ „sŅua{N´eŸ%€‰)Vs$“ęęÄtF,URhģ.l-ØQPĸë…A¨0KvhÔs0Â6!šČđ†ƒžīĮāQGÂāįÆŋxYnšōŠëĮmˇŪÁIÂÎÜîäž×+ˇh~ęú˜ĮE6gũAuķ‰ ˛=Á Ƈ>8ŪüĻ7ާžzjüø}dûš×ŒG{|ėģĪ>ÄÆĢđ¤ŠožˇcžÚ'yö—žXˇ¨g=_ō›msāÕjyžŦ-ū^üpMŌ ˙ųįŸįv—ÃĮ‡äCcŋ}öÍɊØKvģíī™íŋĘīėČ'†8âų Īö‘Å“ ŧ÷ŊŸ2zčaōŋOúKŲíų}5îvŸr8đwėŊ#ŋĖë•üûė/īæ“ž .øß 9xœpÂ1y”įEüŽÄ7žūÍņėĶĪņÛßädįÅqėq‡;÷ŠÎ˜˜t_ˈÖUüÖs•ÕĶļãoŽ?¸ˆ8`-l”ÍšŅ ¯iŧ~÷ǰ›OīÔl_ÚĖ\h{IÛˇW1lnėuŸ4ĸņl;vô‚;íÅ ŠkŸ˜vúĘØ×_-‹iÉ:n5Ķ‘ 9‘ÕŲ‘ôéō-:đ­[ˆi8ē[msĨ?úߍÉ[öđ Æ)|bYqĻotŘQą4ŸÛŦ&SÅč"§Nžs`æ\HôIlüË~Äž‡—Y§ ¸ĩ›TœÃ…Û(zĄĪģ2ƒØÄ‰2JEFœØŌļüö!C“ž•)ęißQ“č-ōp$4\OíÍūKeō%˛ß.%äXQ7 ʰ­čS‰¯Ō•™¯ô–´9"dČIõŖ”ąŅņV´ˇˆlö90ŠŨOƒĀ\3ēüĄĐc&uš TįRŗ!Ĩ’šŦŽĸÉzԐoą"Ny:´|JN{š•ģ ŦZđôHÜé“ēøŽ~ 'ļv8pͰ>WEĄîAZŗŊtZqųNëĢQu ¤ÉÉÎS …´Ã_vÚ9Čf@ë\ęn}U´ƒÄúL€účzādÛ\IWûS”Ō§–đŊļSâykČa‡ÂĀŊƞ§Ÿį•ą?ŋëX㊧ŸīĐCᖒŨãūā–“ŖķiĀũ>ĀOwī9ŧŊ5Č'~čĄ ī5žåĀø9nzæŲgš…é€\%ĩ_Úņ6"?Uxėņ'bۃø}ņáā]æöũō–#Ō?Ë!ģųą*Š;¯‡y$võûļ;ūbŧũėŗs }į]w]ņI1æĀe’¸íķā<3öø}~S÷JúsĪ?—ûååyëĶÁ4öɗláķė|O|váßÏ<:Ž;æ5ã¨ŖŽ7Üxcđ¤ķ&÷ģōÁ'ædåÃĢ­Fv?_Ô=â°CÆ1Į1ž|ęéņړ¯ÃIãĸ¯\†Ÿ?_îũ]ž~ ß qlßw΃ôÅž<6–[ŋˆĶ8XQˆO4įDįŦĄs¯;! 2Ĩ^sę{HL pōûȕgĨ ũ°[„LĄŌĒD) ”å|]u#–ŧæb}–­L\uŠažÂĪúášY]¯Čû)Ą¸YĶôsÚs|įäŊV€r­€īD”'ŧŌÁ’W?õGžˇ;>rŧyËüÁõâŖŠB@lžIVôė†$Xœ““^!qǝ„v‹NŖęŲϝ$aȗūģŽ+SüTģÖAļ‹adG.ØzW+æsõú`ęÃ:f˛ąæštÛŽiÕ_ûäD\ÃXū+/?˛:ÁŋõŊč,ķUU(äÂt¨WüY^ßô*ũ[ĐXįĪž“­`âYlÚ_ÖŦˇ˙͍YÂuŊĨ(•õbdqÚöŠ[‰öoęwŖ-!Žk\™Ĩ8SšCŲbŸyí)VAtÃŗ-Ņ–7ŪN\ZŌíkc‘k‰o“—‹~ā&âëøĶЋ)•Xã\ĒXĸ94­Ĩ/ÁY}iÅouW"ņ]9ûŊM(‡sq ˙ü#5[N›1qŌų1h+æĻÖ ‰E‘âŽxÅ΄ î` Ž *ÖõŖļhčK ¨ÍŋA[  ‰‚AkÅ3H#9ĒÆw‘4šč Œ¨íe5`õà âÁ”Åw$#ã›FéęY28•€°,Ë ?žĖØP‹ĢH"!˙ĨāøŅķ˙„ŒŒqÖÎâ/ ÛÅp“ÖÄī Kĸ”Ņĸ?SNgŦÖ[ė$+ÆX°ęÔBÃÖ-ũBQ=_õK†júY™`H ƒwčzc.Û×Ôs°enõď.Q°¯#ģĨ+WāŒ›4$S|# „úŠ#d|!†"äwŅtKisĩ .sgļvššŠ\JPŠKUēhP×ŋåK5ÜXŲfŅÕ8™øTė#eÖÂh$úņM5cEĒ`Ķíų¨W_0îIūĩcA€!ĨyI—"\č›üĢâÆĻ,žė‰É´Ą`ÎBKž;8“ûqųÎ6ß9Æú´%žIËbĻ‚“K[֓ËÖ?'…đĩ™EąõŠSú@Ė _ŧ‰MÎV?eœĀ°[Ķ^ã¨Zp ËĪ I,Îļ=Å_ūfžBĘIēYāÅg.dûīŋ“+Î<֑ƒå/ÜMŅîÏ=2ÎûÛĮÛŪz:°ģÆ 7Ũ4žđÅ sEûÄãË}ķßá ڃr'yΞÉ#$¯Gu8¯Œ›nūö8˙ŧˇs`˙,ˇŨ…Ŋãį;ŪvÆ[s@{ķˇo~á—ÆKĪŊ<^{ōIÜ*t`>5x‘Ķ?øŖ?æ‰4ûáEFZōįsęō‰qäᇏũøGųTãrōĘøōE_×ŪpúģãĢĄžéo„äøæˇž5.üĘEc7åüŸįž}?ĸv_~Ŋ–'Ũ@øá|jq'7/ō ¸É“ũb…ūüŠ'ˆãÜqæÛŪŊ›ožyüy°_{ōÉã rsî§ŧéMė?:ūøŗŸËÉĖ /ōe[x|”O@Ž9f<øĐCãæo{<Ë˙=÷ÜËö9žŸņîqÆé§‘É×Íã äÃØ×ŊöääČOQ^xņ…ņûøGŧ÷Š<ŪŪ…Đøá>gÜvÛ=ãmgž_.žøĒ<ŋ˙  ûŋ˙įc÷Áģ8ø?Ņ—{BÂIÉKš}|ĪÉ&ÛöŋņvĖÁu¸2ēĖŊãĮv˛1ëLō¤F3ƒXYÆk&4|r}e3\Ņ-RģV¸Íú3ÛbÅt÷Ž9ąENráškWƅ˜"f‚XmL1Ëyũ`¨gŠíC˛ëKĢkƒSOÉ埿jĐmKרŪ9@_éSŅ럡šŸŦŅÔ=IqM3ą-&uû.ŗ­Mąl*l͘øé7Ũ×(U›‚jCe0ÕŗĸMąd§ŠN‹4uÍIōIĐĘJ‹ĖäÛ/fŨž¯E¸đęŗō6Ō ŨvNH%ãGm‹XËņY‘°°•5éëģ|YŅëÚ /zŨŠļ Öxƒļ÷…JŲ_ę—IІrÁ,ĻkŋXFh[/‹UŖŨNjH )ĮJ09ތ’ã_;“œüĀX9.Ģ|/ÜČ×j ›¸¨9B= ĻøŽXɓ ĒáøÚ¯Š,Vs.›ļn%ÄęĒ&!c$:íx ũČnL_DjĶ‹aPá5Iv’_\@,ÜüC?ĖČ÷¨; €ƒĸoā’…I0íĢ/žļmē…ßēívjq-xBLuƒå6ŗXxĻaÁ‚ŠŦŦt,›°´¯åąNîˆŅO) Ķ ¨=U‹‡†X´SÂĐ<âjžāˆ MúOSžJÆŠ-—ˇÄlCÃŽ>L7…tā EéÂâ;¯ä~úåkzÔ ‰BėËĮFōĒMAôT{ })ôlíÛĀȗBŋzE¯‹¸cģbC°ōLNĻ.Œ`'(ļũCoÖĀ‘‚Ÿq…EIfLč*lE3~PÃˇÄC5ũeŊčËĢ|Ī(ÆKl9i“Ķå¯ļ5&€8͆Ö\,Ķ–•ôŊ%Žt„ÖŠ™Å:āĘ¨Šžą5Gę¤Wâö–1į vqĩ.b‰o_ûÁJŌĶ5˛8ÁŒHķĒx”įÜÕ͓¸ÎՀĖ8ŅībvLė¨åÂĒX^oâÄFĖü§GJ˛"č›ÎäŠmkš´ōŖ:yĀ€áę+ˇÖØ'M1@–žxŨa‘o@ú’ČKÛ`õÃvhlôv•¤‚v<15ÄäÁáĢ_úõ ˇ§\Ā­;ī~×;s`z ŌīzįųãŨįŋc\~Uëo|ũëų‚'ø4āé\é?÷œsrP{Äᇍ=Oí÷ß˙ā8’ƒđG{l\wĶ­ãŖ?ú#ãįž;nēņĻqŲe—ĶO;uŧīŨīW^sŨxĮÛĪ?ų$ū›9q°l÷ΐžÚķ4WЏ˙āī˙4_ Ũ{|…˙Įl|čƒ?œ+ķöõũąq֙g1ŽŋáÆņNü=ëmgÄÆ‡~čãÃü⏋Ģũ—^zŲxëi§ķßqŪđ$&WÅ°é ‘āÚöĪ+ô?ĘíLī8īŧq'—_Žß°ŋ—ŧč÷;Ņ˙čG>ĖŸ—|ũœ>~‚““[o˙Î8ėCĮ/ūüĪå͌¯^|1'/Œ˙ŊŸHž/šėŠņ3?õ÷ÆyįžîãŌË.Ûā^võ5äã\ōņąäãĻ›nî¸ >oŋZũuŌIĮãO8š[zŽW_}Ãxœįö_|ņÕc/në9üˆŨüĐã‹1č|j.S&ŲĄâˆpürÍĢŗ/¤Đ2~i:ĝŗŗĘŠŠî0‹?įŧ+‚ĶÁļ˙*ģQV;]gŠ“sVĖ`¨4ĩÍÁÜŌ‰~qīoMãë=Õ:KU(56Z:‘ũRŧҁ*¸‰Ã3gl˛ū1 uNzØęņˆīĘ/ ė`A[YSÎJƒœ%w.°5Ļ]Ę:H>ôŅž8Áfėåd…œÅ.L‘ƒ•6BŅ^ü0ČorOSÁč ’ũÂÔ]Į$f=yWįģŽ=āŌáâXôeí_ꗟD­5¯2˜1wL隞bSOđ"ķšIÍc.ikurØōg\)ŗúbŪ=ˆOŧBLž›„HõÚgÖW;āČšnZ„ˇ_ė7If¸^@ŲĻi…ĩ›FûÂĒą¯\ŽīáAí>4‰ĨūXK ˆ%ũšqR (ĒĻß͕uũFĪ’@ëTŖ fŋŦŧ͔éĢ4_ä-ß­†—ũĮē đėgUŌ4ی%ÁqrõÕ+\%p.$ŪjƨĒ2’}u˙ƒ2,‚"k ē˜üZ¸]ŅÁŗ˜&D'Ē"+Ît ¨Ö'?€FĻV'WHRĸ’~kÁ0 ‰/ŽĄ¨—-īú"ˎČx“ĪŸ' š*Ŧä Ô°ķ7;a)§Æ^°[ ¸Ø+qéfÚN¨ōLXūæ•<ų’cđŖ‰ •ßNŖ™8âĶē5#ÄÄ#žE—ŦęC3pü[ygo‘ÎLާΙ€Č(¯î*N ŨđcAŊBbZ%h#ēũŽŽĀÕc6ĮöËLļƒ$~ĸ(Ž?=ž)˛0KÉĶw4×Ļv2ž´ÁĨ.‹Õ„kl( °ž Ci9úiĢƒ6DyŌōQrøąyČČĸ+9L_PËą!9ˆąUš|ĒžēhÎ$$ÄčהQŪWSY]ېŦŪ˛ĨœŲô¯EŲõEíßZġ9wuũ1zÅq¨j^ß<¨p\ŦŒiŖW Če BUߐ3Sæ×üh[§ŦĢĮ&EO­ģvŧŦŧ6”[‘d"“9€‚ūh{ķH?ãæQp™,bĘåFw†ļQÃüÅ_lŦÜAę(Đ­pÍY~L*aiÔoˆ9ūô4s)šŽš•CdÂĨŸœ;ÁF)QŠĮË+´ÆD}•ČËžąĪÄĖUÜÅR2uä2‹`#<}ę'‰Tė7xLFžÅ[Qyäąq᧟zęøä§~k\ôõË80~%ˇÚ¸Ķ8‚+åī:hÜpũ ÄģˇŸė'žpBpoēåöņ ?û3š÷˙‘ĮŸĮpī˙ĩ×^;Ū÷ÎķÆ)§œŧ+¯švÜûĀÃã`ŽØ{eÜĮŅÜ*t#Ÿ0üŌ¯ūķqĘ_Īswį`ŧ^ņÎ|ōkå–Ąoq2ōŋūo˙ĮØķÜ ã>~A> p,wėąÜÂķâøíßũŊņÍkoČũõ§œōĻ|˛pō Įķ8¨ū“?ųĖøÃĪ|~<Χ ž¸üŖ_ü‡ãž{īÍm@/ŸÛčSīĮ÷֝ĶO{Ë8åÍoŸūß—\vå0ĻC¸ÅIŋwsu˙PrqåUW‰?;øA­ƒšŨč´ˇŧeÜy÷}ãŋûo˙ÉxėŅGĮ?ûĩ˙/Û:^øüÆ˙ū¯ū—ņ?ļuÚ)oÎ Đ'ūí§ÆeW}s<øČ|I÷ĐÄāAūŅGÅmB7Œ˙ęŸūō8ķôˇ oĢōäÄâŗ~˙÷ŋĀm\æS‹'øĩŪvqĢĐāן‘{‘—rvnÆo~"äHȘ`8ļ2|Š8l\'[Öûí4Ŧ‰!'ã%įYđy Ž|dũí—Ėi}Õ~…bĶÅcÍÃĸÁēFĀ1<^އŖ[WdÚrÄk$ötŌvėiˆ–ŧTŒQ‚ Ø­Ę§ž†ŧb¨›ęj_-ũ`yw<ÔũBoÖ×üJŅot˛˙OŪĩG.ĄÕ×åWąã*Ēģۈ¯†˙ē”õ(vĨ n fø•§š<åųS$'W›žė§ҘøĘĨڑ—Đ/õæ Úr*ˆ1ŽŖt=ĄApb…§uq Š#ŽąÛHlq IԘ‹jél­N!ĨĐÍq@öĘE™ū0›tŨī&NM$–öÜ5¨“žėŋáËé1ŅmÎĖłƧČš/ vx@IDATŽŧö`ŖČúœØ >s°,ĮS ‘j+Œø V‘ņk%4Xs hļG-Œ€čØ6‹Ö&RęQâc†)R”ļp˝ÃXznãŠlÍv˙,ķž>ĩ?ĻēŦĮä`é#Ÿēráúž¸Tņ!tH›eՐ¯ôåĮ@žŠš§’{Läĩ§šĘÎK Ē ^š…DŠØBžcÍqˇrĩŨKXˆb‡Q‚HpõÅøÍĸ%ëĮwŨ9.ēäRnQ95ü?øw2žxōIŽâŸšqˇõėŗsŸ<æĶa¤â Ôۅnä÷?6á×q…Û[]Øŋ\‘÷Kˇ/r[ËQÜ*ä•úŗßvÚxŽÛa>÷īŋ0Ūü†×åī7ŌæcÁą}#WÃŊ…į_üÚ/į ũøãOķÉŸ]ôõqÁ˙ظõ–[Į•ßŧvœ:ü=Áš’l¯â?üđ#ãŗöÅqæ[O#ާrBqß}÷{īšīėŸ{â×AzŪŋ&ˇ+yĪÛ9yĐoŋsāÉĘMœŦœōĻ×įāûĪŋøĨq:öÜévØaãļÛoįœyznAú7ŋų‰qüqĮō ÉQų4ánCēõÖÛÆûß÷žÜNtūųįũ°ųx‘|÷ŧŗŪ–OR>û§ŸgŧåÍã¨#ŽČw$šŽ]ũ܁=į?ŪåACNæHXvāÎMx=ˆšc0ɤŗä衒õ’´ „fŨ‹!W~ę;žķpŦHVŪŌ[03hWÆvų”ĢkŠÂ–i‡ZņĖ(OīččKjën‹ĀŒÆ×z-sMĨŌ§úĒ"ÅoĢEčA'˛’7ļâŦ40‚Jž[” Ĩ˜ą_Ķkßåü°ņŗŖ­—k?ĸvö…YŽč…ø­`ŖqÕ~×^ķ#•?tAŊō]zK¨qũÚÕs/ *¤_][ëfąļliå-įHø"†x]S1î?~ö“āčļŸRÅGIzXGŗnU,˛PÃ*ŋVÚxÕĄÅlĒ7Ååíā“ÛŒ‰žú–MpŖ Ž'ԓ+v›ûõ^­ŽyÕm¯5žÄXãQĘãEķjN2>Ws˜‹`1ĒäÖ¤Žž‹œų†6 [›ԟŠ`KuMI+ņ¤6•3é'ķãh‚ÚæĨųV "íc–'ŧķAĶOĢ1ĐX­ÉÃy˙Ė ŅU˜’9ĸEz Ų ĐŪ›8ĩ;% Q›öyØ2†Ãō]^Åâ@bëč’ (oāĀ2rāi"Ô71MÅÍYšĖ™`jČ­ öĨ 1И‘€‰-—ŋ8 Ü$Vœ‚c@Č!ë6 HKLōF+Bí Ú(ô/œÚÔšô ¸ÆA=nÅļløŧĸÆ\ƒ=îHAהidæĘ˙säÂļ‹BIŌ]DToūQDŲx"A%l’Á•Ū4§0tíM­Š O\”‚Sۏĸ÷Ī0; e´Ŗß6ŅŌfŽYą"!6/áĶ'ė ÅÚ^´`<9Č ‹7ūƒ6A”ŅîZÛ×B/oÁrŽ›ųËķ}ÉĨyŠ|ûĀ’‰&æô×."ÆSsˇq ž~Ëwbd°8ČģđD—vĄ“ÃiF‘æ+•ÚKˇ{u[\H‚÷`oļ!mrėuõ⊃niÛņKâVÉøWP˙õ-ŨËxĄ™ēm9ČŦņA34¯4įDT;ęâ,Hłg~Åō56q*æb'=˜ŧ Öt5•éŌĖ[}Š€>‰C>ëŽW*\#W‰bŋēûĄ’šSaŨ&”ūD0žÂk˙Æuŗ '8Ĩéáæ$I˜ęc7ō5›~ŅŽ6kôÆ\Ô[BÄĨŦú‰ądŗ™+†1˜…Ęŗjlæ4ĄK’Đ`áŠĢžs'ęßõ €1ûE\ŋ°úrŊ˛Ŋû⃃ũČŖ×ŋîäņmŽđÉŊ÷{@-œ}ī•yåá ŪG€ŪÆ-0¯įž~ũēëî{ĮÉ'4ŽģūúqŲåWæ^ũ=<s˙öWs°ūvN(Ä{ėą'ō4}đ`vÅfžũ-ũöŨwüüĪū},mųqßũ÷'¯x?ņĖ |AųPnīš+÷ûīáKą'xü1nší;ã'>öããŽ;îģ¸×Ūũˆ>}øƒ?ħOŽ{ī`ŧæ5GĪßDč:ôü ĪįKÆĮōŠÂõ7\Īt.͗wŸäh_žD{Ũõ7æ$ɧúįŨ~4¯~ŅÖđS‹5ĀėC_ö‰ Y+Ļŧõ•Ķ$:˙Saôē†Û§â(Ĩ]û‚@Öé NŽ2‘U‡?5\qmŗŊ^ŦLĩsą)ą!#ž °(Ģģüˇ‘9‰?&ŖsĩÍ#NĀWnŅŨŋDOŠs3Āúá¤X´aΧ$^Ŋĸx‘F@™0õË*¸æÄ‘=ģŗŌŖ„§“ôĨ—PÛÁ‚Õ|â |ĸ cЕ’m‚Ü5uŊPvNǐÕĀųƒ–ɯOx™K9u֟Ø2ņÃ)膤7ņŠÔęÄĄĐĢkƧ´H(ߊ“+áØŌ?š[VÜfášfl×>ņtÚõÉüåā_lꛏ™h+—€š.œzĶčΡšėāč§ ] ņ­ŊHŽ;œmĸ‰=}1ŗnąžGķŋŠĢáĸá$Šî\ lxđ¨œ1§ß( ėâ.īcСXĘ*ž l›bŸôLÖØ‹mĘ0hnڟ Đzōƒœŧ‰wŪa8,ĖF&ŧ4ÂķÎŖ9ãÕk6mĪē1%?1Ußô ž~¸p‚vú ŠSÛ} ) ėEÍëĘĩfԋUˇâ/­Û¯–Ä+`›&6ĮšÁfĨĩí4‚9ĪFG åōŒ§~`CSü‰/Dü "ė9žō’oŨDĒĖËącąiYøĘšc(DįŅʕųS0ņ¤rí;ąä‘d‚.¨9?ŧęļ=AÜîŗĐÎgEëæ×?ŖË™tŗ’[Ÿt| ]gU!€ØŅÚöHĢũéČfœąeœ#߃ˆz`|†ĸ\|́äÔ>÷Š˜s>[ęlīËÁļ_ŊęēoĢŽšžįɟ—ĮyzĪŊ_Ž}ŠVīŋöÆ[Æ?đžÜsã77rûŽåēŦŋGŽî7Ũz;O¤Ų?ßøõ_û•ņË˙ō_g°qöYgåžūc=†įÖ?9îđÁØÕWŸ¨ãÉ´¯|yÕGfžû]īČ­:üÜ<ūá?ūgãō+¯á^÷#ÆˇųbíŲoyˇæėæËÆwrE|ßņ=ņ„ãc˙ b8/ûēöæÛÆíßų‹|:áöcŧûŨOšū‹MōÆöîŲ÷1Ū˛ķ/ūĮ_˙ę_ŋŸ~×ÁOINäãIė<đāÃyzĐŅ<Ũg?>Iøöˇo-ŋÔė}oũÎãî{ī?ú#˧#_įV"O˛Åö?˙īuü ųx–ƒü•ãøÄĀOZîã;;ˆŨ[Ÿļ÷õG)=šž—Ōąu§l:ŧS%cőd‘•QEާ!u°HF4ëŸ@æÅqŖēôŠá¸´Ū1ÍģXv ´\ š},.īÅdtMͯÄ+Ëá—ŧ.X§ąS°ޏŽ:€ŦĒŠ#ė_ ņSá¸āĸ-lÚĘ+;_4sŒ¤­ĩŸU7sBæÔŗŠÅā[] ĘRÅ~`ūč™ķYOÔQl3‡]™ĄéZ.öÁŒĮ´õCØM™ąö#rÚįߞˎE}­NPRQ™‰lú”Û8ƒ_ÛĐ'j.ģvõÚwôŦ~) j/0°­0“_Ū˛¯Púyf čü¯TãĄĩŗb~ˆĸ/!s‚lĨ;^F10ŧu4žϚügl¸:uŊV…Õ‰Œ ĸ_Ū$cȈĒžä¯ēE–†Œ‡ŗŨ˙‹§úę|ČūkcoĢ:_\ûĩ*ĸßIĶßąVØōÁt]2!GŸĄųRš­SE3mĪãĄ¤ ”[@•Į]8Ú¨ŧ 0xIK‘}+´iå6ß`ķļų1„‰‰ĸ6:DBĖj´^6ąÖcWØ$ŧ2îŧ<@Jds Ķ4&mDœ‘. ãŅ%•ö÷w$tB›eŠKßttĄkŖˇ‡Đqb)‡/žk;ÕfŪQd›ZvĀ’”•ĖÖ×´h%ØžQũPLÄQžWÕ!>ĻĶq6ųĒY|ęĀRNŸˇ#Wš–ėZũ_%ų’&>ôŒ1ÛqĀ8•äÍ`)ÃoIŠŠŧ% m%šR2ų‚˙72õÕÜGË\âDNr‚ÆÔŠ5ŗē4ŌL Y8ëNË dŒ"{‘žĨ>ƒWßjĮ„•ˇX*:ŖPÆÆÄĮE#W?`a "„TĐ>†q&ną“3+¤ĻJ#íDëūCÁđ'\ôJB!yÁSDĄĒT›&gY`¸¨…@t–Ŧt@¯Ũēb"Aƒž˛ĐēĩŊƂ,õ: Õ<N?lO]›áM™'Ļ’éƒŨu*īË<Ē9Įj`õ‘J|É cĖj]čʛ@đb_Úļb‹ŌĩĘF“ƲęEĻJŌÍPwTĶļS(;Ր،ŋđĸ5A&&>'Våy‰“úcüÚá-ũŋÆÍōGųč 6ũrē҈Ī+`I™Âų4ŖR礤›[m~í—ūËÜĒr*÷ĩí’KÆ­qOî?ķĖ3Æ?ųĪ˙Sų<Ägq ũÔ¸æēĮô‹g§šˇūĢW]?ūŗüŸđ}‚‡ĮÕ7Ū•Į^ž˙}īŸü­ßÎŗëOįļĸĢxbĐŖH{åū~Žæ?Í-7ĪđˆĪ=ŧz"އ\ņŨąŸžãw |ĒÎAãūõ_Ę‚|ڐ÷âË?ƒ{õ_ā >wŪywx{ž~4ˇ=Â=ø÷?øäæļ›˙éWūižŸp ã´Īnā¤åIęúüzq˛é¨íäÄ♸ũž÷žg|ⓟâ$aßqęŠoßüæ5ãÎįvŸÛoŋŊūq:<Î=‘1į~!ųŋų¯˙‹qÍ5ßâņ̇“N:‘Ģ˙wo\ ˇŨ<>đ÷ûÉßJÎũbņ•W^ɧ '÷r;•õąÍ/điÄė°ô“]č˜Ųé:GŖCŪ1QÔņ Ŋc 6öŗŊŸ!@ÅyîXLQ¨@sŦF>8S†MƒÍŦwYbĀv$Ö@åÕ)cĐ1îHWI ĀŽË‘ëZĨ@„Âķ\ĮKį†vÔˉ"Ęų!ČZkâ4,žōÚÅâNŋ§î<2ČĮ Ų &{ĐÔîa×(%ī&č&?Ú5ŸbŠ&Svˇ1­/5_2ÆGšÄO#ŊČŗųV@Ŋä˙oęÛ*%bĩš8Œu ˆå‘ļÜoå˜EėjhmƒŗōUV–›OÄãĩɨ~‹b l­ˇÄPpm'/ļ$SÜĪö žē‚ æD?Ģë!Aöį騃ŗM˜y°E^Q\dl,uHÁ–šn’Ø FƒđŌ—3¤CÛâüI˙č0˙ëdEMۊ'Ē%Ī6°ņCķ6Äô0ë×ÂÄŧØV&ĮņY#Ĩ…‹€šUzõŸą8žũĶ7šę›(•†įąTĮĶrļ4[ž2gĩ‘āÍKĮVPM*RæbŲ6v1'Ú&žŊĪ9įŊŋš?‘\Bņ 0ƒ°ž`Ļ@d¨Ī~Ų$Ŗ ” U‘Ëâ&N›đW]į;q29!ˇ@G7æLZäI&ÕÖ qiŠŖ&Mt]ˆMjšrĘōßd{€J[™I_uš)ĻÎ?;Đ­ČCÕvĖ4Ūu–´øNG *å¨L)F“Á‡ō¯GlSˇõŽ)ŗW­ãŧīâîģØ/RũŌ7@ÃW~JĶ6_ņD‰bMš´ NÕÉicúÂÖ~ō-ßķžŗÆqĮ3.ųÚãū{y~ųûąŖu‡Ēö_ŽúŲN?Í<Õa,Ø÷ęPĸĒoŧ6ųų3€ úđ++|”•ŸųÍ0IĪ4âîTÁ”3*K­ū:1,ú_ŋÍ …ÍŗtĢ™RÆŖô–Í 4)qˆ­1k^]ëû¸ˇŲ¨9Ũfä•)Ųš˛í˜1GŅ.[ãÚô­JŨäSÁÄ Mv­!ŋM/žŅv'^;PtŲ¯}ænŽ›ô•a) ˇŊE+ĪáO?ĀTW;üiÚdhSe]fĒi0Ķ/fŽŪ+žũ8åGĩÍNäkWcK&žGŗlûŒđ@âKį…Ōņ‘7ĮF ‚@?Čā\*#ŽäËrįĄJĮ¸Ôõ=[ŪRמūûG}-ųĘ+/ŒŊwBŗō"7qČ­(ßēîxwæÖO .äņ”Ÿû…|yõãÖÛnĪ‹|ō‰šJūĨ/_4Žâ ø%N ŧuåÛˇÜ2vƒņ,ŋŽ{Í7×ô¸–[fvîÜ{œpüņ\ī_Ŋø’ņ{ø'üRņqãņ'įņ˜ˇŒ<&ôČ#Gđ˜ĪŨ”/žú īš›gôßpWōy|įk_{2îž2žôå¯ä6$ŋ°û;îäS‚‡Æ]<^Ķöūûî‡ũgĮõāŋöøŖÆMødŒ'đŠ€' W\yÕ¸üŠĢÆCܓ>íÆŽ_<ö÷ üÂí.žŊī8ôQĄûŋŸ&¨wņמ6~û÷ūŨxÃëNÎ ‰'~ZĄMåŊÍČOöpōpˇúø%čÃ9øŋ›[žü•¯’ģÛÆ˜ŧø[Įc{§ųøęÅãĶŋ÷GãuäÔOüŽÃNōīūĮĩnĢ7üņ]gx;ų„€Yž~ĩgīŌßYŸėw_0€a8NŊvėāč¨ę(qņį\ĘĨe°ÅOŨ)šfHåŜW‰üwđFAŽO°t[[ø1×ĒI -čŠ^˙]ēFģ?ÍØÖ/ūŒ[ÁėG… Uģ…RßËųs‚D§[mJ÷]CÖ,ÎËžk‹q8ļŒ%Ōqj›°´gŒ™Ÿn]P"ĮĻIĢßĶJdcWÕ8‡3ŸĄ 6•ÄÖÚ÷ØŽ†y­`ü3Ķ–žĢ|X- Mn 3Äéŋ&gNŖ†Œ˜vš1ņ— ‚xˇb%ДãĨ_YĮŲ†—­5ĩûžļļŒ3¯āG c¤ÖŠáąaŽg´Ąŧîøˇ”ŠĢsî™gŸĮ{ÄxįģÎwßu߸øĸĢųūūčĐ!æF;‰ ôõwŗ ē>ņBNxKlÉĢã(Ícĩ˛ƒ™(ĻÂZƒ;^PÂīâ(%ļī¨!ߜ•ŋöšĘžA™hēÕo|žyZũ q ›‹af|@ˉqxĨ;_ÄJ Đ$[˙ÔīÚ@9ĶĻĸüˇ>ۋáüĐ~ĢˇÆĄŠÉąēt\‡į”ŅöŽ$Bt„=\g&3Ÿ0ĘSNMY•i¤ 81ļ5œé4‘%TXP“č=ņyÅ"‰.Ņl œĖsļ*ålÉhЖ%:i­ļĢÛAŦĒŽ hāļ"ŖÚd#ŋéøH>'ƒūĮNŪ#˜ņ–{@ĩĻgI™´D$Ļ)üˆ1Ēø†Žo9Ķf&<Ą,É’`)Ģ1;ŪHRW†Ēõí6%鐋Oøö•*ļ-[†Š [FڏsĨ-D}e‡ ^¤A 6 3Nsk÷6Ī*å­;ĮÕ2]Ddā›3‹āßË'–/^^ėŽ‰cĸâG•ę­īĒC›rĩEŨēū} Į˜ƒ!‘ŧBTws-¯Đųœą¨$j›R_Ģ_Ptr!˜6ĩ7u˛#…–…@ØS›­šC0š§LUõXū„ §ŗĖĘÂAŒ2Ŗ‡'ŒŌoâĖ" ΔŠÔËĻVã; y|…‘>FÅWÅÜôS-ņTtšŗ +ŠO dgĄoČdcbޤwĀđĩ‹ģFÔģ65î•ËĖãč×wcZ~H1‹ÕĢŋ]—‘ēéų™ŊâÉÔ‡Ž čĸ´Ä0Q•™¨ąQžTdÕQ•J.§H6~zÔÛnūÍ'>A¯ŦīâËŊ>‚sŨ~ō‰OūNž¤ãŽØûÔũUÚ9hžqdĘøoņ$Oād@ÚoüæoĮ‚uoĶ9á¸ã‚§Üëé÷ ŪķŽķse=?R5ûA Äŋpá—Įo~ęwrŌĄÚ¸ö†›é¯ŊâßeW~3?f\Îą+¯ž&öũŅ/å?õ;/ےa>I80WØėÃgũāۂiÜøãĪüirņ}ōĶņßã<ųÄã3ŋÁ­<ûķÃZ~Úrį]÷đŠį¸uj'9ûæ$ꋞ–Į”tũōäÂī-üŸŋų[Ņķ ĀŪru2Ÿ¸Oŧš[Ģüb¯ßyx žã&Ũi›ô#Gö°sÆąPZŽŒĪ>ļˇ3ŋ°?Žî×z•ĢšâÉ ¸˜Ut}I=ZX!ŋÎ˙ŒHäSœĢÚg‚ˆŠŧķG{­C,0RŽ9'„NĄ…ŋö’žķ`¨O×CR9UąßĶĸÍļŸųQQ×ĸ-ˇŗi;§7āëkbŒ? Pá?¯8h=PąL6 ^ü¯îwÍĻ…ƒNÖE@ŗ†Îø5bîŗQ×Z+5ĩ?ôĀÕQ+ëUc˜Kd_NŸëßôÛ´ļ&Ž&@ô"‘ĮņÁ€„@\ŲėįЕŖę ˛d“ŪؘQ29šĸŋsģ“[šf é1”]¸­íä’væf|ŦéėßõÍ5Ī>‡ÔIĄ)iĖ­Ņ´to]{:ī+cĢ9 s"Ybk_,nTæXz¤Q'.ŽSÄ^JŌ¨ØVÄø’Gę8”ėĮžx”‰ãÁ¸%ų\ą +ô h=ƒ$'' RŖŋ}~ŦãX0äXu֝jÕ4m‡Āōæg7•~#—âhGw§_×6ąˇ¸˙tÂÍXYΐĪūņE0iŠ*ũuÆCt’AO5ß m_ŗíhīfÁî^wލv/č6‚uŪ˙îo˙ƒ.T›“Øķ;ņ:!īˇ~ëīˇnë\!â FĶŋôŖo~Æ tãRĪ|ūÃßų­īʈįĶw˙ôĨųį_ũëĶRčĮâã”˛?á—z}úīöâųžÜLŒšņÂųWąįōŊÉō‚;û|ÚaŊũ>¯xnž¸|‚˙đ˃äŸ>û•¤īņÔ˙ˇŅĶĻzÚ56å~õWūvŪиųģ ?äAŒĮ/A›ÆßŸEΛûŋ˟&ŊąŧįÃO=ŒĮīū/{ß ˜"ęĢ1øĶ×Ū8*Õ?¨ãhãp´ÍkÚ @Ϝk ÕēcžCˇßEs'8ŠPŦõė!wđL“oׯrâXŲŖ.ėģUĪbōHī†;.(ā6ŽwÚqN´yD¤.xžl#!āqĩތ35ÍÆ$]õć7?Ō¤=_mlN Ô šŸš„ĖVÕĒŲRŨÖ ķ6ĘHÉØˇą°ņĮĪöĮ qLžd”p+vsu;_‰īģĄr#×úM/ģØō՘#ä¸ÚEˇƒm>¤šyABĪH‰Ōã”Ęäûtžņ5yĨgĪą(ŨŅūÅī ųûh•2btĐ_´sI;ŗėPÄ1ƐՉb´GŸ~×+úHÁØjŖøÎ žGoŨ(‡w,ÔĶ77›WOÜ.úŖ\!°gzblĐU2}$ę¤/¸ü–‡¤đ!$§¤JAGoķQÍIĐJ, iÍ7gžôq'5;Uņ@į "”é ŗ‘7Oųú€įcŒiYē<˙Ö>đdƒÉqcǰã2ēļĖËžŌ´zT×ĩĀÚTŋĞ/ÔG@Ú`nr>âJDv›FTšĩ' •ŋĪ‚Ņ'<hą˜L:D‘ūę6‡;ąĻXŠpĒT'Ûā•ÜúøVÆ@ċO{;IŸ"Ü|P´€ˇ@éK( \Ŋ2,Mė ŊŗŒ8vWEI4ŠT/mPŒãĢŦC÷˜Ë°-ŧ!r?Hžė*ŊíųË=pB˜ xã14ß&Ŋŋ "ōáU,ä?ėbWT0ŗ¤îą”kߍžŒā‰ŲšCÄĐ ž At¨€¸A(Õ≞ŧærŧ™9<üPfŠ4E@ãĢ>{‰ėˆGLßÔāN9*HŸˆR*=Ē4î…qŋ ƒ@ÁAWfÎëop̈́ą6.ס#Ļ|2:tëÔqЏaŠ&×}:ëŦ†k DÃTÖwwÚĐŗŅ¸“'â÷¯6ÄĖ?Ŋį­Ēî¨EĢŧ^ÆKÔšw\ī¯dœų )wüÛŲ֋z{å˛fRž1ŗę^=ínCĐúĮÅ\ĖĪ}ų 3ŸÅ´Ũ<¤–ŸER{ŌŨéķŊĐĄŠļųŠRf8´;¸*#gLõĄ˛ĻķĖUqD„× ĀĄĖĄá\×NHIé˛1 Z•ĒœČĀPaķAN4ũ5Bag 9õĘ?*PMÛ7)zĖOęAŗoyßÚ¸€õ‚ž9ķč9y\äãÃSŸąë‰ĩ­sĄ•§KČ˙<<éņļ?üoô`‚Ëøč'^`ûKĩßôéJ1ģÅō•ęmūöęĩü›‡¸Č˙ķūō‘˜wĶž˙ŲxŸ|ũ—Øœl7ŋÃÉË Æŋ$´ y0oŒ6ūĘ||Cī@|ãā ĪÚnĐÁt›ԁŗļ{›{΅8$]bŽHĢ6˜ ã Ũ-yĩĢŪÎÃÖ÷ũXĶ̟r§s`˜}Äéžúúđm ß㓘íčwŖwŖRÔīĖč=*đgƒ^ĘԚÕķŲx‹Õ§ÚEQ˜;§`Ãāëokôá~ļ¨Ũƒ}SäyŅSĄFuŨ&0ŧ7׊éø*Ž^鸲Ģ'ņĮ͘6ĶŲhqĀ_÷5€ 6„œ×bšķ`662ËKsÆÖÁ“JúéÚ¸8-åĘCסrhđŌ}|ę"˜ãŊ&蟴ęĢ?ˆŪ\šöá.›nËw#Ԕ:ōÆˇöš†k嗉ÕāD•7cŨ8)e^ũÚËsbë÷Đ8œ ŋÄ´Í:k7¸žīé‹{tõ[˛[boÛ: § ŖEGßŅãĮn>,~ō!š_ÅU:|ŽŽgzŨ!éęĘįl[ŗõ tĘąū߸-Ūs8j6bކ<ĐĶS{yoå2—7åõOCØÔDRuÃpwo°Ž'87žįßtŧē"nãŌô1L"“fšˇŠĶ?ÉIžĀŧj‡zŒ@đ"Í”˛A ¸éyęĻ6=…pą;Q:@ “î~Ņė__쉑ŗŗW bpŌõ#pĒn~m æEûäŲųvLúe˜Nēü.<…Ąŋ{*栜<œXËēŠĸm7ųÉĖįŠ BM$ëčƒíjņ;Ņē89<Ÿs%˙,Rƒ.á™ģ}ĪÂģ‹|”Õ?6+˜“´TF€<īģëˆC¸˛Ė‚cnBČx:荮”ęČ57‰ a×M_ÎPäŪķ”Q“RĀvc~贝¨ųbMņǧ=t÷Ņ'؎4ĖV^ĢL5ØØ=–Âqĩ§Žz`€0925:6ˇÖfžnMˆ›‡0‚˜)ÚĖ õ0/ĘI(g> (´Ä2RiXŦņŠM€-‘ėÎ6†ų˜‚nûhM.ų›ˇB|¤„Ü< ą\Μ¸ŗÕ ø18|ģžõv{˛ėÉÆ ÃŅĻīŧŗ !ö:qå¯;ķ¤¯ž^GÕ­?^Éf#"ręŨÎیQ™íg6%1‘כ°‹[Í ĘĄoۅMė8JíĶY´7žæ‹‡ëĮŨĒw4Č~‰xj¸>ą7CWéíøîOÉxįĶ!ņÂYŧã}ŖmBīööˆŋÄ׉ĘËļ1û¤Ü¯āÚ7}ē`÷ø†IoXī1ˆã×}üĒÍģåŪ—_ņ<]8ŒîBõžûéÛ6N˙!?oÅōĸĪŌˇû— ũŦ­Q\+ 9˛Oyoßa7&…–;ÛnԊ&(ŽęŖ,ˆa_cĢ5×úĢõ÷ļWoâgK×DļãÎgûžKüBv•ē~ÉĪ?t‘I7ŠaVŖČg–c_{€_ŧī9o<žŗŨ:]cŪŲ)f}iS@ĸžD:ô’wۈĐ4ūnîõu‰ BdģÃTy'‚l–Ãß‘š3vjšÜsM M‡ ē “$]ģß<´ÄAŒsøsbú*ǝo~¤sv¨Gúđëá }­Ĩ\Šã(č_Čå[M1rž§ã"“žĒŨĖæhæ]ūp,ŽbBÖõą¯š)xTŒŲm¨fÛqļīú'’ņå˙ĩ ˙Ą0įļ°Ž••Gö ~v_ö7ĒŌ4ßDĄ]ሪļk¯ķĀw7l2ø9ŠôUŌģ u—÷ÖĀĘmēŠKŽV’Ƥҕ;%>WᚓÜT2ÅU-Ũü)_įw{š0Z7(öærŪ4Ũąj˜ržčLã-Įæ‹7LftwšY×7ŽÎĻ*įūq,‘%bÉüPQqNĩÜÁ7ČũYĒĄ_˙LĶ’˛EąHÆīļč59îĮ—åG˙3Ŧģ|eIˇSbgVŧ3•Ø.Šcy–îŪ!ÛÅŦļÅãÅ1ģâššP4ÖúÅļmÍĒ@¯īŊSlĒŨ_ĖRt›‹ąĒh ŖäEēâ$8žˆ‹ÖˆWŧI ¯NŸ  >šāíD1œ†WƒgĢߊyėĀģž‰¯7Jįƒã ^|ũz`´-Ë Ąđņ…f7Fō´wučėİņ–ī Ā×F•fL´ÔCCļL8žÖĨĀ:š,¸đĩĨd“nējŠëQEQ1šö’í+ôÆš˛*_ũōÂH@sŧ:Ą‚XŨ#kž9¸ęĪü˜yĩ8đe!h'/uôÄ69ēē°›'ÛØ'Ķ;+kDh“¯G4ü3fE•Ÿ:rũTRvŦ E’u­-˜#´]x1¨íũâRuoėÖČ*k™Øš uh{_ÄSÎēcÜc1RĮõ`Čve ųy Ôĸåŧŧ$Ö$5=SŨ´\Hw—¯@Ķ5˛ķĶõV篜ŽæÆY+×Ę:žD׈œũb´"G'}¸ Đ¤% mą s-Úūxn31Ž{rŧk|ODˇ}’NCšÁ„}tOWŲF–ūÎčÆ"|äEģŅÕs-8:ųÃNčMöÃŖ?īf˛5I˜Ģ%}N˙ō;ŗ×Z’ņĪ :-OĮûáŨ+é|ˇ~õÁ—?rŪgũ˞>z~CŸäi¨Ûu”t<°}íƒeyŊVŸÉ*Ãæ~ëĨík c<™ŌņhgUeԒbģÍņŽ3ę˛<ÖÆČöĸ5a–}_؃Ú/ßĸ~N?Ī|ûB7%'ŧ.ÜŅ›—έ˜5l"ãƒŪiŗ‰ˆķËąĖīü„†hזâčOtëJLĐ$Ģ÷:ëI\„ę(¤{•¸Æô,žB…5ߍÛåN4ļeáÄĸ'nŲôXƒĨYžīÆ ķ˜ âķrĸĸ´‘¸(Yę¤Ú“–YĖT1õo= ÁuR3XņķŨN:ׅĢ|‰ú ˜‹‘šŗ%ļŦm0âkÅ=[˛[ķ„UųʘįpsQ4ŋl!ČVßŅčf€:û„×N Žĩ…åtŽúË)b?~jGyÍûîFTyMø†o׹°[ûń|øŠĀlܔęGts7EÛ+€3– ãHI)Ú {XÄŌŒÃ¯%ÔÖØĄU{G­5^hÅČØĐ÷ōŨM˙z’ĪN“{’Ōãs^“Këß­u YuĢņ;mČĶ–`ģ8JŌ~ˇ|ūkôī¯ÛŪ˙^h juī\qäŦ ÕÔ­#*Á&xGÜú霏ßŲ9cëxLˇ”ŗ †0áx\ƒ/jԟ5ú]¨DF1摙’*Ņī\g (!ödNpąÅŅÉ­„rŧÖÜĀēx˛˜i¯‹ŪüA/˙ôÍ!œžúåĖÆÁØäPÂ`õaNsŅĪČŖĸ€>žĨë8˛ ×Ųq¯%mybotižxtP´]úä ŌG_\<€ŖĄžŧ6å úC;̓vTĐ#ˇüŋ-tļx@8úũ’!NėĸĸíËTܝœöäč.´ŗƒ¸˜ÚBNˇå7Öļm°éÚDÄÁŖ.čgaŌVh['žėl_^BJĨŧÂLëø˙æ,$—LzøG^üdIamCßļ1äAŧü™ĖDã_ē`Š‹ÚāôžpÄŅüqžh‰ yaōūÜ@Ŧ‚RV %Ev¨tv3žūįYXր}ė$îâ@`ģ yÃRGČ´–^ˇČš×¤G}uģ “ūëX7Na§ÂDėĸŗÉŲâ란Ž^žŦX{͑aĢžíD†ĸcŗ“ ˛ø ]/|*gß_¤Ü…šŒ}s2_`[ŠtįĸŒ/īą—Gr•éüT;ĘYā)“Jšüēhļ]¯<5*¤žž¤“/bml5´æáëxž<Ŗ´ÂĐ´—{BæģC§,?Ōf­\ŠcīöŋĐØ8[ņmÖ ķtuēqmė˙ =2ĢĢjbį¯˜Ė‹s´†|úØ:¸IJUÉBĘOĶŧ„Đ (C1 [ûז}ÚĢÁÍEĢZ#™gg]F’ãĖˆöå+ ŽĮų⒤o9 ˇū&MˆŋAĶžŦôØÍžįĖÎ>LA˛$ŽFz⯌J[ƒ°Ā>ÍС+›BÄŪ9@.[FŠø–æõ|xyŽ‚ūÍpjúl<&Ŗŧ˅Ôߖ™ÛFÖÕĸí¨]‘ÔX[ūzėųŠžG%ȋ6Ŧ ^Ķ;ǧ?ëÖĄē;‡Ģŗœ-f-mcŊÄ/e ˇĩVTi ؞?]'Đ>ÎqĀR9S×ö"8Āé;<-ą‚DcdPĮŽúRt$9ĐëÎ I]7hO@ä,! @Éc,lœã¨é>ĻĖK˛xÕšÃsX‡ĮqĖÚvū˜č-’Õ qwQdÆĨŽ[Lxßš_jΓDßÍäkhđŅķfhŖĘƒvt÷hŨvu-[ņ^ģÆ o€ø%`=ÃNŪē+ œ7Ņ`fîĸ¤üŦžjÂÄ"P˙_QacÖŌ´G)C¯Iž´ؤ‰ååŅ˧XASąąÕˇ!8Å•†‰V”TčG'Ÿ-ˇÃĸA4v.ƒcŋœDNŒsĄ„j˛Wį”Īh‡70$ÍM/ņĶĖĐ”‡—}vŒÅxæÔļŦœĐ/ĮI?œļÍé˛uôMÂt•iLUˇ`ÜA@ŽKĩh" G[›–üÚmętâ(ãš)ŽBĢ[mĢĐŧZ ´\ŋš°Čč¨ ĪEĩ_8õ –hŗ¯™CĻÜ Ô_ۀŸßÚH ųԍgēZQŨēŪ&]Įô 6kš¤Ŗį Gs‡¯Nc%ČŲŌ§= Ŋxā&ē7cŲ78ōœyÖV4æƒĐôG˙Đw:g”–qüéú{Zßŏm';ķŦČĨ5~„rģnߛļ՝‘ĸ3Xd&Ŧe€ÔrJÃûĖÉüä"Fô¯^Dc Pw~͟i äĀ3‡eé;6^õštœŲõŦCk4°…‚ķ“,¸)Ú ë—ÛåŊÔŅĒ#æ‰Ģ˜ V[pīVWßštsĶ˜&ĩ¸ŒŲSĸõ!ûeûDœŧ¸YMņ=E=šŪm~Ė–ļĮ€ˆģčyžJßA3`%ō]Z˜ZjŊƒâģ6TzxnûĶïo]đ˛V¯"m.Đ E¯9)ßđ‘КŦëî4*Īė™ÚDNÉ{ŗqé8†žĢ§›ö•ąŨ ĪãšîXåęlŽ?!éŦĒ*ŗXąI.ņÁøâ™D8Ŋė‡žŖ‹”Ķh1kO[†“]X¨ëæ''ÎIŋ;ēTfĄ¯o!o&ûV@œ#‘‹„ íĖĢ<īúH,t ,žŸÆ`W§ü,ãāđt–AČAL\}䐭¨ČM¸æ3[ ČąŲ÷gƒ-åËé2ųh÷´öČËÁžüŨy.AÃCÎl=”éBƒ¯A}>‰2Ų$u\#Ô'ô!ô‘GcĶĻąĩŌūęĖBåUYm)\nVŠĸ$eÍv˛7õūŨõ]´ĢԁÜŋ-ĩ•ÂÂĸR&ZEø6Ü9‘¨Ō5ŌK;‡&JZˆLJuv2užĀ9ėc=œrf sâå/‰Ō ŒĪqēŋD)Œ~øî{r *g>øbg2”ëkŋU`98âæ°1ąī§ē•N_@!JSēÍĪÕĄ:ΟŅ†&øđÃŽžgUjøX=m#”âĨƒąŽ—ržčoôW8×ÎE,1Õ6G2ųą‹ãŌé†Ũēl‡^ĩ ĻvŠ/!v­¯BX#GvpŨ‰Ŋüą*"sPh,(ģ26c¯•3—„Ol +Js# ëČH—āx7tUSŨšQÛúÅgeČ^ōԕīļÜĖīĢTūč(W.8>_u;åsÄã˜aZ%WSļÄl91æŦįšI’yįĄŖŸõÛæh6‘r °īų#vö˙’ ” Â¸ļXâäĒ#C{õh3{ÎŒ× !Mcģ5Ą_ũ-2]ØŌ–%īė-ģ*Ņ ž@ žäiÕDõbŖbĒbŖĀÁ@WÄ(H•OË G×E_M?-Žr *äô”eģt…Ų\ŧfEžņĶ ]zŗW\mĶßOŽé×Пm"š)oĻƒŽ 4/€U­›ē›hÉ›ÅK¤ø‘kæe°Û‹{ ­čé›oļöúŠ€‹’näĢsy@nŲ‹ Íe4k„=ŊtJŦhJ#eŒb.>y96īnÖ;âéÁÛäaŽ:’&Ú ;ÂˇĶ‡sņC_ōΘ.@ī8ČɗÚJ€ą‹§J“R–åd9A•X,Z›P§ ėô@Ō$>eKŸ¤ČÚĐžâōY -Yl ëé?€ŲŅRyzđĨ\ĸ bÁhĩ+ˋ\Į\Úąyŋ€$=č“īâŗŽ•“áąŊ¨ļæYmđFfŽųŅ1čM—oÅėbtĮÁąŅãtüEA -ņk œ<‡LđŦĶ?Ōí¸šĀ ÔØ[”u3BåBSsáę+´ųĻÔæ­NÔjÍß=Ôāho]Ā13n¨Ē÷Y,ķåá :įBr†|cjđĶķkƒį÷-ĀønûkŪyÉßßX=;žĖU8ą%ęĀûÃÛ­ã&Ŋs"hŅĩŪZĶ\Ī-;Ģ{Á˛‰Ģ‚s1ęhģhę ōœĀíFĨʝŧvh=Ķ÷ŗā—ĪWᐍąĩá>X­ŗOMpb8L y‘cĐÍŲLĐÂÖīËĮI¤ØÜĮå ā¨å*6ũ‡­>zú/y`cH û 'žĢ¨&Č­­b—"€#:ŌŊxÚŧ=ÖëlîĪÛaxޞŸôÃÕoŪ]ûĀą&úÄT ų‘ í÷1ĩˍ-Ÿ8_y Û}ņn ¯OYŒĨÁž'6ÅRJ,_ †úq˙bzĖ „4ä.ŨqKēæ˙†äÍâĘ:ÃaXNņŨ6–ĢoԐxõÚÚΈ1Õ%ƒw0ÄQC+UīĒ+Ņ×ySõS$w´‡dg[ĐâøTžWį.„Í[ßrpn0{Ķ‚ā°c8#[ڔĮ§HÚJ7>~Љz}RE^ƒt}D9_ō|{>´e;zc°ēÛX‡rđ§×9ô ėú.er†,?ü#0ŊwĐ9)jĀ„jN•+n|´ŧ:Įô…uW %Γ­nûöŖõ{WbMŽTĢū6Cé"‘c°đ-{é^é‚îŌ…A:&NL;HuŲVRøČƒŧ^‡¤™ļōqė"?UđĪāO~`ތŠHųâH%eĻ[Ņ"™I­ÁEņ"ʡ§˜⠊(&vô+{­ļ¨Îz¸e U;!Ē i=LĄž>EŊVĮ6o1˛Ekē̊ņõ'9Š÷´$€ēī“T¨ØvĖyY›øû^Ĩņ-ŋËŗ)›¤hķĮ‹ßūüŠũƒY1#™îulûæ×´ŠĨĢúcLÆņGŽjĩĻčX..&ž>›#įĸ˛O“ŽoŨ‹hĮ‹Ũhį>šķŠŅČ`ēS§xgÍĒŲ¯@iß:ԇĘÎã­e¤ËąACw?!|Վ€ūĀXnĮ–žueųHM Œm|°F?ŧ”'ë'qÅO úĸ‘RėŊZč*éųKÆ˛ü"öĨŊ˙É9í./ÃÛšjū}5GsZlb)Ę! +K˙lfē"„Õ1Ufy=ã ņŲōĨėĩ׎Z♛:3ˆ–¨’äuĶĪ`õ;ŠË;ôī—pã@IDATü(O“wjž ;ßąãūžFōɇĪX/–gžÛū&dĀēųô“OŠÆ×Aˇf:pôÁBUąyā¸[īÕô]ä#ŗÂAv<+Éۃ ”üĩ?×ŖÎ (û§”ŨÚgĶuà °ú|:ŖĒį…؈ZŗŋæÄA:XŅđKīâ†A:ŸÁ2DMæÍ}Ģ@˙Ē_yRĶ=ÚŽ#ĸņĨ­hyT/Å6foûôîĖßŲ&TĄđĢŧfqvi.W`´ži÷˜JETå/¨1\‹Ņ­ ß6ĸ­ö`”gc“ĮKÚÖ=y‚}ŠUãŪī1)—Ŧö`û.Fčļ”ĶÆÖŧƒ)G'ú8 šęŲXũ,+ú=ģyĶøŠėļõSŋ8‘,!)>Ĩ÷$bžwŽąģ Ēŗ=õ‚fü#m=9Ōōqōæ"’Ácŗņƒī9ąz*Ąá)įųd2ļŸkŠÜx’ āpŒß:^k:zaF*yĮČō€‚nÖč¨AĖĪÚ•Å|áĀ{|‰Q[÷ã地īŧëLrŸ†¤Z0Æ{ė4xØ2'iôԄĖĪF!#ĩcŅKˆ‰ēˇŖ×ČÚUˇ9ŗHDRG<ŋąÚ–ĩ¯F0 ÚĮŋ.`ĐtįĀ8 ŠOŽvoP’ašĘ{GĨ¨€ĄoĐ˅ňrˆ˜øM(0”Ÿ öå¯{”íĻk"uˇ$H<Ųiĸč‡<Œŗė|ĨŅwvģ#•*ĪH´¯I}-ĩíč¯Ģ‘ū‡l›Ļ“ƒƒÛåØ.ûØĄßEÉz¸…ĸūÚû÷•ģåŧš\”dÃ]>Ąt7HˇØŧ~ú؋'x­ë°ÍÚØCŸp‘ōŦ*ĪL-ŗČOÃͰä‡m Žøû>4(–ßB_dũPYeŌŗ¯ŧčbJ+O’˜)ûđĨ(níė"nr‚Ā'MûvëbVN6g'ûBā“ļ|™ęÕā]NĖHtq –FuĀŽ÷#I—mįÔ.^ÚÆ æ’yšŸ&I›n[‡ÔŽÂå% ;XC}'DDAێÃ|PR ž=9˙ŦĢĄæœĖ^tĸm Đ7÷—Ë$Œíú prá‹Cŋ“ĀšŠN@†JŽK‘> åKoŪH4ͯããÅY'Î$Ō1ˇĶÔ˙r@äÆĢ ¯ÅrĄī&ÄCAC‹ũŋwļĩ`ŧBNwöØĪŧú0]{>áâ‡?øŅ‡?˙ėOųĪ´?Åöæ¤˛ ­ņŨū"ŽėÆĐ¯l}ī“|øÁ÷ø N@ …EÃ1ˇV;6ČÖ LTÕžtoôũ^Ž5tiN&ۖz˛Õv€ėœS0Ē={ÎŲ[wGž[“&Úīpą>ōßZoô@î<ÖÆ˜î[×÷4ā˙ŦįŧKΘôũbĨŅĪäõ‘.moZĐ%bé`´.6?‘sÁШŽöZ7Qå‰Ģ9ŊáĨČä‹\ØâcĮM;Ę.ą^ßLHvz^ w9rČvƒķ€˜Õ|§¨MmCZYļ{O?? Y‹.ąãķĀ\Ķ…tÖˇ84 5æđoˇ55Á#›Z×ōpēŅ*åŒÄH‰ē^],yÅĩąW kö`5ÉŊoˉŖŗéŦ}ŗxĶ×Cdzč—]äxÍĶElv”tņĮmÍ^Ž*ÍØeĢŨšd­˙O„ˇŽd %*râü §“K…ē×Ļ„™™×Ļú¯X“÷ôĩ ^Xķ×}V`¨âø§aį^Č e;¯keß?ķõ ¤,eĒ-ØwX<Ց:ŧõ[Ģʡ]{ôwūtL[īŸj*CVvķĘŪV°)So‹CAŸĀ6,ˆV e8čÃ҇@5đÃÕâCå{Q ũŠ dÕ6š-.<ņEĀ€73°”sÔ¯I¯¨åCZč Ŧ„täōiŒV ~šsk’dæŧ§ą˙• eķ0œž¤ÄÄŋ“đUl4×wdē•qņËįé8<_YöĀÖĄī጖}É.Žn"oƒŅŠ%‚5ˇęŪåĀ_ĄīkCņQŅ‹rĀæ"l x‰5÷ķr+ôÁg1ú”˙ĐųÉ÷ŦôLŒX|M@áXí@jēhžŋ{ēĸŒîØÔ›ĐÜd ë€r❌ŲBÍUÍ —rĩšĀ+úę(ĄÍØaę㖠xŲŅx99Ą*ÕEVŨÔéÚĀŖíĶΉ>°ôˆsũ|†Ņâ;ŲŨ OÖâh3Ÿ>õÔ/)ƒŨֆ VߏMCāåļ jÚMūcËu,o!kôĶíë椘Õ÷IŠë~3Š ?Kۓ§ütŧĩ…ŠŌÖĨų qā˙ŨLm\N(ûëE/žųY—|VØNĀÎQâXž•Q€78>šúÔ#NūÉOūøÃ_åĆÔwÛ/N[ ‚%ˆ‹˙O?ũđkŋüë~ôŖøøSV9˜{Â=­é͊F:ĸúŪÄ;æ}zf ¨gÅYÖ/ŧJ“n2Pä?(ÎAkŠ‚ĘöÉ$„ž´Ž@o}ܜP˜ķœ6Mšîĩ#Ņå `së“M}Ä?įGū ęm›OÆ7˙,ŲúŦ€ŋé˙Ģį|tžfR˙ŪGš9ˋI_22šČmĘRAi)×qkįEMô䉧+élÚĖOvįŒ’öœErK3’b¤R{7V¯s›—kˆ65Å˙ģ~ÖĩÎĘÆōŗ|´ŽXŖą4cŽēqaąĖ,}ãĖ×ÚĮīt!¤“Ģ ú­7úm›ƒĢL_Ũ”ęk}|!†¨ë¯XģK˛ēĘ,ãĻ[ûƸœsĒĄöŋ˙=Ž>éO(„g#-ę‹/ŪÛ6æ@=w¨sÎ Ęßmã3ĪÍtÂ;—hî^ķ8ėkĶsŒūÖ­Í›Ø0<_u­‰Mŋ˛m|Ųаq ĒGøˇŒCŽ…:á)ø‰SãĘ×zP8‰F°``Ģ1Âņā:BŽČ÷ũf‹<@@8Y íZã[Z›ĒâšGŊōÁâB‘Ƨ]ŦÁĖ='ƒĻex͙Ä=…íī: 4Ã8)‘-˛4û‹žĨmÉÍÉņp^%KrF›š Ž4ö]0Ōv`Ĩ åN#4O1§ĪÎ>õûčUuļÍ-ũĶĸØđ‚đųetŨŗ8ĒVÎąŗ‹'/îĖ#ūlĪîWü§ÛĪöŲ‡˙ų§_~øŖ?ūŖŸ}ųį|ĩ„ą7„3‹Îą/>īīļ˙÷ØĖÁŽ5tÆų ÖîōÔ˙īūÚo0öŸ}øŗŸü÷ęĖY°_äNœz°ĘΆnS…n7đ:—ZƒYMĢ}ZÔoë.˛ŽuŸPß^3ˇæ4×ÖŽ†+t —š˛žÔĨÛEģs¤Z>sįØVĀúšl pq:GŠ2ü8Kē¤;|ëPœˆÖē‹ĘšŽ×`¯=HČĄŒÛā¤ŽæÅXOŠmĩ+–7Æ/)ښĒc$ė˛=_ąĘ€8ØüØŋnŽ&žĩĻëÚ(P)ŲŪ@)4Û&í4HÃ—Ū¸„ AũĖÛja}e2rüw­HÚßÚh|‰OžÉttíØēđ ēōŋĮ&Ģ—ÅqÖŒÔØå3ĮĄÎæŦŗÖËD/Ŋĩj @Ųp]Ŗ´:h,§ūĘ,įôõBŖ{ūMNp?åFø˙ë˙øđģŋûgŧΟ”Í/í8žQ´9ÜbÔ)7Ž6Ģq×ŧÜW{8…❁Üô*T\ÖrļĮķúcvL”)ũj<öŗĒė‰yî°OX]t­Ę„ôÉXÂ! ÛĐOôđ'6މēs”šôÐkŋŒ=:ÍŗâöžŸ* &W.Y#4Ũ´1p;am<¤˙áúĩYū =ˇ#ŋčē{• ãɰō*:öiŋņ*.„”Û@1 ĘĨ!‘w ØQUÜ&ë û ]ôŠä“ŽĻ@ÆÄģ†:Čâ-¨¯Ay:‚€ÉĶī†K›'ÁËQäîäē`Š;⭈h)ĮŖļ7Ąœ@Ęh Ūņaú`+¯ģ`(ŸŊËģÛĖ*ōb“™pu5Ž4Úæ}i‘đīČK73ˆ%ˇą‚ēŸôĩ•˙Д3Î.ú'ũÖ?Ęæ'-$ÄÉņ˙“ôá7~ķī|øÁ~HĖ­Ö܌‚-<}Ķ7÷“šáDy“KėMî ĨuŖ|•ņE|átôŲžīĢi“~BĐbą[r`^ŧų™m„š€t6ÆQæZöõXš ą7ŖĐO=ũ,¯y|HÕ­HŽ­Ē7ŽhIK‹qx“ŊüĖÕ92Œˇœ(/ˆük‰kô ×îl]O@ęÃŋ‘ ‰ķĮ!_œ—Ü‹~ķ7¤a+gūŦCZ8Ņ”ēÛ°§wéyŒHq#xe^3Îų(uŖ2,)wÎåĐUņŒ´H†2öËŋp€ČoÕDvž^ø,­#¤˙Øé‹/ŋøđųOúáĪ~ú§>˙üsn°?īĸRė[ƒļŋÛūz2Đčœô+ß˙ūxÚų}žú˙RkŸkb_˙ysį¯ÕķŪVd3ė0žÚ~Íe*¯ļËļ:\û…i̚ Ā‡ īŗÖLs4ĢR–‡‹퀎&Å ˇĩķWæD6Ã˙šSÃí}îõĪĨeulqlîôK6ģ=ÕŖS˙ÉT2ģņ@ž¸ę °XŧÃtģE‘U}WDûÚeú1K#7qÄ'(~Šę]Ѓ(ô=Yą ´ņ°`ŧpŧ™1ƍ!2<˛Íqúg˙üŸ ôŨö]žËĀ˙å 8SöųĪ>|ņ…o? âIœÍ}3šFSûhûįl­ōT~ß. ´­!īĖĶžéŋ¨û&úķF}ķj~î_áÆã;ĐĪÃŋ2ߎīōßč ņíž ī´Ķ~‘\WY˙¸điį÷ž÷ŊŽ×öwĮī2đ]Ū2ĀŽsÅIåS)Ŋ†‚âU‹Į]æ0Šē@ŨÚā‹×ĩšrŽ÷šÆ–×-ēî’ĩk¯÷ēԃ§jÔsŨŌÃLDģžzĶŨ'Î/Y%ōÕë§ŧØ^¯õš¯Ž‚í:ÜÅšž`Ãk؜ĘqŦxŨ V]ŽĨlûßËŠÅžAč§2ėĻĻåĶáāõ–ë™—bŨÜ҆”íĩŧv<˜´ü`$ ”¤Ö¯Ŧ€MˆáV H~:â7-v÷ņ(Ο43XŪí +œ˙\Ā'đ}ĨGĢ]¨3 " ņ*’ˆvŽođSgWuÎ4k_9’ŠŠp=¤ŠŽҍV0 Øķ g čJšŪ~ø“ŋú*bOYģûĀ‚ņõä3<õ,Š=¯ •—nnCŒĮ‹7~'+.jÉ]Ũŧ‚ņ’ĨåŒk!ŒŽ$ Ūđ˜ívĨ42dHŲô+ēsŦԇqqÃ)ģų0ņȝ¯aLú+>öūå_ų[~˙÷˙ũ‡Ÿrˇ˙%_YĀü6]bËįŒOwŽŒ—ķŽmŒĻڅ¸R¯#œ+ĐŅ[F<ōn\Åå Cī†ĘŨžiãā>Ė"}ųūr„€ÉcŗÜÂČ ģ:ã%p띭īdwLRŒôŪiO‡Ž9į9…“ĢĢ$čĄOįøŸvøj'4‘ŗ—Žũ7#/:\Ž”y=0/ķCĐĒõ4;oãŲ¸_”Ķ?rĘ<$pÁ k“‰īíKŒÆî÷Ö.~e3uņõĪÚĻBŪdg9Aš0´›˙§=*:1Ž_ļÃ㊠Ճ>ė:ņ¯ėG‰lå˙ŒŨ(ėQÄÍ&ÁŧŋįĀģĄ+<•ģĪvPîQ&xŗšgLæŲēî Aĩ—ęĢõNUö¯ŪÔs;qŨä_ō˜°!”KpÜôbÖ.õåŲ‹ōøķîâe˙e´Ë{?^fōķj_,dôÍR.—åī%ö$íÁDv#.˛ë­Ų<ģüΑŅŪˇ÷*"|ˇˇÔą¤Ũ=†č(ū蜾Ä+œüDBNvr‰\´ˇNMH#&án\ŽÄÉe¯ļ4ų!I~ļ÷Č!^Ûo|i[GÍūŠjJÁãČ ?šŅÖ~ĪŖzņE›1lsëõØŦŒJĶÉØ;-ōŒ>kßųøw11 Y4îæ+üxD™oåL›šŠ^ßEUžvŖĶ ŌhßˇÛ›˛ûgMLlz5aZ ŸđuÉ˙øū× ?ęÁ˛ë˜ęÍc(…ˇ.wiíĘBļO ¸Pņ:°ˆ<>ŌXB¯Ģčģ°Ũ¸'^ųÕí Ú\ãÉŽ2}ûÖFWO./o* }¯-sĢ‘ņÅ ôæJ r]‹M¯ŗž1L^W"įuÉŨĒŲlAķ™R•G–¤,ÖĢˆØÅ6$Í쎛1ĖQ{Ŗ]Î Ü|ų öíŠęĨw:[šlĮhO§mâÕAôqT§ö5°×ÕŪŜԁ´ž“YP˛„jŅwäĐŧÆÕęŨšŽG%ØÂđ+}Zp Q‡ÔoŊQ‚›eČöM;Y:GUXé]L.!AJSįʕ.ŽģņHwčˇÎûåDÁp§kn-“ãbچ<‰“OoŽú=ˆpz%Ŗ5øĮ\.`\  _ ˛6=‹ šŽ;.“ĩ€SDKÅ&­í9ü5{˙ÃīũŪŋûđ/˙Åŋã†_ņv—‹ī Čžú/ Žņō‰ßŋōĶ#Ŗč_Üå⃈ģÕ ã´˙Ĩ“Fp’mM =zšĀ'… Dy‹Ė§ōZāédkäųåsu2*&~¨ŽŒũ9Ļüôu‹>ŖˇÅ„qĐ´ØäĮqļfōē§ųĮ>œžĢ§š‚N!úŅ\ Ņé–Āøˆ¯(°ÅÃxųXS§4ÁÁĨ ˛ æ›ķPsĶī4ġ´ĶXč|ÖXԑî/œî쎙+NãåFF!dĒ@ú-ÎøEÖ aO'Íŗq ėWYĪrežņËé¯ē –Žxn;Öš Õv›9÷-ĻɑqÄëĨ­V.ŽcJft V#G[ßīöūiWŸ`¸6ŪץbÔž@"ÕÜĐ)Žpõ;cÅØ/>ËsŌ—‡N‹ux'Nt›C`ûÅđNxæ´ÅåĶ4˙ü‚č;-õã´}s™ú:Œyķ¤æéŌÚōÄĻ‹{X`.é0Ęĸß?&QJŲ Á?š´6ô×īTˇŗ8bk'¯ Ë։ ^sW˙ ī#íûģęŖ+ž*úE.üũõ‡"¨šĄ'/ãĐÄr#[>4ö>ōˇ­Éž›¯žļgŋ—4ŠU™ūā#zbWSč”g ĢslŠ?%ÕˇFš&iŪ­Äyw~7É|‹[âÜņ×åÅŦOlė§AK^æŲˆ§lįûÖûûé: .ãˇu =ÆĢ§š_Ā3 6}˙‹ė0¸OˇŠŠ;×ü…ÖžÚ€ė]ģNįŽŗsĮ?Ęa.ÍĶW}žÆ–]ųæŠj#į^‚wĮÍø—üâ嘕ĮėöÃ×slĻ;6dÕqÄ7JöüâĮėWãōú.=ē@‹ŪšG |ĶkÃŅÛ8ĖŽęjm0_*Ū1~ˇĨôë‘ŖqjØ…/ÍĖOאīĀ3FÅá‹_4*ZŗÖ8á´đĐÄ Į<Kck)8Čû—dĖĮ6æpėį×Éãl-fW…šĪ0Ôlũ‚æŸ ­–t)9™ú|rą5‘9 Ív/ãQŽžžšö˙ę¯ũøÃüŖÖŒ‚ŲĶĒøęæ°1ãĢļĮÃ+X֙y…‘^ō…´6č7ú­?Ž]´“ēŽķ °H6pŸÅˇX“cŗĒ°q9~üĘâjZ*´Ö6øĸ¨¯Üråč\+úãvŨî§ķcfĄÃ!§…FĀ“^ŽHf9 ā (7ŸõÍŧOCŨ´Ķ””^Gqũ“‰æ%hŸĻB„9Ą6 ôW_}CĻ“žt• OT{Ø$2"éLâÅRąNYgĘŗ¸i؝@ŗž “>i)ãč-.m´éÆ,ˇYą­5üę`Rŋ,ļN´úođ‘M™{GØ M5f‰FČŠådŊäšXÜ4ÛŽ%¸ŠŗcR€æĩQˇĄ9 YNĒę[E“ ×V'$aš3ÛbŨÜ@sõzíÂ%Nˇ`Á/×E´ × fJ‰GŽ€ŦûĨ˙đÏų$ĀIāÉønJ悜q‘(OÅuGH)éú¨ [c¨Ÿ.Oáfjĩ’<_Žéö֌”Åâb+ĻyØbpbŸķ@Ā,žY1 ĩŦ…DįŖØŖ_8dG˗u{âĻ˙ O´í{k¤ÁíÔĢõœ?#ļßNŠ}Ō*vēxŌoTôŖŸŒ?/Įår…Ķīs–˜üd<]*‡ŧp+˜(=šåHÄųŌņø qķ™äĀpŽŠâ_.Įûü b= æg,âOJ‰ üM1q!g|J/JëgŊáQ^jąĄtŪ‹Š–qŌTn'–wiHyÁ8XôéBca äÅM_/ôēúTMõ U%ĮK›;ī!ĶØNžž9‰wN|uǁ`ŪËyÆ‡Äą;œ&7įCލā&É6īüŽ9’ļnáõoá8~ā͘ËŦ~#$íVđ垍¸ÎƒĀ l-y+b)ũÔēÃ%Ŋ†´ä‚f§ûI^“ēéšĶ….Ŋû}Weī×X%ķQ÷´GLũ=lēwkN4ÖĻj6­čy™{ŊUD^˛ļĢģÚr┍'ˇ\Ēŋ‰Ģlz9v° ۍŪĩĪ]ĐY4čG>WoŗĩsĄu ˙ ƒ đĮ6]Î/]O_—/ūēķ%ųČŠ˛|ę¯zY_œpīyws+ũķl.ŅņGĖjOœŲGĘøšoqå•uCŽÍYߎ­Ÿ0ŊŽ­ˆlˇÎėkRÃēvL•*TÅi)É ›ÜĄ.픐Ę×u ÍŸ°‘Ņ^7NŅĮĶÚŽm€G[Ô]BÚķ<§ķú Jze…~”ä[K‘Öž3Å kģîfÖŅë•Oëöā_Ÿ4uė‰āöŦS´M{õgÛLWc ]?ikAwĸÉ#.ŲáĘļooŦŧaéaËéˇFĸŋĩcō ĮW6 îØPļ^ŧ ,÷‹QœšyEyg/Í&ᤍÍĸ’ƒ›dįÄĐ5âđĢ›“ŖÕ5Xw‚¸ö--tũd{k˜4!Ûh$ V7ĮПå!9—ËÆdÂsWû' Tö­%­asũÚĖģŖ\ūĢQ¯<)Ģ—wüã ]ūIˆ˜ŲÆH“׋ˆ€ÜoS/Ûî pœėæėdûēNĩuĐAyYxZƒY€zû1 ŋa–āħsÕíå2!v#ÁOĻÄē`6Û ŨĪ;ÃØd@˙ÄT=ĀMŦ˛ģȜīikGzž…ÉøäFMÚ-˛Iąc[ŠT@“ÚÁęT ņ˜9쨴ĩMVĐms0Nŗ#-0Ģ_ņOÔMd]Â*ĨĸŽ4 éIā”+aū{üŲyŠ-ōE›ģ`ŌĮ’>ŗĻē‰tm#˜’VUL›áL7?ŌÛ žY–Põ?|ā&…‰5bĩˇ'qÕu‚ƒĶ,gįõ‹cbdY@Zˇ/ Ņf{¯6åũrtŗ#’^ė+ĒD1ˆĨDáâ!g]øæĮZ °ģĩŅ&œ=¤øē˙0‰má­J;Īɋ3°yģ7‘Šė‰xŌ[ķëņY—=ĩ[d†n991—ô4Zíé‡}ęē46×&åļ+v7SƟ1(ŗ=˛'¨­}zE—3įžô(b4§Ļztáę›ßƒÍ_VmģšCY-kÎ-=PtŲļĄÔ¸ ŽŪ]IažiHōQ–ķN4Ę܄ ķu+׿ ~†"ŋŸE'NŪ‰ÉĻŒÍ¯ŊhW‰ÎæG×EíBŧë­Vų˛™ä5PaŠ ĪzÕ÷ē×6ŸŗąXÅÆ$ņj¤‹ųwœZO´—Ț*fÛ*ī€`kžIß üÍÖ驓_Œ‡MÚ_â{3Übą*5ŨŌÛåÜŽöEVūKkåôsPN×9õķaą)§~žŪ™ę†gĩ ĮVwņIäö8`ØsHŨ¸ØÔ*×|ڊ$“-)ĶOwœËúéXëHîŧ˛čņbI,ŒÉģ7H%&ī'æÆvk`qBķŒÛž|ˆÔ_™Aßļ?ŨVø‘]zčzūē~drƌž\#X^‘GŠØŠ\:ū āÆŅ˙ƒR9¸Å۟ƒ‡™君 ,bã!yx´ÜI_Ģ=Xyy}eåĩˇÕZ+N×yõ, Žû4ZœŌIß:oŅ4û˛RčđëRœŲ9 ũ4'uå*s|;&‚Ķ´2åUŠUĩZ“<ŪŠŅ#{SåØđ YčaOŒ1ģƒå$VÚģšsö¤9p‚ĸ<0‰^øyČWČ`G īf×EMZq:đhÁižūčK˜įâqĸęŖgü*ä ô†~ÎēéˆgÔ}âhŌŠ‡ ˛/Ŋų vØkLõE"nSâx›Ŗĩ<ä3~•É´¯œyéo5Žūķc8’Ŗã͟˙ZÕ Dŗķ&dŨé÷Š˙ü&"ž'zĀĻMî'MŎ šŅõļ:ĀÛŦVŗtãE–J_ûŠėžt”P˛“āōiÛĒ´Ž|,Ģ)dãÛ[ž›ƒâO‡Y+w„š4 Ģ‹ũ9ō:R-AĐ÷ã’¤hX›ëRāģ°Ѓ|Ū („6åä.÷Ôôā#+ˇ=ųož;PoÚˁ°Å6Į{k_Vp¸“ŋë!¯­cˆ|å˜VI2a,^˙´ŖHņdÁÅ2ŧüØo]R7 $āuA1Ąt÷A˜§āųqČ[lüÄŊų‘îIKĢÍwmlĀŽ7õæē‡°ŠQé đ‡Ä9„Õ)|5¸Ũø!~÷ĩĮÂ6$ķ~Ÿ.SyŗžrŲšąÎøk‹ ?ŲĄ:įGˇ5Õüƒīlüę^āÂ7­w›žš ĮZcmëC>Ņ”Ĩ-sÛe‘O„úĢ]­‰Õ| qĀ=ųÕēÚ7öÍÕWÍ9Îb‹Ë…^9ÆyāĄ }‹Á.¯Ā9DßzlÎh4×yøãņWĩ ĖŖŠĪ/흠ązœūķäSjžaÕÅĸ€\}逑öÅ;ĮØ1ōFŋ‹XMLã4.ÖĨ^_L 6ēØÚgÃJėülNÍF°— ¨•;Ôî|ˆ§råÛܰš¯Eŋ¨}đtddxƒ%_[ŤÕpą˜PĄõ !đæh‹nø×ž˛´Ą9čEŠīZ†ä×fW#PJ8ũ&ŊÉ×ÖÁSmkųlϧÍėņpŦ {Ŗĸž˛Š¸?MkYB:ū8ÆŖolĻ;Ÿ§9'‚JŨ<ų˛¨Ė‡DØŅ_;ĐŠKŖ•YŒun%æĄĒ2´“ĨđTlŧ‘äF'UMéGã8āŠX–xw,iB2ŽŦ lŽĘ‡æ.aëڜEN}…-PIbķē˛čĄãâϤŒ08Ún> SŪ3˜m&/ ë_›zę Ĩ6~ŦÉ­û6ām3bōĢ B… ËÔ_œ”'ĮÔP“ˇŌnYōd"IĮ|ŗQ—ē žĐpĘbFʅšä,X¤„Wũ›œėČҤ‚ŸÍēŊæŲÎņņ6(ŗģÔEüōu’Ÿņ™ÆøŠ,sÎŌŧÉīúß|T0ZÆ'Z oŒOą Ф¯s(6˜B”§â7^}—}‡åD’9(VÚ7ŨrūąK×|‰•2ã0t˸"+s†Ų\ƒn´}—SۂŋPŨĨĒ/ }÷ģUãšL„ĨJ”‘oĐāmüfsŨëE"îŅ56ƒLGim-Fqh™?MYí5­ō.-Ũ9ÕEBE¨ūx5P2&m•"x}mõũôĨ–5éø[“Į : ˆ7JŗŠE›qŨÎavb,WâvęEđŖss\îÄ5p;)•hëkcb#_ËŖTíûĸuyåâøoĩˇ Ļ>#{R‚1ã{sVËđ÷´Ã`ôåĘėPqũ]ô ÷aUŪØ•wŽųG'ŋķįh—$Úv‘õ¸§EëfAzŦ’RNÄSĨüptJˇ\*íĻĸlŪօR-äˆ×(ņĄ{ãQ;<øēč…^Ŗ!œšĶ˛žÚF ŧÚOQŨUkÉĸÂ_~xÅ-ŽdiĩîɰÆ2ŋ= ’sfmCvãØÜŅn‰Fŗ>ĨtžÍ2„Ęhđ YöUĨæ›k_ƒŧŲ8*Ÿ YŸ 6'6&ÚŪĖ܋Å;7˜#ÛéÃG°u˙øĪļRa(šyv<ØØHVØ`æŦßMGã9öt ųåGĻ lŌ3—RĸŠËf.Üēļôō=áˇØ‘sįÄnBES­æōÕfˆ9Ĩ”ˆLŋ2Ô`Ĩ›´sqE믇 ˜,ûįĶ>˙JŠĩ“ãß +„>;•‡āö}ŸĀKr, DwVīæ¤å$¯Ģ1Vr‚Mß Į/% cl'Ÿ^„GˆMŸ:JåĮ1nuŗŒl÷ÍG}P÷<]ūÔąå¸ÎKų›7~]—_•g§ŖŊ›÷q„žŋĢģ|šRlš=9ČqF ĻáWĒŽé0N˛k z9V4=i=ÖĀ!6sēČAæØÖg7ŗ į֒¸ĄrėÚãœ)\…{'sœR;ŦëhC<éúĐ5Š}ڍ  Õ´ī9ø*›§ŽĄ`;6Jēuā>Ŗ[8Ú}…ÎZ¸k„U$÷CŌŽžš…Ēßt=؈Îķ44Wáæ~'T2īÂč†g(Í0qķúR3‘šfœŋ"û—}ĸ—kuļvu%CKذ.mīA×0ˇ&͆o 1Ę-ÕŨ dqŪ매Z+ŒQ!í({ž"Ļ<œfYTĻH‰¤šyâ7pyņw\ķėa÷šĘņOß7'Ō€Ûæäˆˇ€xîE¤›M’Î-ŒđABå1įÕæ^ēÂúã:ë<Ë čņ5”Ô,椑ΠŽ…Đ…,Ūä[ņĩŋŦz|_ģ)€6ÅŲ‘',öRƒåŪBzHÆ!;>"ČtaĄ.Œ[ ét]UĮņ×ĀžÃhĮ –ŲԏųéÆ%ChŒm“š#â >“ũâ?ģ`)Yæ;á|4Sû1SxBˇcíklĩÂŅR,v–ĻLßld& SPŖĮ{Éîf‰ ēP‚IæHáåN&ßÉĨö#čdėķ^ĨlŧrNžpĶ9RöŖ@ļKEBVžnë‹cĢölå:~IŠāPęWeœøī˜*˛<ĀD!¸^\¤ŅZä§lqŸė/ŋōĀŊqžÄdųĐOd|“ŖÍ|š( L_úuBjĐŊœÄĐA);Üē¨_°ęž¸C;Fņdš8Ę)8Ú÷U躛ȯ]”#žNdļįãņ"=!{Ögw7ļĄ¤Ŗ|92@đüJK5Q?õ`ęfdēÍk!åFgëDq<í¯æ'7qįĒŌÄļnœ‡ŽcΜ†n_‹Ęe“–'Č6§Īņø&DģM’GÜąi ‹2×ßäõ§šDÁsÜ7"g*¨yúõ¤w6m9<ævÄ>ö"]GĨ# s¯īåiėzweõCÛĮoeōPíĄ$|o°}ąŽYk›”*,úš\ĖWåÉ ´xČĢ⮓,GO–ŅŨIDĮ<XŽOĶxÅŗŽZŗQŽg§ŌŠ÷ČhN7>ų¯—Ã1uÜA^ŒųEĒ–Å[pi4éĐøŅ/ž{M¤ëš eŒ šlh¯Ņĩ(E´°Žī“ƒúKmĮ0Ÿ$ÕŪkšp!“lĀķéđ=!u1y\ÔļÛn€ĀĐ=vĻlĪŠËuũK!Q&`ŦĸĢ։GßT„ Ö\•+MžDĶQŽųí~Ĩæ‰D9&ؚ_ØŲ“…õa ī‰"øČ Xs¨Į Pˆ×ŧŠ“=…čįŀāii~oL'Ÿ÷Í+1Íéō6ŋ ‚Džöĩ¨¯‰āĮ\ôĩLåbĨ;*WÔ ģ=9sîʕߟŦ=yܜΠ¯Í­NĘā›cۍŖ|Œåģãļ–U _Ŗĸ•ģü1G‡æņđģĐąŋ –€z €zvPTž“„Žŗ°ŗ‘’ūlrÅĪWZF̰~ôËĄę΀­ÅáHJ÷yņÉÁ´bŦëæžsŦSü”ŅNŪqÚÖũÛKöÜ››+)Ž‚/˙:QöwwGíųrKēL#ø*Å%c3@ŒYĘ˙bFMĢÄŽd“DĸSžq&c´ũü°Éëú=ܟ7BC<åîäX=_ũU–;?ONvŽC^8xqĩŦíĶ_ĪĢ“yž`ķ}-˛0Ôīģ€Åí_]Ņ<+¨Õz"ô­Ŗ†Wm×cđ†1ņˇ‘÷Ž÷l(—ōÕ$ɐlĖvČáY nsÔčD7)E˜Õ€a= Úš@ušš]Õ %Ô$æ¯ZSĢŗúK—MlŽŋĩ_Mŧ@āĪō°ōČeCŪ ĪGr”ģ㓟N(ĸYīŸÄ™JįDņÄĨ­/ŒO0r¨/Z‹š+zúÍH]oßy™bŸ$ē|ŋôXaL6ʎ4ēđN ą”ëébĄgđåBųŊ’Í1WĄĶÆ ØĘÍiIí֒¸ĮlxstĶ?üioŽûE_T›-uœ5ȕSį Tđ›ÖbfëŽÕé¨wok:1Ũ ĸŽXˆæÍŨaåô˜ŽG›¯ú™™”Ž.Ŋë\3(vrbĶÕ ČĒU˛'ߎløÖWy›0tqˇû}XĢ‹ûL;ÖęLũÁzÕ¯c• ėž>~Zk}Ē›Ŗú‡įÚ'žgƒ77_ŗ^Īī\XP‹C_´í ¨¨Ŗæ„6ŋL 5…Ļy„#=k˜lļ B"r 4ķNŲŊé8¯DfĀ Ŗōâ*Īkö.Íū´L <í6éĐËØÚíI¨2đŅBPŋ´+LÂ<˜Öđ5iÅyoŽ=ûr:,pëEß(+MeĶŽš´¯~x'âxߐEŒÍ‰'Ļ•Ļ^KžĐ搞¸|ëũ=)™öŧ_’ĨmËCÉÉI Ė 6ŗ ŋÔŦzųNÃ4jWĶdO'ĸ^\0”įĨ ÷B ĩ@NÃBĀī­/oĘ“FŌ<Ú^7=ûÆũhĘWî(å_j7ļÍŲDĐ;p*ŨŸÚKÃÉA7Q’_Ō™”R8FioūžÚˁūŧ´§öh`Ė1ßupųĨ×ÄxĄÁLjį΁—/xĩtĪ^nā.¯[#ĖWŪ°îēî9æž&‡|woęvjaåÃąīšŠ×"ŠúÕŨĩDjƒ„_ģ –æ<ŊLˇ{´íÔ|ÖæPĶĪ‚šq-GŪ Ū;ĪT›äĢe.”ŊQ?Î+‚>.q¤Ą=:Æ0Y19ųûũčWb'Đędų×Êą=íŒŅEĨ.ęéų§~GĶ1äM’kÂ6tæša<ÎsĮM{9›° öĐÉ1gˇú„ÚÉ{~6†GSüo úCgŌØ0î0¯­‚46´ĨL “š’Š,ŋ7´(RgáÉ!ũp€˛€,ÍžaįúÂŨ‰#ŸŸų:›zkū6GP@VwŖÛÆ%1]ÛģĩR¤wâŗ'MĪÚMŧTįƒJĨ@Ŗ‘:~ŨkQ?üąnŌiŋŽÜķ3,:] ķé‚ØÆĄ€1¸ÍÃĩÕs¸×G4ÚtEį-΄ŨgĪXW™&G_5rė”&…}K>bvwmĘ.mûĶŗa{UyŸšëķÆCĻ Ę虠ŖŨëĒaÉÛ;ãG”6CrK‰†~<ŗāhŋ˜Ö=‘XhôÔc*噯Ą8WĒ3¤K^Bå¤Ü× dnėŽÃ ˜đsõęE˛ņ&‚nˆWmäŋ•7lIÎĒHę.ˆ7ÛŽMęģ6Ūķ‡7);ŋŸ#“XīãųƒĢ^F3EO[ ŽūBĐNŋ“įÃͅHęņ§e ĸ„ wˈG÷[Û= Š1-÷ZQ˜I]FčntsúbnüŽGč!ûܙáąIRšDŲ韊É`“–˛´7*ųs“Lûøpûû97 rļ’Gû@Ģ$ϤÎĘ2I[ZŖÕšöYAúŊĪą$ņũ? ž\ r}HuģH(o!WÄSjāEƒo|Ē&K#ûđĪųåx•g‚ą9–ú‡oô]äĘ€ĪII°ä:°Ģė“ĩ-Ž~iËŌ\Ī<¸áctŋ;j7ņ*wu’‚Ö‰,‡Ō2ÉŌ?˛Ĩ Ŗ´ōAīnŨíiÛÉ~1(¨„x΍Ŧ_Ōú æôŨ&{s8’ēyw;c0oÃŨIOlnûđ×mHĐpÔ¸8ˇĀÕ<Ų@‚a|PÂIí*–Ëu¤3 ķ!ČÉGí7vž ‰ˆØšŖ|Û™c åíiÛ+>ŋ¤˛yö5ŧ Û”VTÛđO))Ņ6kà ß]^ęāfvå rcĸ=Ûî˛ŋņPúĩX ˆÖNtî/‡JnmY+'éü´]JËW™Č÷ĩ΍ëĮœ8RĄŠhmŋŒë‘z q˜ļķũÄŖ ‹sį.yķ M‚ũ?WŅĨ){wwŌ0䊚äPû*¸Ĩ¸f]xū$ĸĪø›(„6:Aĩ"NXÍÁ#ģ¯Ī û¨ĮŅIŨÃÄųÔØkĪ÷õKÜĖ °:yäģ5GŅÉûũæœĐO0ˇ>ĘDŠyY#=í6ŋ´ÍÛÛ Íįŋ†H‘‰xÎŋŌrŒŖēLã_˛ôēÎöøâà(gí4đ3ažXžhĻ^}cSˆ­đĻŖ/Հ(ūüōuP¤§`Œ ™åŊ&÷m<ÔĄ,ƒ×j€=°į5Ŧm1m(‹@†ŠmųslꟋĨÜû„NŦüΎúË˙øšøæĶ^đÚøEĻ%l`ëß}M␌å K 7aƒ?}&{nNsLęjXIꕖ+/Đ``(=ãˇîUō‰ĸøŒī÷¯”—ŨmŦK8Ģ7t˛( båĶ=mOc÷t7:´.͕x›? ŋÆ\\Ąûéļ ¨_‹đÁ˛›O°ŦŨՈųž>Č4HúÉe˜ļ'#ۑÕ;kv‚tUÃCÜUÅ0Zü]ŧŠöĪĒŌ6ŽNĘÚU­  |¸/Og =*10B1֗ ˇ¯<œøĒLĪë ~ÚĘ+9{â2p‹9ŨwYã7×bŠ<;å@Į2ĪK$æū.^ĻŖá+ëÍWâę—,ƒĶIË|ÄŎ4sžnûŲB$o՗ĮkRë+ŠŗІģƒŨøĶ7Gļ‰ũō"Ēņ‘5w](ĶŽöO.ĩĩ .k^SbđŅ|OIÕs´yk?æmĸĢĄb>öĻķL˙V?ÆĄ•oĮÔņņ9Âqr€‡QŪ-^hû$ 5ÁœĶ#ˇ´ōmŪ\x܅â|ąV ĨÃxĘ4ŧ$ÛÖ'ëN\ķĘ!kYģõ­yžz€Í–XĢɓ5ÆCV9ôŨ˙["N_#ÃzvIpTķ"ųĸÚWŠŌ€¨ŨN.[÷ęJƸ/mßs’…‰~öŽ ÆZŨ ,ŋ•“sÖXôãÎ×ú(E;yKG `ĘĻŨ”ŧŲ—^d`–q4Đbį¸íK°ŌôAÛæBdu]ÍvžHZŦtåßcųBq‹c~‰Œ~u,’›zr˙tkŸū°nŠŠöâO4'\šķA=åä âiû`SRuIĢOC4?˄|ƒv,!ÜÚSÛ¸ŒDž˙įHž[ķ^AˇãČâYßpíīŲ…–ŋĄ"ŖĒųån}iķŋ|Gcįxę‹ĩq•Ģœ§ãßFFƒÖ@į#AWq7cĘ÷—‰ĶˆîŧRËķ%ųsw6ëϚD۟bÖĸ™šžõrkŠĩōČ‘ØJW[ļŦAœđšÉ\gąįœâ3oÛähC€u6ŠÛBķFĢųr {JCEbģcš64g˙§+Ráop N/€ĖŠģ_˙ˆA•˙L˜ŒLnÍ ˇG°”Oĸ4ĘŽNë܃9‡éäSZ4U´´ô#J}ųlg˛+ĶI„‘PÆXwáD[˜ô/”2äĀ ô ]Ņ™“.Õ8`ÚöJt|AGAÛß ’öNŽhŒ0ˆ„É',úHGˆå ´gC ō`q~…{¸údu&)›H“ŖØIīȚ^ŠJdÁĄĒ¯\Ũpˆ¤aŪ"ŒZŋ @Ģ'¨bŒŠ?jZčR–§|@R6qsh’Ų‘†CNē  š6ˇžģÚĸn*Ē€Lh.¤į͚0 —]dŦąũ¯ bãbjS/‹ā`fŽĐ.jēčfžâ…FžõfëNvúÎŊniDiCÛˇ¸Ú›o2ĨGŦŊ ŦWĢ]*žl*ëģMģ4čį?{u"@ķ‚×v˙ôĒvæ%â3rŌxûTUũ [VĀ#ÜųŲ B‘CNßОPŊŽŒšAĮ„÷ŗÍ°QŽh×UŦ,m\"›RĐŖŽŽjÃlĩŅņ—„×ČõYž˛7ˇŒų̓ ëKŋx™Ķ6lŠŌ_ސJîä<7׈šʇ°’5ß´ú˜V4eGšÎ‰+ũg æį|ånúŖĪøy}=:zŲ<žÎ¤5É Ķ~‚U>tœoiîÄmėbHüŦV#ŌW]1×׆?maeEÖ0͡ōËėÄŨ)lÅsO!ÚŨ )ŋnuÔÔo,–S5}_;öœ˙EÖÛ(ÆÎãH˛;3ũūOŧŨs#"A•ŋģ´K"ņ“H€Ĩ*ûøt?hˆHĻW‰›8ũ­ƒÖ@z€4ļÅŅ~ĩ&™(žŒSĩl5~5ĨReâøjē7ããxmąŽ‚n|8i~{ūī§ģ­}cĘS"vâcßļŗj×öę4q.ÆŅƜô;;đF•VÍ3g—×4qZA˛)âų•“ķåØzS–‡0úhŦÜfÍë<Ąá5Ĩ_ŗFƒž×“–ĘŪ¯ŽēfÄ\ZöЖĪë,÷Ë]{cĐ~2ëqC̝uĐ_ŽÎĄY,ÆbŒQzM¨Õ~•)Į Æ2"Ø÷×b5đŗcKūûë?Ø# ÂĸÄœšŲ?8Ã÷–WõPí\k/ą1fDQŧž6ˇãąŖjvôË;{k°zŽÖ‚NV] Ž=9jNō­sđËW/ÃF2‹Æö>Q9ētWCfÍ\ķ‡ĢķHŋ!6>÷™ņ@ėĄ‡X{> ~ũ+g'ínLfģ‡\lķ îXyšŖi?†Íˆū#ĨEEģâéİÆED›čG@ˇČģØĻÂÁ„´ŧøp†8ŋOą—8ršļÆÍ< ŠĀŪŗXɏ딉‹…Ũņ°>3  |¸mDė“ė۟ëĶTŗžđšECš‡8Č ŋ•Á6Pū“kš´‘ׯąP‹Ŋ@cÖIĒ:3–ŗōžƒgH÷T‰Ú"Đ[s7žWŗ¸†Ģ/XEuĄlAE4#Ņh8ŋŧļáÉī"‘T ŽŖ`=â?ˆ]:oä…"ÚÆƒäÁĄ€Æëj2a‰+õW[œëi[žŒŊhҎŽRš;zü7Ÿ—)~Ž÷)šĘË%°=v˜}B‰ņ—ģĸÅmßqŦ§ĮdūåCßpH~Į]¸1PėŽŅøf,ĪhlŒķÜ{!˜C5„}7+ķĮA]žMŸ‚ÕEXūc§j˜qzs$šņå}¤6SÉ;î`Ėú|TS7ī4ˆß¯ĩĖR@u4ųžĄ8ųx°ēęānUęÖ>ÚÖāķĶnßKPķâ ÷z]›^gC^E`°5-=ÜíøeũÕôaBA”Y{ŅÔˇŦÎRi>7ė°ä<į‹˜Î÷`6 Õ =ŲÜÍ´vôljáАëú?\›¤]đ{Î]ę”p+ÄåÔíÜĄ ×>{åžĖĄ&|ĖsÁãˇÔ_ ,a–ˆŪƒ‘Ö…°^ęÛåB–(äŌÄU_y§ĻÍj?u= l@ž%ä'Ē››Čb–Õ3 ™6čōĘŲ3ĩ/yő>üö͟ūŠ1HËK{yx6ښžVl ũ‰S &×Ōn^úô§˜ooD 16ÜiōÉōƒgžŅ›i%Cžž\lŽ81¨ŪŒ‚K9‹c§†-~shm(ękžzĩ~ĩ=0ĮßÚMüf4dDũ@Ŋŧ\°}sØ"i>ôĐÂWus @~+ŋčY˜ĪãîŲÚž˙ĖLč_ŗ:čåęĩœ‰ã␇ėr´ŽĸŲn”˜kõÃˇCę§Ķ¯bŗ7 ēĖĪšäC†HRcĶąŋëĪ\´B†ÂÚûa•1VīTTĐ8ί6znŧ.lÎÉŅāōÖæZ˜YÔzŧø9GŊš˙xíŨ8J­É btž1žũÍé‹"Tü Gį]hģÉj´YlS˘|¤^jãRšČŦÚ">&Œ]Œjņ¤Ķ~Uĸ hĘŪĻøÆ.ė–ķ•ņŲV@ú“]Ūĩa=é'vcģđ€žĩŲ&s¸3į8ų[Ŗj×đ#ļĩ遛ķ{C÷æ4Ž7×­†ŒĮĄ’ÉÃdU,gˇŲŅŋũI¨Ãįno¸ū<ž˜âYTÕīøŽņ(˛]o (ÂÖU€”™úEķĀXÍŪā9WŽZĪšē`Û_Š(¨ÅaUŠvlĀ†Ûž#Á6nP Đ?hO ­5×í{’‹-¨,>;slŋČ,Ļ—į,ĒI‚ CĢ ÅøÕS;õ­—öôũjÄ~ô\’ë?˛9=œĩjgA­”ë@n#§ØÃPĪu~Jj]dŽšęHõ99˙CMãa5SÉEšučykį­Ë÷&\o׍ōũ"ĮæÂϰëÚQúvø­ŦĮŽ(ŽošÂoĩĐKęĸ}(ĮËËss+ûáŲŗ cā@_΀&Į:ôSmt•ąeÃEéM†8ƕĢļqĐP†ãT Pf˙ZGæ…ŧÚÛĨī7?‹!öꙟĸLÕ;r.(Ø´ŋąß įycxúa§XÖÍJ Úžē eSëˇo$_^ TXŠjGoyy­NŽ}tšÎú 0Иão˙ &˛æ°8ÄĀ˜•ˆÃtrc=ŗwøÆ0Úe¨Œ~­ßķyĪķßÚÜĩũ[˙ŠĨ`(í?;÷ǃ?įĩ–īj”F™ÜŨĶm*ą&¨ųä7xęÄZ“S87?? Ļ-´OlėæĶú•bĩTO^ÖUDÃÅũ`ūrų áúā/†ŨÃäĢßōžÎŖb(đ^ÚŅēîŽīū×E9Ķo1Ž“kl׈đ‘-†X6ʐ‘¨RáĨœOĻŗ’Ą¯åėˇ÷Í'üCüÅk؞uƒfŦGēĘlB|íZŒŪ)öĪĮ˛Ëp-Z2ÍTíkfŌ”äŧ(qfč?ķ÷Ét×\qđá9Ņ ‹ß„[ ņü_ ƒ˜ƒ[ĖŠˇ-¯Ÿ™ e ŨüCŸ…ĮāļēŽĢ“ėĚšZ}}°¯—ž‹úoŒO˜hU5ĄœYrÕGš §õ{2Ŗ;'ÆŖŽÆÚ2:։ŨįB1ċכ<į–oÎ×nņūø) Ķļ–M^bגšĶfã|&F7˛Ųũ8ÉQzüŪ¯Ŧø€¤į ąé,ĸōôPhcō’~nŽīn69ĻĶŽ¯ŗ×¯jU”ļ#&#DėôÉĒžņ|ÁŠņyĩƨ‹čđ˛äėą–ä6ų:Ķ‚žõø|Ū†?įR"kÎŅo}ú _h"ĸ[ü$ôUEđtv—ŸēzS ōĨk”üÆThÆņž5nĩ'7–×Ô`æújĖĪoš*4SĘĩMX<Ú2–;rLŪĩ˜Ŗ“n×­FŠ=q…C¸i;B{=;į9ąļA|ūOžŋ`r3EĪ/ãlũ7#ˇ‹ }o͜íâz˛ o‚ēXœƒØ!Ŋ>žJÉsEp4éö yíÕŽÛ úテÖ[Æ1îâyqÍû2í/ĀŊņâ*ßēÕ0| u5Ö>K:Ģ­“ρĒ€ų"vĘí—gM[3ˇ/#„蛝Īvæ:hØŧûkz~)ō=ŠOՖšøĨbĨF¤ŧš×Ö s˙¨žÄũn°íūÁ'zų€ū@Â~Ãh/žĻzŲŦš¯VįßZ~zį _bŪ<`ÔhËFŨģOokõĖj$˛7΂;”Ŗy/Æã[U$gM撛ÕĖ{đĢ -9qXŽtŦ5ĩt‰÷aԇ|úhΎ/‘Ėĩ~j-ę åņÁĀ`ŧV…Ü"ģzčÕ^aLähz æ<´Īeūg=hĸĩF‡Öš…˛q^īĮéÅØ c*1ß&,ˇą(PõsH^{‡xķ+ī]kxŅßĒĻ|.ÚÕKđ˙`0Šj÷É Ņ“‚2õ.­¨ŠąjßYŸé={ex}Lkũ?NĪĸĖĮ!ũÖ͸áo/Žhæđf‹ķqŌįÕ}ņd)ŠXō‘§ëģF‹šHÖ÷Z|fŅŋ_kŊ‚’|1„aH°…ôqŪž°Ÿâ" Iķ•ÅqzĪc7ģGÂSņ<3ŖG[ķŧîI´%.˛īõirųrnGū€đūÂô.™råF_ãŨ[čO~õ !, ģ=Čāė\:īĮEƒËŪ \úĒ6ņrhtÅŒ+Ņ‹ņą+lHúĖVäšĘ8s™ŌīĶĪŧTmÚ4ŅO MW䓛ƒYõkHåŧĢßšž˙¯Ošu|0h3´ēŒŸ°.N9˜‰äÚS#ÕāvŖâVdδ^+Ūų‹ą@ņ÷IžĻē„5ž]ųeūæĐĻ6ē°]næÛą8ŗĐMܐÚc+oxn‰í?ŽWOšÔō^2í€B—\í{CŽŽzSH=Ė"ÎÕr¨ •›PÕ}70=•‡Å~}ųǤÅ.˙ŌÚųĶ3lÍ(īĄáķBĸĖÖJĩē*ąĨ‘¨}NÂgīĀžõãKŦm ĄkNĶF'˙ūņė;íPÎ͈č§Á|Ēo|ŧvs ™:ymūÅ[’7CģŪīŊĮ0Tbļ͝.ķ‚Ņ›Î{Ā[s,šœĢõK‹˛œŪyr‡ĩŦŌQ‡ūę"æĶ™@×V¯BŽF}ŪÕ¤ũfÄZЇ%ķ {z8+ĖįƒĖQæąNļâ*{ëŧË3 ØÃ"Žø'BũĻ9׿e¨øęäŖ´ũĩĩ*ĻCo{7˛‡įČ9n<ŪŽƒN/†@t9Q™ãŅZM6„ūĶ|aš hígd*2:cZĮÎųęģ7ãĘrÃøËnúEĩ§ōš+÷ksĒ×vŽįúQę`õÃĄ_žQņlķøŠåO^bÃaĢBėÕŦ{šXĪ3įÚÑë֙  hįBāģ:Ķ1–Šø(ßļØĮiņ6ČŪŽœŊšųטŧĮ™÷Û>vÁ{ĀāÚģW$ ֗/­>Ëëė“krõÔŅÎ×̝÷ķŨŲųĨ‡™×˙ūҎW2žß=?ŽåכSlíļė˜‹ĶW.! šĪj‚ę;ÚS–œõā % „^Æ ˜;2‡ß3‹Žuxø `{×Ã_îjŦÉ€—įžo°Â>žÍÃ5×_yąōōõ“ë›—Ûŧym&aõrî÷æ š ¸Ĩ§Ũ׸ūhōäúĮ3žŗŒŖ1ŗ;Ža /ļkõÛ÷å.° ŗyĩ<ĸcvÃ4Ÿ œe]=!Û~"Ļŋæëŋa›…×kĄTÕoX˜âģÂėšXūĩ7[×ė9ËëĮåp4:~æ&îj€Đ ‘ff§GļfåœkOėrdė›>kĮL"ŖĩfWW Šíŗ ô­ ČÍ?cüąĩāa ŋîŒÄ$c‘ą]spĖų߯]ābŲ¯˙éˇāu#ã&;ÁMχS'Ÿ¯ō ˆ´z’ÆXŽ ėÔĪ-×ã9IIāŲ3(1%öŨ$´ÛĨ4ģbe¸I4„cĩí×@ö§âŧ˛xÜÆŽÁøh=š¤ÛĀ1 I49 ôUäƒÄ6—Ml?Îo7[îŗéFE_?9+w}2ĨŗĨy FrËËŨL·ą~žģvCĐSŧ‹:r­vjæļ9;ÁIĮg.0:ŗBęģ^1,rņ—ÛˇQaöĸč™U5[ĖŽ1ōb’ģ\ūŨōœģbķ( xŋÅŨŧÄMāār!n1WúĄh]f'm 1u‹ c0ô͆Co,žL„`FŊ*7aúKfŲ“C>ڃ'|•Ûō[×5lu“˛›Ä+^1Ŋ9ŲWŧ-¸ĶË/ŗĸ t…ųĩfš Žc_ë…ŽŗNŧŦĮvōÆ Kgh­cÔŅ9ž^̎l>˜1ƒHõĐ_&ģ*Ū?æzŌ‹œkCŦj“ĩo‚€ĶF,/ãǟ‚“s´ˆÖ˛O˛KfÄtÉ[ú­ \ˇwX „ø‹ö)tO'D7Ņøáu›wõú6㚌ĄˆŋYRė^˜AŒôąžú™ đķå7UE 3Ģ7yn¸ĐņÚĘæpÎ}Bô[ ™ŋ~ÄĶī(p”ˇ€ō }Ÿh7ŌkžZb{0ÂÛšŅ¨<›ŧzs‚ø=ŧŊŠ9+RĮcQ6?æŖnúŅĸīí+9å˛ÜÅüŽ1°m` ¸Ē9ˇ.OįíqĘ3ĸ”ãæÔž3Fķúī\ˇkaĢAé¸5m ŊNz#,ąvŖ˙‹ŲÃ!ĸ„­1ĀkŽáĶúKˇēl ”ķĐ7ÍČÄčK]:Žo÷Ģ—;ũ­ŦÎ÷ÜĒŅķLī=Wƒ5gh¤ŨįEĄ¨}Î?ęĢßÖĀVöą’s ž{ąüÄĪqFæ‚b‰”bžZí“čĪģÅ/Kí ųáæÔ7œĢ\šfåwíīž€Čp?æœi/—ō”ŖĖ°VVÚ9GŖaąuéØkИŖ€Ņž7V‡ÂØE`ŧ=hu}uÆ"î†ĐÜæ™ ęÍ`8I‹Tuw_tĘsæ`vrįē덇ûąëKúvéû/hŪŋœÄ2ö ƒvbdËĘkĨ&¯Ŋ:ŌVžch(kAžŲ.ŧîG,ßõĻŖļšÛXlÅ8=`1’x`žÔņĨŒoûâĩæQ÷œFļ‹ģöß:ú›“Å-.|Ÿm\'MââÉ-,΅û‰jûŲ_z<ų!Pûã˜ÉN‡ã,2˙€î.NgiEœŊû{ 俯x‚Ë"œŖîI°dŌ…øa6…øĖnQ“`ŠĢ|gÄCŠãĸn‚]čZŌ Įâ0A}U„ĶŊކcl,Q÷;õM¤ļ61ŌŠˇ‰&¨įĩ>‰r“@žõÉĻŪ÷Iœö æÛÃGâ$ŨíŠkÅôôĒĄ Ɯœ`ņĒ–°Õöá›}ėĸ$`ŠŽöņŨü+“^PZp!ĘLx=]œsĢūâč\,Ô <Ŧ Ī4âā˛ŋT0\ŗ˜î-,MŒĪ%ō6KâėŲÜąŊ€z›ąĮG0îęķwūúėÆkÄáëŋO§”Ųf?K†ē*Ž×g¸Ę­Öé3•ų~ã7ĮxéˋūŗsŸ īnøō^ÎXTŖąÔĖÍyģĪÖļqkš’˜2~sŧ#îbŪ‘ņ›qΚÛXoXĮëĶļšGðØõAŽ/KƧ–8›v:ŋ_Âå˜ĸ™čįĢĩ™d(ÉuIˇ •ÉüÁŅËųR?6ĀëģĄ&įāYëęâŪWũ’”Ķp2đđĩ]×ĪwQ{Øk°ēhcFž‘, Ŗe8[b#īĢz˙]Kxz¯ZįĮÅnžt Íũ,8 E™ˇÔå°kąĩ%ږ“ųĢĪ5FȔ~ûōŽ„/kQĪĩÕVũDŒM„Q>P>ŠČŒõų2ŽĮđ‡ŋ6r§›ágŖ?xč ƒŅem_Ô>ūaā }ČoÍŋĐ î™¸Ī´z0P7ģķĮT F1ą<;ū˙9iĩ‡ xö8—áĘ*÷+B,ĘwúösĮČÜž‡)%>œ<ûWáÔmįŌŽ9)U>ŽtlŲĒW3?ĪÆ´ ŌžœÆÆ1ܝ“4ķ srˇú>5r5ĮĀūÛ§d2?PĩŠą°ų0‚wų\ŦYĄÔ‚ēSũël=LhœŪ4œy xîąĘŋš3ÕgÚ5-“áüũ?-Tœãyė9‡§ŋž>qVöEÂOW_æĐöÎuüžO>^ÚåØĄøŧ‘ãđ´Ëę,Ž>ˆ‹`Ą,FvÔÁ‹ŸîY;jëúš^}‡öÛŗŨk0 ülč‡öCßĩŗ=€žk°ûĩŽÚæ ņdWŒ¨ÍRdk7‹>ÁŨÔģÖÉŲÜ´ĩyõWc¨#¯q@Ј/etO¯D™:åķ;u:ķ5–īM­fŗŊ)ĸ „dnãĄRß#Ø^dņZ;bčĨ^\Ķ„ĘžÍps¸e´ūǞüÉK¯ž›Čމ'cŠh•8įđ˙ĩBŠÔĢ ×—›D:xQÚĨŊ‡7—\/)AmųŊ3ŋF1ų€-ˆ?h$‰ ‰Ž?]‹Ĩ~pôét ÂôëŸxaĐ:Ķ–ÍNä„äĪ8,zŒĀgAī6‘KëąwpÃČ ¤>in`ņCrčá*6æ$vđsfÖë†Ägm,ė-”|ėĢsíqژē˜2ãgøšâ§ë[Œ‹:F-j.¤Ųny3x:c¨äĩ ž%ōŪÍĮíyĄÃxuČĨøųJPuqėÛspĩũd–SĖû žŗu/?ôĸœ7Ŋ?ąt2 §ŸÅĢŠžPîGrR˜V@1õĨsģœ?ÖŪfģunQ üD¯°E\™ąV+Ķī"ĸ֌Ŧ+nŧ 0üpŽÎœ)olSļ¸ī•m(qÔw a0ņú‡˙7/ÔÖ<\tƒ?.Īį ŋƒĐ\qczųEĐ>ܲĨ˙| īĩŲ__ã$†Œmũ™MtËāĖd'Ļ_Ųꇮ#o‡ŅíĄq’b\Ũ2ų"ذcnŪNō6I+‚ yõĐŠ‡c į?rŅAéy"HÔŲ7Jß#ÖYÍm\äĒīöBãļmũåôÖø›ķã…hۚ—‹Ŗ.8yí{­nâ–û9ž•ą)9!5}u’UÛwJÖÛÂņmŽ]ĢÃ)W%ú™įŋBMĢ>ŨĢ5ÔÍú¯œÛJXĒōņxyĀÁZš´ōįH(q§3ŽkęŨƒ­¯Ņ”õÁ“´į˙xŊ\ĀBų_<6˙qDFŧls\ŋŸŧüáĄAŅJhؚr&žŦí˜E€æĻĪđVúsâ(šĖąG˜ŨXÕ˙ö:1ūđúęŦmû1ÎĮŠ°Æ ĐøģÚŲaÍŅhßOŠÁ‘‰üģ%ÚhŲę$üru>Ũ‡„ÃrôŦŋK)Ĩēšû™ši^ũĻÃhdΝ1bAž¨ÎEk3¨đļ6˙hH%‡|HØ3/Oț /žĶ86aßYö0gė—zą°ũ>¨Ę‹\$ĘyøžJ˛ÚØá(žēŋ-™ ÷gãÛßū†ģkH?‰?GģÎfAÔËų-ļeëäZžocÆŦ2V˜— Xe)$ Ųū&įŽöđhLåœ”#ļšËą"ācŧ7 9ĩßÂŌëŸĩ0Ž]ЂySėūX9F°…ˆĖīĪF(&ĮéDĪŨø)‹°B㰕•ŒŌØ`! ›ŋá̘ÁŠķģ#*°PYY-Šŗũ@ã‡ˇQ *|QfÉAģZyа>JÆyz2K85ÅGļžöōŽ<įwqĻo==c/Ž}kUÚĒ”U s~› á+AęÅËN[ųQ3įv›}\•=Nũ3ViĸĶëĄÂâôôgszĄ÷ÆûTĸéf+ģú*‡+‡Į+;Tj=|.(âÁĶfKąķm}ēŨ谀œĪĨeÛČyԈ—'ž–`#Õ8īøYiģ—œgĀĪOlü4x\ ĨŸßr.ģŸõ îžb<ŋD&3J€V; ßŪ§­gÜXøĐ¯ ēšÚÔõU6­יv•M›†!ąžÆÃø=˜„ũXiL{ą*øwŖcĪŊ ˆ/ȋY,=HWā}­,4ã5îõŋ۟4îōË dy¸™—ûĪŽ‡"#ĶZÔL¯gĄ¤¸ÃW:mÅķüÔ~Õ)š5ōļS ­Épާ8Qu O…ú͋˙>esæZ-SÅ/ãMY‡nŒ"ԟąŽŲguüuYŒęЄ…čnj?՝ÉjDâú5đĄ¯•ČŪ9Ōiņd$Ļ1ė˜Ž=@–ô”ųÔ5'Ú+¤]ė|p—™ ēĩāūĪ”á5WĮoi1ˇĒtų­˜E@)ĨÕP‰¸Ëcđŋ{™@& Æ5Ķą…†jûËCÇx5Å@Ī†^#îGÖBGų–^ô9OčuŗĐœŋfO'ߒoq|åRŦe¸Æp6ũ2ē¸ÃĐū×/"¸>ŖÛ‡œoŧŌö›Íąæîŗ)jųųˆaû—f/ôKhĒPŅōEÚ4ĻøĶũŠ­nøtÉÃrę;79D;ŗČŧ  qÅe¸˙ÄG‹˙f!š ×âöĐîD-ZqCÔĨĀt –ųōDs (ąC:čz"™ÍûŊÁꂮœ‚Úe¸šÂōw46MīAĒ…¯Đ ËåFæŸđŌĮØ`ƒĖüÖÛNËö‰ōoōÎīĻ×ÕfķËšƒŨęņ.üŲhĒD‡ĮąĪD_ÂŖëøę‘ Øũ9ŌlČŪs–đ&-s2ŧŅ›gô•,;Ō7†yŸ•˜Ķ›ĩ•œwü\ÜæģØi1+3øÅ!&ân°tÄ{œn r]ÖGõÅF¤ä3ŸOÎâaŽĘˊ˜:Gpˇc’Cų‰Ŋą}ą…FėååĘÖEģŪ_ŌŠ: .žŨ,'Ķ×Mw-Ŋr;›ėŒ‚`ΡŠkäzũĶūol5Ûú/@õÔ9H“îēÚ: ŋŨTpԟąžMQXŒåpēY›Ņ9[ßHíjDņoôģĻņelt˙ļNnøÆĩ‰áėû_Ąû0°›ÖÆĢ.:{zę”į“l¤ÕöbđĨY8rġYŋÜB ôŗÃqn똚ÛMÚ:ŦYĢå¨PÁmžŸXģūúģ÷Eƒī’,ŠüXAÖØ/kta–ī̘Î~kĪ‹ļŋ]Cš¨:į‡áœ‰{zëŲŅŗ÷{ŊÆ~V-č_ŧˇ/´Ū!×OjÎåyūĪ7û˙Ī*¨Ę÷“=ŦHišzUÜ/Áí‰ĸ9 5O^+Rsî,.WÍš… _ŽŨUqž uŖéaËáå=Ō<=zt ^ų1¸ōĄ×~׋ë@=-9į8ŗžōåqb€“Ø)´“¯Í°ÃCâäÚxe8§ 4ũ$!ĄôeĀ:Ŋa(A*…ká1 }iđP'úđī,ĪĸúSb(ėēálŧãŨ<› 0ũE8ąk`K('*΂Y‡ãĢįä(ŋ…iK•īC^üîA¸t;Mį`-~8x6Į_–w`¸\ā›m+û…šLÔå_ĩ9Ô¨ŊåŠĘel{9áØšĘ=PÜVz€÷ĶUß1¨H7\Sö'Tš•zréŗ¨"7ßPė,öjāõīuĢ÷•Y€´Y|!>Žs {ûÜKĻ'#äxZO˙NuڏšĒ:¯6bœœ“5xÜŋ{ę[Ž:¸o*õūĸˆžU×ß:toĘåLƒ­ôøz÷ščāīē|÷Ä(å?ä2{ō‹÷A ĐĘ´ņW퇃ĸ^zŽĨ­9°ä=öXkšŗØÚ0‡^ü F|[lÍ5Á}^…ՀΠC?<,ž#õ_Ûĸ ›Jũ7˙4:‚šfŽŸ…O':ų‹ĮPãŠLŋITp1°ĘĮß ķáŗÍG› Ë3~å×hų 0Ėe,ΐa XøO"Ęb#s9ÎgÕ<Ąu¤}G‚„rōnüô—‡viķx\c=„a•ŨÛŦëcs*ĢÜÂÔ ™áĖ`ŋŗ&Ęb)h~“e<#_ OoRŽŽÅ°–r.¯ˇ8O&„M?^A8ä̞߅„{¯ĨđŦIļ,t6&íŨŸŪlõã@Œũt­0bë%›eHKõkۘŋ֙ĀŪ84 2B{Ņ>ũ.uCį)2öíāe­ĘŨūįŽ}ēC<Ū=pāˇOM†§Ÿęé´7o°’q0?OĘä:čôŽk(›cjgąčėīU`ûŒŗ]€U\žÁsÖęmŽÚl ŗŲP¨žĮŗf>‡Ģ ×åˆ:gͧWŖî¤Aņäj>N‚oöŋF׌æpéT6rĀŲÚë;ÎȂ; ååMĘ?ŽoĪKdH}t=Á÷?ũ’ė8+ąå4ÛtE{ž¯jË;jE˛ŧâ@§9N°‡ĸ÷˜*Ļ5˛æĪõ'Žâwą‡ÄŅÜ5ôô|ĒãĚļŸ˙_Æ]ÕŲ5ĨãĪĻZu՘7—åۊxqŸcšt ī˙G,ŸÁGÄąëúōMkŪ<$ŧY•ŗ‰C#išCoÔΌûY×]6Í2WÄÚ-ŅŅšŠqõÎņNĻüö)ņBÂŋžŧëšôäŖė^^2jéž,ZYY×ã'ã߅c-Ę7kđ>I <kÅPqëÄø+î‚ŨÜ´š¤ÄC÷âkcÚAĻrøų͇üž\f[ĶæAÃž‡gŖœ°"˜3`×ÄfIsĻÛ~ĒÁ ZdĖöŨÉC8‹E_‹ĩçŽĘē/šxų^.qØšG(ŸŽĶøŲIĒ_ ķmjR ĪŽåéūųäa; ĄÔĮtÖä 'ĶŧlģŪ¸š<ŒŦ8øā$Ų7'Ķ*Įēy3šíÖĒëĮ{īęĻĪęÖuÛüäž4.„ '0eYãĶ}%k°fZMÔīčđŦO~øh&7×R­ĩŠÔ!'õâNđdęgN9hŖÕ{Č`ģAažkRnëėĢÛ qq.Äö…7ĀF†dØ_ŠęiT`I.ˇWü6‹Ķĸ‹N<@†U¤č+‡¯Ėšm. žųĒ›RИ”Ķkt8hD1æ^ŧĄįé„:ë=oôŸßŲkWúhŋNÖ7@D0U6mâ†Ão!Ģ_žØSۗ1ãž#Õ5j˜î0Ԏ ’úô§ŗŖÜĖģ –ō˜Ģ‹pŋšt‹3(䞱ô]žúĸÅOĀĩlûĢZģũŽ.“ņ-|äŽj5Ņ~‚ūáŽ\Ė䷍>,Ëų0ëq˜Iõmšœal01Ų9Ŧ{ĩķ´ˆĢEhˆņyÚỷXn¨:fôsøq4‹ÎÛȰ›WhÅÄė{`Áy×ÚnfnČ>p4ģĖĨąũ UģáŌˇ$Î^ ˙n÷ύB/8‚)§â~K€ĖēĐ`ĶhÂāĨÆˇdČĐë”o•IJ…K_ å'nMØī/öôd÷4bģxŨäņĶŗ˜a¨ŗļkÖ\o=CQaāÎh8r8ë0­Ë°Æpú‘ōĒß˙/Îã#bŧhA§ˆ&indõxŲoĪûęM]B1õ3W:ö­õyLszeúd¯ŲđŌžŦz xĻΑkåšå!gí­‡ ‹ŊšÚuŒrčŠi7F&ū/Ž9šc¸ZÎĮŸžŠw× }ÅÎ+ekeúGxF´æH?upFö§öŽ›øÎŗSMBE%ÆĐešä0Ã7Žöô1^ÖŽŪbl„UÂxoęēËëĒø\˙`„(° Ÿâ5đĻú…ĸōxŲĨIQņoŒã×Į§Ô {Ø^îŋÔ1ãyÄĀÁ¸qŠfœåĻd'Ø ŪæûÕsÜļŠˆ'cĘËĶ>™„•û¯H/Jú›sWãÖĖī*rŽ™elļ—,˙qĐ9‡öŽ€ÉMų}"vųÉĄ1Ķy°tFrB]ĩ´™IĖw_t­üO-Īk ģ4ąIÉĩÚ}OáÛßčŋĩ÷rŌ^lm?ûGüe€ž/ 9•čŊSūOöŦ*ŧr>-ĻßЉŌīŗm0Ō¸l­ËIÛĩw=5¸}|QËĖ8í§ę.ļžī׌]cˀlÎ_ŸĶŽĒ­ iŊũcUœ|ŽXC˙ŌũI°aU‘ŖēĨ*¯ a§„čđmˇįĢKŌüŠjŊ‘9ęW]ôģæ\ĩįį‹0°ėōĩüfŧžDØĩûbŽßw 7vĪ âōe{5zå/‚ĢVŽYzā5¯õeT~Ķq@0?„ļžO@|8úâŗÁĄ5Ô5sGļŗsžCTĻ,m ė‡ŪDĄLU%9Ν¤éâ\’túĮ"¨øŊbĹNĸˆg—žŗæôiŽ/¸.9ĘĄdIĸ0ŗLŠ l4™*×÷Ÿ-CD/îޕŸéۅD .a˙‚„7Ėڇm@%+œũņ› qzMžÍHmââ?ŊĨ0jŌjCĪĄ­„83–×ĖÃioŦđ4÷‹yėņcö–žŋÎÁˆt{kŊ_ŧøx‘k`ë‚įDˇ äęd\eī8{qgąÂËnv€cpŲ… ’˛F×î{( â jūJW›=d 'Š÷ŽÜŠøÖiáŊŠÂ…[Įüž$ä/ÆĘąKN¯Ûå@/OTRô!ū5}ö:k'ØĒøe폰ž™á‹Aē Đīį ædđũ­…ęą[„oķDūoüŒˇĒčkŦņj-5îA ˇŽ\#ō{ņ[×zļ°ôĀ*ĶKFOaÔŌÆĮ9ļ*Ÿē÷7Øũ0¯¯īƒ{žŪŧü>Ž[—#VO“D–¯â†ĮģûŊâ;iųũę_>†žËKyûEöx‡a@›Aŧũņe—~7:ÆŠVĶ/Ļ{štRĢ/2ũ|)rÅU2—@9TÃė6o{ĶĄßāąfņĻŗž‚Áyė†O ˙—õŗč œÖ4Ŗņ’­kÎĘøÕcGš)MyūŽgcü`Ąk˙Ģi:­•/]Ņũöo€W}ڞŲt!žõ(7ƒ"Ŧ˛đ ŧøūÔUÎʅãĒä\čh̝#Mũud;ąŽč¨AŽT ŋE÷_¯Äĸãīú1Æ~uJßÍĮË\GŰÃ~ŖCLōˆĢÖķĮâkE–7ŨÄöß˙#Ųą’”:Gˆj[—Äž‡v(ķqâ+ä|ļļîúV%vHۋļ?MĄÎXŊņÃĖ–Īúę ¯Ü_%ų/ČŪ´Tׯ&$Ûú/xBl%a3N`Ģ GÍŗzéDG1ĻS6v´Ūš‡í‡b¸ā”ōnoÔëÂĸ?B†Å*Sdõø G oĄûōŅŗŸRœ`kY_7÷Ä÷'EÖ~rs1ޘHÄãĐ¯L+ƒz1O—ĨįŖŊhÖ°ëŗē‰,â°Ã´ŋįXM‡% Āֈ* ›p;™ûL×ĩŋ2Ŗ˙?÷ĖëÃũƒrPÃ6C\ÛuOũ”¨ÛÁ4UįÚh>Ëkę)~bøLĄ—ĩuŦžēbūæāĀfÕ i,Ŧ<)ČvJ%}ͰžžÉs;ôįo0‹HØ}ī[]{ģá0>{ĶRôįølN…ˇũë# I÷鏉á<˙/ŋ|Ú-M o‚žéM+cü?/LtÄčÛŪ¤}~!Ë;P„]Î.šņąp ˆĨîF‹]ĻßjŖīu ‚ŪáŖ•Ėv7lßĻ´Z¨4Asā@‹ƒ]ƒNå);ąÍÛ¯žŗ]ڐ|žãåÖōxnĄhŧ a ŠĄ>ōõæÁ ŋ [ņÕu Î7tq’Uķ€ĸņ|īrŊhTmQ ­čšvڋĢį?ÃæÃŲ›!r´˙0'mSiŗtžŽÛL5UŲčCņE¯üĔ_ ōŨPģ!"UÉšMķėŧ~ŽâAõÄöˇÖ!ī¯?gsÛæŦ+xX3åxm€¸/čĮŋžÔķęĄõáŪE.‹7‡C,‰‡žG9í7¯ņ—Âĸ}73Ķ—Ãęmp›äÕM f¯ŗ&px˛ė=` 2ÃoŨÕ_‡ŧ4Eáw2Įë ú|zƒũôš’ŋ`x}¨Ē"t;Ļ..súÎrį:b´ ¯ļˇ$E@WōŅÖī”Á Øbîč.ŊŲĄæ{ Įû!Į-€´æžsœ‡Ų 8 • ]ģÎC$‹°õŒüđÆŖm{ƒŅ,.žđ¯FØîĻŧjäi6l+šx>t5ŠīÕ=r­Ŋ"]!ēî§h˜qí´ˆCĪ—mGŦÍG|öĶtrG´5x{UžbķĘáæĢųt°Éu{Eü!žxžÁ%ÖpŊ&ū4÷û’ņ/wšˆųŗĩŽ­Û€Ä㋞úB_ëá[[–‰ÁŠÂ Ā`éz€FžkpüēڜSbX˙ęP׀"€/ēęlbĄÜņÍKĪ5cj́Ÿc4æ/ŽümĄ*<ûZ¯îņrxu†—Û8•¨!†%'t|‡ŗČÅËÃŨ õžBuŸ§“×U×E¸!į˙÷Ë fk~ã1N*¯feŨ]65EzH„Œ¯úk0žŦa†VpëAg”É:…5D¯ud,MWá Íõ‹ í{ÖjHō=síöŗæąË-ũ"čâí˙ë‘vaÍŪņō1!Œąå Æené#IA8ŗĄŨęɡ}ąŨ iÛžNļđtÄq§đ>ßÜ (Tr­Ö†L ëĸˆCkf ĸŧÜO{ÆDĻØ7Ԑ@>wWU?”;ËÂz¸¯1÷p5@qŌ¨cäK•úL°9Ŗ†„yŋîEÖüÎ˙ÔHŸëő×täˆo”4zųqž0[ÆjĢņœŊ}ë |žŲ¤c[`‹ËoPFÎgĸķ] ŒM/C¸CÄĀՉ‘Įzō¤¯v }q>\ä5&§­XՕÃÛ4g4í6­˜´ Š<´Ú,3‹Đ7<{@H…tdúrr]Æ@ĒkNô›‚{:˙ ôBŽ&j—ƒÖz÷uzŊC8Ŧũū¨uŅęZ oĢfh p[ŧ7”Ŧ‹A“į#ČE\X5Ú<ÉII>‹ŽÃųaæ§PĖ0VFQdŲBĻ€ĪOyۗũ)ē WwĮmkÅŊ)V%"–h°¨æI˛:Yu§_Ö ĀZ^Ų!SUŋGZ‡žkW˞ŽTú:[úŠ]3{˜|ˆ‹SæØ¸w“[Œ6—ædš+ęP=ôą 8š/r7_™Ĩá`SŸÎĩęÅ?ĖR΃‘ë” Žã]7DqGM¯cNŽĀ´íÁõ|ä÷T´.šŽ&ĸáĪ?fˇčÅ{.Ų!f\|ŒäŧÛz„¤öZŒ;r9éĀkģĮ|Ŗĸb@ē‡ëÜšÁü|ė;G°Fœ7ŪûŊcį%Íôęęôå+ÚôĻģ–ūc׊?ûa:Ę>!d$÷Lčķsũ˜1ũÎČŋ>1­.sŦW×YĘãl5ļŗˆk+sû?ŽûuDŠ­[ÉkŲnə~ ä[dįĒŊėŽUÜËĮ(ũ[øz-hŊℯûŠߛ1,Œažåį_$ōĻ•îŠßÛëÅfLįȎøIĩŖgTÛxYŽũšÛTŲ/ĒvÆcĜ|?Õ @„Xåå5ē}Ũ€D*đęf ‡áXĖĸį]ŸĄÜÛßo>dZds2m˜“Ũ›J73˄7ūŖ¤šPĩ_Pň ‡ųĖFlcu­Ģ\ÉņaÕõ…,ĩ´Ļ§"{ŋCĀÁ÷2Ō`f ē>Š˙…BcmtÉÄžu W‰š |ĩĩĢîâÆ7ŸA=ųß˙+Ā ļŪ°+’qTč„ÅŪ‡eX'Šž3ÃÅž­šŖ~ûÔæā/'NėãŧÚĘÆŠŅžUĀÉŒ˙Ĩ=Ŋļī9Ŋ1gÛíÕĢRūáįžŊg =Æ{'âcÜÜ!ŽĄ>tcLúI2Jņŋ}Jh˛j>–‹ŧ´ņ0[úYîÍģuA€Ī`­J’C5EĸŽøt†ZÆVđ*—OvE{:m\䨞zÛÃ;(ą…Pĸ=;Ų&@æzÆŪõŦ†.aĮCė0vøÕsx›§>ËŅyv@öiĄŋØbÚŋûVķÁxœŨ÷eŧĻYļ#ÅČx/' IĶCžî™ĶÔq}ōļ_mŦšĘ^2ĶWgD>9c­^ËGßöėÁ˜‰´WÉ^w´pOåoŸ/{ŗĶû8ģO3XkŒáXsļP­QúĮÉâk4yqęĘËĀÉ+Æî‹›5 Ξ ũU6õā}ĪUÅË3Í[cŋ:‡Ĩ~ä2đ’Ž'TU՟öŪČŲŊdōĘ}¨Ž_Ōbú-_ e¨ūÁaØ[Ģ„äĶjh~î- wmEĘi=D×Ų!éG‘‹vöZŲ´Ķwåd$¯„jõķ› ”ÛS×S%ęJ¤y˛2ęÛ$p\Ņ/žá;ļ›ST…†hņ÷ĩuõ šĩ# eŽH8‰Yŗsœ[\éNB:Bü͌ļŪšFE1–}^%á+Ũũâü×ŋ*—V-LâR›ŖūÚ͜;€K7į„",Æu°ÕøŨđN´šo ėm_ÛŽk÷Ÿ˙õ?ũ9tģ ^}Ô,YzŲYwe|zđ!ą§]ĩáŨ~”mB!Ü2šJBhŋ_KåzČZģĶGcĩ]ĩSt;­L_UŖôŪEƒpÉÅ9ŌqUČbĸÃ&Ļ›_#§€KL!9Áŋ‡môÚhf}[;šÍķ7îÚūA~ŦĖ$ûû¨/žŸŧ’ë"†g䞛Įšû€7­’¯É}×\nBäØ',Qįaŗ‹ĀqķáŌŋ# oCîŨŧ6f'æ8Mī0ĻØŗ–Ŋ˛zEŧĨ˙ŦK ŗË'’đr­ØįÕ.†›ôkŽĢbã ÆļíããdøÆdÍ/ Įņ›ßWĪ~Šā•Ág4–ƒE‘ōė¯~k>z âŽÍ Ö)—ÂiđšėÁœoũŒŗŧ‘l†Y¤cIi0ˌĶ›û“&Ę:䀗ũwŨ>¤ŨO~ĩmÄ1Î×Ŧ9ļĘÇķænšjŸĨ׉ķP2œÂ9 ‘¨åiÁiē¤; ˙];čZŒŧŲŋ°ŗÅžÚ,ŒGųĘ͝-‹Ģŋ1_œI´3ũį|­9Áųī}âéūžŖ‰ÛĀ /æjoM^}ÜãÚå ˆa=Ëļë2PĩúX3mûwv|Q,Ką<)‰"=8îč´Ë88‹{ūb/Â"Ic‘čH ģ÷Ķ!Č]ŊöíįÚˇk¤Ģ–æ¨ -ĖĀ|Y]ŅBܑBf¯čîã$„|[•jļ!˛’ŲöÜã(„|Q[‹Ŧę3PåI^uˆÖŊGŪāX=Ü0J$ãÖZûļ=sĶyۇ´‡Û&Cšõ]āAí÷é‡+šüšŸæ~ŧĖy×ÃķC§PNš7‘‚ËéËQŒU†•ÃXįLíĄŗĻû@؜nīÉL/ãiī—AŋŪiMábŊy‘ËÂ͖~(œßÜæã! "ŧũ‰Tˇæ˜Īˇ3øqԖoėÅēöåũĮæôÚ›zû+@GĸĸŠÖXKҝ›‰QYŧTˇd×˙gr h4ôųņaŠüŊĶr!âęsQØo‰VN–ņÜ\ĮõžĄ´žŦ.ŧĻߨ-dãw3ÕgšΈ¨uˇÅ´#Xhđų–—>“ŌĄĩ|ŸˆiĢÎEĸ‚#N-™%h1ĶTí.˜‹‡0ôp„Z€Nj”#âģ֏Њq1ķ+rᆤí6^‘Šįč<ãßÅÎâ$2 ĶüDéâũÃCT#ųĶ$?¤öSÕ"TĶŧ,˛g$Ņë`_#ĨIt2ú{°ēZ#Úæ§Ŧõ1Ŋ­ X[;ôŲ¤üRŪŨ+H$ÔWŽēûrShOĶŅkĒmšvgŊéĶIģĄØ¸}Yv”ęMQŲŽC~O“ņŗ5/ĖnՂa>>āVņ0ˇ'ļĄ/,xŲĖSßõWgäš7ŽÛté?¯Ķû“ ¯˜ ŸEd˙Ír!ģI\î­'‹bkŲE˛ĩԆ–Ę5§3Gųaã#ļiák}hm˜Úh-r0ÄĄWXdęĮéCNæÁßšöK›øzÎÖąą=ŖˇĶ|Ķy\1PüÖߌsËGģxálטŊ|đ“cĀ l‰IČŲ™—0‡eŦڐĄ/T0ڗûl[cÚ %÷P$ŋôëávsEŨ{ōŽÜ8>}×!:}é<ÔKį–ÕĐ:˜Ŗ1U#,œ^ŲxŊTįųˇ&+GÎ^ {¨ÜuĢãbáí78{ķÆu2ā WÛ\š‡aVWĪēsˆË˒¨ÖßEą§geЎĮÍE÷‘¯āĖuāéEŽãíũūJbų˙å"ôî|>qí+Jãí D.yÎÄØW•ŠąĒʍG÷ĶęG-zŽ˙ŽK=mŅ=ß!ŪüZ"¸mšņų°PŦ>‰´ŖÂr˛Æoõ‚”31PIDATÕõđˆ áéįdIĘK†CĻᐿI<ŋWel¨ˇ†•šĩÎ"Ú˙Ŗņ&^ŋū­_ŗÉQ:Ķú,ĸÆE_ģL,”bž´-pyČĮ¸ĸq‡ŽŸÂ*_Ē9 āæ–‘ļ^] haY]ĒE‘†÷2ôÕ%p1õ@ņ1­‚%å~ũÔä†Īî‹Drá`Ná7k OkĀx×är ǏČņũ“ļ‹c#kƒÕZŌ:ųŌ.|,€æį:äÃ"Y¯€§ß!ŋÉמû6fq Z0ß°ŒûA˙p\t §ņ-„–n0QX)Ž'ŧû­‡ŗC[-âåč́:Áhé]_ åäš\Ėj‘Ą.‘ˇR*§{‹‹åj+æž3ŦŅÖöŒ‚mAŠXd‡Wnɖ'o.Å}æōHƒ—‰˜mŧÔ Ā ęSC°_ a”ö‡'Ą˛ ë$'ãĐú`ņwüĸ˙n&UĒ­äķŠbfƒÄŦz1GxœuCtŲm Ģ?˙nĒÃ-Ň̗ˇāA.ŋŽÎūæ`6*:rhŦJ\hzg|xŖlŨZQ.…,­ėžŦ‰û›­Íē^€?e–Åq|.væ77Ŗ!ŽŅ,;Øúx`‚ŧˇ/ūí_ čš-:_Ũ ‰)N‹ĖāļŠ>ŊUP×´RTâZčĮ”œÕģŲU3úzîHבĩ|ĐÚ1đî¨[7}|‡AûŪ1‹ÜLß¨ J“ŋ]ŧ;Ęwđ‹ĢšíĄ–„ÅÚĸW–˛œ@4Žb…Œ÷ŸldV$•męú˛“ÆM&ÚûMW^6†ũØ´iiĄM6‹Ų?ģ…kVō›č͑T.Ķ0Ē\i=(‰‚5ŠŖE}9h™üÕ$UJ!_š-įí Ŧ,͏ˆg3l` Š|Ŧém(L­,e&c°ô›Ÿƒęfԛ]Ĩtl-Üš#p{ČËYô#cžC_fĶiēžg9y­ŒĪęŖ‘ëdkn>ķÎöĐu›Ö#ž„b9ŒúqP˙ÖŽž*›CõęŽķ(7&Zb}ūúė× ƒ‚´„Ÿ_ŋ˜F(ŽķĮ5 ƒƒ ~šûæŅ˜j­I5ƒāŊą9l)úŌNš&;Éŧuuzk6=\ãøŧšūĖųŲ]õĶ7˙āˇD‘¸žĖÉD÷÷âÅŠøČä$–cåŦÛbĒo•Κ*/¤b&€s›˜ūVîo+rŠ…Ŧô„×aSė4ÆjN•ž÷0ÜCú5ĢĖĮđZ.s mĩûÆ –âũtŅ7BšĖg'č4‡ēXzNÔØūˇ2>ßL”ËgpķqP1'_ööŸgķ52C ō´›Wâj3Ĩ–Ģ‹.ösÎ'qRúg‹Hi::E[2ågoOŠy'c­YyŸ´•‹Û+Ē˙ØÄõa1ørÖGLՔŅęeŊÕĪĢĘčæĀ؀ôģ֊žŅHØÅČZžë?Ü|RƸ°tĒę¸ĩõË0ĶļW"ķ^5~čž'‚>‘6Ļb›ŋüņâûr Zī›žĒ;õDą% L×õrŌ3­{´GÁ*ęąGFe`āÛ/ú0éj4Sm ô*aŎC ųBT[‡lX{ģ&5ą6ͤĩÃܲWš<­mv ŨßsØâSétũĒīˆ×a`#\Ėé+]#jt÷đ÷“€Iđ ˙͕ãÕˆkčW‘Ņg&Ŋ ÅšôۈžôHūąŗkŋ$ˇE*-’cĶS[ԊųB ×ĪUMm[A3x.ˆ›GVMāģ@œ™Į‡ŗŊą@+_^B?ø6Ú¯ yĄ{E°0Ĩ)Y–R¤§˜Ķ›Ējōåo)i/ļß ^ømC"^rĪōu0*> ŨÁZÜŲ(¸gÛ õ0Ž,‚Û/ČäŦMôÂÕ÷âæ!‹Í™FNAŽ9„(К:jU jē›{ŸqHŋ­Ąč­Q‚õ?=fG@ÅIĪąÍ6" ;X—>#7Đf×yöĢ„ˇŦ5ކŪÕĀđr[áŸæHop– gëŗvcŧHčRĨSŨKû܆!НÖg/9+ĮąÃĩ,̧ã.hDJÍiœėë´ųĨĐVb6}qØMđĪ–bČ÷ų‡j š8ÍŠãŒ˛BĘāũ^ōĢ‚RbäkĐÃÅkP˙Š„œĖ52C×ķŪltŅN}vØŧŠStÎ|ŋY2"}%{ųßŧkמĨœūQ œDĨcžúŋæÍÆŅ{ŊÎw]ļÎôËđ&ĢŨĩ×ڒ}#×õ‘15ũd˙đ°~sĩ–īƒ¸č‡2(É3ļ˙ö,jųdķ˛(Ú"$ĸ^’Áž­žÚ/#yŦ"#ÛFŒÍÖK@ĐíúŗĐڍŨ¯}PoÎĶÅ6ã<¤ÖŠBÄ^ ũãŖˇĸĒ…ö‹s@76ĻuĘ9 Ą6§•ĨX •Ldī^S†b—ú‰ÁåˆÅū+9†ČąëBeddėC‚gį;TɁhâčHíĪŪ¤ũZSwŨ0/§i•ekÃ6…ËŋëĸũãøįZ~Į‹ņ¯ÃiL>Åy36oÉšÎ;cęũaúÕ儯‚_ķ<õąņ§ÆŅōŨ3E" Æ9 ËōÕÂķŌą0čƒä3eE_Œ[7öEöeß[P<ņĩõ:/}uķËØâ]ŽŅhĶ_āYÃ¸{ЍŨčŅ8ļ>tš{údO‹ž ōŸd÷NûpLgōėx°ŽÖNĮõ„ÅüxŸūr[oCT[í+ķZЎ9jĪRžbr­,sę_˛Åc7×g—ŨĒ Y\”ŧäa,Ģ?ãæ†0L€r;~ö×xŠ7^ßĖ–OŽĖ3‰]=ã:o~)˜ņø;düøŠríųægņÎĘPÆõÕú°.úķE•-šjİx[§ēh§ogŽÅ'öũéfü[ģgĶü荟/cÔ_\Áü#ļvē%ŽĄwd;܃ˆ2ņØŧ,‰6B ë"zFܒUŽxåˆĨdŸP!čģ‡å‹­Ÿ‚´‘ë‡Aü8”Fë ļ‹3íôĪöĸČk—åđ_Ņ´Ų†#K‹y\Qr¸ž‚īƒŧ—ĮVÄęˇĘă”Ãá¨uŧ aÎ[öO<ŋO€poLyåĢ­õŌn~Įې4oöĪæû‰ ‚7;ÚāÖėvƒÍÚYŗę Ū "9;‹a‹‡ ļŸYƒX8†¨ßēKĖŦÆúĄ…vhú4{kŋšQn%,Z˙á/TÉ1nčėį/á‡ŧJ zrUŧ\ˇëé‡-ÃˇP,W.ÎUU[ôŧėųīŦŠuđ†ŌÍÉ,ĩũõ$DckU@; ­SŅíė{cBáfĻŧŊÃÔŧœ_9‹˙ã BÎŊՉ+ũõ)ŧ` Ö]å'ĪRëųSĨŽ•W—]ŗŦs…hßõ`ŧ5ķE˙8ŖXtžČLĩvâe_9g‡‰8ÛļŪķŠ‹ë­,ykOŦŪ@<]~‹úއqLæeŋ§>FûSpö÷ŸĘõ‰îYĮ-ˇoéģûėâxg™ŨōĶOnīē+öåf”ōļÜ_-­A FpÜ]ë-~&ĪŽÄÔ˛6Ψy×Öåp ׀ ęņō:ãŧV/ųæÂˇ¸cōcäng ˛÷ĀĢu’Dģ/5sG0fŸ­šk•tôÅPĄ´Ú0öŦŧkTũjX÷˜™Ŗ¯ŦÜw-¯˛á¨Yîĝx…ČÃŧ5tí^FhÆ!žmš,F<ĩ9ŽÚĸŠūžÖí^R•t$õ퍘‘äÖŽgú f­o^ÚÅ8Ēga]ę]ũč[“‘ûúYC/GÍ1{tXH–§~Ãu\hņy ũúJ}ÎčđiŊä„\ŧ§æŧëˇÎúįū&ĨF>˜ÉĄ{’šŸ°?íÖĀåx›6Vqû‚0ã]ÆpŽ•nŅēqV÷Ĩ“†XŽ=ƒœô4WÅļˇ^7büxp—iŒ'÷ŗÕ ßgĮé0{n ŖJäüįFYí‡ÄÔ¤|ŽæŨļTĪÍéQoĘŋu_=,‚F–f÷ĄÉڇ˟q¸Öũęč°ģÃQ7}sp>ĸ×ųîytđۓ |ą‰C"ĒIŧ˙ļfTŌhÁcĩ*ŦZėã¯Õ%ž…8´›ÅĸČíaēŅŦp.žn›7 ō͏c=ԑ“3T/ÍÛ,yđĐ÷üŖŪâ€* Jeĩ eq‡•…Ņ€Ŗ×6ŲusŽ€$tŨÆŸęgKôëQts\ŖŸÃŪĩšx‹%ääû*ō3>­c5pĀHg]¸“Ģ’Å׎d˜éŪ?¨Éfį’ŊoB#´ˇZLûdÎZhpņ°ĶŊĒ<@˛ãâå“tš—÷‹ C^Õnx­G3ƒ.€‚×\_I,5)ŋggÃŪ8h^ĀÕa@„–zžõ4@YT;šöTXĐÉÖ@Úž›˛îŒßz1„ĩQė]¯úƒpSi]žœ7įãĒØZ´†,•ŦY“Fq ˜<*ø~ŠŽŧuī&}aoöŨJož‘SĘBe:}‡OcķåC5ŅzÃQíŒAs3Ō# iæ¨ L7YëÅ`5ŌÂMũėĘXŸ;Leæšũl<-ĄÖøÛėļžÜLäeŽ3ŌÍ6Žĸî8ŪV6#cƒxí‘@>œ_Ķō÷ŦŒ5Ęœ…Ŗ,ÚęĐđj>‰.–îīáXyÁ”ŲKįÁ™p.ėz6Î&]Ū8ĨĮÆo9Ôô /ņöõ “›ĀĄô0õΝ›¤Ö˜÷Ķ}Ģĩ/Ž1m˛đtĩqšûĸ9gëɏ•æ Üåčs3XūËŖâ?• ž.ęĮ¯õę'ÎjCTĖwm+ɑņbcŽ×­úŲ Ŗ×û8iĢŨŽi1ŪË<č_,Ĩ6íúË9b_ÆŨOš„|ŋ=-đė†j į&šĢö×&_môkåcļZ"¯€‹oÎÚĮŲMÕĖĀöúđēųÉiĶĩ‹š~¯¸‰"ŽxŪwÚįôĶ> 'Ī`vŽĘ6ßūEŽ]“r!7Ũjų^‡“Š;Q´ų9HņV—÷ī¨Ú×äāÖŌ-Rƒ*äČôĢ—æÂž5n‰Ģ¨ÁöMf(ļ΀ëĩģRsQ ųĪĩũ) ķĄ9_BŊ+gv_īĮ{kgY§×VŨénä˜WK‰|sĪ*ØoēļNÛãâB%u$šŧëßėúBÕޝųûâˆŋvĪ‘­=kŽŪuRRÍĨu˛Q-ärĘYy°Ą‡,>6ë\VsŒųŪķšëŸž1äęŠ%?;TÕĘWÛÃˇhĘtĸũ+ú*r<]Ë[kęĮŲÕÜīksūüĐZĮæTéGj@ĩU/…ŋc-ZS.ŧę6ūډÚQŨ×ō`ôLjŽ9UrķŽs|4}ũķfķŦŋœ5š5Áā…5¤ũit~\vÎQ° †#ĸ^ÆŽ]lYiöĪvą$@Ûiūöí]Dĩųī“Jį‹ôĀ#¯ęcÜEV%Ôõ‡ļßÖ ¯k9ÅļÜæh%°Hƒkķ¤ŋēwŨmS˛Uyĩ™Ŋ9Q/^ΖŊw¸ž=Ėž°ác Gk—,wz~Ŗ˙Öå‚bû‘†\vŦ}GÅ;¤V ‡=ĀĖŽ‚ÁŸÕP.ųÛW‡ßZ֑f]CnöxŠ‚˙ö.­ĖOŽĶÅAŽš×č\.wZV…Ú÷–2}훝öXÅã[} Ŗ!Ŧt U8ĮčĒQ)gMž=Īģ¯ņIģō‡mdSļūž{qΟŖkā0å!FKGŖ—8ŝ.,ÔŲqhŽĶŸlHķAī—{Ds%&˛‡'‘o­ĩ`ˡŅÍN[˙Ķ`ũí/ƒ#C˙ęĮ(Ģ@ŸAč…õp3{|§Áá/#ë(„ķÎôŊ§ĖjowŸÅ†"ø×ŽĘŅØį>ëĐãē R_Z9aĐŧŖáǝæQ ´ļ:Dæ=ã=øĐß&‹Ą5;*Éŧ*Åh ƒ#ļsãjsmôáZßÜ/ĻQÄ6žk[w~JģGčRfC¯xĸ”…'sĐŌxų#ŗĪõ ũ[%…3„ęü6×7V6ÅĻįhĀŅZøiu@¨Ü–ū đÕ#.;ŒLę›ëxũæmŲÄUÄ8 ë„ŦLt¤py_I䟋¯Ūõv­9ûO[Ôqt/ąJågŨ´}ųëfq_#ÖöÆeeæksđ8‚˜`ĖfBŸbãĸfhúûÂņâŌŨēBއ­Ũ¸˛ žkžš ˙j˙} +Ņ!,ū›ÄÖ QĒ@Ĩɧ…:J¨MéöVC(u-vDíši­îޞŨ¨‘c(„m¸ēŅęĖŅyÛÃ)ŧĨ Ķęđy?Zįŧ­_—+gõ’(˛đÅlÎęmĸb‚°I›Gyøĸ́|°1¸Í áÔ0\åg¯žĄĄ5P3ģzô—qũ>}͕Kįbõã Sœ5ņüíĶpAėÍ ųĩGËąQÔ´ít#ŠHęÔ\ V¨'G5Š ,īÂ1ãoh§ãÍĶŋ¯šb E›oŽ/bV’¯žh/‘Ē&œëDLäũžęņË×ûDŦaLģīōU)9Ŧˇnâ§ÎēÃYXc!/ˇŠV$seäI/ų~?2Í9 YĶ^ĩEĻúo+–@†JyqķfEv*¸šé[3׀fytš_Há.VAWĩ›l>ôWKūé0jį }R7KÛōčAœž˙ã…ëĢĩ!$Fū‚á6bŅ֜ˇųOį j_ėf*jģÖUęûVK‡]{úՔ[ˇ[3ˆWyöS:js!Wgm•„…ŊĖŦ‰é<‡ēČËĪQĩ5įÖ|ÍG×`BFĀāŊÉŌĻęe‡×į_ū/öpē^‡(ĒHSHץ§(šĘötšÔö߲ĖĐɆpp˙ŗrŽĐ}sg0Xx¯Dd1¸Âĩæl>ÚÉчō’cCcĨ™ßI7|~˙ÆŪ}{øvQä7_1†hfŦÖĮK9ž™đŦDåŠe¨Ÿbmå*öÔĨ×FdÃņ4ų[Õ\#='ŒlÁ›‡HNL<}8Ŋ˙KC 9ģĻRē<´ģá¸÷ô´áĮ„bęĩøÕņ xķ'~FãW'ņËÜėЊ)QŅ‘ųˇÎĢîaų´UđÍ>ןqEvû”YČfNŨķĒŊûēsč:á×D¯?Éx¯\õ§ikķ*s'öŗŽeû5âꝲüvn9(éSņųīIĩķĸíF‡§¯-ėąÛÁõq^4ˆß8įz˛.+úģΰŧš,€<ˇŽšGŸŊîí˛!_øÛŒéę1V īæ`úôÜŧ'ĶwÉe/Îjŋ(­HĄŒÕ\ŊšIĨ*čblÚÖnŨÕ¨gĩ$eBOŖWƒ¸™'|ŒȝÕõŽ^˙<8˜AYpô›#Ĩ‡ëLS$Öš5íÍ`ŪÕŋLj×ŧõ͍ãĐ>ėĸ~oA‚įpŦ¤‘čö:å¤fŠ}uŽ` ôūU1gŊJ¯2"%Ą” Ō~Ē „Ū"čƒ| Ä"ˆĘ6žĨŒĖĀ|‹Ķū.Ü÷fÄ Đ÷ė1i2MŪ42QXĸ„ŨŅLĘ&NŦ]ZČ Nˇ uÂ×}T†Mã!PgQũ”Üø>8qbĩ€Āũģ)ę×Îũũ‡c ŊŖ|…qŠøMŅ Đ]]´=ųŠķ.Ec÷ãHGOOĩ–\•Š4Ŋ ÚĖĖųĶ`ßŧč>=gõįëƒÉuŅ\°lÍ)Įá;ÖĒ5Â=ˆQ<Ŧû6‘<ī8ļJû+NÕü0í'‡īś'BÛ\qöŽ˙¸TŒ&DôļøÕ:CdF–C{éŽĪØŪÂplhĨ‡îΎãؤɸķĐ(Û؟œ“—ōnjčšN~ų‰*č›3ˆ‡\](ôé Žt]Ģî=_íūŦ”މŲ,ābî8U°ĩX ū(š—0ø8sūšØß¸|9ŧõđ[§ĮRܟˇ{‹ɛÎFFŗë#&XŨzĶŖŊVœå§o—¨ĻŧĒ}y{=Ģĩũ8Ɲ{úÉæĶęõÖžą„ÚŠą'ô{X­Õ}āh_¯<ģ.Ļ7k֘¨~ɡųŨDeåO’YĪI û`Bc¨ņå°2`\Đaķ"íƒķķ]ˇęĩõōo¨åĮQ]ÅŗīÉĀJŸ›[‡Z8MíU¨ũ­îPUē{‹Yø’ĐNēÛ+ŅVÆTîËN­‘lgķŲ'ĄęŸņķ.2–Ã~įŊÄ&kęCģ5–^ƒģy3žæ|…œ5ąi÷Đ wžû•3­| ÕQāEĘÃsŸƒŸ.ŽM=eējŊ˛[Û]aęk†Øŧƒ§ @'‘ˆ8GÃ.:ęģ8ąŲBĪS ¯%ŅģY=Åģ¸,Ēņ{¤˜ ŧū'3øyküēôKX|Ŋ_úÅnǗ•û|ƒģ›UX1¸|ÄÄĀOK\É&ũņĀø8ė’œĢ‘˜ĮĶĖZîJ×4ŖĸŽ’ŸŽD´ģzéŨ…ÎA:â{z¯ß†äÔ ū~Y1ž­ęaũpr|Â@X€]åY˙™;įįŧĩXÍ×cįa‰ģIIvŨė ĶÚÁ?ûCŒģ‡_A[\ ŗĩË<^L8~ęL0ßĻ\ŧ\…Ô‡‘°P Œ˜Ûšxn@X:ö_Í[åËQ÷vÕüˆd/pVā­?ä]T#¯‹ƒm@[FĢIŽļčœũd •㇤EpiãČãī*WīËõʙ\K3hÚ8‹ÛK‰†wč†ę:2WžŽ°ž_Lc\ųžŗČBš,ŽCÖãbÅŦ+!ųx­æZŠé¨g ^Ø> xˆŨŦ2Đ(ŗuėķÚܝ(öúše™TËĖ>+%×F°œ—;ļß5<¯äˇFĖņ\¨räbŧĐÉÍū2Ä_ˆ`8\­—÷6ėrP>Šļŗ~'Í/Ą–ØŧxČÚ§?˙ßZkŪģÍxî-'ÁG°ãcPLķp‰%Ķ/ķ@–]Hø„k'uCCŦ‹šjaĀw%æÜ›)\Ķæ5ChŧŗŊ§%f0d}yL3߸4øæ/ÆPĒY¸¯ūĶtĀąË_ĪīîfŽŒ,HNŦqúõ°PnˆŪˆĶaāũáøš/Ŋš#ųšÄ\›Ž\˛@ŠÍĨufË7Đ/WšÆƒîÚá7ŠHŦĀ<°Åŋē‰á:UĄļÎgĩ‡q§íÖî-ǟ¯šlm*ģ†tmoŋ1Îb&ŧ¸†ą^Ęâų5_ąW)’vwpH ĪēbāĢûģ åuņŗļĨ¤^Ũ,(…×ĸ.GŊ,ŧūÆm{ĩrmēzÃÚúGŠī(ŽÉî§ãKzūŗ -Ē nĻDW)Úûd>dÄŨĮ>_8H?]rØûŧŌ7_^ƒŽ‡¨üĖQČÔ[h‡ėŦYå`9ü] –ë:ŽįĖū×üē#"A•ûÉ%‰‰)JöuÕõŠ1ÄC_u{žęuVHŠ+4:Ģ7ž2V!„ž öŊyYâ|ęhÁÍ›^¨˛á=gFã¸?sąėÖĮ×ŖT“Æpˆ Ūņl-#‚ũė´‘n=åáAĻÜ”?bēŠ^ų?Š;W+>/¸å‡ƒŪí9^Éč…ĨgT ģ ĸØulC ģ<ÂĨŗøOÎÅZ+Đų1ôR”dįČ H˙" Ŋa‹ü›üȋ~Ū€  ]‘čđķxģ[Āõ&¨JaÅĩ¤b×`“MŗÛ$žm„šR˛BÉŖÂcIÂLž8Œ>/.ÆcbÚYGĪ{–>ŸøŪÃdôOnŪåĒ’įO}ÉzĐÎÔžÎĀwŌßúÂûĀÆ.Õ÷Ō¸‰‡NúBāĖ›y2–&öŗųaôY†Ã7fčʛœÕšđĘ-âŒ˙onE¯/ĨōŽDč$ ‹„z!ô‹Í&“I…hû2šlãÔËübhę‰]Cx´3š.§MHŠæĢyĢÃļĮ}ŨŦ‡-z°Ÿę CžedKi=ųėېZģ´įę̊Ų9Oģú­ģœąyxcõÉķ_~Ņ{Îīøö° 2€ŧãėŊ‡vŨ¸84˛Nđ_ßë¯7Æú-LŪ,—›sÚOŦŒ‘–{°ÅOzå|GÁZpŦ!¸ĀëãÅaÔ/Ö÷5ĮL\kôųŠūøRwy°õŗ5ɉƒ<ņmmŒ[ۘ8íī‰+2p0ų’qģ6UČŋĩl¯?{„ą•bŅ/–š{"Uáī õWz+†ā˛Ų5Ŗ¸ž[rz"šGÆ-O6’ųī›Ąâļ†ūŠbų5—fÛĩ6rrhŗÜ%´åc‡yMS¤`÷ o‚™Kz5­ ÛHÆ ũÂûDMLØEŠ=Ŋ|ƒÃĪâŲ)UMĮŠFĶ nķfR7/΍'ãŸÚęfŒt}Z.Ëu`t@ŊؘswâQdŽÎĢ1(¯˙ģŲwŒvkĢ÷q‡ą đᰚĐ^|Ö Uq€);ÆĒBŽëëÅ܃āaĒgã?˙ũQ„šķ”‹¨9ā'Õčõ°¸™K¤ōŋë<{ƒaÛX¯^Ή1×E÷,vö¸–XZ'XĘH‡#îƒVu'˜hŽdŧöö灍—ĻØo9o­ĄVDJņ!öÆsĪDÚ({1¤ŋ׍kŲūB÷äîĖĄAŨUOrÕbé$„¤9ÁŲæNœUÎÖļãŨúvôŊ›W!­øâpZ7>šáÍ"Å!ūyĶé*đ­Ũ`ū­_—ŋĻÆÔōĢ9Û>PU˙xs´ųũÅ­LLWÄâ:øę‡ú]`3äp[§xRÖĩdŧ‹é&­=\퍕>¯åäōÆ`rĮāī°`ˆå`“YĮ›;ģŸŋ*‰Ŋ$­š7„,¯*Bˆwëa4‰¨…ę?tŌØ ¸ü)žˆLdÎJŠAiŪŠ7rIÎŪå^ õĒj„[>8<ŗŸ˙:’ҝ-‰=e¯ÆOĩßvC0ģOŗú‹‚Æ$“î Í2­6en™pļVS-̌ō,ÃwÖÚa#1[qō5žrēsp˛ģ'ÉɸËJœ§q™ÖŌvcĸ6Ššņ.,Ģ*>›<Ú)đü]Ā S\Îú͒ūĒķđ=ŒIqÛ´trúįož<ĘuSÂá4…æ…ûł?Ķ~1Īžž›FpM]5ļęBMž:dA¸UēÄ4^u—ĩ~´CŒęO€¨šœÕI/JkgĪŒ×OŦF Øėhc_ldvw}ņ߸jĸ ŖŋXNk$îÕėoÜtŧØĐ‰É72œđ Ä› ‹Ë/ßĐškÁyzĐTT4jgŽļôJŊRčwxi˙6•ˇŧ`ėŋ5p¸ãĄŖ>ĪqęüņæLĪKĘNĐWEčēīBĨ}›lōƒë }ė¯Tß˙^ÛÖ ¯Ų ,Į8§qM F1Î AT9éŋģŌˇ7šō:ßomĖ/˛yÔ‰Ū°?qÛÖvW‹ĩ4#;ay!+ų|"ôįĢ j3PĒ6ÕFž¯_Ę@zô7Úúø˙Ęūû/;Ļģk5ŲŋqˆÕcĪ…åũøÔíšč0ā—Ëjđ,‹›Nŗ–ķ‹sõ2b7Ŗ{EJ[aÔôÔî`éikg[̤_é×ãđ¤:ÖÄXzx ë6lJČé;Ģeé1°ö-ÚôĘl˜Ņģ¨ ¯udß̓2īFīž0´Ũ ū&ĸŸrm3!ÃŦ%fāl?ū78'Ÿļã“ŧŠ™›9NŪĘȐ|P1žŠÕđ‚¸‹ī܍T;k1ŒÆŲkkŖœ”?NåˇÍúëĐPwúėĩ°OæâõGs2ĸoŒÕ‹.F1ؕ‰ŨËKÎ]^p>`iOũ’`3WpíēÍʉĸ•Ûo,buŖq9"°_Ŋ”ˇ-o>} õÍŠč[ĖÁĮ§4Ŧļß$Ų)G@| vĀ]ō*ævđ2÷]FDû3\ŪUčŅŊ÷÷č]œũÚjŪV (›ũ“G#†‹žüÕˇ˜Åt1Ûî•ÍÜËŦŋ§ŗ“Įæŧrļ āũ…§LP\Há¸[ZŸ}J>ŗW[{Ŋäz|6ōG>*jõÅ ļ<œOqåČÈöéēa<pŒY9aļüÕ2wC&Ō¸QĶĨcįiŠũzŨ8IŪpkfTp|ģ}cFāˆŽū΂˜c}uƒLŊ›ŌßÉČ>ë?Œ­ņ1OãŌ˙ĻĐäOŋķx52ÎlqԞ÷˙y¨ `ܛĮxđ’1˙5ŒnsŒũ|’ī{)ëÚ¤@é¯P]ˇÉvãˇ˙byÜK)öéŦŠbiEްdÄ-ž+ŒÔ"›šÃŨX~ąÖ¸'[ģéūŲWP>”~~ē`/7gWs÷…Ëۘ™OëæīCũ1ęG{zsÔiū‡ēņŪŧö­sZëí;w8'ÚtŒ°û ū:ߨ”o‹5˛xÔāĪu™øæڍ1Ēo\@z…RLŲŖx33~mZŖP1&Ī´Ëå,V1VAĨ>؝;Čē¤AĄÜ6Í/rĮÜψÆh89ÆFđl”;]ĶÚŊu>\yņ+ŪÁā¨ļzÖvōIqŌú]?Ēšį{y•šØxß^W’ĩÅI9čÚ¨GŽ$ūr>ŗ)ێÆ"Z>dęĖģҘŪBˆß\œîĪ~-ķLˆŧ‘<;åūĸ71ĀeĻ—ÚķË…bMö×ANo–4aˇVīŠõĐĘ0ûpÁ3ŧ[=ôÃĢëS˙ Hëâr”ėÍÂĐûĘĪ˙0öîšlËy54įč.ÖŨG›°éķ_H°įôõ!­úõ¯ŠO‡Á[EWG+!5Ģ*ëõK¤ÖųiA3fíj‘”}o*õĖčWģôŌÜ?†ÂĄõ*cMÆî™Î­ī"ē^ˇÆP™ļ{´ö^>>ÍŧjŠÜŽí?/Ëcžĩ™˙˛ėŖ¸Fī›-Ī“áÅåĄöėūÃīĻb˛ā G ‡%d0öĀ^‚FNJąÉ r˙یûm›ZPØßC‡°Môe2„ĮŊ~귂‡‹Yņ9ųBxō"Ō'Æm[š"›îÅŧūöÉ<~é‹6P]œÉ›h’ ˜^ØĸĨÖ Õ› †—;MaŖŧ<Ō{Š.gĪæmdM ņŦĩrŲ^ž4“IũøįĄ¯!$sđĩœŧPÃ!JÆņqŖųˆmģxlBj¯…ŌPø•Û‘uZĄ7NōVŊO@gĶe“âÔT÷=Ę:+ŧŨēm.F5yÆ[šKųKÖB’čü?gmëú‘°ÉÕüíŠnÁę• ÍĢ‚#åFˆūŅ>ÍWģĮ;zÄĘbt_DD[.cũĢ›~ņ/ámßŦĐmņčÜÎû%ķåĨÅ˙÷ŽōôÅb[>°p>ÍQOģŖX“ŧģhn,‡]ķ/ÕUŒ¯‡Ųā^yžØšģq,Ƨ˛ŌęŦ…ĩ^}ČW:íøąHf6^Kc”pą¤Îž¤„ļ+˜}ŲŠbS˛ž([ũķņi‹ōÚ6o5­ÉĮ s:/{2ā“)Á蚓&ŖGnRöú“Ļ8‚BŲL¤?KâątaãlŠƒãŖČ_=i‡ķXˇ_[ã7÷ōu7.YMO}ۂü|(˙§OôĖąmÄÖÂLs~„ƒ;žßŸÆJˆ],†ĩŧõ˙1šcž1؃0'åÆr›14 Õ}íˇfÄUmiũ;8ė ˇ,õsą¯2ķ­ũļåžû΅“!æėیÃíYŊüâl|Ŧ/(öÖ°eŒAךšņczŗ+„+‰ÚC`#ĩúqž„!Ļ;ßtAąád_t P˛c*ÕÕ ÎŒgúķĘZ–ÅûڞÆėŨŊũøjĩYČŊüÂÂKöú÷>ŋÔ5ĮēČ~€‘TŋŠ‚đ稭§LĨl;××{:Î*Uđ×ééTC4­íęk]pmĐā˛Ë~×Ûķ†ÎDÕ8æoF ŠiØđä1ÛWGEļÅzæŪŊėT°!{–õ=—Ī…rZ×ņ6YE$÷ŦÆŨøĶæ¨n˜—÷,d3žõ†wœ ㄏđÆÆŲwOķYa+WŌ|_,hHM$Ŧl9_?ŒtĻs÷¯`iûpēOázä ķ.Ņxel9žy&dųásÅ3ĖŲo’Y8w9ˆ'ÚÆ”āžĘ 5Į&äĘŖĐrēČÛæ‡ÆÖ*˗ˆŖ>îb5_ąāšŽĪ¯ŪJö"BÖw•ÍÆhX‚åkt7öã›Y.Dŧ\r™‰öl @tˇ0#ƒgQė!ØDŋi–Ûēƒ9\öŗG\čã9ģqʸhĖÎŲŅMÚ:°ĮÄČ­ĨrŪv8vN}yiŖXŽėeP‹æ›ŅčÚáJÎ,Īųøąqŧ&Oė„,Jlw9/:šŌ(Ú'*ÖÆ…P.ėŦ–ėČLöâЁl:ņŦŋgĩx&#€Á~Qjã|müÁhÆcXķ]ŧ'ÛE—Ë€¯ũ¸‡“k×AÎNŅænNnbZãAČe~‚†`_Lå­r?sûÚ˛ëĪ´2÷ĻI â\k}Å}WwOĖ‘ˆƒ—“yQũÖüü7:cJ'NˆĮW‹ÍŒė3gŪûL€sîC¯īdķbäͧ]ĢQõhĄâl6ŅÖmŗPc.|ÚôM§y$ģĀۄ4!<™ģØÔ´1ŌŽÍ3giüņŪŋb ë_ˇ“§Đ5a˙§Ã5#ųį„h/–îÁ§ĪŌ‚aĻ2Åj눊M¤•ķĩ:í>Š|æķrĐĖíõĮ‡Ôö}°"@ŋĸ´{qg'ŸÚŲmŽØÍŠÃ(ųją¯ãöĪ˙ũ´Ąã5XÖ.}IENDŽB`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/block_template.rst0000644000175100001770000000264614637570305021527 0ustar00runnerdocker:orphan: BlockClassName ============== [Link to code if it exists] A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/culler_and_ender.rst0000644000175100001770000000266014637570305022023 0ustar00runnerdockerCullerAndEnder ============== EJT: No currently existing code in `photutils`. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/finder.rst0000644000175100001770000000305114637570305020000 0ustar00runnerdockerObjectFinder ============ EJT: Existing code documented at https://photutils.readthedocs.io/en/stable/api/photutils.detection.StarFinderBase.html - see the ``find_stars`` function for the basic API. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/fitter.rst0000644000175100001770000000057714637570305020040 0ustar00runnerdockerFitter ====== The fitter block is unique in that it is a class not implemented as part of `photutils`. Rather, it is an object that follows the interface for fitters in the `astropy.modeling` package. Note that implicitly the fitters for PSF photometry are always fitters appropriate for *2D* models (i.e., 2 input dimensions for the x and y pixel coordinates and a "flux" output). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/group_maker.rst0000644000175100001770000000331514637570305021047 0ustar00runnerdockerGroupMaker ========== EJT: Documented as the ``__call__`` method of ``GroupStarsBase`` - see https://photutils.readthedocs.io/en/stable/api/photutils.psf.groupstars.GroupStarsBase.html It'll be substantial work to re-design the photometry loops if this is changed in a backwards-incompatible manner, but of course that's possible if there's a good reason for it. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/index.rst0000644000175100001770000000055614637570305017647 0ustar00runnerdocker:orphan: PSF Photometry Block Diagram Specification ========================================== The block diagram: .. image:: block_diagram.png Blocks ------ .. toctree:: :maxdepth: 1 background_estimator block_template culler_and_ender finder fitter group_maker noise_data psf_model scene_maker single_object_model ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/noise_data.rst0000644000175100001770000000265014637570305020643 0ustar00runnerdockerNoiseModel ========== EJT: No currently existing code in `photutils`. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/psf_model.rst0000644000175100001770000000355714637570305020514 0ustar00runnerdockerPsfModel ======== EJT: the PSF models in the current ``photutils.psf`` are not explicitly defined as a specific class, but any 2D model (inputs: x,y, output: flux) can be considered a PSF Model. The `~photutils.psf.PRFAdapter` class is the clearest application specific example, however, and demonstrates the required convention for *names* of the PSF model's parameters. Hopefully that class can be used *directly* as this block, as it is meant to wrap any arbitrary other models to make it compatible with the machinery. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/scene_maker.rst0000644000175100001770000000317314637570305021012 0ustar00runnerdockerSceneMaker ========== EJT: This object is not currently in the block diagram, as it represents a step beyond the "baseline" PSF fitting machinery. It should be developed in parallel with the ``SingleObjectModel``, which really doesn't have reason to exist without the scene maker. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/psf_spec/single_object_model.rst0000644000175100001770000000373014637570305022524 0ustar00runnerdockerSingleObjectModel ================= EJT: This does not exist in the current `photutils.psf` model, because there is not an explicit separate single object model. Instead the psf_model is used directly, as the "single object model" is implicitly a delta function. To maintain backwards-compatibility, the new ``SingleObjectModel`` will need to default to the "point source" object model, and behave the same as the current behavior of a model with shape parameters is provided as the "psf model". But arguable that is *not* the desired behavior in the "new" paradigm that combines the ``SceneMaker`` and the ``SingleObjectModel``. A single sentence summarizing this block. A longer description. Can be multiple paragraphs. You can link to other things like `photutils.background`. Parameters ---------- first_parameter_name : `~astropy.table.Table` Description of first input second_parameter_name : SomeOtherType Description of second input (if any) Returns ------- first_return : `~astropy.table.Table` Description of the first thing this block outputs. second_return Many blocks will only return one object, but if more things are returned they can be described here (e.g., in python this is ``first, second = some_function(...)``) Methods ------- Not all blocks will have these, but if desired some blocks can have methods that let you do something other than just running the block. E.g:: some_block = BlockClassName() output = some_block(input1, input2, ...) # this is what is documented above result = some_block.method_name(...) #this is documented here method_name ^^^^^^^^^^^ Description of method Parameters """""""""" first_parameter : type Description ... second_parameter : type Description ... Returns """"""" first_return : type Description ... Example Usage ------------- An example of *using* the block should be provided. This needs to be after a ``::`` in the rst and indented:: print("This is example code") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/segmentation.rst0000644000175100001770000006250514637570305017435 0ustar00runnerdocker.. _image_segmentation: Image Segmentation (`photutils.segmentation`) ============================================= Introduction ------------ Photutils includes general-use functions to detect sources (both point-like and extended) in an image using a process called `image segmentation `_. After detecting sources using image segmentation, we can then measure their photometry, centroids, and shape properties. Source Extraction Using Image Segmentation ------------------------------------------ Image segmentation is a process of assigning a label to every pixel in an image such that pixels with the same label are part of the same source. Detected sources must have a minimum number of connected pixels that are each greater than a specified threshold value in an image. The threshold level is usually defined as some multiple of the background noise (sigma level) above the background. The image is usually filtered before thresholding to smooth the noise and maximize the detectability of objects with a shape similar to the filter kernel. Let's start by making a synthetic image provided by the :ref:`photutils.datasets ` module:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() Next, we need to subtract the background from the image. In this example, we'll use the :class:`~photutils.background.Background2D` class to produce a background and background noise image: .. doctest-requires:: scipy >>> from photutils.background import Background2D, MedianBackground >>> bkg_estimator = MedianBackground() >>> bkg = Background2D(data, (50, 50), filter_size=(3, 3), ... bkg_estimator=bkg_estimator) >>> data -= bkg.background # subtract the background After subtracting the background, we need to define the detection threshold. In this example, we'll define a 2D detection threshold image using the background RMS image. We set the threshold at the 1.5-sigma (per pixel) noise level: .. doctest-requires:: scipy >>> threshold = 1.5 * bkg.background_rms Next, let's convolve the data with a 2D Gaussian kernel with a FWHM of 3 pixels:: >>> from astropy.convolution import convolve >>> from photutils.segmentation import make_2dgaussian_kernel >>> kernel = make_2dgaussian_kernel(3.0, size=5) # FWHM = 3.0 >>> convolved_data = convolve(data, kernel) Now we are ready to detect the sources in the background-subtracted convolved image. Let's find sources that have 10 connected pixels that are each greater than the corresponding pixel-wise ``threshold`` level defined above (i.e., 1.5 sigma per pixel above the background noise). Note that by default "connected pixels" means "8-connected" pixels, where pixels touch along their edges or corners. One can also use "4-connected" pixels that touch only along their edges by setting ``connectivity=4``: .. doctest-requires:: scipy >>> from photutils.segmentation import detect_sources >>> segment_map = detect_sources(convolved_data, threshold, npixels=10) >>> print(segment_map) shape: (300, 500) nlabels: 86 labels: [ 1 2 3 4 5 ... 82 83 84 85 86] The result is a :class:`~photutils.segmentation.SegmentationImage` object with the same shape as the data, where detected sources are labeled by different positive integer values. Background pixels (non-sources) always have a value of zero. Because the segmentation image is generated using image thresholding, the source segments represent the isophotal footprints of each source. Let's plot both the background-subtracted image and the segmentation image showing the detected sources: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) >>> ax1.set_title('Background-subtracted Data') >>> ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, ... interpolation='nearest') >>> ax2.set_title('Segmentation Image') .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import detect_sources, make_2dgaussian_kernel data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) segment_map = detect_sources(convolved_data, threshold, npixels=10) norm = ImageNormalize(stretch=SqrtStretch()) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) ax1.set_title('Background-subtracted Data') ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, interpolation='nearest') ax2.set_title('Segmentation Image') plt.tight_layout() Source Deblending ----------------- In the example above, overlapping sources are detected as single sources. Separating those sources requires a deblending procedure, such as a multi-thresholding technique used by `SourceExtractor`_. Photutils provides a :func:`~photutils.segmentation.deblend_sources` function that deblends sources uses a combination of multi-thresholding and `watershed segmentation `_. Note that in order to deblend sources, they must be separated enough such that there is a saddle point between them. The amount of deblending can be controlled with the two :func:`~photutils.segmentation.deblend_sources` keywords ``nlevels`` and ``contrast``. ``nlevels`` is the number of multi-thresholding levels to use. ``contrast`` is the fraction of the total source flux that a local peak must have to be considered as a separate object. Here's a simple example of source deblending: .. doctest-requires:: scipy, skimage >>> from photutils.segmentation import deblend_sources >>> segm_deblend = deblend_sources(convolved_data, segment_map, ... npixels=10, nlevels=32, contrast=0.001, ... progress_bar=False) where ``segment_map`` is the :class:`~photutils.segmentation.SegmentationImage` that was generated by :func:`~photutils.segmentation.detect_sources`. Note that the ``convolved_data`` and ``npixels`` input values should match those used in :func:`~photutils.segmentation.detect_sources` to generate ``segment_map``. The result is a new :class:`~photutils.segmentation.SegmentationImage` object containing the deblended segmentation image: .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (deblend_sources, detect_sources, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 segment_map = detect_sources(convolved_data, threshold, npixels=npixels) segm_deblend = deblend_sources(convolved_data, segment_map, npixels=npixels, progress_bar=False) norm = ImageNormalize(stretch=SqrtStretch()) fig, ax = plt.subplots(1, 1, figsize=(10, 6.5)) ax.imshow(segm_deblend, origin='lower', cmap=segm_deblend.cmap, interpolation='nearest') ax.set_title('Deblended Segmentation Image') plt.tight_layout() Let's plot one of the deblended sources: .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (deblend_sources, detect_sources, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 segment_map = detect_sources(convolved_data, threshold, npixels=npixels) segm_deblend = deblend_sources(convolved_data, segment_map, npixels=npixels, progress_bar=False) norm = ImageNormalize(stretch=SqrtStretch()) fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(10, 4)) slc = (slice(273, 297), slice(425, 444)) ax1.imshow(data[slc], origin='lower') ax1.set_title('Background-subtracted Data') cmap1 = segment_map.cmap ax2.imshow(segment_map.data[slc], origin='lower', cmap=cmap1, interpolation='nearest') ax2.set_title('Original Segment') cmap2 = segm_deblend.cmap ax3.imshow(segm_deblend.data[slc], origin='lower', cmap=cmap2, interpolation='nearest') ax3.set_title('Deblended Segments') plt.tight_layout() SourceFinder ------------ The :class:`~photutils.segmentation.SourceFinder` class is a convenience class that combines the functionality of `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources`. After defining the object with the desired detection and deblending parameters, you call it with the background-subtracted (convolved) image and threshold: .. doctest-requires:: scipy, skimage >>> from photutils.segmentation import SourceFinder >>> finder = SourceFinder(npixels=10, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> print(segment_map) shape: (300, 500) nlabels: 93 labels: [ 1 2 3 4 5 ... 89 90 91 92 93] Modifying a Segmentation Image ------------------------------ The :class:`~photutils.segmentation.SegmentationImage` object provides several methods that can be used to modify itself (e.g., combining labels, removing labels, removing border segments) prior to measuring source photometry and other source properties, including: * :meth:`~photutils.segmentation.SegmentationImage.reassign_label`: Reassign one or more label numbers. * :meth:`~photutils.segmentation.SegmentationImage.relabel_consecutive`: Reassign the label numbers consecutively, such that there are no missing label numbers. * :meth:`~photutils.segmentation.SegmentationImage.keep_labels`: Keep only the specified labels. * :meth:`~photutils.segmentation.SegmentationImage.remove_labels`: Remove one or more labels. * :meth:`~photutils.segmentation.SegmentationImage.remove_border_labels`: Remove labeled segments near the image border. * :meth:`~photutils.segmentation.SegmentationImage.remove_masked_labels`: Remove labeled segments located within a masked region. Photometry, Centroids, and Shape Properties ------------------------------------------- The :class:`~photutils.segmentation.SourceCatalog` class is the primary tool for measuring the photometry, centroids, and shape/morphological properties of sources defined in a segmentation image. In its most basic form, it takes as input the (background-subtracted) image and the segmentation image. Usually the convolved image is also input, from which the source centroids and shape/morphological properties are measured (if not input, the unconvolved image is used instead). Let's continue our example from above and measure the properties of the detected sources: .. doctest-requires:: scipy, skimage >>> from photutils.segmentation import SourceCatalog >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> print(cat) Length: 93 labels: [ 1 2 3 4 5 ... 89 90 91 92 93] The source properties can be accessed using `~photutils.segmentation.SourceCatalog` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.segmentation.SourceCatalog.to_table` method. Please see :class:`~photutils.segmentation.SourceCatalog` for the many properties that can be calculated for each source. More properties are likely to be added in the future. Here we'll use the :meth:`~photutils.segmentation.SourceCatalog.to_table` method to generate a `~astropy.table.QTable` of source properties. Each row in the table represents a source. The columns represent the calculated source properties. The ``label`` column corresponds to the label value in the input segmentation image. Note that only a small subset of the source properties are shown below: .. doctest-requires:: scipy, skimage >>> tbl = cat.to_table() >>> tbl['xcentroid'].info.format = '.2f' # optional format >>> tbl['ycentroid'].info.format = '.2f' >>> tbl['kron_flux'].info.format = '.2f' >>> print(tbl) label xcentroid ycentroid ... segment_fluxerr kron_flux kron_fluxerr ... ----- --------- --------- ... --------------- --------- ------------ 1 235.38 1.44 ... nan 490.35 nan 2 493.78 5.84 ... nan 489.37 nan 3 207.29 10.26 ... nan 694.24 nan 4 364.87 11.13 ... nan 681.20 nan 5 257.85 12.18 ... nan 748.18 nan ... ... ... ... ... ... ... 89 292.77 244.93 ... nan 792.63 nan 90 32.66 241.24 ... nan 930.77 nan 91 42.60 249.43 ... nan 580.54 nan 92 433.80 280.74 ... nan 663.44 nan 93 434.03 288.88 ... nan 879.64 nan Length = 93 rows The error columns are NaN because we did not input an error array (see the :ref:`photutils-segmentation_errors` section below). Let's plot the calculated elliptical Kron apertures (based on the shapes of each source) on the data: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> norm = simple_norm(data, 'sqrt') >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) >>> ax1.set_title('Data') >>> ax2.imshow(segm_deblend, origin='lower', cmap=segm_deblend.cmap, ... interpolation='nearest') >>> ax2.set_title('Segmentation Image') >>> cat.plot_kron_apertures(ax=ax1, color='white', lw=1.5) >>> cat.plot_kron_apertures(ax=ax2, color='white', lw=1.5) .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (SourceCatalog, SourceFinder, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) norm = simple_norm(data, 'sqrt') ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) ax1.set_title('Data with Kron apertures') ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, interpolation='nearest') ax2.set_title('Segmentation Image with Kron apertures') cat.plot_kron_apertures(ax=ax1, color='white', lw=1.5) cat.plot_kron_apertures(ax=ax2, color='white', lw=1.5) plt.tight_layout() We can also create a `~photutils.segmentation.SourceCatalog` object containing only a specific subset of sources, defined by their label numbers in the segmentation image: .. doctest-requires:: scipy, skimage >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> tbl2 = cat_subset.to_table() >>> tbl2['xcentroid'].info.format = '.2f' # optional format >>> tbl2['ycentroid'].info.format = '.2f' >>> tbl2['kron_flux'].info.format = '.2f' >>> print(tbl2) label xcentroid ycentroid ... segment_fluxerr kron_flux kron_fluxerr ... ----- --------- --------- ... --------------- --------- ------------ 1 235.38 1.44 ... nan 490.35 nan 5 257.85 12.18 ... nan 748.18 nan 20 347.17 66.45 ... nan 855.34 nan 50 381.02 174.67 ... nan 438.55 nan 75 74.44 259.78 ... nan 876.02 nan 80 14.93 60.06 ... nan 878.52 nan By default, the :meth:`~photutils.segmentation.SourceCatalog.to_table` includes only a small subset of source properties. The output table properties can be customized in the `~astropy.table.QTable` using the ``columns`` keyword: .. doctest-requires:: scipy, skimage >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> columns = ['label', 'xcentroid', 'ycentroid', 'area', 'segment_flux'] >>> tbl3 = cat_subset.to_table(columns=columns) >>> tbl3['xcentroid'].info.format = '.4f' # optional format >>> tbl3['ycentroid'].info.format = '.4f' >>> tbl3['segment_flux'].info.format = '.4f' >>> print(tbl3) label xcentroid ycentroid area segment_flux pix2 ----- --------- --------- ----- ------------ 1 235.3827 1.4439 47.0 433.3546 5 257.8501 12.1764 84.0 489.9653 20 347.1743 66.4462 103.0 625.9668 50 381.0186 174.6745 50.0 249.0170 75 74.4448 259.7843 66.0 836.4803 80 14.9296 60.0641 87.0 666.6014 A `~astropy.wcs.WCS` transformation can also be input to :class:`~photutils.segmentation.SourceCatalog` via the ``wcs`` keyword, in which case the sky coordinates of the source centroids can be calculated. Background Properties ^^^^^^^^^^^^^^^^^^^^^ Like with :func:`~photutils.aperture.aperture_photometry`, the ``data`` array that is input to :class:`~photutils.segmentation.SourceCatalog` should be background subtracted. If you input the background image that was subtracted from the data into the ``background`` keyword of :class:`~photutils.segmentation.SourceCatalog`, the background properties for each source will also be calculated: .. doctest-requires:: scipy, skimage >>> cat = SourceCatalog(data, segm_deblend, background=bkg.background) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> columns = ['label', 'background_centroid', 'background_mean', ... 'background_sum'] >>> tbl4 = cat_subset.to_table(columns=columns) >>> tbl4['background_centroid'].info.format = '{:.10f}' # optional format >>> tbl4['background_mean'].info.format = '{:.10f}' >>> tbl4['background_sum'].info.format = '{:.10f}' >>> print(tbl4) label background_centroid background_mean background_sum ----- ------------------- --------------- -------------- 1 5.2383296240 5.1952756242 244.1779543392 5 5.2926300845 5.2065435089 437.3496547461 20 5.2901502015 5.2182858995 537.4834476464 50 5.0822645472 5.2277566101 261.3878305070 75 5.1889235577 5.2203644547 344.5440540106 80 5.2014082564 5.2174773439 453.9205289152 .. _photutils-segmentation_errors: Photometric Errors ^^^^^^^^^^^^^^^^^^ :class:`~photutils.segmentation.SourceCatalog` requires inputting a *total* error array, i.e., the background-only error plus Poisson noise due to individual sources. The :func:`~photutils.utils.calc_total_error` function can be used to calculate the total error array from a background-only error array and an effective gain. The ``effective_gain``, which is the ratio of counts (electrons or photons) to the units of the data, is used to include the Poisson noise from the sources. ``effective_gain`` can either be a scalar value or a 2D image with the same shape as the ``data``. A 2D effective gain image is useful for mosaic images that have variable depths (i.e., exposure times) across the field. For example, one should use an exposure-time map as the ``effective_gain`` for a variable depth mosaic image in count-rate units. Let's assume our synthetic data is in units of electrons per second. In that case, the ``effective_gain`` should be the exposure time (here we set it to 500 seconds). Here we use :func:`~photutils.utils.calc_total_error` to calculate the total error and input it into the :class:`~photutils.segmentation.SourceCatalog` class. When a total ``error`` is input, the `~photutils.segmentation.SourceCatalog.segment_fluxerr` and `~photutils.segmentation.SourceCatalog.kron_fluxerr` properties are calculated. `~photutils.segmentation.SourceCatalog.segment_flux` and `~photutils.segmentation.SourceCatalog.segment_fluxerr` are the instrumental flux and propagated flux error within the source segments: .. doctest-requires:: scipy, skimage >>> from photutils.utils import calc_total_error >>> effective_gain = 500.0 >>> error = calc_total_error(data, bkg.background_rms, effective_gain) >>> cat = SourceCatalog(data, segm_deblend, error=error) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) # select a subset of objects >>> columns = ['label', 'xcentroid', 'ycentroid', 'segment_flux', ... 'segment_fluxerr'] >>> tbl5 = cat_subset.to_table(columns=columns) >>> tbl5['xcentroid'].info.format = '{:.4f}' # optional format >>> tbl5['ycentroid'].info.format = '{:.4f}' >>> tbl5['segment_flux'].info.format = '{:.4f}' >>> tbl5['segment_fluxerr'].info.format = '{:.4f}' >>> for col in tbl5.colnames: ... tbl5[col].info.format = '%.8g' # for consistent table output >>> print(tbl5) label xcentroid ycentroid segment_flux segment_fluxerr ----- --------- --------- ------------ --------------- 1 235.24302 1.1928271 433.35463 14.167067 5 257.82267 12.228232 489.96534 18.998371 20 347.15384 66.417567 625.96683 22.475065 50 380.94448 174.57181 249.01701 15.261334 75 74.413068 259.76066 836.4803 17.193721 80 14.920217 60.024006 666.60139 19.605394 Pixel Masking ^^^^^^^^^^^^^ Pixels can be completely ignored/excluded (e.g., bad pixels) when measuring the source properties by providing a boolean mask image via the ``mask`` keyword (`True` pixel values are masked) to the :class:`~photutils.segmentation.SourceCatalog` class. Note that non-finite ``data`` values (NaN and inf) are automatically masked. Filtering ^^^^^^^^^ `SourceExtractor`_'s centroid and morphological parameters are always calculated from a convolved, or filtered, "detection" image (``convolved_data``), i.e., the image used to define the segmentation image. The usual downside of the filtering is the sources will be made more circular than they actually are. If you wish to reproduce `SourceExtractor`_ centroid and morphology results, then input the ``convolved_data`` (or ``kernel``, but not both). If ``convolved_data`` and ``kernel`` are both `None`, then the unfiltered ``data`` will be used for the source centroid and morphological parameters. Note that photometry is *always* performed on the unfiltered ``data``. Reference/API ------------- .. automodapi:: photutils.segmentation :no-heading: .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/utils.rst0000644000175100001770000000040114637570305016063 0ustar00runnerdockerUtility Functions (`photutils.utils`) ===================================== Introduction ------------ The `photutils.utils` package contains general-purpose utility functions. Reference/API ------------- .. automodapi:: photutils.utils :no-heading: ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.010885 photutils-1.13.0/docs/whats_new/0000755000175100001770000000000014637570322016174 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.1.rst0000644000175100001770000000540714637570305017234 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.1? **************************** Overview ======== Photutils 1.1 is a major release that adds new functionality since the 1.0 release. Here we highlight some of the major changes. Please see the :ref:`changelog` for the complete list of changes. New SourceCatalog class ======================= A new, significantly faster, `~photutils.segmentation.SourceCatalog` class was implemented. This new class simplifies the API and takes the place of the ``source_properties`` function and the ``SourceProperties`` ``LegacySourceCatalog`` classes. The ``source_properties`` function and ``SourceProperties`` class are now deprecated and will eventually be removed. The ``source_properties`` function now returns ``LegacySourceCatalog`` class (deprecated) to distinguish it from the new `~photutils.segmentation.SourceCatalog`. Optional keyword arguments in `~photutils.segmentation.SourceCatalog` can not be input as positional arguments. Please see the `~photutils.segmentation.SourceCatalog` documentation for the keywords inputs and their allowed values. Renamed properties ------------------ Note that many of the source properties have been slightly renamed in the new `~photutils.segmentation.SourceCatalog` class, e.g., * 'id' -> 'label' * 'background_at_centroid' -> 'background_centroid' * 'background_cutout' -> 'background' * 'background_cutout_ma' -> 'background_ma' * 'data_cutout' -> 'data' * 'data_cutout_ma' -> 'data_ma' * 'error_cutout' -> 'error' * 'error_cutout_ma' -> 'error_ma' * 'filtered_data_cutout_ma' -> 'convdata_ma' * 'minval_pos' -> 'minval_index' * 'minval_xpos' -> 'minval_xindex' * 'minval_ypos' -> 'minval_yindex' * 'maxval_pos' -> 'maxval_index' * 'maxval_xpos' -> 'maxval_xindex' * 'maxval_ypos' -> 'maxval_yindex' * 'semimajor_axis_sigma' -> 'semimajor_sigma' * 'semiminor_axis_sigma' -> 'semiminor_sigma' * 'source_sum' -> 'segment_flux' * 'source_sum_err' -> 'segment_fluxerr' Also, the 'centroid' and 'cutout_centroid' properties now return centroids in (x, y) order to be consistent with the tools in ``photutils.centroid``. New methods and attributes -------------------------- The new `~photutils.segmentation.SourceCatalog` class has the following new methods: * :meth:`~photutils.segmentation.SourceCatalog.circular_photometry` * :meth:`~photutils.segmentation.SourceCatalog.fluxfrac_radius` * :meth:`~photutils.segmentation.SourceCatalog.get_label` * :meth:`~photutils.segmentation.SourceCatalog.get_labels` and new attributes: * :attr:`~photutils.segmentation.SourceCatalog.fwhm` * :attr:`~photutils.segmentation.SourceCatalog.segment` * :attr:`~photutils.segmentation.SourceCatalog.segment_ma` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.10.rst0000644000175100001770000000315514637570305017312 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.10? ***************************** Here we highlight some of the new functionality of the 1.10 release. In addition to these changes, Photutils 1.10 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Reading WebbPSF Gridded PSF Models ---------------------------------- The `~photutils.psf.GriddedPSFModel` ``read`` method can now read FITS files containing ePSF grids that were generated by `WebbPSF `_. Minimum separation parameter for ``DAOStarFinder`` and ``IRAFStarFinder`` ------------------------------------------------------------------------- An optional ``min_separation`` keyword is now available in the `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` classes. This parameter defines a minimum separation (in pixels) for detected objects. PSF photometry models --------------------- A `~photutils.psf.make_psf_model` function was added for making a PSF model from a 2D Astropy model. Compound models are also supported. The output PSF model can be used in the PSF photometry classes. This function replaces the deprecated ``~photutils.psf.prepare_psf_model`` function. ``GriddedPSFModel`` oversampling -------------------------------- The `~photutils.psf.GriddedPSFModel` oversampling can now be different in the x and y directions. The ``oversampling`` attribute is now stored as a 1D `numpy.ndarray` with two elements. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.11.rst0000644000175100001770000000416314637570305017313 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.11? ***************************** Here we highlight some of the new functionality of the 1.11 release. In addition to these changes, Photutils 1.11 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. ``SourceFinder`` ``npixels`` tuple input ---------------------------------------- The `~photutils.segmentation.SourceFinder` ``npixels`` keyword can now be a tuple corresponding to the values used for the source finder and source deblender, respectively. ``GriddedPSFModel`` Memory Usage -------------------------------- The memory usage during PSF photometry when using a `~photutils.psf.GriddedPSFModel` PSF model has been significantly reduced. This is especially noticeable when fitting a large number of stars. New ``IterativePSFPhotometry`` ``mode`` keyword ----------------------------------------------- A ``mode`` keyword was added to `~photutils.psf.IterativePSFPhotometry` for controlling the fitting mode. The ``mode`` keyword can be set to 'new' or 'all'. For the 'new' mode (default), PSF photometry is run in each iteration only on the new sources detected in the residual image. The 'new' mode preserves the previous behavior of `~photutils.psf.IterativePSFPhotometry`. For the 'all' mode, PSF photometry is run in each iteration on all the detected sources (from all previous iterations) on the original, unsubtracted, data. For the 'all' mode, a source grouper must be input. Initial tests indicate that the 'all' mode may give better results than the older 'new' method. New ``include_localbkg`` keyword -------------------------------- The PSF photometry ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default, which causes issues if the ``psf_shape`` of sources overlap. This is a backwards-incompatible change. These methods now accept an ``include_localbkg`` keyword . If the previous behavior is desired, set ``include_localbkg=True``. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.12.rst0000644000175100001770000000041314637570305017306 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.12? ***************************** The Photutils 1.12 release was made to support NumPy 2.0. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.13.rst0000644000175100001770000001242414637570305017314 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.13? ***************************** Here we highlight some of the new functionality of the 1.13 release. In addition to these changes, Photutils 1.13 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Testing the installed version of Photutils ========================================== To test your installed version of Photutils, you can run the test suite using the `pytest `_ command. Running the test suite requires installing the `pytest-astropy `_ (0.11 or later) package. To run the test suite, use the following command:: pytest --pyargs photutils This method replaces the old method of running the test suite using the ``photutils.test()`` Python function, which has been removed. Datasets subpackage reoganization ================================= The ``photutils.datasets`` subpackage has been reorganized and the ``make`` module has been deprecated. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. Changed noise pixel values in example datasets ============================================== The randomly-generated optional noise in the simulated example images returned by the ``make_4gaussians_image`` and ``make_100gaussians_image`` is now slightly different. The noise sigma is the same, but the pixel values differ. This is due to a change from the legacy NumPy random number generator to the redesigned and preferred random number generator introduced in NumPy 1.17. Making simulated images with model sources ========================================== The new :func:`~photutils.datasets.make_model_image` function creates a simulated image with model sources. This function is useful for testing source detection and photometry algorithms. This function has more options and is significantly faster than the now-deprecated ``mask_model_sources_image`` function. A new :func:`~photutils.datasets.make_model_params` function was also added to make a table of randomly generated model positions and fluxes for simulated sources. These two new functions along with the existing :func:`~photutils.datasets.make_random_models_table` function provide a complete set of tools for creating simulated images with model sources. Please see the examples in the documentation of these functions. The ``make_model_sources_image``, ``make_gaussian_sources_image``, ``make_gaussian_prf_sources_image``, ``make_test_psf_data``, and ``make_random_gaussians_table`` functions are now deprecated and will be removed in a future release. Making simulated images with a PSF model ======================================== A specialized function, :func:`~photutils.psf.make_psf_model_image` function was also added to generated simulated images from a PSF model. This function returns both an image and a table of the model parameters. PSF photometry initial parameter guesses ======================================== The ``init_params`` table input when calling the `~photutils.psf.PSFPhotometry` or `~photutils.psf.IterativePSFPhotometry` class now allows the user to input columns for additional model parameters other than x, y, and flux if those parameters are free vary in the fitting routine (i.e., not fixed parameters). The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. Removed deprecated PSF photometry tools ======================================= The deprecated ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, ``DAOPhotPSFPhotometry``, ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase``, and ``NonNormalizable`` classes and the ``prepare_psf_model``, ``get_grouped_psf_model``, and ``subtract_psf`` functions were removed. Updates to Star finders ======================= The `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, and `~photutils.detection.StarFinder` classes and the `~photutils.detection.find_peaks` functions now support input arrays with units. This requires inputing a ``threshold`` value that also has compatible units to the input data array. Sources that have non-finite properties (e.g., centroid, roundness, sharpness, etc.) are now automatically excluded from the output table in `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, and `~photutils.detection.StarFinder`. The ``sky`` keyword in `~photutils.detection.DAOStarFinder`, and `~photutils.detection.IRAFStarFinder` is now deprecated and will be removed in a future version. One should background subtract the image before calling the star finders. Improvements to Radial Profile tools ===================================== The `~photutils.profiles.CurveOfGrowth` class now has ``calc_ee_from_radius`` and ``calc_radius_from_ee`` methods to calculate the encircled energy (EE) at a given radius and vice versa using a cubic interpolator. The `~photutils.profiles.CurveOfGrowth` and `~photutils.profiles.RadialProfile` classes now have a ``unnormalize`` method to return the profile to the state before any ``normalize`` calls were run. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.2.rst0000644000175100001770000000025714637570305017233 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.2? **************************** Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.3.rst0000644000175100001770000000026014637570305017226 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.3? **************************** Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.4.rst0000644000175100001770000000632014637570305017232 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.4? **************************** New ApertureStats class ======================= A new :class:`~photutils.aperture.ApertureStats` class was added. This class can be used to compute statistics of unmasked pixel within an aperture. It can be used to create a catalog of properties, including local-background subtracted aperture photometry with the "exact", "center", or "subpixel" method, for sources. The :class:`~photutils.aperture.ApertureStats` class can calculate many properties, including statistics like :attr:`~photutils.aperture.ApertureStats.min`, :attr:`~photutils.aperture.ApertureStats.max`, :attr:`~photutils.aperture.ApertureStats.mean`, :attr:`~photutils.aperture.ApertureStats.median`, :attr:`~photutils.aperture.ApertureStats.std`, :attr:`~photutils.aperture.ApertureStats.sum_aper_area`, and :attr:`~photutils.aperture.ApertureStats.sum`. It also can be used to calculate morphological properties like :attr:`~photutils.aperture.ApertureStats.centroid`, :attr:`~photutils.aperture.ApertureStats.fwhm`, :attr:`~photutils.aperture.ApertureStats.semimajor_sigma`, :attr:`~photutils.aperture.ApertureStats.semiminor_sigma`, :attr:`~photutils.aperture.ApertureStats.orientation`, and :attr:`~photutils.aperture.ApertureStats.eccentricity`. The properties can be accessed using `~photutils.aperture.ApertureStats` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.aperture.ApertureStats.to_table` method. Please see :class:`~photutils.aperture.ApertureStats` for the the complete list of properties that can be calculated and the :ref:`photutils-aperture-stats` documentation for examples. New clip keyword in BkgZoomInterpolator ======================================= A ``clip`` keyword was added to the :class:`~photutils.background.BkgZoomInterpolator` class, which is used by :class:`~photutils.background.Background2D`. By default, :class:`~photutils.background.BkgZoomInterpolator` sets ``clip=True`` to prevent the interpolation from producing values outside the given input range. If backwards-compatiblity is needed with older Photutils versions, set ``clip=False``. Segmentation Performance Improvements ===================================== A ``convolved_data`` keyword was added to the :class:`~photutils.segmentation.SourceCatalog` class that allows the convolved image to be directly input instead of using the ``kernel`` keyword. Convolved data can also be directly input to the `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions (using the ``data`` parameter) instead of using the ``kernel`` keyword. For performance, it is strongly recommended that the user first convolve their data, if desired, and then input the convolved data to each of these segmentation functions. Doing so improves the overall performance by omitting extra convolution steps within each function or class. Significant improvements were also made to the performance of the :class:`~photutils.segmentation.SegmentationImage` and `~photutils.segmentation.SourceCatalog` classes in the case of large data arrays. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.5.rst0000644000175100001770000001053714637570305017240 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.5? **************************** Smoothing data prior to source detection and deblending ======================================================= The ``kernel`` keyword was deprecated from the `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions. Instead the user should create a (background-subtracted) convolved image and input it directly into these functions. Doing so improves the overall performance by omitting extra convolution steps within each function or class. Both the (background subtracted) unconvolved and convolved images should be input into the `~photutils.segmentation.SourceCatalog` class. A `~photutils.segmentation.make_2dgaussian_kernel` convenience function was added for creating 2D Gaussian kernels. New SourceFinder class ====================== A new :class:`~photutils.segmentation.SourceFinder` convenience class was added, combining source detection and deblending. The `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions also still remain available. The separate tools can be used, for example, to effienciently explore the various deblending parameters. Source Deblending Performance Improvements ========================================== The performance of the `~photutils.segmentation.deblend_sources` has been significantly improved. Also, `~photutils.segmentation.deblend_sources` and `~photutils.segmentation.SourceFinder` now have a ``nproc`` keyword to enable multiprocessing during source deblending. Please note that due to overheads, multiprocessing may be slower than serial processing. This is especially true if one only has a small number of sources to deblend. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. Also, a new ``sinh`` multi-thresholding mode was added to `~photutils.segmentation.deblend_sources` (also available in the new `~photutils.segmentation.SourceFinder`). New `~photutils.segmentation.SegmentationImage` methods ======================================================= `~photutils.segmentation.SegmentationImage` has a new `~photutils.segmentation.SegmentationImage.make_source_mask` method to create a source mask by dilating the segmentation image with a user-defined footprint. A new `~photutils.utils.circular_footprint` convenience function was added to create circular footprints. There is also a new `~photutils.segmentation.SegmentationImage.imshow` convenience method for plotting the segmentation image. `~photutils.segmentation.SourceCatalog` minimum Kron radius =========================================================== A minimum value for the unscaled Kron radius can now be specified as the second element of the ``kron_params`` keyword input to `~photutils.segmentation.SourceCatalog`. The ``kron_params`` keyword now has an optional third item representing the minimum circular radius. Custom cutouts from `~photutils.segmentation.SourceCatalog` =========================================================== The `~photutils.segmentation.SourceCatalog` has a new `~photutils.segmentation.SourceCatalog.make_cutouts` method for making custom-sized image cutouts for each labeled source centered at their centroid. The cutouts are instances of a new `~photutils.utils.CutoutImage` class. PSF-Fitting Masks ================= The ``~photutils.psf.BasicPSFPhotometry``, ``~photutils.psf.IterativelySubtractedPSFPhotometry`` and ``~photutils.psf.DAOPhotPSFPhotometry`` PSF-fitting instances now accept a ``mask`` keyword when called with the input data to mask bad pixels. Invalid data values (i.e., NaN or inf) are now automatically masked when performing PSF fitting. The Astropy/Scipy fitters do not actually perform a fit if such invalid values are in the data. Keyword-only arguments are now required for PSF tools ===================================================== Keyword arguments used in the PSF tools must now be explicitly input using the keyword name. Progress Bars ============= The `~photutils.segmentation.deblend_sources` function and the `~photutils.psf.EPSFBuilder` class now have options to use a progress bar using the new `tqdm `_ optional dependency. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.6.rst0000644000175100001770000000644214637570305017241 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.6? **************************** Here we highlight some of the new functionality of the 1.6 release. In addition to these major changes, Photutils 1.6 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. New centroids available in SourceCatalog ======================================== New centroids were added to the :class:`~photutils.segmentation.SourceCatalog` class, including iteratively-calculated "windowed" centroids and centroids calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. The "windowed" centroids are equivalent the SourceExtractors ``XWIN_IMAGE`` and ``YWIN_IMAGE`` parameters. The new "windowed" centroid properties are: * :attr:`~photutils.segmentation.SourceCatalog.centroid_win` * :attr:`~photutils.segmentation.SourceCatalog.cutout_centroid_win` * :attr:`~photutils.segmentation.SourceCatalog.xcentroid_win` * :attr:`~photutils.segmentation.SourceCatalog.ycentroid_win` * :attr:`~photutils.segmentation.SourceCatalog.sky_centroid_win` The "quadratic" centroids are calculated using `~photutils.centroids.centroid_quadratic`. The new quadratic centroid properties are: * :attr:`~photutils.segmentation.SourceCatalog.centroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.cutout_centroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.xcentroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.ycentroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.sky_centroid_quad` Slicing a SegmentationImage =========================== :class:`~photutils.segmentation.SegmentationImage` objects can now be sliced in x and y, generating a new :class:`~photutils.segmentation.SegmentationImage` object. New ImageDepth class ==================== A new :class:`~photutils.utils.ImageDepth` class was added to compute the limiting fluxes and magnitudes of an image. ApertureStats ============= The :class:`~photutils.aperture.ApertureStats` class now accepts `~astropy.nddata.NDData` objects as input. Progress Bars in SourceCatalog and PSF fitting ============================================== An ``progress_bar`` keyword option was added to `~photutils.segmentation.SourceCatalog` to enable progress bars when calculating some properties (e.g., ``kron_radius``, ``kron_flux``, ``fluxfrac_radius``, ``circular_photometry``, ``centroid_win``, ``centroid_quad``). An option to enable progress bars during PSF fitting was added. To enable it, set ``progress_bar=True`` when calling the PSF-fitting object on your data. The progress bar tracks progress over the star groups. The progress bars require installation of the `tqdm `_ optional dependency. New subshape keyword in PSF fitting =================================== A new ``subshape`` keyword was added to the PSF-fitting classes to define the shape over which the PSF is subtracted when computing the residual image. Previously, the PSF-subtraction region was always defined by the ``fitshape`` keyword. By default (and for backwards compatibility), ``subshape`` is set to `None`, which means the ``fitshape`` value will be used. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.7.rst0000644000175100001770000000402314637570305017233 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.7? **************************** Here we highlight some of the new functionality of the 1.7 release. In addition to these major changes, Photutils 1.7 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. New Profiles Subpackage ======================= A new :ref:`profile subpackage ` was added containing tools for computing radial profiles and curves of growth: * `~photutils.profiles.RadialProfile` * `~photutils.profiles.CurveOfGrowth` Converting ``SegmentationImage`` segments to polygons ===================================================== The ``SegmentationImage`` class now has a ``polygons`` attribute, which returns a list of `Shapely`_ polygons representing each source segment. It also now has a ``to_patches`` and a ``plot_patches`` method, which returns or plots, respectively, a list of `matplotlib.patches.Polygon` objects. These features require that both the `Rasterio`_ and `Shapely`_ optional dependencies are installed. ``ApertureStats`` local background ================================== The `~photutils.aperture.ApertureStats` ``local_bkg`` keyword can now be input as a scalar value, which will be broadcast for apertures with multiple positions. This can be useful to avoid loading large memory-mapped images into memory if the background level is constant. Performance Improvements ======================== A number of significant performance improvements have been made: * :func:`~photutils.aperture.aperture_photometry` and :meth:`~photutils.aperture.PixelAperture.do_photometry` * :meth:`~photutils.aperture.PixelAperture.area_overlap` * :class:`~photutils.psf.GriddedPSFModel` * :meth:`~photutils.segmentation.SegmentationImage.make_source_mask` Other changes ============= Please see the :ref:`changelog` for the complete list of changes. .. _Rasterio: https://rasterio.readthedocs.io/en/stable/ .. _Shapely: https://shapely.readthedocs.io/en/stable/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.8.rst0000644000175100001770000000204114637570305017232 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.8? **************************** Here we highlight some of the new functionality of the 1.8 release. In addition to these major changes, Photutils 1.8 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. API changes to ``RadialProfile`` and ``CurveOfGrowth`` ------------------------------------------------------ The API for defining the radial bins for the `~photutils.profiles.RadialProfile` and `~photutils.profiles.CurveOfGrowth` classes was changed. The new API provides more flexibility by allowing the user full control of the radial bins, including non-uniform radial spacing. Unfortunately, due to the nature of this change, it was not possible to have a deprecation phase for the inputs to these classes. Because the changes are not backwards-compatible, one will need to update how these classes are created. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/1.9.rst0000644000175100001770000000652014637570305017241 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.9? **************************** Here we highlight some of the new functionality of the 1.9 release. In addition to these major changes, Photutils 1.9 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Improved PSF Photometry classes ------------------------------- The `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes were added to perform PSF photometry. They represent a complete rewrite of the previous ``~photutils.psf.BasicPSFPhotometry`` and ``~photutils.psf.IterativelySubtractedPSFPhotometry`` classes, but have a similar API and functionality. The new classes are more flexible and significantly faster than the previous classes. The new classes also allow the input of error arrays, which will be used as weights in the fitting. When using astropy 5.3+, these errors will also be propagated to the model fit parameters. Some other features of the new classes include: * The source grouper is optional * The output table is always in source ID order (not group ID order) * Added two quality-of-fit metrics to the output table * Added more information (columns and metadata) in the output table, including a flags column * Fit warnings are not emitted for each source. A single warning is emitted at the end of fitting. * Fixes issues with source masking * The initial parameters table is more flexible for the x, y, and flux column names * Supports `~astropy.nddata.NDData` objects * Supports units * Allows access to the fitter details (e.g., fit info, parameter covariances) * Allows access to the finder results * Adds a local background subtraction option The old PSF photometry classes (``~photutils.psf.BasicPSFPhotometry``, ``~photutils.psf.IterativelySubtractedPSFPhotometry``, and ``~photutils.psf.DAOPhotPSFPhotometry``) are still available in this release, but are deprecated. They will be removed in a future release. New ``SourceGrouper`` class --------------------------- The `~photutils.psf.SourceGrouper` class was added to group sources using hierarchical agglomerative clustering with a distance criterion. This class is used by the new `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes. The old source grouping classes (``~photutils.psf.DAOGroup`` and ``~photutils.psf.DBSCANGroup``) are still available in this release, but are deprecated. They will be removed in a future release. New ``LocalBackground`` class ----------------------------- The `~photutils.background.LocalBackground` class was added to compute a local background using a circular annulus aperture. Reading and plotting Gridded PSF Models --------------------------------------- A read method was added to the `~photutils.psf.GriddedPSFModel` class to read STDPSF FITS files containing grids of ePSF models. The `~photutils.psf.GriddedPSFModel` class also has a new ``plot_grid`` method to plot the ePSF models. Similarly, the `~photutils.psf.STDPSFGrid` class was added to read STDPSF FITS files. This class can read and plot multi-detector ePSF grids. Note that it is merely a container for STDPDF files. It cannot be used as a PSF model in the photometry classes. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/docs/whats_new/index.rst0000644000175100001770000000104014637570305020031 0ustar00runnerdocker********** What's New ********** .. toctree:: :maxdepth: 1 1.13.rst Past Releases ------------- Examples in these documents are frozen in time to respect the status of the API at the time of the release they are describing. Please refer to the main, up-to-date documentation if you run into any issues with the functionality highlighted in these pages. .. toctree:: :maxdepth: 1 1.12.rst 1.11.rst 1.10.rst 1.9.rst 1.8.rst 1.7.rst 1.6.rst 1.5.rst 1.4.rst 1.3.rst 1.2.rst 1.1.rst ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.010885 photutils-1.13.0/photutils/0000755000175100001770000000000014637570322015300 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/CITATION.rst0000644000175100001770000000451514637570305017252 0ustar00runnerdockerCiting Photutils ---------------- If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment: .. code-block:: text This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. BibTex files for all Photutils versions can be found at https://doi.org/10.5281/zenodo.596036. For example, for Photutils v1.12.0, one should cite Bradley et al. 2024 with the BibTeX entry (https://zenodo.org/records/10967176/export/bibtex): .. code-block:: text @software{larry_bradley_2024_10967176, author = {Larry Bradley and Brigitta Sip{\H o}cz and Thomas Robitaille and Erik Tollerud and Z\`e Vin{\'{\i}}cius and Christoph Deil and Kyle Barbary and Tom J Wilson and Ivo Busko and Axel Donath and Hans Moritz G{\"u}nther and Mihai Cara and P. L. Lim and Sebastian Me{\ss}linger and Zach Burnett and Simon Conseil and Michael Droettboom and Azalee Bostroem and E. M. Bray and Lars Andersen Bratholm and William Jamieson and Adam Ginsburg and Geert Barentsen and Matt Craig and Sergio Pascual and Shivangee Rathi and Marshall Perrin and Brett M. Morris and Gabriel Perren}, title = {astropy/photutils: 1.12.0}, month = apr, year = 2024, publisher = {Zenodo}, version = {1.12.0}, doi = {10.5281/zenodo.10967176}, url = {https://doi.org/10.5281/zenodo.10967176} } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/__init__.py0000644000175100001770000000732214637570305017416 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Photutils is an Astropy affiliated package to provide tools for detecting and performing photometry of astronomical sources. It also has tools for background estimation, ePSF building, PSF matching, radial profiles, centroiding, and morphological measurements. """ import warnings from . import aperture from . import background from . import detection from . import psf from . import segmentation try: from .version import version as __version__ except ImportError: __version__ = '' # deprecations __depr__ = {} __depr__[aperture] = ('BoundingBox', 'CircularMaskMixin', 'CircularAperture', 'CircularAnnulus', 'SkyCircularAperture', 'SkyCircularAnnulus', 'Aperture', 'SkyAperture', 'PixelAperture', 'EllipticalMaskMixin', 'EllipticalAperture', 'EllipticalAnnulus', 'SkyEllipticalAperture', 'SkyEllipticalAnnulus', 'ApertureMask', 'aperture_photometry', 'RectangularMaskMixin', 'RectangularAperture', 'RectangularAnnulus', 'SkyRectangularAperture', 'SkyRectangularAnnulus', 'ApertureStats') __depr__[background] = ('Background2D', 'BackgroundBase', 'BackgroundRMSBase', 'MeanBackground', 'MedianBackground', 'ModeEstimatorBackground', 'MMMBackground', 'SExtractorBackground', 'BiweightLocationBackground', 'StdBackgroundRMS', 'MADStdBackgroundRMS', 'BiweightScaleBackgroundRMS', 'BkgZoomInterpolator', 'BkgIDWInterpolator') __depr__[detection] = ('StarFinderBase', 'DAOStarFinder', 'IRAFStarFinder', 'find_peaks', 'StarFinder') __depr__[psf] = ('EPSFFitter', 'EPSFBuilder', 'EPSFStar', 'EPSFStars', 'LinkedEPSFStar', 'extract_stars', 'FittableImageModel', 'EPSFModel', 'GriddedPSFModel', 'IntegratedGaussianPRF', 'PRFAdapter', 'resize_psf', 'create_matching_kernel', 'SplitCosineBellWindow', 'HanningWindow', 'TukeyWindow', 'CosineBellWindow', 'TopHatWindow') __depr__[segmentation] = ('SourceCatalog', 'SegmentationImage', 'Segment', 'deblend_sources', 'detect_threshold', 'detect_sources', 'SourceFinder', 'make_2dgaussian_kernel') __depr_mesg__ = ('`photutils.{attr}` is a deprecated alias for ' '`{module}.{attr}` and will be removed in the future. ' 'Instead, please use `from {module} import {attr}` to ' 'silence this warning.') __depr_attrs__ = {} for k, vals in __depr__.items(): for val in vals: __depr_attrs__[val] = (getattr(k, val), __depr_mesg__.format(module=k.__name__, attr=val)) del k, val, vals def __getattr__(attr): if attr in __depr_attrs__: obj, message = __depr_attrs__[attr] warnings.warn(message, DeprecationWarning, stacklevel=2) return obj raise AttributeError(f'module {__name__!r} has no attribute {attr!r}') # Set the bibtex entry to the article referenced in CITATION.rst. def _get_bibtex(): import os citation_file = os.path.join(os.path.dirname(__file__), 'CITATION.rst') with open(citation_file) as citation: refs = citation.read().split('@software')[1:] if len(refs) == 0: return '' bibtexreference = f'@software{refs[0]}' return bibtexreference __citation__ = __bibtex__ = _get_bibtex() del _get_bibtex ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils/_compiler.c0000644000175100001770000000524014637570321017415 0ustar00runnerdocker#include /*************************************************************************** * Macros for determining the compiler version. * * These are borrowed from boost, and majorly abridged to include only * the compilers we care about. ***************************************************************************/ #define STRINGIZE(X) DO_STRINGIZE(X) #define DO_STRINGIZE(X) #X #if defined __clang__ /* Clang C++ emulates GCC, so it has to appear early. */ # define COMPILER "Clang version " __clang_version__ #elif defined(__INTEL_COMPILER) || defined(__ICL) || defined(__ICC) || defined(__ECC) /* Intel */ # if defined(__INTEL_COMPILER) # define INTEL_VERSION __INTEL_COMPILER # elif defined(__ICL) # define INTEL_VERSION __ICL # elif defined(__ICC) # define INTEL_VERSION __ICC # elif defined(__ECC) # define INTEL_VERSION __ECC # endif # define COMPILER "Intel C compiler version " STRINGIZE(INTEL_VERSION) #elif defined(__GNUC__) /* gcc */ # define COMPILER "GCC version " __VERSION__ #elif defined(__SUNPRO_CC) /* Sun Workshop Compiler */ # define COMPILER "Sun compiler version " STRINGIZE(__SUNPRO_CC) #elif defined(_MSC_VER) /* Microsoft Visual C/C++ Must be last since other compilers define _MSC_VER for compatibility as well */ # if _MSC_VER < 1200 # define COMPILER_VERSION 5.0 # elif _MSC_VER < 1300 # define COMPILER_VERSION 6.0 # elif _MSC_VER == 1300 # define COMPILER_VERSION 7.0 # elif _MSC_VER == 1310 # define COMPILER_VERSION 7.1 # elif _MSC_VER == 1400 # define COMPILER_VERSION 8.0 # elif _MSC_VER == 1500 # define COMPILER_VERSION 9.0 # elif _MSC_VER == 1600 # define COMPILER_VERSION 10.0 # else # define COMPILER_VERSION _MSC_VER # endif # define COMPILER "Microsoft Visual C++ version " STRINGIZE(COMPILER_VERSION) #else /* Fallback */ # define COMPILER "Unknown compiler" #endif /*************************************************************************** * Module-level ***************************************************************************/ struct module_state { /* The Sun compiler can't handle empty structs */ #if defined(__SUNPRO_C) || defined(_MSC_VER) int _dummy; #endif }; static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "compiler_version", NULL, sizeof(struct module_state), NULL, NULL, NULL, NULL, NULL }; #define INITERROR return NULL PyMODINIT_FUNC PyInit_compiler_version(void) { PyObject* m; m = PyModule_Create(&moduledef); if (m == NULL) INITERROR; PyModule_AddStringConstant(m, "compiler", COMPILER); return m; } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0148852 photutils-1.13.0/photutils/aperture/0000755000175100001770000000000014637570322017127 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/__init__.py0000644000175100001770000000074114637570305021243 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to perform aperture photometry. """ from .bounding_box import * # noqa: F401, F403 from .circle import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .ellipse import * # noqa: F401, F403 from .mask import * # noqa: F401, F403 from .photometry import * # noqa: F401, F403 from .rectangle import * # noqa: F401, F403 from .stats import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/attributes.py0000644000175100001770000001443214637570305021674 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines descriptor classes for aperture attribute validation. """ import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord __all__ = ['ApertureAttribute', 'PixelPositions', 'SkyCoordPositions', 'PositiveScalar', 'ScalarAngle', 'ScalarAngleOrValue'] class ApertureAttribute: """ Base descriptor class for aperture attribute validation. Parameters ---------- doc : str, optional The description string for the attribute. """ def __init__(self, doc=''): self.__doc__ = doc self.name = '' def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self # pragma: no cover return instance.__dict__[self.name] def __set__(self, instance, value): self._validate(value) if not isinstance(value, (u.Quantity, SkyCoord)): value = float(value) # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) instance.__dict__[self.name] = value def _reset_lazyproperties(self, instance): # reset lazyproperties (if they exist) for aperture # parameter changes try: for key in instance._lazyproperties: instance.__dict__.pop(key, None) except AttributeError: pass def __delete__(self, instance): del instance.__dict__[self.name] # pragma: no cover def _validate(self, value): """ Validate the attribute value. An exception is raised if the value is invalid. """ raise NotImplementedError # pragma: no cover class PixelPositions(ApertureAttribute): """ Validate and set positions for pixel-based apertures. Pixel positions are converted to a 2D `~numpy.ndarray`. """ def __set__(self, instance, value): # this is needed for zip (e.g., positions = zip(xpos, ypos)) if isinstance(value, zip): value = tuple(value) value = self._validate(value) # np.ndarray # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) instance.__dict__[self.name] = value def _validate(self, value): try: value = np.asanyarray(value).astype(float) # np.ndarray except TypeError: # value is a zip object containing Quantity objects raise TypeError(f'{self.name!r} must not be a Quantity') if isinstance(value, u.Quantity): raise TypeError(f'{self.name!r} must not be a Quantity') if np.any(~np.isfinite(value)): raise ValueError(f'{self.name!r} must not contain any non-finite ' '(e.g., NaN or inf) positions') value_2d = np.atleast_2d(value) if value_2d.ndim > 2 or value_2d.shape[1] != 2: raise ValueError(f'{self.name!r} must be a (x, y) pixel position ' 'or a list or array of (x, y) pixel positions, ' 'e.g., [(x1, y1), (x2, y2), (x3, y3)]') return value class SkyCoordPositions(ApertureAttribute): """ Check that value is a `~astropy.coordinates.SkyCoord`. """ def _validate(self, value): if not isinstance(value, SkyCoord): raise ValueError(f'{self.name!r} must be a SkyCoord instance') class PositiveScalar(ApertureAttribute): """ Check that value is a strictly positive (> 0) scalar. """ def _validate(self, value): if not np.isscalar(value) or value <= 0: raise ValueError(f'{self.name!r} must be a positive scalar') class ScalarAngle(ApertureAttribute): """ Check that value is a scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units. """ def _validate(self, value): if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') else: raise ValueError(f'{self.name!r} must be a scalar angle') class PositiveScalarAngle(ApertureAttribute): """ Check that value is a positive scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units. """ def _validate(self, value): if value <= 0: raise ValueError(f'{self.name!r} must be greater than zero') if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') else: raise ValueError(f'{self.name!r} must be a scalar angle') class ScalarAngleOrValue(ApertureAttribute): """ Check that value is a scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units, or a scalar float. """ def __set__(self, instance, value): self._validate(value) # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) instance.__dict__[self.name] = value # also store the angle in radians as a float if isinstance(value, u.Quantity): value = value.to(u.radian).value name = f'_{self.name}_radians' instance.__dict__[name] = value def _validate(self, value): if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') else: if not np.isscalar(value): raise ValueError(f'If not an angle Quantity, {self.name!r} ' 'must be a scalar float in radians') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/bounding_box.py0000644000175100001770000002705514637570305022170 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines a class for a rectangular bounding box. """ import math import numpy as np __all__ = ['BoundingBox'] class BoundingBox: """ A rectangular bounding box in integer (not float) pixel indices. Parameters ---------- ixmin, ixmax, iymin, iymax : int The bounding box pixel indices. Note that the upper values (``iymax`` and ``ixmax``) are exclusive as for normal slices in Python. The lower values (``ixmin`` and ``iymin``) must not be greater than the respective upper values (``ixmax`` and ``iymax``). Examples -------- When constructing a BoundingBox, it's better to use keyword arguments for readability: >>> from photutils.aperture import BoundingBox >>> bbox = BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) >>> bbox BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) Sometimes it's useful to check if two bounding boxes are the same: >>> bbox == BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) True >>> bbox == BoundingBox(ixmin=7, ixmax=10, iymin=2, iymax=20) False The "center" and "shape" attributes can be useful when working with numpy arrays: >>> bbox.center # numpy order: (y, x) (10.5, 5.0) >>> bbox.shape # numpy order: (y, x) (18, 9) The "extent" is useful when plotting the BoundingBox with matplotlib: >>> bbox.extent # matplotlib order: (xmin, xmax, ymin, ymax) (0.5, 9.5, 1.5, 19.5) """ def __init__(self, ixmin, ixmax, iymin, iymax): for value in (ixmin, ixmax, iymin, iymax): if not isinstance(value, (int, np.integer)): raise TypeError('ixmin, ixmax, iymin, and iymax must all be ' 'integers') if ixmin > ixmax: raise ValueError('ixmin must be <= ixmax') if iymin > iymax: raise ValueError('iymin must be <= iymax') self.ixmin = ixmin self.ixmax = ixmax self.iymin = iymin self.iymax = iymax @classmethod def from_float(cls, xmin, xmax, ymin, ymax): """ Return the smallest bounding box that fully contains a given rectangle defined by float coordinate values. Following the pixel index convention, an integer index corresponds to the center of a pixel and the pixel edges span from (index - 0.5) to (index + 0.5). For example, the pixel edge spans of the following pixels are: - pixel 0: from -0.5 to 0.5 - pixel 1: from 0.5 to 1.5 - pixel 2: from 1.5 to 2.5 In addition, because `BoundingBox` upper limits are exclusive (by definition), 1 is added to the upper pixel edges. See examples below. Parameters ---------- xmin, xmax, ymin, ymax : float Float coordinates defining a rectangle. The lower values (``xmin`` and ``ymin``) must not be greater than the respective upper values (``xmax`` and ``ymax``). Returns ------- bbox : `BoundingBox` object The minimal ``BoundingBox`` object fully containing the input rectangle coordinates. Examples -------- >>> from photutils.aperture import BoundingBox >>> BoundingBox.from_float(xmin=1.0, xmax=10.0, ymin=2.0, ymax=20.0) BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=21) >>> BoundingBox.from_float(xmin=1.4, xmax=10.4, ymin=1.6, ymax=10.6) BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=12) """ ixmin = math.floor(xmin + 0.5) ixmax = math.ceil(xmax + 0.5) iymin = math.floor(ymin + 0.5) iymax = math.ceil(ymax + 0.5) return cls(ixmin, ixmax, iymin, iymax) def __eq__(self, other): if not isinstance(other, BoundingBox): raise TypeError('Can compare BoundingBox only to another ' 'BoundingBox.') return ((self.ixmin == other.ixmin) and (self.ixmax == other.ixmax) and (self.iymin == other.iymin) and (self.iymax == other.iymax)) def __or__(self, other): return self.union(other) def __and__(self, other): return self.intersection(other) def __repr__(self): return (f'{self.__class__.__name__}(ixmin={self.ixmin}, ' f'ixmax={self.ixmax}, iymin={self.iymin}, ' f'iymax={self.iymax})') @property def center(self): """ The ``(y, x)`` center of the bounding box. """ return (0.5 * (self.iymax - 1 + self.iymin), 0.5 * (self.ixmax - 1 + self.ixmin)) @property def shape(self): """ The ``(ny, nx)`` shape of the bounding box. """ return self.iymax - self.iymin, self.ixmax - self.ixmin def get_overlap_slices(self, shape): """ Get slices for the overlapping part of the bounding box and an 2D array. Parameters ---------- shape : 2-tuple of int The shape of the 2D array. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. slices_small : tuple of slices or `None` A tuple of slice objects for each axis of an array enclosed by the bounding box such that ``small_array[slices_small]`` extracts the region that is inside the large array. `None` is returned if there is no overlap of the bounding box with the given image shape. """ if len(shape) != 2: raise ValueError('input shape must have 2 elements.') xmin = self.ixmin xmax = self.ixmax ymin = self.iymin ymax = self.iymax if xmin >= shape[1] or ymin >= shape[0] or xmax <= 0 or ymax <= 0: # no overlap of the bounding box with the input shape return None, None slices_large = (slice(max(ymin, 0), min(ymax, shape[0])), slice(max(xmin, 0), min(xmax, shape[1]))) slices_small = (slice(max(-ymin, 0), min(ymax - ymin, shape[0] - ymin)), slice(max(-xmin, 0), min(xmax - xmin, shape[1] - xmin))) return slices_large, slices_small @property def extent(self): """ The extent of the mask, defined as the ``(xmin, xmax, ymin, ymax)`` bounding box from the bottom-left corner of the lower-left pixel to the upper-right corner of the upper-right pixel. The upper edges here are the actual pixel positions of the edges, i.e., they are not "exclusive" indices used for python indexing. This is useful for plotting the bounding box using Matplotlib. """ return (self.ixmin - 0.5, self.ixmax - 0.5, self.iymin - 0.5, self.iymax - 0.5) def as_artist(self, **kwargs): """ Return a `matplotlib.patches.Rectangle` that represents the bounding box. Parameters ---------- **kwargs : dict Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- result : `matplotlib.patches.Rectangle` A matplotlib rectangular patch. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.aperture import BoundingBox bbox = BoundingBox(2, 7, 3, 8) fig = plt.figure() ax = fig.add_subplot(1, 1, 1) rng = np.random.default_rng(0) ax.imshow(rng.random((10, 10)), interpolation='nearest', cmap='viridis') ax.add_patch(bbox.as_artist(facecolor='none', edgecolor='white', lw=2.)) """ from matplotlib.patches import Rectangle return Rectangle(xy=(self.extent[0], self.extent[2]), width=self.shape[1], height=self.shape[0], **kwargs) def to_aperture(self): """ Return a `~photutils.aperture.RectangularAperture` that represents the bounding box. """ # prevent circular import from photutils.aperture.rectangle import RectangularAperture xypos = self.center[::-1] # xy order height, width = self.shape return RectangularAperture(xypos, w=width, h=height, theta=0.0) def plot(self, ax=None, origin=(0, 0), **kwargs): """ Plot the `BoundingBox` on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `matplotlib.patches.Patch` The matplotlib patch object for the plotted bounding box. The patch can be used, for example, when adding a plot legend. """ aper = self.to_aperture() return aper.plot(ax=ax, origin=origin, **kwargs)[0] def union(self, other): """ Return a `BoundingBox` representing the union of this `BoundingBox` with another `BoundingBox`. Parameters ---------- other : `BoundingBox` The `BoundingBox` to join with this one. Returns ------- result : `BoundingBox` A `BoundingBox` representing the union of the input `BoundingBox` with this one. """ if not isinstance(other, BoundingBox): raise TypeError('BoundingBox can be joined only with another ' 'BoundingBox.') ixmin = min((self.ixmin, other.ixmin)) ixmax = max((self.ixmax, other.ixmax)) iymin = min((self.iymin, other.iymin)) iymax = max((self.iymax, other.iymax)) return BoundingBox(ixmin=ixmin, ixmax=ixmax, iymin=iymin, iymax=iymax) def intersection(self, other): """ Return a `BoundingBox` representing the intersection of this `BoundingBox` with another `BoundingBox`. Parameters ---------- other : `BoundingBox` The `BoundingBox` to intersect with this one. Returns ------- result : `BoundingBox` A `BoundingBox` representing the intersection of the input `BoundingBox` with this one. """ if not isinstance(other, BoundingBox): raise TypeError('BoundingBox can be intersected only with ' 'another BoundingBox.') ixmin = max(self.ixmin, other.ixmin) ixmax = min(self.ixmax, other.ixmax) iymin = max(self.iymin, other.iymin) iymax = min(self.iymax, other.iymax) if ixmax < ixmin or iymax < iymin: return None return BoundingBox(ixmin=ixmin, ixmax=ixmax, iymin=iymin, iymax=iymax) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/circle.py0000644000175100001770000003656214637570305020757 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines circular and circular-annulus apertures in both pixel and sky coordinates. """ import math from astropy.utils import lazyproperty from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import circular_overlap_grid __all__ = ['CircularMaskMixin', 'CircularAperture', 'CircularAnnulus', 'SkyCircularAperture', 'SkyCircularAnnulus'] class CircularMaskMixin: """ Mixin class to create masks for circular and circular-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ use_exact, subpixels = self._translate_mask_mode(method, subpixels) if hasattr(self, 'r'): radius = self.r elif hasattr(self, 'r_out'): # annulus radius = self.r_out else: raise ValueError('Cannot determine the aperture radius.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges): ny, nx = bbox.shape mask = circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, radius, use_exact, subpixels) # subtract the inner circle for an annulus if hasattr(self, 'r_in'): mask -= circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r_in, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] else: return masks class CircularAperture(CircularMaskMixin, PixelAperture): """ A circular aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs r : float The radius of the circle in pixels. Raises ------ ValueError : `ValueError` If the input radius, ``r``, is negative. Examples -------- >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture([10.0, 20.0], 3.0) >>> aper = CircularAperture((10.0, 20.0), 3.0) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = CircularAperture([pos1, pos2, pos3], 3.0) >>> aper = CircularAperture((pos1, pos2, pos3), 3.0) """ _params = ('positions', 'r') positions = PixelPositions('The center pixel position(s).') r = PositiveScalar('The radius in pixels.') def __init__(self, positions, r): self.positions = positions self.r = r @lazyproperty def _xy_extents(self): return self.r, self.r @lazyproperty def area(self): return math.pi * self.r**2 def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [] for xy_position in xy_positions: patches.append(mpatches.Circle(xy_position, self.r, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return CircularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyCircularAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyCircularAperture` object A `SkyCircularAperture` object. """ return SkyCircularAperture(**self._to_sky_params(wcs)) class CircularAnnulus(CircularMaskMixin, PixelAperture): """ A circular annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs r_in : float The inner radius of the circular annulus in pixels. r_out : float The outer radius of the circular annulus in pixels. Raises ------ ValueError : `ValueError` If inner radius (``r_in``) is greater than outer radius (``r_out``). ValueError : `ValueError` If inner radius (``r_in``) is negative. Examples -------- >>> from photutils.aperture import CircularAnnulus >>> aper = CircularAnnulus([10.0, 20.0], 3.0, 5.0) >>> aper = CircularAnnulus((10.0, 20.0), 3.0, 5.0) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = CircularAnnulus([pos1, pos2, pos3], 3.0, 5.0) >>> aper = CircularAnnulus((pos1, pos2, pos3), 3.0, 5.0) """ _params = ('positions', 'r_in', 'r_out') positions = PixelPositions('The center pixel position(s).') r_in = PositiveScalar('The inner radius in pixels.') r_out = PositiveScalar('The outer radius in pixels.') def __init__(self, positions, r_in, r_out): if not r_out > r_in: raise ValueError('r_out must be greater than r_in') self.positions = positions self.r_in = r_in self.r_out = r_out @lazyproperty def _xy_extents(self): return self.r_out, self.r_out @lazyproperty def area(self): return math.pi * (self.r_out**2 - self.r_in**2) def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [] for xy_position in xy_positions: patch_inner = mpatches.Circle(xy_position, self.r_in) patch_outer = mpatches.Circle(xy_position, self.r_out) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return CircularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyCircularAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyCircularAnnulus` object A `SkyCircularAnnulus` object. """ return SkyCircularAnnulus(**self._to_sky_params(wcs)) class SkyCircularAperture(SkyAperture): """ A circular aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. r : scalar `~astropy.units.Quantity` The radius of the circle in angular units. Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyCircularAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyCircularAperture(positions, 0.5*u.arcsec) """ _params = ('positions', 'r',) positions = SkyCoordPositions('The center position(s) in sky coordinates.') r = PositiveScalarAngle('The radius in angular units.') def __init__(self, positions, r): self.positions = positions self.r = r def to_pixel(self, wcs): """ Convert the aperture to a `CircularAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `CircularAperture` object A `CircularAperture` object. """ return CircularAperture(**self._to_pixel_params(wcs)) class SkyCircularAnnulus(SkyAperture): """ A circular annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. r_in : scalar `~astropy.units.Quantity` The inner radius of the circular annulus in angular units. r_out : scalar `~astropy.units.Quantity` The outer radius of the circular annulus in angular units. Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyCircularAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyCircularAnnulus(positions, 0.5*u.arcsec, 1.0*u.arcsec) """ _params = ('positions', 'r_in', 'r_out') positions = SkyCoordPositions('The center position(s) in sky coordinates.') r_in = PositiveScalarAngle('The inner radius in angular units.') r_out = PositiveScalarAngle('The outer radius in angular units.') def __init__(self, positions, r_in, r_out): if r_in.unit.physical_type != r_out.unit.physical_type: raise ValueError('r_in and r_out should either both be angles ' 'or in pixels.') self.positions = positions self.r_in = r_in self.r_out = r_out def to_pixel(self, wcs): """ Convert the aperture to a `CircularAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `CircularAnnulus` object A `CircularAnnulus` object. """ return CircularAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/core.py0000644000175100001770000007366014637570305020446 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines the base aperture classes. """ import abc import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.utils import lazyproperty from photutils.aperture.bounding_box import BoundingBox from photutils.utils._wcs_helpers import _pixel_scale_angle_at_skycoord __all__ = ['Aperture', 'SkyAperture', 'PixelAperture'] class Aperture(metaclass=abc.ABCMeta): """ Abstract base class for all apertures. """ _params = () def __len__(self): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'has no len()') return self.shape[0] def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') kwargs = {} for param in self._params: if param == 'positions': # slice the positions array kwargs[param] = getattr(self, param)[index] else: kwargs[param] = getattr(self, param) return self.__class__(**kwargs) def __iter__(self): for i in range(len(self)): yield self.__getitem__(i) def _positions_str(self, prefix=None): if isinstance(self, PixelAperture): return np.array2string(self.positions, separator=', ', prefix=prefix) elif isinstance(self, SkyAperture): return repr(self.positions) else: raise TypeError('Aperture must be a subclass of PixelAperture ' 'or SkyAperture') def __repr__(self): prefix = f'{self.__class__.__name__}' cls_info = [] for param in self._params: if param == 'positions': cls_info.append(self._positions_str(prefix)) else: cls_info.append(f'{param}={getattr(self, param)}') cls_info = ', '.join(cls_info) return f'<{prefix}({cls_info})>' def __str__(self): cls_info = [('Aperture', self.__class__.__name__)] for param in self._params: if param == 'positions': prefix = 'positions' cls_info.append((prefix, self._positions_str(prefix + ': '))) else: cls_info.append((param, getattr(self, param))) fmt = [f'{key}: {val}' for key, val in cls_info] return '\n'.join(fmt) def __eq__(self, other): """ Equality operator for `Aperture`. All Aperture properties are compared for strict equality except for Quantity parameters, which allow for different units if they are directly convertible. """ if not isinstance(other, self.__class__): return False self_params = list(self._params) other_params = list(other._params) # check that both have identical parameters if self_params != other_params: return False # now check the parameter values # Note that Quantity comparisons allow for different units if they # are directly convertible (e.g., 1.0 * u.deg == 60.0 * u.arcmin) try: for param in self_params: # np.any is used for SkyCoord array comparisons if np.any(getattr(self, param) != getattr(other, param)): return False except TypeError: # TypeError is raised from SkyCoord comparison when they do # not have equivalent frames. Here return False instead of # the TypeError. return False return True def __ne__(self, other): """ Inequality operator for `Aperture`. """ return not self == other @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def copy(self): """ Make an independent (deep) copy. """ params_copy = {} for param in list(self._params): params_copy[param] = deepcopy(getattr(self, param)) return self.__class__(**params_copy) @abc.abstractmethod def positions(self): raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def shape(self): """ The shape of the instance. """ if isinstance(self.positions, SkyCoord): return self.positions.shape else: return self.positions.shape[:-1] @lazyproperty def isscalar(self): """ Whether the instance is scalar (i.e., a single position). """ return self.shape == () class PixelAperture(Aperture): """ Abstract base class for apertures defined in pixel coordinates. """ @lazyproperty def _default_patch_properties(self): """ A dictionary of default matplotlib.patches.Patch properties. """ mpl_params = {} # matplotlib.patches.Patch default is ``fill=True`` mpl_params['fill'] = False return mpl_params @staticmethod def _translate_mask_mode(mode, subpixels, rectangle=False): if mode not in ('center', 'subpixel', 'exact'): raise ValueError(f'Invalid mask mode: {mode}') if rectangle and mode == 'exact': mode = 'subpixel' subpixels = 32 if mode == 'subpixels': if not isinstance(subpixels, int) or subpixels <= 0: raise ValueError('subpixels must be a strictly positive ' 'integer') if mode == 'center': use_exact = 0 subpixels = 1 elif mode == 'subpixel': use_exact = 0 elif mode == 'exact': use_exact = 1 subpixels = 1 return use_exact, subpixels @abc.abstractmethod def _xy_extents(self): """ The (x, y) extents of the aperture measured from the center position. In other words, the (x, y) extents are half of the aperture minimal bounding box size in each dimension. """ raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def _positions(self): """ The aperture positions, always as a 2D ndarray. """ return np.atleast_2d(self.positions) @lazyproperty def _bbox(self): """ The minimal bounding box for the aperture, always as a list of `~photutils.aperture.BoundingBox` instances. """ x_delta, y_delta = self._xy_extents xmin = self._positions[:, 0] - x_delta xmax = self._positions[:, 0] + x_delta ymin = self._positions[:, 1] - y_delta ymax = self._positions[:, 1] + y_delta return [BoundingBox.from_float(x0, x1, y0, y1) for x0, x1, y0, y1 in zip(xmin, xmax, ymin, ymax)] @lazyproperty def bbox(self): """ The minimal bounding box for the aperture. If the aperture is scalar then a single `~photutils.aperture.BoundingBox` is returned, otherwise a list of `~photutils.aperture.BoundingBox` is returned. """ if self.isscalar: return self._bbox[0] else: return self._bbox @lazyproperty def _centered_edges(self): """ A list of ``(xmin, xmax, ymin, ymax)`` tuples, one for each position, of the pixel edges after recentering the aperture at the origin. These pixel edges are used by the low-level `photutils.geometry` functions. """ edges = [] for position, bbox in zip(self._positions, self._bbox): xmin = bbox.ixmin - 0.5 - position[0] xmax = bbox.ixmax - 0.5 - position[0] ymin = bbox.iymin - 0.5 - position[1] ymax = bbox.iymax - 0.5 - position[1] edges.append((xmin, xmax, ymin, ymax)) return edges @abc.abstractmethod def area(self): """ The exact analytical area of the aperture shape. Use the `area_overlap` method to return the area of overlap between the data and the aperture, taking into account the aperture mask method, masked data pixels (``mask`` keyword), and partial/no overlap of the aperture with the data. Returns ------- area : float The aperture area. See Also -------- area_overlap """ raise NotImplementedError('Needs to be implemented in a subclass.') def area_overlap(self, data, *, mask=None, method='exact', subpixels=5): """ Return the area of overlap between the data and the aperture. This method takes into account the aperture mask method, masked data pixels (``mask`` keyword), and partial/no overlap of the aperture with the data. In other words, it returns the area that used to compute the aperture sum (assuming identical inputs). Use the `area` method to calculate the exact analytical area of the aperture shape. Parameters ---------- data : array_like or `~astropy.units.Quantity` A 2D array. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from the area overlap. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- areas : float or array_like The area (in pixels**2) of overlap between the data and the aperture. See Also -------- area """ apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) if mask is not None: mask = np.asarray(mask) if mask.shape != data.shape: raise ValueError('mask and data must have the same shape') areas = [] for apermask in apermasks: slc_large, slc_small = apermask.get_overlap_slices(data.shape) # if the aperture does not overlap the data return np.nan if slc_large is None: area = np.nan else: aper_weights = apermask.data[slc_small] if mask is not None: aper_weights[mask[slc_large]] = 0.0 area = np.sum(aper_weights) areas.append(area) areas = np.array(areas) if self.isscalar: return areas[0] else: return areas @abc.abstractmethod def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') def do_photometry(self, data, error=None, mask=None, method='exact', subpixels=5): """ Perform aperture photometry on the input data. Parameters ---------- data : array_like or `~astropy.units.Quantity` instance The 2D array on which to perform photometry. ``data`` should be background subtracted. error : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- aperture_sums : `~numpy.ndarray` or `~astropy.units.Quantity` The sums within each aperture. aperture_sum_errs : `~numpy.ndarray` or `~astropy.units.Quantity` The errors on the sums within each aperture. Notes ----- `RectangularAperture` and `RectangularAnnulus` photometry with the "exact" method uses a subpixel approximation by subdividing each data pixel by a factor of 1024 (``subpixels = 32``). For rectangular aperture widths and heights in the range from 2 to 100 pixels, this subpixel approximation gives results typically within 0.001 percent or better of the exact value. The differences can be larger for smaller apertures (e.g., aperture sizes of one pixel or smaller). For such small sizes, it is recommend to set ``method='subpixel'`` with a larger ``subpixels`` size. """ data = np.asanyarray(data) if data.ndim != 2: raise ValueError('data must be a 2D array.') if error is not None: error = np.asanyarray(error) if error.shape != data.shape: raise ValueError('error and data must have the same shape.') # check Quantity inputs unit = {getattr(arr, 'unit', None) for arr in (data, error) if arr is not None} if len(unit) > 1: raise ValueError('If data or error has units, then they both must ' 'have the same units.') # strip data and error units for performance unit = unit.pop() if unit is not None: unit = data.unit data = data.value if error is not None: error = error.value apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) aperture_sums = [] aperture_sum_errs = [] for apermask in apermasks: (slc_large, aper_weights, pixel_mask) = apermask._get_overlap_cutouts(data.shape, mask=mask) # no overlap of the aperture with the data if slc_large is None: aperture_sums.append(np.nan) aperture_sum_errs.append(np.nan) continue with warnings.catch_warnings(): # ignore multiplication with non-finite data values warnings.simplefilter('ignore', RuntimeWarning) values = (data[slc_large] * aper_weights)[pixel_mask] aperture_sums.append(values.sum()) if error is not None: variance = (error[slc_large]**2 * aper_weights)[pixel_mask] aperture_sum_errs.append(np.sqrt(variance.sum())) aperture_sums = np.array(aperture_sums) aperture_sum_errs = np.array(aperture_sum_errs) # apply units if unit is not None: aperture_sums <<= unit aperture_sum_errs <<= unit return aperture_sums, aperture_sum_errs @staticmethod def _make_annulus_path(patch_inner, patch_outer): """ Define a matplotlib annulus path from two patches. This preserves the cubic Bezier curves (CURVE4) of the aperture paths. """ import matplotlib.path as mpath path_inner = patch_inner.get_path() transform_inner = patch_inner.get_transform() path_inner = transform_inner.transform_path(path_inner) path_outer = patch_outer.get_path() transform_outer = patch_outer.get_transform() path_outer = transform_outer.transform_path(path_outer) verts_inner = path_inner.vertices[:-1][::-1] verts_inner = np.concatenate((verts_inner, [verts_inner[-1]])) verts = np.vstack((path_outer.vertices, verts_inner)) codes = np.hstack((path_outer.codes, path_inner.codes)) return mpath.Path(verts, codes) def _define_patch_params(self, origin=(0, 0), **kwargs): """ Define the aperture patch position and set any default matplotlib patch keywords (e.g., ``fill=False``). Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- xy_positions : `~numpy.ndarray` The aperture patch positions. patch_params : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. """ xy_positions = deepcopy(self._positions) xy_positions[:, 0] -= origin[0] xy_positions[:, 1] -= origin[1] patch_params = self._default_patch_properties.copy() patch_params.update(kwargs) return xy_positions, patch_params @abc.abstractmethod def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') def plot(self, ax=None, origin=(0, 0), **kwargs): """ Plot the aperture on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : list of `~matplotlib.patches.Patch` A list of matplotlib patches for the plotted aperture. The patches can be used, for example, when adding a plot legend. """ import matplotlib.pyplot as plt if ax is None: ax = plt.gca() patches = self._to_patch(origin=origin, **kwargs) if self.isscalar: patches = (patches,) for patch in patches: ax.add_patch(patch) return patches def _to_sky_params(self, wcs): """ Convert the pixel aperture parameters to those for a sky aperture. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- sky_params : `dict` A dictionary of parameters for an equivalent sky aperture. """ sky_params = {} xpos, ypos = np.transpose(self.positions) sky_params['positions'] = skypos = wcs.pixel_to_world(xpos, ypos) # Aperture objects require scalar shape parameters (e.g., # radius, a, b, theta, etc.), therefore we must calculate the # pixel scale and angle at only a single sky position, which # we take as the first aperture position. For apertures with # multiple positions used with a WCS that contains distortions # (e.g., a spatially-dependent pixel scale), this may lead to # unexpected results (e.g., results that are dependent of the # order of the positions). There is no good way to fix this with # the current Aperture API allowing multiple positions. if not self.isscalar: skypos = skypos[0] _, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs) for param in self._params: value = getattr(self, param) if param == 'positions': continue if param == 'theta': # photutils aperture sky angles are defined as the PA of # the semimajor axis (i.e., relative to the WCS latitude # axis). region sky angles are defined relative to the WCS # longitude axis. value = (value * u.rad) - angle.to(u.rad) else: value = (value * u.pix * pixscale).to(u.arcsec) sky_params[param] = value return sky_params @abc.abstractmethod def to_sky(self, wcs): """ Convert the aperture to a `SkyAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyAperture` object A `SkyAperture` object. """ raise NotImplementedError('Needs to be implemented in a subclass.') class SkyAperture(Aperture): """ Abstract base class for all apertures defined in celestial coordinates. """ def _to_pixel_params(self, wcs): """ Convert the sky aperture parameters to those for a pixel aperture. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- pixel_params : `dict` A dictionary of parameters for an equivalent pixel aperture. """ pixel_params = {} xpos, ypos = wcs.world_to_pixel(self.positions) pixel_params['positions'] = np.transpose((xpos, ypos)) # Aperture objects require scalar shape parameters (e.g., # radius, a, b, theta, etc.), therefore we must calculate the # pixel scale and angle at only a single sky position, which # we take as the first aperture position. For apertures with # multiple positions used with a WCS that contains distortions # (e.g., a spatially-dependent pixel scale), this may lead to # unexpected results (e.g., results that are dependent of the # order of the positions). There is no good way to fix this with # the current Aperture API allowing multiple positions. if self.isscalar: skypos = self.positions else: skypos = self.positions[0] _, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs) for param in self._params: value = getattr(self, param) if param == 'positions': continue if param == 'theta': # photutils aperture sky angles are defined as the PA of # the semimajor axis (i.e., relative to the WCS latitude # axis). region sky angles are defined relative to the WCS # longitude axis. value = (value + angle).to(u.radian).value else: if value.unit.physical_type == 'angle': value = (value / pixscale).to(u.pixel).value else: value = value.value pixel_params[param] = value return pixel_params @abc.abstractmethod def to_pixel(self, wcs): """ Convert the aperture to a `PixelAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `PixelAperture` object A `PixelAperture` object. """ raise NotImplementedError('Needs to be implemented in a subclass.') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/ellipse.py0000644000175100001770000005217214637570305021146 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines elliptical and elliptical-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import elliptical_overlap_grid __all__ = ['EllipticalMaskMixin', 'EllipticalAperture', 'EllipticalAnnulus', 'SkyEllipticalAperture', 'SkyEllipticalAnnulus'] class EllipticalMaskMixin: """ Mixin class to create masks for elliptical and elliptical-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ use_exact, subpixels = self._translate_mask_mode(method, subpixels) if hasattr(self, 'a'): a = self.a b = self.b elif hasattr(self, 'a_in'): # annulus a = self.a_out b = self.b_out else: raise ValueError('Cannot determine the aperture shape.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges): ny, nx = bbox.shape mask = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, a, b, self._theta_radians, use_exact, subpixels) # subtract the inner ellipse for an annulus if hasattr(self, 'a_in'): mask -= elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a_in, self.b_in, self._theta_radians, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] else: return masks @staticmethod def _calc_extents(semimajor_axis, semiminor_axis, theta): """ Calculate half of the bounding box extents of an ellipse. """ cos_theta = np.cos(theta) sin_theta = np.sin(theta) semimajor_x = semimajor_axis * cos_theta semimajor_y = semimajor_axis * sin_theta semiminor_x = semiminor_axis * -sin_theta semiminor_y = semiminor_axis * cos_theta x_extent = np.sqrt(semimajor_x**2 + semiminor_x**2) y_extent = np.sqrt(semimajor_y**2 + semiminor_y**2) return x_extent, y_extent class EllipticalAperture(EllipticalMaskMixin, PixelAperture): """ An elliptical aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs a : float The semimajor axis of the ellipse in pixels. b : float The semiminor axis of the ellipse in pixels. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If either axis (``a`` or ``b``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAperture >>> theta = Angle(80, 'deg') >>> aper = EllipticalAperture([10.0, 20.0], 5.0, 3.0) >>> aper = EllipticalAperture((10.0, 20.0), 5.0, 3.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = EllipticalAperture([pos1, pos2, pos3], 5.0, 3.0) >>> aper = EllipticalAperture((pos1, pos2, pos3), 5.0, 3.0, theta=theta) """ _params = ('positions', 'a', 'b', 'theta') positions = PixelPositions('The center pixel position(s).') a = PositiveScalar('The semimajor axis in pixels.') b = PositiveScalar('The semiminor axis in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, a, b, theta=0.0): self.positions = positions self.a = a self.b = b self._theta_radians = 0.0 # defined by theta setter self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.a, self.b, self._theta_radians) @property def area(self): return math.pi * self.a * self.b def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [] theta_deg = self._theta_radians * 180.0 / np.pi for xy_position in xy_positions: patches.append(mpatches.Ellipse(xy_position, 2.0 * self.a, 2.0 * self.b, angle=theta_deg, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return EllipticalMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyEllipticalAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyEllipticalAperture` object A `SkyEllipticalAperture` object. """ return SkyEllipticalAperture(**self._to_sky_params(wcs)) class EllipticalAnnulus(EllipticalMaskMixin, PixelAperture): r""" An elliptical annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs a_in : float The inner semimajor axis of the elliptical annulus in pixels. a_out : float The outer semimajor axis of the elliptical annulus in pixels. b_out : float The outer semiminor axis of the elliptical annulus in pixels. b_in : `None` or float, optional The inner semiminor axis of the elliptical annulus in pixels. If `None`, then the inner semiminor axis is calculated as: .. math:: b_{in} = b_{out} \left(\frac{a_{in}}{a_{out}}\right) theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If inner semimajor axis (``a_in``) is greater than outer semimajor axis (``a_out``). ValueError : `ValueError` If either the inner semimajor axis (``a_in``) or the outer semiminor axis (``b_out``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAnnulus >>> theta = Angle(80, 'deg') >>> aper = EllipticalAnnulus([10.0, 20.0], 3.0, 8.0, 5.0) >>> aper = EllipticalAnnulus((10.0, 20.0), 3.0, 8.0, 5.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = EllipticalAnnulus([pos1, pos2, pos3], 3.0, 8.0, 5.0) >>> aper = EllipticalAnnulus((pos1, pos2, pos3), 3.0, 8.0, 5.0, ... theta=theta) """ _params = ('positions', 'a_in', 'a_out', 'b_in', 'b_out', 'theta') positions = PixelPositions('The center pixel position(s).') a_in = PositiveScalar('The inner semimajor axis in pixels.') a_out = PositiveScalar('The outer semimajor axis in pixels.') b_in = PositiveScalar('The inner semiminor axis in pixels.') b_out = PositiveScalar('The outer semiminor axis in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0): if not a_out > a_in: raise ValueError('"a_out" must be greater than "a_in".') self.positions = positions self.a_in = a_in self.a_out = a_out self.b_out = b_out if b_in is None: b_in = self.b_out * self.a_in / self.a_out else: if not b_out > b_in: raise ValueError('"b_out" must be greater than "b_in".') self.b_in = b_in self._theta_radians = 0.0 # defined by theta setter self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.a_out, self.b_out, self._theta_radians) @property def area(self): return math.pi * (self.a_out * self.b_out - self.a_in * self.b_in) def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [] theta_deg = self._theta_radians * 180.0 / np.pi for xy_position in xy_positions: patch_inner = mpatches.Ellipse(xy_position, 2.0 * self.a_in, 2.0 * self.b_in, angle=theta_deg) patch_outer = mpatches.Ellipse(xy_position, 2.0 * self.a_out, 2.0 * self.b_out, angle=theta_deg) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return EllipticalMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyEllipticalAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyEllipticalAnnulus` object A `SkyEllipticalAnnulus` object. """ return SkyEllipticalAnnulus(**self._to_sky_params(wcs)) class SkyEllipticalAperture(SkyAperture): """ An elliptical aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. a : scalar `~astropy.units.Quantity` The semimajor axis of the ellipse in angular units. b : scalar `~astropy.units.Quantity` The semiminor axis of the ellipse in angular units. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the ellipse semimajor axis. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyEllipticalAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyEllipticalAperture(positions, 1.0*u.arcsec, 0.5*u.arcsec) """ _params = ('positions', 'a', 'b', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') a = PositiveScalarAngle('The semimajor axis in angular units.') b = PositiveScalarAngle('The semiminor axis in angular units.') theta = ScalarAngle('The position angle in angular units of the ellipse ' 'semimajor axis.') def __init__(self, positions, a, b, theta=0.0 * u.deg): self.positions = positions self.a = a self.b = b self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to an `EllipticalAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `EllipticalAperture` object An `EllipticalAperture` object. """ return EllipticalAperture(**self._to_pixel_params(wcs)) class SkyEllipticalAnnulus(SkyAperture): r""" An elliptical annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. a_in : scalar `~astropy.units.Quantity` The inner semimajor axis in angular units. a_out : scalar `~astropy.units.Quantity` The outer semimajor axis in angular units. b_out : scalar `~astropy.units.Quantity` The outer semiminor axis in angular units. b_in : `None` or scalar `~astropy.units.Quantity` The inner semiminor axis in angular units. If `None`, then the inner semiminor axis is calculated as: .. math:: b_{in} = b_{out} \left(\frac{a_{in}}{a_{out}}\right) theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the ellipse semimajor axis. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyEllipticalAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyEllipticalAnnulus(positions, 0.5*u.arcsec, 2.0*u.arcsec, ... 1.0*u.arcsec) """ _params = ('positions', 'a_in', 'a_out', 'b_in', 'b_out', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') a_in = PositiveScalarAngle('The inner semimajor axis in angular units.') a_out = PositiveScalarAngle('The outer semimajor axis in angular units.') b_in = PositiveScalarAngle('The inner semiminor axis in angular units.') b_out = PositiveScalarAngle('The outer semiminor axis in angular units.') theta = ScalarAngle('The position angle in angular units of the ellipse ' 'semimajor axis.') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0 * u.deg): if not a_out > a_in: raise ValueError('"a_out" must be greater than "a_in".') self.positions = positions self.a_in = a_in self.a_out = a_out self.b_out = b_out if b_in is None: b_in = self.b_out * self.a_in / self.a_out else: if not b_out > b_in: raise ValueError('"b_out" must be greater than "b_in".') self.b_in = b_in self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to an `EllipticalAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `EllipticalAnnulus` object An `EllipticalAnnulus` object. """ return EllipticalAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/mask.py0000644000175100001770000002624614637570305020447 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines a class for aperture masks. """ import warnings import astropy.units as u import numpy as np from astropy.utils import minversion __all__ = ['ApertureMask'] COPY_IF_NEEDED = False if not minversion(np, '2.0.0.dev') else None class ApertureMask: """ Class for an aperture mask. Parameters ---------- data : array_like A 2D array representing the fractional overlap of an aperture on the pixel grid. This should be the full-sized (i.e., not truncated) array that is the direct output of one of the low-level `photutils.geometry` functions. bbox : `photutils.aperture.BoundingBox` The bounding box object defining the aperture minimal bounding box. """ def __init__(self, data, bbox): self.data = np.asanyarray(data) if self.data.shape != bbox.shape: raise ValueError('mask data and bounding box must have the same ' 'shape') self.bbox = bbox self._mask = (self.data == 0) def __array__(self, dtype=None, copy=COPY_IF_NEEDED): """ Array representation of the mask data array (e.g., for matplotlib). """ return np.array(self.data, dtype=dtype, copy=copy) @property def shape(self): """ The shape of the mask data array. """ return self.data.shape def get_overlap_slices(self, shape): """ Get slices for the overlapping part of the aperture mask and a 2D array. Parameters ---------- shape : 2-tuple of int The shape of the 2D array. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. slices_small : tuple of slices or `None` A tuple of slice objects for each axis of the aperture mask array such that ``small_array[slices_small]`` extracts the region that is inside the large array. `None` is returned if there is no overlap of the bounding box with the given image shape. """ return self.bbox.get_overlap_slices(shape) def to_image(self, shape, dtype=float): """ Return an image of the mask in a 2D array of the given shape, taking any edge effects into account. Parameters ---------- shape : tuple of int The ``(ny, nx)`` shape of the output array. dtype : data-type, optional The desired data type for the array. This should be a floating data type if the `ApertureMask` was created with the "exact" or "subpixel" mode, otherwise the fractional mask weights will be altered. A integer data type may be used if the `ApertureMask` was created with the "center" mode. Returns ------- result : `~numpy.ndarray` A 2D array of the mask. """ if len(shape) != 2: raise ValueError('input shape must have 2 elements.') # find the overlap of the mask on the output image shape slices_large, slices_small = self.get_overlap_slices(shape) if slices_small is None: return None # no overlap # insert the mask into the output image image = np.zeros(shape, dtype=dtype) image[slices_large] = self.data[slices_small] return image def cutout(self, data, fill_value=0.0, copy=False): """ Create a cutout from the input data over the mask bounding box, taking any edge effects into account. Parameters ---------- data : array_like A 2D array on which to apply the aperture mask. fill_value : float, optional The value used to fill pixels where the aperture mask does not overlap with the input ``data``. The default is 0. copy : bool, optional If `True` then the returned cutout array will always be hold a copy of the input ``data``. If `False` and the mask is fully within the input ``data``, then the returned cutout array will be a view into the input ``data``. In cases where the mask partially overlaps or has no overlap with the input ``data``, the returned cutout array will always hold a copy of the input ``data`` (i.e., this keyword has no effect). Returns ------- result : `~numpy.ndarray` or `None` A 2D array cut out from the input ``data`` representing the same cutout region as the aperture mask. If there is a partial overlap of the aperture mask with the input data, pixels outside of the data will be assigned to ``fill_value``. `None` is returned if there is no overlap of the aperture with the input ``data``. """ data = np.asanyarray(data) if data.ndim != 2: raise ValueError('data must be a 2D array.') # find the overlap of the mask on the output image shape slices_large, slices_small = self.get_overlap_slices(data.shape) if slices_small is None: return None # no overlap cutout_shape = (slices_small[0].stop - slices_small[0].start, slices_small[1].stop - slices_small[1].start) if cutout_shape == self.shape: cutout = data[slices_large] if copy: cutout = np.copy(cutout) return cutout # cutout is always a copy for partial overlap if ~np.isfinite(fill_value): dtype = float else: dtype = data.dtype cutout = np.zeros(self.shape, dtype=dtype) cutout[:] = fill_value cutout[slices_small] = data[slices_large] if isinstance(data, u.Quantity): cutout <<= data.unit return cutout def multiply(self, data, fill_value=0.0): """ Multiply the aperture mask with the input data, taking any edge effects into account. The result is a mask-weighted cutout from the data. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array to multiply with the aperture mask. fill_value : float, optional The value is used to fill pixels where the aperture mask does not overlap with the input ``data``. The default is 0. Returns ------- result : `~numpy.ndarray` or `None` A 2D mask-weighted cutout from the input ``data``. If there is a partial overlap of the aperture mask with the input data, pixels outside of the data will be assigned to ``fill_value`` before being multiplied with the mask. `None` is returned if there is no overlap of the aperture with the input ``data``. """ cutout = self.cutout(data, fill_value=fill_value) if cutout is None: return None else: # ignore multiplication with non-finite data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) weighted_cutout = cutout * self.data # fill values outside of the mask but within the bounding box weighted_cutout[self._mask] = fill_value return weighted_cutout def _get_overlap_cutouts(self, shape, mask=None): """ Get the aperture mask weights, pixel mask, and slice for the overlap with the input shape. If input, the ``mask`` is included in the output pixel mask cutout. Parameters ---------- shape : tuple of int The shape of data. mask : array_like (bool), optional A boolean mask with the same shape as ``shape`` where a `True` value indicates a masked pixel. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array of given ``shape``, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. aper_weights: 2D float `~numpy.ndarray` The cutout aperture mask weights for the overlap. pixel_mask: 2D bool `~numpy.ndarray` The cutout pixel mask for the overlap. Notes ----- This method is separate from ``get_values`` to facilitate applying the same slices, aper_weights, and pixel_mask to multiple associated arrays (e.g., data and error arrays). It is used in this way by the `PixelAperture.do_photometry` method. """ if mask is not None: if mask.shape != shape: raise ValueError('mask and data must have the same shape') slc_large, slc_small = self.get_overlap_slices(shape) if slc_large is None: # no overlap return None, None, None aper_weights = self.data[slc_small] pixel_mask = (aper_weights > 0) # good pixels if mask is not None: pixel_mask &= ~mask[slc_large] return slc_large, aper_weights, pixel_mask def get_values(self, data, mask=None): """ Get the mask-weighted pixel values from the data as a 1D array. If the ``ApertureMask`` was created with ``method='center'``, (where the mask weights are only 1 or 0), then the returned values will simply be pixel values extracted from the data. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array from which to get mask-weighted values. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is not returned in the result. Returns ------- result : `~numpy.ndarray` A 1D array of mask-weighted pixel values from the input ``data``. If there is no overlap of the aperture with the input ``data``, the result will be an empty array with shape (0,). """ slc_large, aper_weights, pixel_mask = self._get_overlap_cutouts( data.shape, mask=mask) if slc_large is None: return np.array([]) # ignore multiplication with non-finite data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # pixel_mask is used so that pixels value where data = 0 and # aper_weights != 0 are still returned return (data[slc_large] * aper_weights)[pixel_mask] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/photometry.py0000644000175100001770000002260214637570305021716 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines tools to perform aperture photometry. """ import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture.core import Aperture, SkyAperture from photutils.utils._misc import _get_meta __all__ = ['aperture_photometry'] def aperture_photometry(data, apertures, error=None, mask=None, method='exact', subpixels=5, wcs=None): """ Perform aperture photometry on the input data by summing the flux within the given aperture(s). Note that this function returns the sum of the (weighted) input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this function. Parameters ---------- data : array_like, `~astropy.units.Quantity`, `~astropy.nddata.NDData` The 2D array on which to perform photometry. ``data`` should be background-subtracted. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` (if input) must also be a `~astropy.units.Quantity` array with the same units. See the Notes section below for more information about `~astropy.nddata.NDData` input. apertures : `~photutils.aperture.Aperture` or list of `~photutils.aperture.Aperture` The aperture(s) to use for the photometry. If ``apertures`` is a list of `~photutils.aperture.Aperture` then they all must have the same position(s). error : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. wcs : WCS object, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Used only if the input ``apertures`` contains a `SkyAperture` object. Returns ------- table : `~astropy.table.QTable` A table of the photometry with the following columns: * ``'id'``: The source ID. * ``'xcenter'``, ``'ycenter'``: The ``x`` and ``y`` pixel coordinates of the input aperture center(s). * ``'sky_center'``: The sky coordinates of the input aperture center(s). Returned only if the input ``apertures`` is a `SkyAperture` object. * ``'aperture_sum'``: The sum of the values within the aperture. * ``'aperture_sum_err'``: The corresponding uncertainty in the ``'aperture_sum'`` values. Returned only if the input ``error`` is not `None`. The table metadata includes the Astropy and Photutils version numbers and the `aperture_photometry` calling arguments. Notes ----- `RectangularAperture` and `RectangularAnnulus` photometry with the "exact" method uses a subpixel approximation by subdividing each data pixel by a factor of 1024 (``subpixels = 32``). For rectangular aperture widths and heights in the range from 2 to 100 pixels, this subpixel approximation gives results typically within 0.001 percent or better of the exact value. The differences can be larger for smaller apertures (e.g., aperture sizes of one pixel or smaller). For such small sizes, it is recommend to set ``method='subpixel'`` with a larger ``subpixels`` size. If the input ``data`` is a `~astropy.nddata.NDData` instance, then the ``error``, ``mask``, and ``wcs`` keyword inputs are ignored. Instead, these values should be defined as attributes in the `~astropy.nddata.NDData` object. In the case of ``error``, it must be defined in the ``uncertainty`` attribute with a `~astropy.nddata.StdDevUncertainty` instance. """ if isinstance(data, NDData): nddata_attr = {'error': error, 'mask': mask, 'wcs': wcs} for key, value in nddata_attr.items(): if value is not None: warnings.warn(f'The {key!r} keyword is be ignored. Its value ' 'is obtained from the input NDData object.', AstropyUserWarning) mask = data.mask wcs = data.wcs if isinstance(data.uncertainty, StdDevUncertainty): if data.uncertainty.unit is None: error = data.uncertainty.array else: error = data.uncertainty.array * data.uncertainty.unit if data.unit is not None: data = u.Quantity(data.data, unit=data.unit) else: data = data.data return aperture_photometry(data, apertures, error=error, mask=mask, method=method, subpixels=subpixels, wcs=wcs) single_aperture = False if isinstance(apertures, Aperture): single_aperture = True apertures = (apertures,) # convert sky to pixel apertures skyaper = False if isinstance(apertures[0], SkyAperture): if wcs is None: raise ValueError('A WCS transform must be defined by the input ' 'data or the wcs keyword when using a ' 'SkyAperture object.') # used to include SkyCoord position in the output table skyaper = True skycoord_pos = apertures[0].positions apertures = [aper.to_pixel(wcs) for aper in apertures] # compare positions in pixels to avoid comparing SkyCoord objects positions = apertures[0].positions for aper in apertures[1:]: if not np.array_equal(aper.positions, positions): raise ValueError('Input apertures must all have identical ' 'positions.') # define output table meta data meta = _get_meta() calling_args = f"method='{method}', subpixels={subpixels}" meta['aperture_photometry_args'] = calling_args tbl = QTable() tbl.meta.update(meta) # keep tbl.meta type positions = np.atleast_2d(apertures[0].positions) tbl['id'] = np.arange(positions.shape[0], dtype=int) + 1 xypos_pixel = np.transpose(positions) * u.pixel tbl['xcenter'] = xypos_pixel[0] tbl['ycenter'] = xypos_pixel[1] if skyaper: if skycoord_pos.isscalar: # create length-1 SkyCoord array tbl['sky_center'] = skycoord_pos.reshape((-1,)) else: tbl['sky_center'] = skycoord_pos sum_key_main = 'aperture_sum' sum_err_key_main = 'aperture_sum_err' for i, aper in enumerate(apertures): aper_sum, aper_sum_err = aper.do_photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) sum_key = sum_key_main sum_err_key = sum_err_key_main if not single_aperture: sum_key += f'_{i}' sum_err_key += f'_{i}' tbl[sum_key] = aper_sum if error is not None: tbl[sum_err_key] = aper_sum_err return tbl ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/rectangle.py0000644000175100001770000005631014637570305021453 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines rectangular and rectangular-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import rectangular_overlap_grid __all__ = ['RectangularMaskMixin', 'RectangularAperture', 'RectangularAnnulus', 'SkyRectangularAperture', 'SkyRectangularAnnulus'] class RectangularMaskMixin: """ Mixin class to create masks for rectangular or rectangular-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ _, subpixels = self._translate_mask_mode(method, subpixels, rectangle=True) if hasattr(self, 'w'): w = self.w h = self.h elif hasattr(self, 'w_out'): # annulus w = self.w_out h = self.h_out else: raise ValueError('Cannot determine the aperture radius.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges): ny, nx = bbox.shape mask = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, w, h, self._theta_radians, 0, subpixels) # subtract the inner circle for an annulus if hasattr(self, 'w_in'): mask -= rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w_in, self.h_in, self._theta_radians, 0, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] else: return masks @staticmethod def _calc_extents(width, height, theta): """ Calculate half of the bounding box extents of an ellipse. """ half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta) cos_theta = math.cos(theta) x_extent1 = abs((half_width * cos_theta) - (half_height * sin_theta)) x_extent2 = abs((half_width * cos_theta) + (half_height * sin_theta)) y_extent1 = abs((half_width * sin_theta) + (half_height * cos_theta)) y_extent2 = abs((half_width * sin_theta) - (half_height * cos_theta)) x_extent = max(x_extent1, x_extent2) y_extent = max(y_extent1, y_extent2) return x_extent, y_extent @staticmethod def _lower_left_positions(positions, width, height, theta): """ Calculate lower-left positions from the input center positions. Used for creating `~matplotlib.patches.Rectangle` patch for the aperture. """ half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta) cos_theta = math.cos(theta) xshift = (half_height * sin_theta) - (half_width * cos_theta) yshift = -(half_height * cos_theta) - (half_width * sin_theta) return np.atleast_2d(positions) + np.array([xshift, yshift]) class RectangularAperture(RectangularMaskMixin, PixelAperture): """ A rectangular aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs w : float The full width of the rectangle in pixels. For ``theta=0`` the width side is along the ``x`` axis. h : float The full height of the rectangle in pixels. For ``theta=0`` the height side is along the ``y`` axis. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If either width (``w``) or height (``h``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import RectangularAperture >>> theta = Angle(80, 'deg') >>> aper = RectangularAperture([10.0, 20.0], 5.0, 3.0) >>> aper = RectangularAperture((10.0, 20.0), 5.0, 3.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = RectangularAperture([pos1, pos2, pos3], 5.0, 3.0) >>> aper = RectangularAperture((pos1, pos2, pos3), 5.0, 3.0, theta=theta) """ _params = ('positions', 'w', 'h', 'theta') positions = PixelPositions('The center pixel position(s).') w = PositiveScalar('The full width in pixels.') h = PositiveScalar('The full height in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, w, h, theta=0.0): self.positions = positions self.w = w self.h = h self._theta_radians = 0.0 # defined by theta setter self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.w, self.h, self._theta_radians) @property def area(self): return self.w * self.h def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) xy_positions = self._lower_left_positions(xy_positions, self.w, self.h, self._theta_radians) patches = [] theta_deg = self._theta_radians * 180.0 / np.pi for xy_position in xy_positions: patches.append(mpatches.Rectangle(xy_position, self.w, self.h, angle=theta_deg, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return RectangularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyRectangularAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyRectangularAperture` object A `SkyRectangularAperture` object. """ return SkyRectangularAperture(**self._to_sky_params(wcs)) class RectangularAnnulus(RectangularMaskMixin, PixelAperture): r""" A rectangular annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs w_in : float The inner full width of the rectangular annulus in pixels. For ``theta=0`` the width side is along the ``x`` axis. w_out : float The outer full width of the rectangular annulus in pixels. For ``theta=0`` the width side is along the ``x`` axis. h_out : float The outer full height of the rectangular annulus in pixels. h_in : `None` or float The inner full height of the rectangular annulus in pixels. If `None`, then the inner full height is calculated as: .. math:: h_{in} = h_{out} \left(\frac{w_{in}}{w_{out}}\right) For ``theta=0`` the height side is along the ``y`` axis. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If inner width (``w_in``) is greater than outer width (``w_out``). ValueError : `ValueError` If either the inner width (``w_in``) or the outer height (``h_out``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import RectangularAnnulus >>> theta = Angle(80, 'deg') >>> aper = RectangularAnnulus([10.0, 20.0], 3.0, 8.0, 5.0) >>> aper = RectangularAnnulus((10.0, 20.0), 3.0, 8.0, 5.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = RectangularAnnulus([pos1, pos2, pos3], 3.0, 8.0, 5.0) >>> aper = RectangularAnnulus((pos1, pos2, pos3), 3.0, 8.0, 5.0, theta=theta) """ _params = ('positions', 'w_in', 'w_out', 'h_in', 'h_out', 'theta') positions = PixelPositions('The center pixel position(s).') w_in = PositiveScalar('The inner full width in pixels.') w_out = PositiveScalar('The outer full width in pixels.') h_in = PositiveScalar('The inner full height in pixels.') h_out = PositiveScalar('The outer full height in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0): if not w_out > w_in: raise ValueError('"w_out" must be greater than "w_in"') self.positions = positions self.w_in = w_in self.w_out = w_out self.h_out = h_out if h_in is None: h_in = self.w_in * self.h_out / self.w_out else: if not h_out > h_in: raise ValueError('"h_out" must be greater than "h_in"') self.h_in = h_in self._theta_radians = 0.0 # defined by theta setter self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.w_out, self.h_out, self._theta_radians) @property def area(self): return self.w_out * self.h_out - self.w_in * self.h_in def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.patch` or list of `~matplotlib.patches.patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.patch` is returned, otherwise a list of `~matplotlib.patches.patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) inner_xy_positions = self._lower_left_positions(xy_positions, self.w_in, self.h_in, self._theta_radians) outer_xy_positions = self._lower_left_positions(xy_positions, self.w_out, self.h_out, self._theta_radians) patches = [] theta_deg = self._theta_radians * 180.0 / np.pi for xy_in, xy_out in zip(inner_xy_positions, outer_xy_positions): patch_inner = mpatches.Rectangle(xy_in, self.w_in, self.h_in, angle=theta_deg) patch_outer = mpatches.Rectangle(xy_out, self.w_out, self.h_out, angle=theta_deg) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] else: return patches def to_mask(self, method='exact', subpixels=5): return RectangularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyRectangularAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyRectangularAnnulus` object A `SkyRectangularAnnulus` object. """ return SkyRectangularAnnulus(**self._to_sky_params(wcs)) class SkyRectangularAperture(SkyAperture): """ A rectangular aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. w : scalar `~astropy.units.Quantity` The full width of the rectangle in angular units. For ``theta=0`` the width side is along the North-South axis. h : scalar `~astropy.units.Quantity` The full height of the rectangle in angular units. For ``theta=0`` the height side is along the East-West axis. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the rectangle "width" side. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyRectangularAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyRectangularAperture(positions, 1.0*u.arcsec, 0.5*u.arcsec) """ _params = ('positions', 'w', 'h', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') w = PositiveScalarAngle('The full width in angular units.') h = PositiveScalarAngle('The full height in angular units.') theta = ScalarAngle('The position angle (in angular units) of the ' 'rectangle "width" side.') def __init__(self, positions, w, h, theta=0.0 * u.deg): self.positions = positions self.w = w self.h = h self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to a `RectangularAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `RectangularAperture` object A `RectangularAperture` object. """ return RectangularAperture(**self._to_pixel_params(wcs)) class SkyRectangularAnnulus(SkyAperture): r""" A rectangular annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. w_in : scalar `~astropy.units.Quantity` The inner full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. w_out : scalar `~astropy.units.Quantity` The outer full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. h_out : scalar `~astropy.units.Quantity` The outer full height of the rectangular annulus in angular units. h_in : `None` or scalar `~astropy.units.Quantity` The outer full height of the rectangular annulus in angular units. If `None`, then the inner full height is calculated as: .. math:: h_{in} = h_{out} \left(\frac{w_{in}}{w_{out}}\right) For ``theta=0`` the height side is along the East-West axis. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the rectangle "width" side. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyRectangularAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyRectangularAnnulus(positions, 3.0*u.arcsec, 8.0*u.arcsec, ... 5.0*u.arcsec) """ _params = ('positions', 'w_in', 'w_out', 'h_in', 'h_out', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') w_in = PositiveScalarAngle('The inner full width in angular units.') w_out = PositiveScalarAngle('The outer full width in angular units.') h_in = PositiveScalarAngle('The inner full height in angular units.') h_out = PositiveScalarAngle('The outer full height in angular units.') theta = ScalarAngle('The position angle (in angular units) of the ' 'rectangle "width" side.') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0 * u.deg): if not w_out > w_in: raise ValueError('"w_out" must be greater than "w_in".') self.positions = positions self.w_in = w_in self.w_out = w_out self.h_out = h_out if h_in is None: h_in = self.w_in * self.h_out / self.w_out else: if not h_out > h_in: raise ValueError('"h_out" must be greater than "h_in".') self.h_in = h_in self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to a `RectangularAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `RectangularAnnulus` object A `RectangularAnnulus` object. """ return RectangularAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/stats.py0000644000175100001770000016156514637570305020656 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating the properties of sources defined by an Aperture. """ import functools import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import (SigmaClip, biweight_location, biweight_midvariance, mad_std) from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import Aperture, SkyAperture from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import process_quantities __all__ = ['ApertureStats'] # default table columns for `to_table()` output DEFAULT_COLUMNS = ['id', 'xcentroid', 'ycentroid', 'sky_centroid', 'sum', 'sum_err', 'sum_aper_area', 'center_aper_area', 'min', 'max', 'mean', 'median', 'mode', 'std', 'mad_std', 'var', 'biweight_location', 'biweight_midvariance', 'fwhm', 'semimajor_sigma', 'semiminor_sigma', 'orientation', 'eccentricity'] def as_scalar(method): """ Return a scalar value from a method if the class is scalar. """ @functools.wraps(method) def _decorator(*args, **kwargs): result = method(*args, **kwargs) try: return (result[0] if args[0].isscalar and len(result) == 1 else result) except TypeError: # if result has no len return result return _decorator class ApertureStats: """ Class to create a catalog of statistics for pixels within an aperture. Note that this class returns the statistics of the input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this function. Parameters ---------- data : 2D `~numpy.ndarray`, `~astropy.units.Quantity`, `~astropy.nddata.NDData` The 2D array from which to calculate the source properties. For accurate source properties, ``data`` should be background-subtracted. Non-finite ``data`` values (NaN and inf) are automatically masked. aperture : `~photutils.aperture.Aperture` The aperture to apply to the data. The aperture object may contain more than one position. If ``aperture`` is a `~photutils.aperture.SkyAperture` object, then a WCS must be input using the ``wcs`` keyword. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The total error array corresponding to the input ``data`` array. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``error`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Non-finite ``error`` values (NaN and +/- inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. mask : 2D `~numpy.ndarray` (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Non-finite values (NaN and inf) in the input ``data`` are automatically masked. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). ``wcs`` is required if the input ``aperture`` is a `~photutils.aperture.SkyAperture`. If `None`, then all sky-based properties will be set to `None`. sigma_clip : `None` or `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. sum_method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. This method is used only for calculating the ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout`` properties. All other properties use the "center" aperture mask method. Not all options are available for all aperture types. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``sum_method='subpixel'``. local_bkg : float, `~numpy.ndarray`, `~astropy.units.Quantity`, or `None` The per-pixel local background values to subtract from the data before performing measurements. If input as an array, the order of ``local_bkg`` values corresponds to the order of the input ``aperture`` positions. ``local_bkg`` must have the same length as the input ``aperture`` or must be a scalar value, which will be broadcast to all apertures. If `None`, then no local background subtraction is performed. If the input ``data`` has units, then ``local_bkg`` must be a `~astropy.units.Quantity` with the same units. Notes ----- ``data`` should be background-subtracted for accurate source properties. In addition to global background subtraction, local background subtraction can be performed using the ``local_bkg`` keyword values. Most source properties are calculated using the "center" aperture-mask method, which gives aperture weights of 0 or 1. This avoids the need to compute weighted statistics --- the ``data`` pixel values are directly used. The input ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method when calculating the sum-related properties: ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ Examples -------- >>> from photutils.datasets import make_4gaussians_image >>> from photutils.aperture import CircularAperture, ApertureStats >>> data = make_4gaussians_image() >>> aper = CircularAperture((150, 25), 8) >>> aperstats = ApertureStats(data, aper) >>> print(aperstats.xcentroid) # doctest: +FLOAT_CMP 149.99080259251238 >>> print(aperstats.ycentroid) # doctest: +FLOAT_CMP 24.97484633000507 >>> print(aperstats.centroid) # doctest: +FLOAT_CMP [149.99080259 24.97484633] >>> print(aperstats.mean, aperstats.median, aperstats.std) # doctest: +FLOAT_CMP 47.76300955780609 31.913789514433084 39.193655383492974 >>> print(aperstats.sum) # doctest: +FLOAT_CMP 9286.709206410273 >>> print(aperstats.sum_aper_area) # doctest: +FLOAT_CMP 201.0619298297468 pix2 >>> # more than one aperture position >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.xcentroid) # doctest: +FLOAT_CMP [149.98470724 89.97893946] >>> print(aperstats2.sum) # doctest: +FLOAT_CMP [10177.62548482 36653.97704059] """ def __init__(self, data, aperture, *, error=None, mask=None, wcs=None, sigma_clip=None, sum_method='exact', subpixels=5, local_bkg=None): if isinstance(data, NDData): data, error, mask, wcs = self._unpack_nddata(data, error, mask, wcs) inputs = (data, error, local_bkg) names = ('data', 'error', 'local_bkg') inputs, unit = process_quantities(inputs, names) (data, error, local_bkg) = inputs self._data = self._validate_array(data, 'data', shape=False) self._data_unit = unit self.aperture = self._validate_aperture(aperture) if isinstance(aperture, SkyAperture): if wcs is None: raise ValueError('A wcs is required when using a SkyAperture') self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._wcs = wcs if sigma_clip is not None and not isinstance(sigma_clip, SigmaClip): raise TypeError('sigma_clip must be a SigmaClip instance') self.sigma_clip = sigma_clip self.sum_method = sum_method self.subpixels = subpixels self._local_bkg = np.zeros(self.n_apertures) # no local bkg if local_bkg is not None: local_bkg = np.atleast_1d(local_bkg) if local_bkg.ndim != 1: raise ValueError('local_bkg must be a 1D array') n_local_bkg = len(local_bkg) if n_local_bkg != 1 and n_local_bkg != self.n_apertures: raise ValueError('local_bkg must be scalar or have the same ' 'length as the input aperture') local_bkg = np.broadcast_to(local_bkg, self.n_apertures) if np.any(~np.isfinite(local_bkg)): raise ValueError('local_bkg must not contain any non-finite ' '(e.g., inf or NaN) values') self._local_bkg = local_bkg # always an iterable self._ids = np.arange(self.n_apertures) + 1 self.default_columns = DEFAULT_COLUMNS self.meta = _get_meta() @staticmethod def _unpack_nddata(data, error, mask, wcs): nddata_attr = {'error': error, 'mask': mask, 'wcs': wcs} for key, value in nddata_attr.items(): if value is not None: warnings.warn(f'The {key!r} keyword is be ignored. Its ' 'value is obtained from the input NDData ' 'object.', AstropyUserWarning) mask = data.mask wcs = data.wcs if isinstance(data.uncertainty, StdDevUncertainty): if data.uncertainty.unit is None: error = data.uncertainty.array else: error = data.uncertainty.array * data.uncertainty.unit if data.unit is not None: data = u.Quantity(data.data, unit=data.unit) else: data = data.data return data, error, mask, wcs @staticmethod def _validate_aperture(aperture): if not isinstance(aperture, Aperture): raise TypeError('aperture must be an Aperture object') return aperture def _validate_array(self, array, name, ndim=2, shape=True): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != ndim: raise ValueError(f'{name} must be a {ndim}D array.') if shape and array.shape != self._data.shape: raise ValueError(f'data and {name} must have the same shape.') return array @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] @property def properties(self): """ A sorted list of built-in source properties. """ lazyproperties = [name for name in self._lazyproperties if not name.startswith('_')] lazyproperties.sort() return lazyproperties def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') newcls = object.__new__(self.__class__) # attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_data_unit', '_error', '_mask', '_wcs', 'sigma_clip', 'sum_method', 'subpixels', 'default_columns', 'meta') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # need to slice _aperture and _ids; # aperture determines isscalar (needed below) attrs = ('aperture', '_ids') for attr in attrs: setattr(newcls, attr, getattr(self, attr)[index]) # slice evaluated lazyproperty objects keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('_local_bkg') # iterable defined in __init__ for key in keys: value = self.__dict__[key] # do not insert attributes that are always scalar (e.g., # isscalar, n_apertures), i.e., not an array/list for each # source if np.isscalar(value): continue try: # keep most _ as length-1 iterables if (newcls.isscalar and key.startswith('_') and key != '_pixel_aperture'): if isinstance(value, np.ndarray): val = value[:, np.newaxis][index] else: val = [value[index]] else: val = value[index] except TypeError: # apply fancy indices (e.g., array/list or bool # mask) to lists # see https://numpy.org/doc/stable/release/1.20.0-notes.html # #arraylike-objects-which-do-not-define-len-and-getitem arr = np.empty(len(value), dtype=object) arr[:] = list(value) val = arr[index].tolist() newcls.__dict__[key] = val return newcls def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' with np.printoptions(threshold=25, edgeitems=5): fmt = [f'Length: {self.n_apertures}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: raise TypeError(f'Scalar {self.__class__.__name__!r} object has ' 'no len()') return self.n_apertures def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single aperture position). """ return self._pixel_aperture.isscalar def copy(self): """ Return a deep copy of this object. """ return deepcopy(self) @lazyproperty def _null_object(self): """ Return `None` values. """ return np.array([None] * self.n_apertures) @lazyproperty def _null_value(self): """ Return np.nan values. """ values = np.empty(self.n_apertures) values.fill(np.nan) return values @property @as_scalar def id(self): """ The aperture identification number(s). """ return self._ids @property def ids(self): """ The aperture identification number(s), always as an iterable `~numpy.ndarray`. """ _ids = self._ids if self.isscalar: _ids = np.array((_ids,)) return _ids def get_id(self, id_num): """ Return a new `ApertureStats` object for the input ID number only. Parameters ---------- id_num : int The aperture ID number. Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the source with the input ID number. """ return self.get_ids(id_num) def get_ids(self, id_nums): """ Return a new `ApertureStats` object for the input ID numbers only. Parameters ---------- id_nums : list, tuple, or `~numpy.ndarray` of int The aperture ID number(s). Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the sources with the input ID numbers. """ for id_num in np.atleast_1d(id_nums): if id_num not in self.ids: raise ValueError(f'{id_num} is not a valid source ID number') sorter = np.argsort(self.id) indices = sorter[np.searchsorted(self.id, id_nums, sorter=sorter)] return self[indices] def to_table(self, columns=None): """ Create a `~astropy.table.QTable` of source properties. Parameters ---------- columns : str, list of str, `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are any of the `ApertureStats` properties. If ``columns`` is `None`, then a default list of scalar-valued properties (as defined by the ``default_columns`` attribute) will be used. Returns ------- table : `~astropy.table.QTable` A table of sources properties with one row per source. """ if columns is None: table_columns = self.default_columns else: table_columns = np.atleast_1d(columns) tbl = QTable() tbl.meta.update(self.meta) # keep tbl.meta type for column in table_columns: values = getattr(self, column) # column assignment requires an object with a length if self.isscalar: values = (values,) tbl[column] = values return tbl @lazyproperty def n_apertures(self): """ The number of positions in the input aperture. """ if self.isscalar: return 1 return len(self._pixel_aperture) @lazyproperty def _pixel_aperture(self): """ The input aperture as a PixelAperture. """ if isinstance(self.aperture, SkyAperture): return self.aperture.to_pixel(self._wcs) return self.aperture @lazyproperty def _aperture_masks_center(self): """ The aperture masks (`ApertureMask`) generated with the 'center' method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method='center') if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _aperture_masks(self): """ The aperture masks (`ApertureMask`) generated with the ``sum_method`` method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method=self.sum_method, subpixels=self.subpixels) if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _overlap_slices(self): """ The aperture mask overlap slices with the data, always as an iterable. The overlap slices are the same for all aperture mask methods. """ overlap_slices = [] for apermask in self._aperture_masks_center: (slc_large, slc_small) = apermask.get_overlap_slices( self._data.shape) overlap_slices.append((slc_large, slc_small)) return overlap_slices @lazyproperty def _data_cutouts(self): """ The local-background-subtracted unmasked data cutouts using the aperture bounding box, always as a iterable. """ cutouts = [] for (slices, local_bkg) in zip(self._overlap_slices, self._local_bkg): if slices[0] is None: cutout = None # no aperture overlap with the data else: # copy is needed to preserve input data because masks are # applied to these cutouts later cutout = (self._data[slices[0]].astype(float, copy=True) - local_bkg) cutouts.append(cutout) return cutouts def _make_aperture_cutouts(self, aperture_masks): """ Make aperture-weighted cutouts for the data and variance, and cutouts for the total mask and aperture mask weights. Parameters ---------- aperture_masks : list of `ApertureMask` A list of `ApertureMask` objects. Returns ------- data, variance, mask, weights : list of `~numpy.ndarray` A list of cutout arrays for the data, variance, mask and weight arrays for each source (aperture position). """ data_cutouts = [] variance_cutouts = [] mask_cutouts = [] weight_cutouts = [] overlaps = [] for (data_cutout, apermask, slices) in zip( self._data_cutouts, aperture_masks, self._overlap_slices): slc_large, slc_small = slices if slc_large is None: # aperture does not overlap the data overlap = False data_cutout = np.array([np.nan]) variance_cutout = np.array([np.nan]) mask_cutout = np.array([False]) weight_cutout = np.array([np.nan]) else: # create a mask of non-finite ``data`` values combined # with the input ``mask`` array. data_mask = ~np.isfinite(data_cutout) if self._mask is not None: data_mask |= self._mask[slc_large] overlap = True aperweight_cutout = apermask.data[slc_small] weight_cutout = aperweight_cutout * ~data_mask # apply the aperture mask; for "exact" and "subpixel" # this is an expanded boolean mask using the aperture # mask zero values mask_cutout = (aperweight_cutout == 0) | data_mask data_cutout = data_cutout.copy() if self.sigma_clip is None: # data_cutout will have zeros where mask_cutout is True data_cutout *= ~mask_cutout else: # to input a mask, SigmaClip needs a MaskedArray data_cutout_ma = np.ma.masked_array(data_cutout, mask=mask_cutout) data_sigclip = self.sigma_clip(data_cutout_ma) # define a mask of only the sigma-clipped pixels sigclip_mask = data_sigclip.mask & ~mask_cutout weight_cutout *= ~sigclip_mask mask_cutout = data_sigclip.mask data_cutout = data_sigclip.filled(0.0) # need to apply the aperture weights data_cutout *= aperweight_cutout if self._error is None: variance_cutout = None else: # apply the exact weights and total mask; # error_cutout will have zeros where mask_cutout is True variance = self._error[slc_large]**2 variance_cutout = (variance * aperweight_cutout * ~mask_cutout) data_cutouts.append(data_cutout) variance_cutouts.append(variance_cutout) mask_cutouts.append(mask_cutout) weight_cutouts.append(weight_cutout) overlaps.append(overlap) # use zip (instead of np.transpose) because these may contain # arrays that have different shapes return list(zip(data_cutouts, variance_cutouts, mask_cutouts, weight_cutouts, overlaps)) @lazyproperty def _aperture_cutouts_center(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the "center" aperture mask method. """ return self._make_aperture_cutouts(self._aperture_masks_center) @lazyproperty def _aperture_cutouts(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the input ``sum_method`` aperture mask method. """ return self._make_aperture_cutouts(self._aperture_masks) @lazyproperty def _mask_cutout_center(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the "center" method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts_center))[2] @lazyproperty def _mask_cutout(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the ``sum_method`` method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts))[2] def _make_masked_array_center(self, array): """ Return a list of cutout masked arrays using the ``_mask_cutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout_center)] def _make_masked_array(self, array): """ Return a list of cutout masked arrays using the ``_mask_sumcutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout)] @lazyproperty @as_scalar def data_cutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center))[0]) @lazyproperty @as_scalar def data_sumcutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array(list(zip(*self._aperture_cutouts))[0]) @lazyproperty def _variance_cutout_center(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center))[1]) @lazyproperty def _variance_cutout(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array(list(zip(*self._aperture_cutouts))[1]) @lazyproperty @as_scalar def error_sumcutout(self): """ A 2D aperture-weighted error cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return [np.sqrt(var) for var in self._variance_cutout] @lazyproperty def _weight_cutout_center(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the "center" method. The mask is `True` for pixels outside of the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center))[3]) @lazyproperty def _weight_cutout(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the ``sum_method`` method. The mask is `True` for pixels outside of the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array(list(zip(*self._aperture_cutouts))[3]) @lazyproperty def _moment_data_cutout(self): """ A list of 2D `~numpy.ndarray` cutouts from the data. Masked pixels are set to zero in these arrays (zeros do not contribute to the image moments). The aperture mask weights are for the "center" method. These arrays are used to derive moment-based properties. """ data = deepcopy(self.data_cutout) # self.data_cutout is a list if self.isscalar: data = (data,) cutouts = [] for arr in data: if arr.size == 1 and np.isnan(arr[0]): # no aperture overlap arr_ = np.empty((2, 2)) arr_.fill(np.nan) else: arr_ = arr.data arr_[arr.mask] = 0.0 cutouts.append(arr_) return cutouts @lazyproperty def _all_masked(self): """ True if all pixels within the aperture are masked. """ return np.array([np.all(mask) for mask in self._mask_cutout_center]) @lazyproperty def _overlap(self): """ True if there is no overlap of the aperture with the data. """ return list(zip(*self._aperture_cutouts_center))[4] def _get_values(self, array): """ Get a 1D array of unmasked aperture-weighted values from the input array. An array with a single NaN is returned for completely-masked sources. """ if self.isscalar: array = (array,) return [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in array] @lazyproperty def _data_values_center(self): """ A 1D array of unmasked aperture-weighted data values using the "center" method. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.data_cutout) @lazyproperty @as_scalar def moments(self): """ Spatial moments up to 3rd order of the source. """ return np.array([_moments(arr, order=3) for arr in self._moment_data_cutout]) @lazyproperty @as_scalar def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ cutout_centroid = self.cutout_centroid if self.isscalar: cutout_centroid = cutout_centroid[np.newaxis, :] return np.array([_moments_central(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutout, cutout_centroid[:, 0], cutout_centroid[:, 1])]) @lazyproperty @as_scalar def cutout_centroid(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the centroid within the aperture. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ moments = self.moments if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((xcentroid, ycentroid)) @lazyproperty @as_scalar def centroid(self): """ The ``(x, y)`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid + origin @lazyproperty def _xcentroid(self): """ The ``x`` coordinate of the centroid, always as an iterable. """ xcentroid = np.transpose(self.centroid)[0] if self.isscalar: xcentroid = (xcentroid,) return xcentroid @lazyproperty @as_scalar def xcentroid(self): """ The ``x`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return self._xcentroid @lazyproperty def _ycentroid(self): """ The ``y`` coordinate of the centroid, always as an iterable. """ ycentroid = np.transpose(self.centroid)[1] if self.isscalar: ycentroid = (ycentroid,) return ycentroid @lazyproperty @as_scalar def ycentroid(self): """ The ``y`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return self._ycentroid @lazyproperty @as_scalar def sky_centroid(self): """ The sky coordinate of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self._wcs.pixel_to_world(self.xcentroid, self.ycentroid) @lazyproperty @as_scalar def sky_centroid_icrs(self): """ The sky coordinate in the International Celestial Reference System (ICRS) frame of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self.sky_centroid.icrs @lazyproperty def _bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture, always as an iterable. """ apertures = self._pixel_aperture if self.isscalar: apertures = (apertures,) return [aperture.bbox for aperture in apertures] @lazyproperty @as_scalar def bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture. Note that the aperture bounding box is calculated using the exact size of the aperture, which may be slightly larger than the aperture mask calculated using the "center" mode. """ return self._bbox @lazyproperty @as_scalar def _bbox_bounds(self): """ The bounding box x/y minimum and maximum bounds. """ bbox = self.bbox if self.isscalar: bbox = (bbox,) return np.array([(bbox_.ixmin, bbox_.ixmax - 1, bbox_.iymin, bbox_.iymax - 1) for bbox_ in bbox]) @lazyproperty @as_scalar def bbox_xmin(self): """ The minimum ``x`` pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[0] @lazyproperty @as_scalar def bbox_xmax(self): """ The maximum ``x`` pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[1] @lazyproperty @as_scalar def bbox_ymin(self): """ The minimum ``y`` pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[2] @lazyproperty @as_scalar def bbox_ymax(self): """ The maximum ``y`` pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[3] def _calculate_stats(self, stat_func, unit=None): """ Apply the input ``stat_func`` to the 1D array of unmasked data values in the aperture. Units are applied if the input ``data`` has units. Parameters ---------- stat_func : callable The callable to apply to the 1D `~numpy.ndarray` of unmasked data values. unit : `None` or `astropy.unit.Unit`, optional The unit to apply to the output data. This is used only if the input ``data`` has units. If `None` then the input ``data`` unit will be used. """ result = np.array([stat_func(arr) for arr in self._data_values_center]) if unit is None: unit = self._data_unit if unit is not None: result <<= unit return result @lazyproperty @as_scalar def center_aper_area(self): """ The total area of the unmasked pixels within the aperture using the "center" aperture mask method. """ areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout_center]) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @as_scalar def sum_aper_area(self): """ The total area of the unmasked pixels within the aperture using the input ``sum_method`` aperture mask method. """ areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout]) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @as_scalar def sum(self): r""" The sum of the unmasked ``data`` values within the aperture. .. math:: F = \sum_{i \in A} I_i where :math:`F` is ``sum``, :math:`I_i` is the background-subtracted ``data``, and :math:`A` are the unmasked pixels in the aperture. Non-finite pixel values (NaN and inf) are excluded (automatically masked). """ if self.sum_method == 'center': return self._calculate_stats(np.sum) data_values = self._get_values(self.data_sumcutout) result = np.array([np.sum(arr) for arr in data_values]) if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty @as_scalar def sum_err(self): r""" The uncertainty of `sum` , propagated from the input ``error`` array. ``sum_err`` is the quadrature sum of the total errors over the unmasked pixels within the aperture: .. math:: \Delta F = \sqrt{\sum_{i \in A} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `sum`, :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total errors (``error``), and :math:`A` are the unmasked pixels in the aperture. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. """ if self._error is None: err = self._null_value else: if self.sum_method == 'center': variance = self._variance_cutout_center else: variance = self._variance_cutout var_values = [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in variance] err = np.sqrt([np.sum(arr) for arr in var_values]) if self._data_unit is not None: err <<= self._data_unit return err @lazyproperty @as_scalar def min(self): """ The minimum of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.min) @lazyproperty @as_scalar def max(self): """ The maximum of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.max) @lazyproperty @as_scalar def mean(self): """ The mean of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.mean) @lazyproperty @as_scalar def median(self): """ The median of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.median) @lazyproperty @as_scalar def mode(self): """ The mode of the unmasked pixel values within the aperture. The mode is estimated as ``(3 * median) - (2 * mean)``. """ return 3.0 * self.median - 2.0 * self.mean @lazyproperty @as_scalar def std(self): """ The standard deviation of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.std) @lazyproperty @as_scalar def mad_std(self): r""" The standard deviation calculated using the `median absolute deviation (MAD) `_. The standard deviation estimator is given by: .. math:: \sigma \approx \frac{\textrm{MAD}}{\Phi^{-1}(3/4)} \approx 1.4826 \ \textrm{MAD} where :math:`\Phi^{-1}(P)` is the normal inverse cumulative distribution function evaluated at probability :math:`P = 3/4`. """ return self._calculate_stats(mad_std) @lazyproperty @as_scalar def var(self): """ The variance of the unmasked pixel values within the aperture. """ unit = self._data_unit if unit is not None: unit **= 2 return self._calculate_stats(np.var, unit=unit) @lazyproperty @as_scalar def biweight_location(self): """ The biweight location of the unmasked pixel values within the aperture. See `astropy.stats.biweight_location`. """ return self._calculate_stats(biweight_location) @lazyproperty @as_scalar def biweight_midvariance(self): """ The biweight midvariance of the unmasked pixel values within the aperture. See `astropy.stats.biweight_midvariance` """ unit = self._data_unit if unit is not None: unit **= 2 return self._calculate_stats(biweight_midvariance, unit=unit) @lazyproperty @as_scalar def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] mu_02 = moments[:, 0, 2] mu_11 = -moments[:, 1, 1] mu_20 = moments[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) * u.pix**2 @lazyproperty def _covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source, always as an iterable. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mu_norm = moments / moments[:, 0, 0][:, np.newaxis, np.newaxis] covar = np.array([mu_norm[:, 0, 2], mu_norm[:, 1, 1], mu_norm[:, 1, 1], mu_norm[:, 2, 0]]).swapaxes(0, 1) covar = covar.reshape((covar.shape[0], 2, 2)) # Modify the covariance matrix in the case of "infinitely" thin # detections. This follows SourceExtractor's prescription of # incrementally increasing the diagonal elements by 1/12. delta = 1.0 / 12 delta2 = delta**2 # ignore RuntimeWarning from NaN values in covar with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) # covariance should be positive semidefinite idx = np.where(covar_det < 0)[0] covar[idx] = np.array([[np.nan, np.nan], [np.nan, np.nan]]) idx = np.where(covar_det < delta2)[0] while idx.size > 0: covar[idx, 0, 0] += delta covar[idx, 1, 1] += delta covar_det = np.linalg.det(covar) idx = np.where(covar_det < delta2)[0] return covar @lazyproperty @as_scalar def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. """ return self._covariance * (u.pix**2) @lazyproperty @as_scalar def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ eigvals = np.empty((self.n_apertures, 2)) eigvals.fill(np.nan) # np.linalg.eivals requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvals(self._covariance[idx]) # check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) # pragma: no cover eigvals[idx2] = (np.nan, np.nan) # pragma: no cover # sort each eigenvalue pair in descending order eigvals.sort(axis=1) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @as_scalar def semimajor_sigma(self): """ The 1-sigma standard deviation along the semimajor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's A parameter return np.sqrt(eigvals[:, 0]) @lazyproperty @as_scalar def semiminor_sigma(self): """ The 1-sigma standard deviation along the semiminor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's B parameter return np.sqrt(eigvals[:, 1]) @lazyproperty @as_scalar def fwhm(self): r""" The circularized full width at half maximum (FWHM) of the 2D Gaussian function that has the same second-order central moments as the source. .. math:: \mathrm{FWHM} & = 2 \sqrt{2 \ln(2)} \sqrt{0.5 (a^2 + b^2)} \\ & = 2 \sqrt{\ln(2) \ (a^2 + b^2)} where :math:`a` and :math:`b` are the 1-sigma lengths of the semimajor (`semimajor_sigma`) and semiminor (`semiminor_sigma`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_sigma**2 + self.semiminor_sigma**2)) @lazyproperty @as_scalar def orientation(self): """ The angle between the ``x`` axis and the major axis of the 2D Gaussian function that has the same second-order moments as the source. The angle increases in the counter-clockwise direction. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return orient_radians * 180.0 / np.pi * u.deg @lazyproperty @as_scalar def eccentricity(self): r""" The eccentricity of the 2D Gaussian function that has the same second-order moments as the source. The eccentricity is the fraction of the distance along the semimajor axis at which the focus lies. .. math:: e = \sqrt{1 - \frac{b^2}{a^2}} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ semimajor_var, semiminor_var = np.transpose(self.covariance_eigvals) return np.sqrt(1.0 - (semiminor_var / semimajor_var)) @lazyproperty @as_scalar def elongation(self): r""" The ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{elongation} = \frac{a}{b} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return self.semimajor_sigma / self.semiminor_sigma @lazyproperty @as_scalar def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semimajor and semiminor axes (or 1.0 minus the `elongation`). .. math:: \mathrm{ellipticity} = 1 - \frac{b}{a} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return 1.0 - (self.semiminor_sigma / self.semimajor_sigma) @lazyproperty @as_scalar def covar_sigx2(self): r""" The ``(0, 0)`` element of the `covariance` matrix, representing :math:`\sigma_x^2`, in units of pixel**2. """ return self._covariance[:, 0, 0] * u.pix**2 @lazyproperty @as_scalar def covar_sigy2(self): r""" The ``(1, 1)`` element of the `covariance` matrix, representing :math:`\sigma_y^2`, in units of pixel**2. """ return self._covariance[:, 1, 1] * u.pix**2 @lazyproperty @as_scalar def covar_sigxy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_x \sigma_y`, in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @lazyproperty @as_scalar def cxx(self): r""" `SourceExtractor`_'s CXX ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.cos(self.orientation) / self.semimajor_sigma)**2 + (np.sin(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @as_scalar def cyy(self): r""" `SourceExtractor`_'s CYY ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.sin(self.orientation) / self.semimajor_sigma)**2 + (np.cos(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @as_scalar def cxy(self): r""" `SourceExtractor`_'s CXY ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return (2.0 * np.cos(self.orientation) * np.sin(self.orientation) * ((1.0 / self.semimajor_sigma**2) - (1.0 / self.semiminor_sigma**2))) @lazyproperty @as_scalar def gini(self): r""" The `Gini coefficient `_ of the unmasked pixel values within the aperture. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over pixel values :math:`x_i` within the aperture. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. """ gini = [] for arr in self._data_values_center: if np.all(np.isnan(arr)): gini.append(np.nan) continue npix = np.size(arr) normalization = np.abs(np.mean(arr)) * npix * (npix - 1) kernel = ((2.0 * np.arange(1, npix + 1) - npix - 1) * np.abs(np.sort(arr))) gini.append(np.sum(kernel) / normalization) return np.array(gini) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0148852 photutils-1.13.0/photutils/aperture/tests/0000755000175100001770000000000014637570322020271 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/__init__.py0000644000175100001770000000000014637570305022371 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_aperture_common.py0000644000175100001770000000406214637570305025104 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides base classes for aperture tests. """ from astropy.coordinates import SkyCoord from astropy.tests.helper import assert_quantity_allclose from numpy.testing import assert_equal class BaseTestApertureParams: index = 2 slc = slice(0, 2) expected_slc_len = 2 class BaseTestAperture(BaseTestApertureParams): def test_index(self): aper = self.aperture[self.index] assert isinstance(aper, self.aperture.__class__) assert aper.isscalar expected_positions = self.aperture.positions[self.index] for param in aper._params: if param == 'positions': if isinstance(expected_positions, SkyCoord): assert_quantity_allclose(aper.positions.ra, expected_positions.ra) assert_quantity_allclose(aper.positions.dec, expected_positions.dec) else: assert_equal(getattr(aper, param), expected_positions) else: assert (getattr(aper, param) == getattr(self.aperture, param)) def test_slice(self): aper = self.aperture[self.slc] assert isinstance(aper, self.aperture.__class__) assert len(aper) == self.expected_slc_len expected_positions = self.aperture.positions[self.slc] for param in aper._params: if param == 'positions': if isinstance(expected_positions, SkyCoord): assert_quantity_allclose(aper.positions.ra, expected_positions.ra) assert_quantity_allclose(aper.positions.dec, expected_positions.dec) else: assert_equal(getattr(aper, param), expected_positions) else: assert (getattr(aper, param) == getattr(self.aperture, param)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_bounding_box.py0000644000175100001770000001142314637570305024361 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the bounding_box module. """ import pytest from numpy.testing import assert_allclose from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.rectangle import RectangularAperture from photutils.utils._optional_deps import HAS_MATPLOTLIB def test_bounding_box_init(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.ixmin == 1 assert bbox.ixmax == 10 assert bbox.iymin == 2 assert bbox.iymax == 20 def test_bounding_box_init_minmax(): with pytest.raises(ValueError): BoundingBox(100, 1, 1, 100) with pytest.raises(ValueError): BoundingBox(1, 100, 100, 1) def test_bounding_box_inputs(): with pytest.raises(TypeError): BoundingBox([1], [10], [2], [9]) with pytest.raises(TypeError): BoundingBox([1, 2], 10, 2, 9) with pytest.raises(TypeError): BoundingBox(1.0, 10.0, 2.0, 9.0) with pytest.raises(TypeError): BoundingBox(1.3, 10, 2, 9) with pytest.raises(TypeError): BoundingBox(1, 10.3, 2, 9) with pytest.raises(TypeError): BoundingBox(1, 10, 2.3, 9) with pytest.raises(TypeError): BoundingBox(1, 10, 2, 9.3) def test_bounding_box_from_float(): # This is the example from the method docstring bbox = BoundingBox.from_float(xmin=1.0, xmax=10.0, ymin=2.0, ymax=20.0) assert bbox == BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=21) bbox = BoundingBox.from_float(xmin=1.4, xmax=10.4, ymin=1.6, ymax=10.6) assert bbox == BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=12) def test_bounding_box_eq(): bbox = BoundingBox(1, 10, 2, 20) assert bbox == BoundingBox(1, 10, 2, 20) assert bbox != BoundingBox(9, 10, 2, 20) assert bbox != BoundingBox(1, 99, 2, 20) assert bbox != BoundingBox(1, 10, 9, 20) assert bbox != BoundingBox(1, 10, 2, 99) with pytest.raises(TypeError): assert bbox == (1, 10, 2, 20) def test_bounding_box_repr(): bbox = BoundingBox(1, 10, 2, 20) assert repr(bbox) == 'BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20)' def test_bounding_box_shape(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.shape == (18, 9) def test_bounding_box_center(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.center == (10.5, 5) def test_bounding_box_get_overlap_slices(): bbox = BoundingBox(1, 10, 2, 20) slc = ((slice(2, 20, None), slice(1, 10, None)), (slice(0, 18, None), slice(0, 9, None))) assert bbox.get_overlap_slices((50, 50)) == slc bbox = BoundingBox(-10, -1, 2, 20) assert bbox.get_overlap_slices((50, 50)) == (None, None) bbox = BoundingBox(-10, 10, -10, 20) slc = ((slice(0, 20, None), slice(0, 10, None)), (slice(10, 30, None), slice(10, 20, None))) assert bbox.get_overlap_slices((50, 50)) == slc def test_bounding_box_extent(): bbox = BoundingBox(1, 10, 2, 20) assert_allclose(bbox.extent, (0.5, 9.5, 1.5, 19.5)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_as_artist(): bbox = BoundingBox(1, 10, 2, 20) patch = bbox.as_artist() assert_allclose(patch.get_xy(), (0.5, 1.5)) assert_allclose(patch.get_width(), 9) assert_allclose(patch.get_height(), 18) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_plot(): from matplotlib.patches import Rectangle bbox = BoundingBox(1, 10, 2, 20) patch = bbox.plot() assert isinstance(patch, Rectangle) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_to_aperture(): bbox = BoundingBox(1, 10, 2, 20) aper = RectangularAperture((5.0, 10.5), w=9.0, h=18.0, theta=0.0) bbox_aper = bbox.to_aperture() assert_allclose(bbox_aper.positions, aper.positions) assert bbox_aper.w == aper.w assert bbox_aper.h == aper.h assert bbox_aper.theta == aper.theta def test_bounding_box_union(): bbox1 = BoundingBox(1, 10, 2, 20) bbox2 = BoundingBox(5, 21, 7, 32) bbox_union_expected = BoundingBox(1, 21, 2, 32) bbox_union1 = bbox1 | bbox2 bbox_union2 = bbox1.union(bbox2) assert bbox_union1 == bbox_union_expected assert bbox_union1 == bbox_union2 with pytest.raises(TypeError): bbox1.union((5, 21, 7, 32)) def test_bounding_box_intersect(): bbox1 = BoundingBox(1, 10, 2, 20) bbox2 = BoundingBox(5, 21, 7, 32) bbox_intersect_expected = BoundingBox(5, 10, 7, 20) bbox_intersect1 = bbox1 & bbox2 bbox_intersect2 = bbox1.intersection(bbox2) assert bbox_intersect1 == bbox_intersect_expected assert bbox_intersect1 == bbox_intersect2 with pytest.raises(TypeError): bbox1.intersection((5, 21, 7, 32)) assert bbox1.intersection(BoundingBox(30, 40, 50, 60)) is None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_circle.py0000644000175100001770000001460414637570305023151 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circle module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from numpy.testing import assert_allclose from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture from photutils.utils._optional_deps import HAS_MATPLOTLIB POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestCircularAperture(BaseTestAperture): aperture = CircularAperture(POSITIONS, r=3.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self): self.aperture.plot() @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_returns_patches(self): from matplotlib import pyplot as plt from matplotlib.patches import Patch my_patches = self.aperture.plot() assert isinstance(my_patches, list) for patch in my_patches: assert isinstance(patch, Patch) # test creating a legend with these patches plt.legend(my_patches, list(range(len(my_patches)))) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): CircularAperture(POSITIONS, radius) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r = 2.0 assert aper != self.aperture class TestCircularAnnulus(BaseTestAperture): aperture = CircularAnnulus(POSITIONS, r_in=3.0, r_out=7.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self): self.aperture.plot() @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_returns_patches(self): from matplotlib import pyplot as plt from matplotlib.patches import Patch my_patches = self.aperture.plot() assert isinstance(my_patches, list) for p in my_patches: assert isinstance(p, Patch) # make sure I can create a legend with these patches labels = list(range(len(my_patches))) plt.legend(my_patches, labels) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): CircularAnnulus(POSITIONS, r_in=radius, r_out=7.0) with pytest.raises(ValueError): CircularAnnulus(POSITIONS, r_in=3.0, r_out=radius) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r_in = 2.0 assert aper != self.aperture class TestSkyCircularAperture(BaseTestAperture): aperture = SkyCircularAperture(SKYCOORD, r=3.0 * UNIT) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyCircularAperture(SKYCOORD, r=radius * UNIT) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r = 2.0 * UNIT assert aper != self.aperture class TestSkyCircularAnnulus(BaseTestAperture): aperture = SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=7.0 * UNIT) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyCircularAnnulus(SKYCOORD, r_in=radius * UNIT, r_out=7.0 * UNIT) with pytest.raises(ValueError): SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=radius * UNIT) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r_in = 2.0 * UNIT assert aper != self.aperture def test_slicing(): xypos = [(10, 10), (20, 20), (30, 30)] aper1 = CircularAperture(xypos, r=3) aper2 = aper1[0:2] assert len(aper2) == 2 aper3 = aper1[0] assert aper3.isscalar with pytest.raises(TypeError): len(aper3) with pytest.raises(TypeError): _ = aper3[0] def test_area_overlap(): data = np.ones((11, 11)) xypos = [(0, 0), (5, 5), (50, 50)] aper = CircularAperture(xypos, r=3) areas = aper.area_overlap(data) assert_allclose(areas, [10.304636, np.pi * 9.0, np.nan]) data2 = np.ones((11, 11)) * u.Jy areas = aper.area_overlap(data2) assert not isinstance(areas[0], u.Quantity) assert_allclose(areas, [10.304636, np.pi * 9.0, np.nan]) aper2 = CircularAperture(xypos[1], r=3) area2 = aper2.area_overlap(data) assert_allclose(area2, np.pi * 9.0) area2 = aper2.area_overlap(data2) assert not isinstance(area2, u.Quantity) assert_allclose(area2, np.pi * 9.0) def test_area_overlap_mask(): data = np.ones((11, 11)) mask = np.zeros((11, 11), dtype=bool) mask[0, 0:2] = True mask[5, 5:7] = True xypos = [(0, 0), (5, 5), (50, 50)] aper = CircularAperture(xypos, r=3) areas = aper.area_overlap(data, mask=mask) areas_exp = np.array([10.304636, np.pi * 9.0, np.nan]) - 2.0 assert_allclose(areas, areas_exp) with pytest.raises(ValueError): mask = np.zeros((3, 3), dtype=bool) aper.area_overlap(data, mask=mask) def test_invalid_positions(): with pytest.raises(ValueError): _ = CircularAperture([], r=3) with pytest.raises(ValueError): _ = CircularAperture([1], r=3) with pytest.raises(ValueError): _ = CircularAperture([[1]], r=3) with pytest.raises(ValueError): _ = CircularAperture([1, 2, 3], r=3) with pytest.raises(ValueError): _ = CircularAperture([[1, 2, 3]], r=3) with pytest.raises(TypeError): x = np.arange(3) y = np.arange(3) xypos = np.transpose((x, y)) * u.pix _ = CircularAperture(xypos, r=3) with pytest.raises(TypeError): x = np.arange(3) * u.pix y = np.arange(3) xypos = zip(x, y) _ = CircularAperture(xypos, r=3) with pytest.raises(TypeError): x = np.arange(3) * u.pix y = np.arange(3) * u.pix xypos = zip(x, y) _ = CircularAperture(xypos, r=3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_ellipse.py0000644000175100001770000001276214637570305023350 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import Angle, SkyCoord from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestEllipticalAperture(BaseTestAperture): aperture = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=np.pi / 2.0) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): EllipticalAperture(POSITIONS, a=radius, b=5.0, theta=np.pi / 2.0) with pytest.raises(ValueError): EllipticalAperture(POSITIONS, a=10.0, b=radius, theta=np.pi / 2.0) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a = 20.0 assert aper != self.aperture class TestEllipticalAnnulus(BaseTestAperture): aperture = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): EllipticalAnnulus(POSITIONS, a_in=radius, a_out=20.0, b_out=17.0, theta=np.pi / 3) with pytest.raises(ValueError): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=radius, b_out=17.0, theta=np.pi / 3) with pytest.raises(ValueError): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=radius, theta=np.pi / 3) with pytest.raises(ValueError): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, b_in=radius, theta=np.pi / 3) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a_in = 2.0 assert aper != self.aperture class TestSkyEllipticalAperture(BaseTestAperture): aperture = SkyEllipticalAperture(SKYCOORD, a=10.0 * UNIT, b=5.0 * UNIT, theta=30 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyEllipticalAperture(SKYCOORD, a=radius * UNIT, b=5.0 * UNIT, theta=30 * u.deg) with pytest.raises(ValueError): SkyEllipticalAperture(SKYCOORD, a=10.0 * UNIT, b=radius * UNIT, theta=30 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a = 2.0 * UNIT assert aper != self.aperture class TestSkyEllipticalAnnulus(BaseTestAperture): aperture = SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyEllipticalAnnulus(SKYCOORD, a_in=radius * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=radius * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=radius * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, b_in=radius * UNIT, theta=60 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a_in = 2.0 * UNIT assert aper != self.aperture def test_ellipse_theta_quantity(): aper1 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=np.pi / 2.0) theta = u.Quantity(90 * u.deg) aper2 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=theta) theta = Angle(90 * u.deg) aper3 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=theta) assert aper1._theta_radians == aper2._theta_radians assert aper1._theta_radians == aper3._theta_radians def test_ellipse_annulus_theta_quantity(): aper1 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3) theta = u.Quantity(60 * u.deg) aper2 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=theta) theta = Angle(60 * u.deg) aper3 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=theta) assert aper1._theta_radians == aper2._theta_radians assert aper1._theta_radians == aper3._theta_radians ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_mask.py0000644000175100001770000001624014637570305022641 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the mask module. """ import astropy.units as u import numpy as np import pytest from astropy.utils import minversion from numpy.testing import assert_allclose, assert_almost_equal from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.mask import ApertureMask from photutils.aperture.rectangle import RectangularAnnulus NUMPY_LT_2_0 = not minversion(np, '2.0.dev') COPY_IF_NEEDED = False if NUMPY_LT_2_0 else None POSITIONS = [(-20, -20), (-20, 20), (20, -20), (60, 60)] def test_mask_input_shapes(): with pytest.raises(ValueError): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 10, 5, 10) ApertureMask(mask_data, bbox) def test_mask_array(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(mask_data, bbox) data = np.array(mask) assert_allclose(data, mask.data) def test_mask_copy(): bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=True) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 1.0 mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=False) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # no copy; copy=None returns a copy only if __array__ returns a copy # copy=None was introduced in NumPy 2.0 mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=COPY_IF_NEEDED) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # no copy mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.asarray(mask) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # needs to copy because of the dtype change mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.asarray(mask, dtype=int) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 1.0 def test_mask_get_overlap_slices(): aper = CircularAperture((5, 5), r=10.0) mask = aper.to_mask() slc = ((slice(0, 16, None), slice(0, 16, None)), (slice(5, 21, None), slice(5, 21, None))) assert mask.get_overlap_slices((25, 25)) == slc def test_mask_cutout_shape(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(mask_data, bbox) with pytest.raises(ValueError): mask.cutout(np.arange(10)) with pytest.raises(ValueError): mask.to_image((10,)) def test_mask_cutout_copy(): data = np.ones((50, 50)) aper = CircularAperture((25, 25), r=10.0) mask = aper.to_mask() cutout = mask.cutout(data, copy=True) data[25, 25] = 100.0 assert cutout[10, 10] == 1.0 # test quantity data data2 = np.ones((50, 50)) * u.adu cutout2 = mask.cutout(data2, copy=True) assert cutout2.unit == data2.unit data2[25, 25] = 100.0 * u.adu assert cutout2[10, 10].value == 1.0 @pytest.mark.parametrize('position', POSITIONS) def test_mask_cutout_no_overlap(position): data = np.ones((50, 50)) aper = CircularAperture(position, r=10.0) mask = aper.to_mask() cutout = mask.cutout(data) assert cutout is None weighted_data = mask.multiply(data) assert weighted_data is None image = mask.to_image(data.shape) assert image is None @pytest.mark.parametrize('position', POSITIONS) def test_mask_cutout_partial_overlap(position): data = np.ones((50, 50)) aper = CircularAperture(position, r=30.0) mask = aper.to_mask() cutout = mask.cutout(data) assert cutout.shape == mask.shape weighted_data = mask.multiply(data) assert weighted_data.shape == mask.shape image = mask.to_image(data.shape) assert image.shape == data.shape def test_mask_multiply(): radius = 10.0 data = np.ones((50, 50)) aper = CircularAperture((25, 25), r=radius) mask = aper.to_mask() data_weighted = mask.multiply(data) assert_almost_equal(np.sum(data_weighted), np.pi * radius**2) # test that multiply() returns a copy data[25, 25] = 100.0 assert data_weighted[10, 10] == 1.0 def test_mask_multiply_quantity(): radius = 10.0 data = np.ones((50, 50)) * u.adu aper = CircularAperture((25, 25), r=radius) mask = aper.to_mask() data_weighted = mask.multiply(data) assert data_weighted.unit == u.adu assert_almost_equal(np.sum(data_weighted.value), np.pi * radius**2) # test that multiply() returns a copy data[25, 25] = 100.0 * u.adu assert data_weighted[10, 10].value == 1.0 @pytest.mark.parametrize('value', (np.nan, np.inf)) def test_mask_nonfinite_fill_value(value): aper = CircularAnnulus((0, 0), 10, 20) data = np.ones((101, 101)).astype(int) cutout = aper.to_mask().cutout(data, fill_value=value) assert ~np.isfinite(cutout[0, 0]) def test_mask_multiply_fill_value(): aper = CircularAnnulus((0, 0), 10, 20) data = np.ones((101, 101)).astype(int) cutout = aper.to_mask().multiply(data, fill_value=np.nan) xypos = ((20, 20), (5, 5), (5, 35), (35, 5), (35, 35)) for x, y in xypos: assert np.isnan(cutout[y, x]) def test_mask_nonfinite_in_bbox(): """ Regression test that non-finite data values outside of the mask but within the bounding box are set to zero. """ data = np.ones((101, 101)) data[33, 33] = np.nan data[67, 67] = np.inf data[33, 67] = -np.inf data[22, 22] = np.nan data[22, 23] = np.inf radius = 20.0 aper1 = CircularAperture((50, 50), r=radius) aper2 = CircularAperture((5, 5), r=radius) wdata1 = aper1.to_mask(method='exact').multiply(data) assert_allclose(np.sum(wdata1), np.pi * radius**2) wdata2 = aper2.to_mask(method='exact').multiply(data) assert_allclose(np.sum(wdata2), 561.6040111923013) def test_mask_get_values(): aper = CircularAnnulus(((0, 0), (50, 50), (100, 100)), 10, 20) data = np.ones((101, 101)) values = [mask.get_values(data) for mask in aper.to_mask()] shapes = [val.shape for val in values] sums = [np.sum(val) for val in values] assert shapes[0] == (278,) assert shapes[1] == (1068,) assert shapes[2] == (278,) sums_expected = (245.621534, 942.477796, 245.621534) assert_allclose(sums, sums_expected) def test_mask_get_values_no_overlap(): aper = CircularAperture((-100, -100), r=3) data = np.ones((51, 51)) values = aper.to_mask().get_values(data) assert values.shape == (0,) def test_mask_get_values_mask(): aper = CircularAperture((24.5, 24.5), r=10.0) data = np.ones((51, 51)) mask = aper.to_mask() with pytest.raises(ValueError): mask.get_values(data, mask=np.ones(3)) arr = mask.get_values(data, mask=None) assert_allclose(np.sum(arr), 100.0 * np.pi) data_mask = np.zeros(data.shape, dtype=bool) data_mask[25:] = True arr2 = mask.get_values(data, mask=data_mask) assert_allclose(np.sum(arr2), 100.0 * np.pi / 2.0) def test_rectangular_annulus_hin(): aper = RectangularAnnulus((25, 25), 2, 4, 20, h_in=18, theta=0) mask = aper.to_mask(method='center') assert mask.data.shape == (21, 5) assert np.count_nonzero(mask.data) == 40 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_photometry.py0000644000175100001770000007665514637570305024140 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.io import fits from astropy.nddata import NDData, StdDevUncertainty from astropy.table import Table from astropy.wcs import WCS from numpy.testing import assert_allclose, assert_array_less, assert_equal from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.photometry import aperture_photometry from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.datasets import (get_path, make_4gaussians_image, make_gwcs, make_wcs) from photutils.utils._optional_deps import HAS_GWCS, HAS_MATPLOTLIB APERTURE_CL = [CircularAperture, CircularAnnulus, EllipticalAperture, EllipticalAnnulus, RectangularAperture, RectangularAnnulus] TEST_APERTURES = list(zip(APERTURE_CL, ((3.0,), (3.0, 5.0), (3.0, 5.0, 1.0), (3.0, 5.0, 4.0, 12.0 / 5.0, 1.0), (5, 8, np.pi / 4), (8, 12, 8, 16.0 / 3.0, np.pi / 8)))) @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_outside_array(aperture_class, params): data = np.ones((10, 10), dtype=float) aperture = aperture_class((-60, 60), *params) fluxtable = aperture_photometry(data, aperture) # aperture is fully outside array: assert np.isnan(fluxtable['aperture_sum']) @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_inside_array_simple(aperture_class, params): data = np.ones((40, 40), dtype=float) aperture = aperture_class((20.0, 20.0), *params) table1 = aperture_photometry(data, aperture, method='center', subpixels=10) table2 = aperture_photometry(data, aperture, method='subpixel', subpixels=10) table3 = aperture_photometry(data, aperture, method='exact', subpixels=10) true_flux = aperture.area assert table1['aperture_sum'] < table3['aperture_sum'] if not isinstance(aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum'], true_flux) assert_allclose(table2['aperture_sum'], table3['aperture_sum'], atol=0.1) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_aperture_plots(aperture_class, params): # This test should run without any errors, and there is no return # value. # TODO: check the content of the plot aperture = aperture_class((20.0, 20.0), *params) aperture.plot() def test_aperture_pixel_positions(): pos1 = (10, 20) pos2 = [(10, 20)] r = 3 ap1 = CircularAperture(pos1, r) ap2 = CircularAperture(pos2, r) assert not np.array_equal(ap1.positions, ap2.positions) class BaseTestAperturePhotometry: def test_array_error(self): # Array error error = np.ones(self.data.shape, dtype=float) if not hasattr(self, 'mask'): mask = None true_error = np.sqrt(self.area) else: mask = self.mask # 1 masked pixel true_error = np.sqrt(self.area - 1) table1 = aperture_photometry(self.data, self.aperture, method='center', mask=mask, error=error) table2 = aperture_photometry(self.data, self.aperture, method='subpixel', subpixels=12, mask=mask, error=error) table3 = aperture_photometry(self.data, self.aperture, method='exact', mask=mask, error=error) if not isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum'], self.true_flux) assert_allclose(table2['aperture_sum'], table3['aperture_sum'], atol=0.1) assert np.all(table1['aperture_sum'] < table3['aperture_sum']) if not isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum_err'], true_error) assert_allclose(table2['aperture_sum_err'], table3['aperture_sum_err'], atol=0.1) assert np.all(table1['aperture_sum_err'] < table3['aperture_sum_err']) class TestCircular(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.true_flux = self.area class TestCircularArray(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = ((20.0, 20.0), (25.0, 25.0)) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.area = np.array((self.area,) * 2) self.true_flux = self.area class TestCircularAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) r_in = 8.0 r_out = 10.0 self.aperture = CircularAnnulus(position, r_in, r_out) self.area = np.pi * (r_out * r_out - r_in * r_in) self.true_flux = self.area class TestCircularAnnulusArray(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = ((20.0, 20.0), (25.0, 25.0)) r_in = 8.0 r_out = 10.0 self.aperture = CircularAnnulus(position, r_in, r_out) self.area = np.pi * (r_out * r_out - r_in * r_in) self.area = np.array((self.area,) * 2) self.true_flux = self.area class TestElliptical(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) a = 10.0 b = 5.0 theta = -np.pi / 4.0 self.aperture = EllipticalAperture(position, a, b, theta=theta) self.area = np.pi * a * b self.true_flux = self.area class TestEllipticalAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) a_in = 5.0 a_out = 8.0 b_out = 5.0 theta = -np.pi / 4.0 self.aperture = EllipticalAnnulus(position, a_in, a_out, b_out, theta=theta) self.area = (np.pi * (a_out * b_out) - np.pi * (a_in * b_out * a_in / a_out)) self.true_flux = self.area class TestRectangularAperture(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) h = 5.0 w = 8.0 theta = np.pi / 4.0 self.aperture = RectangularAperture(position, w, h, theta=theta) self.area = h * w self.true_flux = self.area class TestRectangularAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) h_out = 8.0 w_in = 8.0 w_out = 12.0 h_in = w_in * h_out / w_out theta = np.pi / 8.0 self.aperture = RectangularAnnulus(position, w_in, w_out, h_out, theta=theta) self.area = h_out * w_out - h_in * w_in self.true_flux = self.area class TestMaskedSkipCircular(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) self.mask = np.zeros((40, 40), dtype=bool) self.mask[20, 20] = True position = (20.0, 20.0) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.true_flux = self.area - 1 class BaseTestDifferentData: def test_basic_circular_aperture_photometry(self): aperture = CircularAperture(self.position, self.radius) table = aperture_photometry(self.data, aperture, method='exact') assert_allclose(table['aperture_sum'].value, self.true_flux) assert table['aperture_sum'].unit, self.fluxunit assert np.all(table['xcenter'].value == np.transpose(self.position)[0]) assert np.all(table['ycenter'].value == np.transpose(self.position)[1]) class TestInputNDData(BaseTestDifferentData): def setup_class(self): data = np.ones((40, 40), dtype=float) self.data = NDData(data, unit=u.adu) self.radius = 3 self.position = [(20, 20), (30, 30)] self.true_flux = np.pi * self.radius * self.radius self.fluxunit = u.adu @pytest.mark.remote_data def test_wcs_based_photometry_to_catalog(): pathcat = get_path('spitzer_example_catalog.xml', location='remote') pathhdu = get_path('spitzer_example_image.fits', location='remote') hdu = fits.open(pathhdu) data = u.Quantity(hdu[0].data, unit=hdu[0].header['BUNIT']) wcs = WCS(hdu[0].header) catalog = Table.read(pathcat) pos_skycoord = SkyCoord(catalog['l'], catalog['b'], frame='galactic') photometry_skycoord = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 4 * u.arcsec), wcs=wcs) # Photometric unit conversion is needed to match the catalog factor = (1.2 * u.arcsec) ** 2 / u.pixel converted_aperture_sum = (photometry_skycoord['aperture_sum'] * factor).to(u.mJy / u.pixel) fluxes_catalog = catalog['f4_5'].filled() # There shouldn't be large outliers, but some differences is OK, as # fluxes_catalog is based on PSF photometry, etc. assert_allclose(fluxes_catalog, converted_aperture_sum.value, rtol=1e0) assert np.mean(np.fabs((fluxes_catalog - converted_aperture_sum.value) / fluxes_catalog)) < 0.1 # close the file hdu.close() def test_wcs_based_photometry(): data = make_4gaussians_image() wcs = make_wcs(data.shape) # hard wired positions in make_4gaussian_image pos_orig_pixel = u.Quantity(([160.0, 25.0, 150.0, 90.0], [70.0, 40.0, 25.0, 60.0]), unit=u.pixel) pos_skycoord = wcs.pixel_to_world(pos_orig_pixel[0], pos_orig_pixel[1]) pos_skycoord_s = pos_skycoord[2] photometry_skycoord_circ = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 3 * u.arcsec), wcs=wcs) photometry_skycoord_circ_2 = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 2 * u.arcsec), wcs=wcs) photometry_skycoord_circ_s = aperture_photometry( data, SkyCircularAperture(pos_skycoord_s, 3 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_circ['aperture_sum'][2], photometry_skycoord_circ_s['aperture_sum']) photometry_skycoord_circ_ann = aperture_photometry( data, SkyCircularAnnulus(pos_skycoord, 2 * u.arcsec, 3 * u.arcsec), wcs=wcs) photometry_skycoord_circ_ann_s = aperture_photometry( data, SkyCircularAnnulus(pos_skycoord_s, 2 * u.arcsec, 3 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_circ_ann['aperture_sum'][2], photometry_skycoord_circ_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_circ_ann['aperture_sum'], photometry_skycoord_circ['aperture_sum'] - photometry_skycoord_circ_2['aperture_sum']) photometry_skycoord_ell = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_2 = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord, 2 * u.arcsec, 2.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_s = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord_s, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_ann = aperture_photometry( data, SkyEllipticalAnnulus(pos_skycoord, 2 * u.arcsec, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_ann_s = aperture_photometry( data, SkyEllipticalAnnulus(pos_skycoord_s, 2 * u.arcsec, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_ell['aperture_sum'][2], photometry_skycoord_ell_s['aperture_sum']) assert_allclose(photometry_skycoord_ell_ann['aperture_sum'][2], photometry_skycoord_ell_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_ell['aperture_sum'], photometry_skycoord_circ['aperture_sum'], rtol=5e-3) assert_allclose(photometry_skycoord_ell_ann['aperture_sum'], photometry_skycoord_ell['aperture_sum'] - photometry_skycoord_ell_2['aperture_sum'], rtol=1e-4) photometry_skycoord_rec = aperture_photometry( data, SkyRectangularAperture(pos_skycoord, 6 * u.arcsec, 6 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_4 = aperture_photometry( data, SkyRectangularAperture(pos_skycoord, 4 * u.arcsec, 4 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_s = aperture_photometry( data, SkyRectangularAperture(pos_skycoord_s, 6 * u.arcsec, 6 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_ann = aperture_photometry( data, SkyRectangularAnnulus(pos_skycoord, 4 * u.arcsec, 6 * u.arcsec, 6 * u.arcsec, theta=0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_ann_s = aperture_photometry( data, SkyRectangularAnnulus(pos_skycoord_s, 4 * u.arcsec, 6 * u.arcsec, 6 * u.arcsec, theta=0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) assert_allclose(photometry_skycoord_rec['aperture_sum'][2], photometry_skycoord_rec_s['aperture_sum']) assert np.all(photometry_skycoord_rec['aperture_sum'] > photometry_skycoord_circ['aperture_sum']) assert_allclose(photometry_skycoord_rec_ann['aperture_sum'][2], photometry_skycoord_rec_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_rec_ann['aperture_sum'], photometry_skycoord_rec['aperture_sum'] - photometry_skycoord_rec_4['aperture_sum'], rtol=1e-4) def test_basic_circular_aperture_photometry_unit(): radius = 3 true_flux = np.pi * radius * radius aper = CircularAperture((12, 12), radius) data1 = np.ones((25, 25), dtype=float) table1 = aperture_photometry(data1, aper) assert_allclose(table1['aperture_sum'], true_flux) unit = u.adu data2 = u.Quantity(data1 * unit) table2 = aperture_photometry(data2, aper) assert_allclose(table2['aperture_sum'].value, true_flux) assert table2['aperture_sum'].unit == data2.unit == unit error1 = np.ones((25, 25)) with pytest.raises(ValueError): # data has unit, but error does not aperture_photometry(data2, aper, error=error1) error2 = u.Quantity(error1 * u.Jy) with pytest.raises(ValueError): # data and error have different units aperture_photometry(data2, aper, error=error2) def test_aperture_photometry_with_error_units(): """Test aperture_photometry when error has units (see #176).""" data1 = np.ones((40, 40), dtype=float) data2 = u.Quantity(data1, unit=u.adu) error = u.Quantity(data1, unit=u.adu) radius = 3 true_flux = np.pi * radius * radius unit = u.adu position = (20, 20) table1 = aperture_photometry(data2, CircularAperture(position, radius), error=error) assert_allclose(table1['aperture_sum'].value, true_flux) assert_allclose(table1['aperture_sum_err'].value, np.sqrt(true_flux)) assert table1['aperture_sum'].unit == unit assert table1['aperture_sum_err'].unit == unit def test_aperture_photometry_inputs_with_mask(): """ Test that aperture_photometry does not modify the input data or error array when a mask is input. """ data = np.ones((5, 5)) aperture = CircularAperture((2, 2), 2.0) mask = np.zeros_like(data, dtype=bool) data[2, 2] = 100.0 # bad pixel mask[2, 2] = True error = np.sqrt(data) data_in = data.copy() error_in = error.copy() t1 = aperture_photometry(data, aperture, error=error, mask=mask) assert_equal(data, data_in) assert_equal(error, error_in) assert_allclose(t1['aperture_sum'][0], 11.5663706144) t2 = aperture_photometry(data, aperture) assert_allclose(t2['aperture_sum'][0], 111.566370614) TEST_ELLIPSE_EXACT_APERTURES = [(3.469906, 3.923861394, 3.0), (0.3834415188257778, 0.3834415188257778, 0.3)] @pytest.mark.parametrize('x,y,r', TEST_ELLIPSE_EXACT_APERTURES) def test_ellipse_exact_grid(x, y, r): """ Test elliptical exact aperture photometry on a grid of pixel positions. This is a regression test for the bug discovered in this issue: https://github.com/astropy/photutils/issues/198 """ data = np.ones((10, 10)) aperture = EllipticalAperture((x, y), r, r, 0.0) t = aperture_photometry(data, aperture, method='exact') actual = t['aperture_sum'][0] / (np.pi * r**2) assert_allclose(actual, 1) @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_nan_inf_mask(value): """Test that nans and infs are properly masked [267].""" data = np.ones((9, 9)) mask = np.zeros_like(data, dtype=bool) data[4, 4] = value mask[4, 4] = True radius = 2.0 aper = CircularAperture((4, 4), radius) tbl = aperture_photometry(data, aper, mask=mask) desired = (np.pi * radius**2) - 1 assert_allclose(tbl['aperture_sum'], desired) def test_aperture_partial_overlap(): data = np.ones((20, 20)) error = np.ones((20, 20)) xypos = [(10, 10), (0, 0), (0, 19), (19, 0), (19, 19)] r = 5.0 aper = CircularAperture(xypos, r=r) tbl = aperture_photometry(data, aper, error=error) assert_allclose(tbl['aperture_sum'][0], np.pi * r**2) assert_array_less(tbl['aperture_sum'][1:], np.pi * r**2) unit = u.MJy / u.sr tbl = aperture_photometry(data * unit, aper, error=error * unit) assert_allclose(tbl['aperture_sum'][0].value, np.pi * r**2) assert_array_less(tbl['aperture_sum'][1:].value, np.pi * r**2) assert_array_less(tbl['aperture_sum_err'][1:].value, np.pi * r**2) assert tbl['aperture_sum'].unit == unit assert tbl['aperture_sum_err'].unit == unit def test_pixel_aperture_repr(): aper = CircularAperture((10, 20), r=3.0) assert ', r=3.0 deg)>') a_str = ('Aperture: SkyCircularAperture\npositions: \n' 'r: 3.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyCircularAnnulus(s, r_in=3.0 * u.deg, r_out=5 * u.deg) a_repr = (', r_in=3.0 deg, r_out=5.0 deg)>') a_str = ('Aperture: SkyCircularAnnulus\npositions: \n' 'r_in: 3.0 deg\nr_out: 5.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyEllipticalAperture(s, a=3 * u.deg, b=5 * u.deg, theta=15 * u.deg) a_repr = (', a=3.0 deg, b=5.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyEllipticalAperture\npositions: \n' 'a: 3.0 deg\nb: 5.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyEllipticalAnnulus(s, a_in=3 * u.deg, a_out=5 * u.deg, b_out=3 * u.deg, theta=15 * u.deg) a_repr = (', a_in=3.0 deg, ' 'a_out=5.0 deg, b_in=1.8 deg, b_out=3.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyEllipticalAnnulus\npositions: \n' 'a_in: 3.0 deg\na_out: 5.0 deg\nb_in: 1.8 deg\n' 'b_out: 3.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyRectangularAperture(s, w=3 * u.deg, h=5 * u.deg, theta=15 * u.deg) a_repr = (', w=3.0 deg, h=5.0 deg' ', theta=15.0 deg)>') a_str = ('Aperture: SkyRectangularAperture\npositions: \n' 'w: 3.0 deg\nh: 5.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyRectangularAnnulus(s, w_in=5 * u.deg, w_out=10 * u.deg, h_out=6 * u.deg, theta=15 * u.deg) a_repr = (', w_in=5.0 deg, ' 'w_out=10.0 deg, h_in=3.0 deg, h_out=6.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyRectangularAnnulus\npositions: \n' 'w_in: 5.0 deg\nw_out: 10.0 deg\nh_in: 3.0 deg\n' 'h_out: 6.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str def test_rectangular_bbox(): # test odd sizes width = 7 height = 3 a = RectangularAperture((50, 50), w=width, h=height, theta=0) assert a.bbox.shape == (height, width) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=0) assert a.bbox.shape == (height + 1, width + 1) a = RectangularAperture((50, 50), w=width, h=height, theta=90.0 * np.pi / 180.0) assert a.bbox.shape == (width, height) # test even sizes width = 8 height = 4 a = RectangularAperture((50, 50), w=width, h=height, theta=0) assert a.bbox.shape == (height + 1, width + 1) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=0) assert a.bbox.shape == (height, width) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=90.0 * np.pi / 180.0) assert a.bbox.shape == (width, height) def test_elliptical_bbox(): # integer axes a = 7 b = 3 ap = EllipticalAperture((50, 50), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b + 1, 2 * a + 1) ap = EllipticalAperture((50.5, 50.5), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b, 2 * a) ap = EllipticalAperture((50, 50), a=a, b=b, theta=90.0 * np.pi / 180.0) assert ap.bbox.shape == (2 * a + 1, 2 * b + 1) # fractional axes a = 7.5 b = 4.5 ap = EllipticalAperture((50, 50), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b, 2 * a) ap = EllipticalAperture((50.5, 50.5), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b + 1, 2 * a + 1) ap = EllipticalAperture((50, 50), a=a, b=b, theta=90.0 * np.pi / 180.0) assert ap.bbox.shape == (2 * a, 2 * b) @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') @pytest.mark.parametrize('wcs_type', ('wcs', 'gwcs')) def test_to_sky_pixel(wcs_type): data = make_4gaussians_image() if wcs_type == 'wcs': wcs = make_wcs(data.shape) elif wcs_type == 'gwcs': wcs = make_gwcs(data.shape) ap = CircularAperture(((12.3, 15.7), (48.19, 98.14)), r=3.14) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.r, ap2.r) ap = CircularAnnulus(((12.3, 15.7), (48.19, 98.14)), r_in=3.14, r_out=5.32) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.r_in, ap2.r_in) assert_allclose(ap.r_out, ap2.r_out) ap = EllipticalAperture(((12.3, 15.7), (48.19, 98.14)), a=3.14, b=5.32, theta=103.0 * np.pi / 180.0) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.a, ap2.a) assert_allclose(ap.b, ap2.b) assert_allclose(ap.theta, ap2.theta) ap = EllipticalAnnulus(((12.3, 15.7), (48.19, 98.14)), a_in=3.14, a_out=15.32, b_out=4.89, theta=103.0 * np.pi / 180.0) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.a_in, ap2.a_in) assert_allclose(ap.a_out, ap2.a_out) assert_allclose(ap.b_out, ap2.b_out) assert_allclose(ap.theta, ap2.theta) ap = RectangularAperture(((12.3, 15.7), (48.19, 98.14)), w=3.14, h=5.32, theta=103.0 * np.pi / 180.0) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.w, ap2.w) assert_allclose(ap.h, ap2.h) assert_allclose(ap.theta, ap2.theta) ap = RectangularAnnulus(((12.3, 15.7), (48.19, 98.14)), w_in=3.14, w_out=15.32, h_out=4.89, theta=103.0 * np.pi / 180.0) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.w_in, ap2.w_in) assert_allclose(ap.w_out, ap2.w_out) assert_allclose(ap.h_out, ap2.h_out) assert_allclose(ap.theta, ap2.theta) def test_scalar_aperture(): """ Regression test to check that length-1 aperture list appends a "_0" on the column names to be consistent with list inputs. """ data = np.ones((20, 20), dtype=float) ap = CircularAperture((10, 10), r=3.0) colnames1 = aperture_photometry(data, ap, error=data).colnames assert (colnames1 == ['id', 'xcenter', 'ycenter', 'aperture_sum', 'aperture_sum_err']) colnames2 = aperture_photometry(data, [ap], error=data).colnames assert (colnames2 == ['id', 'xcenter', 'ycenter', 'aperture_sum_0', 'aperture_sum_err_0']) colnames3 = aperture_photometry(data, [ap, ap], error=data).colnames assert (colnames3 == ['id', 'xcenter', 'ycenter', 'aperture_sum_0', 'aperture_sum_err_0', 'aperture_sum_1', 'aperture_sum_err_1']) def test_nan_in_bbox(): """ Regression test that non-finite data values outside of the aperture mask but within the bounding box do not affect the photometry. """ data1 = np.ones((101, 101)) data2 = data1.copy() data1[33, 33] = np.nan data1[67, 67] = np.inf data1[33, 67] = -np.inf data1[22, 22] = np.nan data1[22, 23] = np.inf error = data1.copy() aper1 = CircularAperture((50, 50), r=20.0) aper2 = CircularAperture((5, 5), r=20.0) tbl1 = aperture_photometry(data1, aper1, error=error) tbl2 = aperture_photometry(data2, aper1, error=error) assert_allclose(tbl1['aperture_sum'], tbl2['aperture_sum']) assert_allclose(tbl1['aperture_sum_err'], tbl2['aperture_sum_err']) tbl3 = aperture_photometry(data1, aper2, error=error) tbl4 = aperture_photometry(data2, aper2, error=error) assert_allclose(tbl3['aperture_sum'], tbl4['aperture_sum']) assert_allclose(tbl3['aperture_sum_err'], tbl4['aperture_sum_err']) def test_scalar_skycoord(): """ Regression test to check that scalar SkyCoords are added to the table as a length-1 SkyCoord array. """ data = make_4gaussians_image() wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(90, 60) aper = SkyCircularAperture(skycoord, r=0.1 * u.arcsec) tbl = aperture_photometry(data, aper, wcs=wcs) assert isinstance(tbl['sky_center'], SkyCoord) def test_nddata_input(): data = np.arange(400).reshape((20, 20)) error = np.sqrt(data) mask = np.zeros((20, 20), dtype=bool) mask[8:13, 8:13] = True unit = 'adu' wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) aper = SkyCircularAperture(skycoord, r=0.7 * u.arcsec) tbl1 = aperture_photometry(data * u.adu, aper, error=error * u.adu, mask=mask, wcs=wcs) uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty, mask=mask, wcs=wcs, unit=unit) tbl2 = aperture_photometry(nddata, aper) for column in tbl1.columns: if column == 'sky_center': # cannot test SkyCoord equality continue assert_allclose(tbl1[column], tbl2[column]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_rectangle.py0000644000175100001770000001315714637570305023656 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangle module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import Angle, SkyCoord from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestRectangularAperture(BaseTestAperture): aperture = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=np.pi / 2.0) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): RectangularAperture(POSITIONS, w=radius, h=5.0, theta=np.pi / 2.0) with pytest.raises(ValueError): RectangularAperture(POSITIONS, w=10.0, h=radius, theta=np.pi / 2.0) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w = 20.0 assert aper != self.aperture class TestRectangularAnnulus(BaseTestAperture): aperture = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): RectangularAnnulus(POSITIONS, w_in=radius, w_out=20.0, h_out=17, theta=np.pi / 3) with pytest.raises(ValueError): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=radius, h_out=17, theta=np.pi / 3) with pytest.raises(ValueError): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=radius, theta=np.pi / 3) with pytest.raises(ValueError): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, h_in=radius, theta=np.pi / 3) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w_in = 2.0 assert aper != self.aperture class TestSkyRectangularAperture(BaseTestAperture): aperture = SkyRectangularAperture(SKYCOORD, w=10.0 * UNIT, h=5.0 * UNIT, theta=30 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyRectangularAperture(SKYCOORD, w=radius * UNIT, h=5.0 * UNIT, theta=30 * u.deg) with pytest.raises(ValueError): SkyRectangularAperture(SKYCOORD, w=10.0 * UNIT, h=radius * UNIT, theta=30 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w = 20.0 * UNIT assert aper != self.aperture class TestSkyRectangularAnnulus(BaseTestAperture): aperture = SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): with pytest.raises(ValueError): SkyRectangularAnnulus(SKYCOORD, w_in=radius * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=radius * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=radius * UNIT, theta=60 * u.deg) with pytest.raises(ValueError): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, h_in=radius * UNIT, theta=60 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w_in = 2.0 * UNIT assert aper != self.aperture def test_rectangle_theta_quantity(): aper1 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=np.pi / 2.0) theta = u.Quantity(90 * u.deg) aper2 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=theta) theta = Angle(90 * u.deg) aper3 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=theta) assert aper1._theta_radians == aper2._theta_radians assert aper1._theta_radians == aper3._theta_radians def test_rectangle_annulus_theta_quantity(): aper1 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3) theta = u.Quantity(60 * u.deg) aper2 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=theta) theta = Angle(60 * u.deg) aper3 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=theta) assert aper1._theta_radians == aper2._theta_radians assert aper1._theta_radians == aper3._theta_radians ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/aperture/tests/test_stats.py0000644000175100001770000003272514637570305023052 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the stats module. """ import astropy.units as u import numpy as np import pytest from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_equal from photutils.aperture.circle import CircularAperture from photutils.aperture.stats import ApertureStats from photutils.datasets import make_100gaussians_image, make_wcs class TestApertureStats: data = make_100gaussians_image() error = np.sqrt(np.abs(data)) wcs = make_wcs(data.shape) positions = [(145.1, 168.3), (84.7, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) sigclip = SigmaClip(sigma=3.0, maxiters=10) apstats1 = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=None) apstats2 = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=sigclip) unit = u.Jy apstats1_units = ApertureStats(data * u.Jy, aperture, error=error * u.Jy, wcs=wcs, sigma_clip=None) apstats2_units = ApertureStats(data * u.Jy, aperture, error=error * u.Jy, wcs=wcs, sigma_clip=sigclip) @pytest.mark.parametrize('with_units', (True, False)) @pytest.mark.parametrize('with_sigmaclip', (True, False)) def test_properties(self, with_units, with_sigmaclip): apstats = [self.apstats1.copy(), self.apstats2.copy(), self.apstats1_units.copy(), self.apstats2_units.copy()] if with_sigmaclip: index = [1, 3] else: index = [0, 2] if with_units: index = index[1] else: index = index[0] apstats1 = apstats[index] apstats2 = apstats1.copy() idx = 1 scalar_props = ('isscalar', 'n_apertures') # evaluate (cache) properties before slice for prop in apstats1.properties: _ = getattr(apstats1, prop) apstats3 = apstats1[idx] for prop in apstats1.properties: if prop in scalar_props: continue assert_equal(getattr(apstats1, prop)[idx], getattr(apstats3, prop)) # slice catalog before evaluating catalog properties apstats4 = apstats2[idx] for prop in apstats1.properties: if prop in scalar_props: continue assert_equal(getattr(apstats4, prop), getattr(apstats1, prop)[idx]) def test_skyaperture(self): pix_apstats = ApertureStats(self.data, self.aperture, wcs=self.wcs) skyaper = self.aperture.to_sky(self.wcs) sky_apstats = ApertureStats(self.data, skyaper, wcs=self.wcs) exclude_props = ('bbox', 'error_sumcutout', 'sum_error', 'sky_centroid', 'sky_centroid_icrs') for prop in pix_apstats.properties: if prop in exclude_props: continue assert_allclose(getattr(pix_apstats, prop), getattr(sky_apstats, prop), atol=1e-7) with pytest.raises(ValueError): _ = ApertureStats(self.data, skyaper) def test_minimal_inputs(self): apstats = ApertureStats(self.data, self.aperture) props = ('sky_centroid', 'sky_centroid_icrs', 'error_sumcutout') for prop in props: assert set(getattr(apstats, prop)) == {None} assert np.all(np.isnan(apstats.sum_err)) assert set(apstats._variance_cutout) == {None} apstats = ApertureStats(self.data, self.aperture, sum_method='center') assert set(apstats._variance_cutout_center) == {None} @pytest.mark.parametrize('sum_method', ('exact', 'subpixel')) def test_sum_method(self, sum_method): apstats1 = ApertureStats(self.data, self.aperture, error=self.error, sum_method='center') apstats2 = ApertureStats(self.data, self.aperture, error=self.error, sum_method=sum_method, subpixels=4) scalar_props = ('isscalar', 'n_apertures') # evaluate (cache) properties before slice for prop in apstats1.properties: if prop in scalar_props: continue if 'sum' in prop: # test that these properties are not equal with pytest.raises(AssertionError): assert_equal(getattr(apstats1, prop), getattr(apstats2, prop)) else: assert_equal(getattr(apstats1, prop), getattr(apstats2, prop)) def test_sum_method_photometry(self): for method in ('center', 'exact', 'subpixel'): subpixels = 4 apstats = ApertureStats(self.data, self.aperture, error=self.error, sum_method=method, subpixels=subpixels) apsum, apsum_err = self.aperture.do_photometry(self.data, self.error, method=method, subpixels=subpixels) assert_allclose(apstats.sum, apsum) assert_allclose(apstats.sum_err, apsum_err) def test_mask(self): mask = np.zeros(self.data.shape, dtype=bool) mask[225:240, 80:90] = True # partially mask id=2 mask[190:210, 40:60] = True # completely mask id=3 apstats = ApertureStats(self.data, self.aperture, mask=mask, error=self.error) # id=2 is partially masked assert apstats[1].sum < self.apstats1[1].sum assert apstats[1].sum_err < self.apstats1[1].sum_err exclude = ('isscalar', 'n_apertures', 'sky_centroid', 'sky_centroid_icrs') apstats1 = apstats[2] for prop in apstats1.properties: if (prop in exclude or 'bbox' in prop or 'cutout' in prop or 'moments' in prop): continue assert np.all(np.isnan(getattr(apstats1, prop))) # test that mask=None is the same as mask=np.ma.nomask apstats1 = ApertureStats(self.data, self.aperture, mask=None) apstats2 = ApertureStats(self.data, self.aperture, mask=np.ma.nomask) assert_equal(apstats1.centroid, apstats2.centroid) def test_local_bkg(self): data = np.ones(self.data.shape) * 100.0 local_bkg = (10, 20, 30) apstats = ApertureStats(data, self.aperture, local_bkg=local_bkg) for i, locbkg in enumerate(local_bkg): apstats0 = ApertureStats(data - locbkg, self.aperture[i], local_bkg=None) for prop in apstats.properties: assert_equal(getattr(apstats[i], prop), getattr(apstats0, prop)) # test broadcasting local_bkg = (12, 12, 12) apstats1 = ApertureStats(data, self.aperture, local_bkg=local_bkg) apstats2 = ApertureStats(data, self.aperture, local_bkg=local_bkg[0]) assert_equal(apstats1.sum, apstats2.sum) with pytest.raises(ValueError): _ = ApertureStats(data, self.aperture, local_bkg=(10, 20)) with pytest.raises(ValueError): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(10, np.nan)) with pytest.raises(ValueError): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(-np.inf, 10)) with pytest.raises(ValueError): _ = ApertureStats(data, self.aperture[0:2], local_bkg=np.ones((3, 3))) def test_no_aperture_overlap(self): aperture = CircularAperture(((0, 0), (100, 100), (-100, -100)), r=5) apstats = ApertureStats(self.data, aperture) assert_equal(apstats._overlap, [True, True, False]) exclude = ('isscalar', 'n_apertures', 'sky_centroid', 'sky_centroid_icrs') apstats1 = apstats[2] for prop in apstats1.properties: if (prop in exclude or 'bbox' in prop or 'cutout' in prop or 'moments' in prop): continue assert np.all(np.isnan(getattr(apstats1, prop))) def test_to_table(self): tbl = self.apstats1.to_table() assert tbl.colnames == self.apstats1.default_columns assert len(tbl) == len(self.apstats1) == 3 columns = ['id', 'min', 'max', 'mean', 'median', 'std', 'sum'] tbl = self.apstats1.to_table(columns=columns) assert tbl.colnames == columns assert len(tbl) == len(self.apstats1) == 3 def test_slicing(self): apstats = self.apstats1 _ = apstats.to_table() apstat0 = apstats[1] assert apstat0.n_apertures == 1 assert apstat0.ids == np.array([2]) apstat1 = apstats.get_id(2) assert apstat1.n_apertures == 1 assert apstat0.sum_aper_area == apstat1.sum_aper_area apstat0 = apstats[0:1] assert len(apstat0) == 1 apstat0 = apstats[0:2] assert len(apstat0) == 2 apstat0 = apstats[0:3] assert len(apstat0) == 3 apstat0 = apstats[1:] apstat1 = apstats.get_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[1:] apstat1 = apstats.get_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[[2, 1, 0]] apstat1 = apstats.get_ids([3, 2, 1]) assert len(apstat0) == len(apstat1) == 3 assert_equal(apstat0.ids, [3, 2, 1]) assert_equal(apstat1.ids, [3, 2, 1]) # test get_ids when ids are not sorted apstat0 = apstats[[2, 1, 0]] apstat1 = apstat0.get_ids(2) assert apstat1.ids == 2 mask = apstats.id >= 2 apstat0 = apstats[mask] assert len(apstat0) == 2 assert_equal(apstat0.ids, [2, 3]) # test iter for (i, apstat) in enumerate(apstats): assert apstat.isscalar assert apstat.id == (i + 1) with pytest.raises(TypeError): _ = len(apstats[0]) with pytest.raises(TypeError): apstat0 = apstats[0] apstat1 = apstat0[0] with pytest.raises(TypeError): apstat0 = apstats[0] apstat1 = apstat0[0] # can't slice scalar object with pytest.raises(ValueError): apstat0 = apstats.get_ids([-1, 0]) def test_scalar_aperture_stats(self): apstats = self.apstats1[0] assert apstats.n_apertures == 1 assert apstats.ids == np.array([1]) tbl = apstats.to_table() assert len(tbl) == 1 def test_invalid_inputs(self): with pytest.raises(TypeError): ApertureStats(self.data, 10.0) with pytest.raises(TypeError): ApertureStats(self.data, self.aperture, sigma_clip=10) with pytest.raises(ValueError): ApertureStats(self.data, self.aperture, error=10.0) with pytest.raises(ValueError): ApertureStats(self.data, self.aperture, error=np.ones(3)) with pytest.raises(ValueError): ApertureStats(self.data, self.aperture, error=np.ones((3, 3))) def test_repr_str(self): assert repr(self.apstats1) == str(self.apstats1) assert 'Length: 3' in repr(self.apstats1) def test_data_dtype(self): """ Regression test that input ``data`` with int dtype does not raise UFuncTypeError due to subtraction of float array from int array. """ data = np.ones((25, 25), dtype=np.uint16) aper = CircularAperture((12, 12), 5) apstats = ApertureStats(data, aper) assert apstats.min == 1.0 assert apstats.max == 1.0 assert apstats.mean == 1.0 assert apstats.xcentroid == 12.0 assert apstats.ycentroid == 12.0 @pytest.mark.parametrize('with_units', (True, False)) def test_nddata_input(self, with_units): mask = np.zeros(self.data.shape, dtype=bool) mask[225:240, 80:90] = True # partially mask id=2 data = self.data error = self.error if with_units: unit = u.Jy data <<= unit error <<= unit else: unit = None apstats1 = ApertureStats(data, self.aperture, error=error, mask=mask, wcs=self.wcs, sigma_clip=None) uncertainty = StdDevUncertainty(self.error) nddata = NDData(self.data, uncertainty=uncertainty, mask=mask, wcs=self.wcs, unit=unit) apstats2 = ApertureStats(nddata, self.aperture, sigma_clip=None) assert_allclose(apstats1.xcentroid, apstats2.xcentroid) assert_allclose(apstats1.ycentroid, apstats2.ycentroid) assert_allclose(apstats1.sum, apstats2.sum) if with_units: assert apstats1.sum.unit == unit def test_tiny_source(self): data = np.zeros((21, 21)) data[5, 5] = 1.0 aperture = CircularAperture(((5, 5), (15, 15)), r=1) apstats = ApertureStats(data, aperture) assert_allclose(apstats.sum, (1.0, 0.0)) assert_allclose(apstats[0].covariance, [(1 / 12, 0), (0, 1 / 12)] * u.pix**2) assert_allclose(apstats[1].covariance, [(np.nan, np.nan), (np.nan, np.nan)] * u.pix**2) assert_allclose(apstats.fwhm, [0.67977799, np.nan] * u.pix) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0188851 photutils-1.13.0/photutils/background/0000755000175100001770000000000014637570322017417 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/__init__.py0000644000175100001770000000054114637570305021531 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to estimate the background and background RMS in an image. """ from .background_2d import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .interpolators import * # noqa: F401, F403 from .local_background import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/background_2d.py0000644000175100001770000007060314637570305022504 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate the 2D background and background RMS in an image. """ import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData from astropy.stats import SigmaClip from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import RectangularAperture from photutils.background.core import SExtractorBackground, StdBackgroundRMS from photutils.background.interpolators import BkgZoomInterpolator from photutils.utils import ShepardIDWInterpolator from photutils.utils._parameters import as_pair from photutils.utils._repr import make_repr from photutils.utils._stats import nanmedian __all__ = ['Background2D'] __doctest_requires__ = {'Background2D': ['scipy']} class Background2D: """ Class to estimate a 2D background and background RMS noise in an image. The background is estimated using (sigma-clipped) statistics in each box of a grid that covers the input ``data`` to create a low-resolution, and possibly irregularly-gridded, background map. The final background map is calculated by interpolating the low-resolution background map. Invalid data values (i.e., NaN or inf) are automatically masked. Parameters ---------- data : array_like or `~astropy.nddata.NDData` The 2D array from which to estimate the background and/or background RMS map. box_size : int or array_like (int) The box size along each axis. If ``box_size`` is a scalar then a square box of size ``box_size`` will be used. If ``box_size`` has two elements, they must be in ``(ny, nx)`` order. For best results, the box shape should be chosen such that the ``data`` are covered by an integer number of boxes in both dimensions. When this is not the case, see the ``edge_method`` keyword for more options. mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from calculations. ``mask`` is intended to mask sources or bad pixels. Use ``coverage_mask`` to mask blank areas of an image. ``mask`` and ``coverage_mask`` differ only in that ``coverage_mask`` is applied to the output background and background RMS maps (see ``fill_value``). coverage_mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. ``coverage_mask`` should be `True` where there is no coverage (i.e., no data) for a given pixel (e.g., blank areas in a mosaic image). It should not be used for bad pixels (in that case use ``mask`` instead). ``mask`` and ``coverage_mask`` differ only in that ``coverage_mask`` is applied to the output background and background RMS maps (see ``fill_value``). fill_value : float, optional The value used to fill the output background and background RMS maps where the input ``coverage_mask`` is `True`. exclude_percentile : float in the range of [0, 100], optional The percentage of masked pixels in a box, used as a threshold for determining if the box is excluded. If a box has more than ``exclude_percentile`` percent of its pixels masked then it will be excluded from the low-resolution map. Masked pixels include those from the input ``mask`` and ``coverage_mask``, those resulting from the data padding (i.e., if ``edge_method='pad'``), and those resulting from sigma clipping (if ``sigma_clip`` is used). Setting ``exclude_percentile=0`` will exclude boxes that have any masked pixels. Note that completely masked boxes are always excluded. For best results, ``exclude_percentile`` should be kept as low as possible (as long as there are sufficient pixels for reasonable statistical estimates). The default is 10.0. filter_size : int or array_like (int), optional The window size of the 2D median filter to apply to the low-resolution background map. If ``filter_size`` is a scalar then a square box of size ``filter_size`` will be used. If ``filter_size`` has two elements, they must be in ``(ny, nx)`` order. ``filter_size`` must be odd along both axes. A filter size of ``1`` (or ``(1, 1)``) means no filtering. filter_threshold : int, optional The threshold value for used for selective median filtering of the low-resolution 2D background map. The median filter will be applied to only the background boxes with values larger than ``filter_threshold``. Set to `None` to filter all boxes (default). edge_method : {'pad', 'crop'}, optional The method used to determine how to handle the case where the image size is not an integer multiple of the ``box_size`` in either dimension. Both options will resize the image for internal calculations to give an exact multiple of ``box_size`` in both dimensions. * ``'pad'``: pad the image along the top and/or right edges. This is the default and recommended method. Ideally, the ``box_size`` should be chosen such that an integer number of boxes is only slightly larger than the ``data`` size to minimize the amount of padding. * ``'crop'``: crop the image along the top and/or right edges. This method should be used sparingly. Best results will occur when ``box_size`` is chosen such that an integer number of boxes is only slightly smaller than the ``data`` size to minimize the amount of cropping. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=10``. bkg_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundBase` subclass) used to estimate the background in each of the boxes. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. Internally, the background will be calculated along ``axis=1`` and in this case the callable object must return a 1D `~numpy.ndarray`, where np.nan values are used for masked pixels. If ``bkg_estimator`` includes sigma clipping, it will be ignored (use the ``sigma_clip`` keyword here to define sigma clipping). The default is an instance of `~photutils.background.SExtractorBackground`. bkgrms_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundRMSBase` subclass) used to estimate the background RMS in each of the boxes. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. Internally, the background RMS will be calculated along ``axis=1`` and in this case the callable object must return a 1D `~numpy.ndarray`, where np.nan values are used for masked pixels. If ``bkgrms_estimator`` includes sigma clipping, it will be ignored (use the ``sigma_clip`` keyword here to define sigma clipping). The default is an instance of `~photutils.background.StdBackgroundRMS`. interpolator : callable, optional A callable object (a function or object) used to interpolate the low-resolution background or background RMS image to the full-size background or background RMS maps. The default is an instance of `BkgZoomInterpolator`, which uses the `scipy.ndimage.zoom` function. Notes ----- Better performance will generally be obtained if you have the `bottleneck`_ package installed. If there is only one background box element (i.e., ``box_size`` is the same size as (or larger than) the ``data``), then the background map will simply be a constant image. .. _bottleneck: https://github.com/pydata/bottleneck """ def __init__(self, data, box_size, *, mask=None, coverage_mask=None, fill_value=0.0, exclude_percentile=10.0, filter_size=(3, 3), filter_threshold=None, edge_method='pad', sigma_clip=SigmaClip(sigma=3.0, maxiters=10), bkg_estimator=SExtractorBackground(sigma_clip=None), bkgrms_estimator=StdBackgroundRMS(sigma_clip=None), interpolator=BkgZoomInterpolator()): if isinstance(data, (u.Quantity, NDData)): # includes CCDData self.unit = data.unit data = data.data else: self.unit = None self.data = self._validate_array(data, 'data', shape=False) self.mask = self._validate_array(mask, 'mask') self.coverage_mask = self._validate_array(coverage_mask, 'coverage_mask') self.total_mask = self._combine_masks() # box_size cannot be larger than the data array size self.box_size = as_pair('box_size', box_size, lower_bound=(0, 1), upper_bound=data.shape) self.fill_value = fill_value if exclude_percentile < 0 or exclude_percentile > 100: raise ValueError('exclude_percentile must be between 0 and 100 ' '(inclusive).') self.exclude_percentile = exclude_percentile self.filter_size = as_pair('filter_size', filter_size, lower_bound=(0, 1), check_odd=True) self.filter_threshold = filter_threshold self.edge_method = edge_method self.sigma_clip = sigma_clip bkg_estimator.sigma_clip = None bkgrms_estimator.sigma_clip = None self.bkg_estimator = bkg_estimator self.bkgrms_estimator = bkgrms_estimator self.interpolator = interpolator self.nboxes = None self.box_npixels = None self.nboxes_tot = None self._box_data = None self._box_idx = None self._mesh_idx = None self._bkg_stats = None self._bkgrms_stats = None self._prepare_box_data() self._params = ('data', 'box_size', 'mask', 'coverage_mask', 'fill_value', 'exclude_percentile', 'filter_size', 'filter_threshold', 'edge_method', 'sigma_clip', 'bkg_estimator', 'bkgrms_estimator', 'interpolator') def __repr__(self): ellipsis = ('data', 'mask', 'coverage_mask') return make_repr(self, self._params, ellipsis=ellipsis) def __str__(self): ellipsis = ('data', 'mask', 'coverage_mask') return make_repr(self, self._params, ellipsis=ellipsis, long=True) def _validate_array(self, array, name, shape=True): if name in ('mask', 'coverage_mask') and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if shape and array.shape != self.data.shape: raise ValueError(f'data and {name} must have the same shape.') return array def _combine_masks(self): if self.mask is None and self.coverage_mask is None: return None if self.mask is None: return self.coverage_mask elif self.coverage_mask is None: return self.mask else: return np.logical_or(self.mask, self.coverage_mask) def _prepare_data(self): """ Prepare the data. This method: * converts the data to float dtype (and makes a copy) * automatically masks non-finite values that aren't already masked * replaces all masked values with NaN * converts MaskedArray to ndarray using NaN as masked values """ # float array type is needed to insert nans into the array self.data = self.data.astype(float) # makes a copy # add non-finite values not already masked to the total mask bad_mask = np.isfinite(self.data) if self.total_mask is not None: bad_mask |= self.total_mask bad_mask = np.invert(bad_mask, out=bad_mask) if np.any(bad_mask): if self.total_mask is None: self.total_mask = bad_mask else: self.total_mask |= bad_mask warnings.warn('Input data contains invalid values (NaNs or ' 'infs), which were automatically masked.', AstropyUserWarning) # replace all masked values with NaN if self.total_mask is not None: self.data[self.total_mask] = np.nan # convert MaskedArray to ndarray using np.nan as masked values if isinstance(self.data, np.ma.MaskedArray): self.data = self.data.filled(np.nan) def _reshape_data(self): """ First, pad or crop the 2D data array so that there are an integer number of boxes in both dimensions. Then reshape it into a different 2D array where each row represents the data in a single box. """ self.nboxes = self.data.shape // self.box_size extra_size = self.data.shape % self.box_size if np.sum(extra_size) != 0: # pad or crop the data if self.edge_method == 'pad': pad_size = (np.ceil(self.data.shape / self.box_size).astype(int) * self.box_size) - self.data.shape pad_width = ((0, pad_size[0]), (0, pad_size[1])) data = np.pad(self.data, pad_width, mode='constant', constant_values=np.nan) self.nboxes = data.shape // self.box_size elif self.edge_method == 'crop': crop_size = self.nboxes * self.box_size crop_slc = np.index_exp[0:crop_size[0], 0:crop_size[1]] data = self.data[crop_slc] else: raise ValueError('edge_method must be "pad" or "crop"') else: data = self.data self.box_npixels = np.prod(self.box_size) self.nboxes_tot = np.prod(self.nboxes) # a reshaped 2D array with box data along the x axis self._box_data = np.swapaxes(data.reshape( self.nboxes[0], self.box_size[0], self.nboxes[1], self.box_size[1]), 1, 2).reshape(self.nboxes_tot, self.box_npixels) @lazyproperty def _box_npixels_threshold(self): # * boxes that are completely masked are always excluded # * boxes that contain more than ``exclude_percentile`` percent # masked pixels are also excluded: # - for exclude_percentile=0, only boxes where nmasked=0 will # be included # - for exclude_percentile=100, all boxes will be included # *unless* they are completely masked threshold = self.exclude_percentile / 100.0 * self.box_npixels # always exclude completely masked boxes if self.exclude_percentile == 100: threshold -= 1 return threshold def _get_box_indices(self): """ Define the x and y indices of the boxes that will be used to compute background statistics. The box array (self._box_data) is a 2D array where each row represents the data in a single box. The ``exclude_percentile`` keyword determines which boxes are not used for the background interpolation. """ # the number of NaN pixels in each box nmasked = np.count_nonzero(np.isnan(self._box_data), axis=1) # define indices of good (included) boxes box_idx = np.where(nmasked <= self._box_npixels_threshold)[0] if box_idx.size == 0: raise ValueError('All boxes contain > ' f'{self._box_npixels_threshold} ' f'({self.exclude_percentile} percent per ' 'box) masked pixels (or all are completely ' 'masked). Please check your data or increase ' '"exclude_percentile" to allow more boxes to ' 'be included.') return box_idx def _select_initial_boxes(self): # perform a first cut on rejecting boxes self._box_idx = self._get_box_indices() if self._box_idx.size != self._box_data.shape[0]: self._box_data = self._box_data[self._box_idx, :] def _sigmaclip_boxes(self): with warnings.catch_warnings(): warnings.simplefilter('ignore', category=AstropyUserWarning) if self.sigma_clip is not None: self._box_data = self.sigma_clip(self._box_data, axis=1, masked=False) # perform box rejection on sigma-clipped data (i.e., for any # newly-masked pixels) idx = self._get_box_indices() self._box_idx = self._box_idx[idx] if self._box_idx.size != self._box_data.shape[0]: self._box_data = self._box_data[idx, :] # the indices of the good pixels in the low-resolution 2D mesh self._mesh_idx = np.unravel_index(self._box_idx, self.nboxes) def _prepare_box_data(self): """ Prepare the box data by reshaping, masking (with NaNs), and sigma clipping the data. """ self._prepare_data() self._reshape_data() self._select_initial_boxes() self._sigmaclip_boxes() def _make_2d_array(self, data): """ Convert a 1D array of values to a 2D array given the indices in ``self._mesh_idx``. Parameters ---------- data : 1D `~numpy.ndarray` A 1D array of values. Returns ------- result : 2D `~numpy.ndarray` A 2D array. Pixels not defined in ``mesh_idx`` are assigned a value of np.nan. """ data2d = np.full(self.nboxes, np.nan) data2d[self._mesh_idx] = data return data2d def _interpolate_meshes(self, data, n_neighbors=10, eps=0.0, power=1.0, reg=0.0): """ Use IDW interpolation to fill in any masked pixels in the low-resolution 2D mesh background and background RMS images. This is required to use a regular-grid interpolator to expand the low-resolution image to the full size image. Parameters ---------- data : 1D `~numpy.ndarray` A 1D array of mesh values. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. eps : float, optional Set to use approximate nearest neighbors; the kth neighbor is guaranteed to be no further than (1 + ``eps``) times the distance to the real *k*-th nearest neighbor. See `scipy.spatial.cKDTree.query` for further information. power : float, optional The power of the inverse distance used for the interpolation weights. See the Notes section for more details. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. See the Notes section for more details. Returns ------- result : 2D `~numpy.ndarray` A 2D array of the mesh values where masked pixels have been filled by IDW interpolation. """ yx = np.column_stack(self._mesh_idx) interp_func = ShepardIDWInterpolator(yx, data) yi, xi = np.mgrid[0:self.nboxes[0], 0:self.nboxes[1]] yx_indices = np.column_stack((yi.ravel(), xi.ravel())) img1d = interp_func(yx_indices, n_neighbors=n_neighbors, power=power, eps=eps, reg=reg) return img1d.reshape(self.nboxes) def _make_mesh_image(self, box_stats): """ Calculate the filtered low-resolution background or background RMS "mesh" image from the 1D box statistics data. """ # make the unfiltered 2D mesh arrays (these are not masked) if box_stats.size == self.nboxes_tot: # no masked boxes mesh_img = self._make_2d_array(box_stats) else: # interpolate masked boxes mesh_img = self._interpolate_meshes(box_stats) return mesh_img def _selective_filter(self, data): """ Filter only pixels above ``filter_threshold`` in the background mesh. The same pixels are filtered in both the background and background RMS meshes. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of mesh values. Returns ------- filtered_data : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ data_out = np.copy(data) yx_indices = np.column_stack( np.nonzero(self._unfiltered_background_mesh > self.filter_threshold)) for i, j in yx_indices: yfs, xfs = self.filter_size hyfs, hxfs = yfs // 2, xfs // 2 yidx0 = max(i - hyfs, 0) yidx1 = min(i - hyfs + yfs, data.shape[0]) xidx0 = max(j - hxfs, 0) xidx1 = min(j - hxfs + xfs, data.shape[1]) data_out[i, j] = np.median(data[yidx0:yidx1, xidx0:xidx1]) return data_out def _filter_meshes(self, data): """ Apply a 2D median filter to a low-resolution 2D mesh image. """ if np.array_equal(self.filter_size, [1, 1]): return data if self.filter_threshold is None: # filter the entire array from scipy.ndimage import generic_filter filtdata = generic_filter(data, nanmedian, size=self.filter_size, mode='constant', cval=np.nan) else: # selectively filter the array filtdata = self._selective_filter(data) return filtdata @lazyproperty def _unfiltered_background_mesh(self): """ The unfiltered low-resolution background image. This array is needed separately from background_mesh to compute which pixels are to be selectively filtered (if ``filter_threshold`` is input). """ self._bkg_stats = self.bkg_estimator(self._box_data, axis=1) return self._make_mesh_image(self._bkg_stats) @lazyproperty def background_mesh(self): """ The low-resolution background image. This image is equivalent to the low-resolution "MINIBACKGROUND" background map in SourceExtractor. """ return self._filter_meshes(self._unfiltered_background_mesh) @lazyproperty def background_rms_mesh(self): """ The low-resolution background RMS image. This image is equivalent to the low-resolution "MINIBACKGROUND" background rms map in SourceExtractor. """ self._bkgrms_stats = self.bkgrms_estimator(self._box_data, axis=1) mesh_img = self._make_mesh_image(self._bkgrms_stats) return self._filter_meshes(mesh_img) @lazyproperty def background_mesh_masked(self): """ The background 2D (masked) array mesh prior to any interpolation. The array has NaN values where meshes were excluded. """ data = np.full(self.background_mesh.shape, np.nan) data[self._mesh_idx] = self.background_mesh[self._mesh_idx] return data @lazyproperty def background_rms_mesh_masked(self): """ The background RMS 2D (masked) array mesh prior to any interpolation. The array has NaN values where meshes were excluded. """ data = np.full(self.background_rms_mesh.shape, np.nan) data[self._mesh_idx] = self.background_rms_mesh[self._mesh_idx] return data @lazyproperty def _mesh_yxpos(self): box_cen = (self.box_size - 1) / 2.0 return (self._mesh_idx * self.box_size[:, None]) + box_cen[:, None] @lazyproperty def _mesh_xypos(self): return np.flipud(self._mesh_yxpos) @lazyproperty def mesh_nmasked(self): """ A 2D array of the number of masked pixels in each mesh. NaN values indicate where meshes were excluded. """ return self._make_2d_array( np.count_nonzero(np.isnan(self._box_data), axis=1)) @lazyproperty def background_median(self): """ The median value of the 2D low-resolution background map. This is equivalent to the value SourceExtractor prints to stdout (i.e., "(M+D) Background: "). """ _median = np.median(self.background_mesh) if self.unit is not None: _median <<= self.unit return _median @lazyproperty def background_rms_median(self): """ The median value of the low-resolution background RMS map. This is equivalent to the value SourceExtractor prints to stdout (i.e., "(M+D) RMS: "). """ _rms_median = np.median(self.background_rms_mesh) if self.unit is not None: _rms_median <<= self.unit return _rms_median @lazyproperty def background(self): """A 2D `~numpy.ndarray` containing the background image.""" bkg = self.interpolator(self.background_mesh, self) if self.coverage_mask is not None: bkg[self.coverage_mask] = self.fill_value if self.unit is not None: bkg <<= self.unit return bkg @lazyproperty def background_rms(self): """A 2D `~numpy.ndarray` containing the background RMS image.""" bkg_rms = self.interpolator(self.background_rms_mesh, self) if self.coverage_mask is not None: bkg_rms[self.coverage_mask] = self.fill_value if self.unit is not None: bkg_rms <<= self.unit return bkg_rms def plot_meshes(self, *, ax=None, marker='+', markersize=None, color='blue', alpha=None, outlines=False, **kwargs): """ Plot the low-resolution mesh boxes on a matplotlib Axes instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. marker : str, optional The `matplotlib marker `_ to use to mark the center of the boxes. markersize : float, optional The box center marker size in ``points ** 2`` (typographical points are 1/72 inch) . The default is ``matplotlib.rcParams['lines.markersize'] ** 2``. If set to 0, then the box center markers will not be plotted. color : str, optional The color for the box center markers and outlines. alpha : float, optional The alpha blending value, between 0 (transparent) and 1 (opaque), for the box center markers and outlines. outlines : bool, optional Whether or not to plot the box outlines. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`, which is used to draw the box outlines. Used only if ``outlines`` is True. """ import matplotlib.pyplot as plt kwargs['color'] = color if ax is None: ax = plt.gca() ax.scatter(*self._mesh_xypos, s=markersize, marker=marker, color=color, alpha=alpha) if outlines: xypos = np.column_stack(self._mesh_xypos) apers = RectangularAperture(xypos, self.box_size[1], self.box_size[0], 0.0) apers.plot(ax=ax, alpha=alpha, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/core.py0000644000175100001770000005712114637570305020730 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate the background and background RMS in an array of any dimension. """ import abc import warnings import numpy as np from astropy.stats import SigmaClip, biweight_location, biweight_scale, mad_std from photutils.utils._repr import make_repr from photutils.utils._stats import nanmean, nanmedian, nanstd SIGMA_CLIP = SigmaClip(sigma=3.0, maxiters=10) __all__ = ['BackgroundBase', 'BackgroundRMSBase', 'MeanBackground', 'MedianBackground', 'ModeEstimatorBackground', 'MMMBackground', 'SExtractorBackground', 'BiweightLocationBackground', 'StdBackgroundRMS', 'MADStdBackgroundRMS', 'BiweightScaleBackgroundRMS'] class BackgroundBase(metaclass=abc.ABCMeta): """ Base class for classes that estimate scalar background values. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. """ def __init__(self, sigma_clip=SIGMA_CLIP): if not isinstance(sigma_clip, SigmaClip) and sigma_clip is not None: raise TypeError('sigma_clip must be an astropy SigmaClip ' 'instance or None') self.sigma_clip = sigma_clip def __repr__(self): return make_repr(self, ('sigma_clip',)) def __call__(self, data, axis=None, masked=False): return self.calc_background(data, axis=axis, masked=masked) @abc.abstractmethod def calc_background(self, data, axis=None, masked=False): """ Calculate the background value. Parameters ---------- data : array_like or `~numpy.ma.MaskedArray` The array for which to calculate the background value. axis : int or `None`, optional The array axis along which the background is calculated. If `None`, then the entire array is used. masked : bool, optional If `True`, then a `~numpy.ma.MaskedArray` is returned. If `False`, then a `~numpy.ndarray` is returned, where masked values have a value of NaN. The default is `False`. Returns ------- result : float, `~numpy.ndarray`, or `~numpy.ma.MaskedArray` The calculated background value. If ``masked`` is `False`, then a `~numpy.ndarray` is returned, otherwise a `~numpy.ma.MaskedArray` is returned. A scalar result is always returned as a float. """ raise NotImplementedError() # pragma: no cover class BackgroundRMSBase(metaclass=abc.ABCMeta): """ Base class for classes that estimate scalar background RMS values. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. """ def __init__(self, sigma_clip=SIGMA_CLIP): if not isinstance(sigma_clip, SigmaClip) and sigma_clip is not None: raise TypeError('sigma_clip must be an astropy SigmaClip ' 'instance or None') self.sigma_clip = sigma_clip def __repr__(self): return make_repr(self, ('sigma_clip',)) def __call__(self, data, axis=None, masked=False): return self.calc_background_rms(data, axis=axis, masked=masked) @abc.abstractmethod def calc_background_rms(self, data, axis=None, masked=False): """ Calculate the background RMS value. Parameters ---------- data : array_like or `~numpy.ma.MaskedArray` The array for which to calculate the background RMS value. axis : int or `None`, optional The array axis along which the background RMS is calculated. If `None`, then the entire array is used. masked : bool, optional If `True`, then a `~numpy.ma.MaskedArray` is returned. If `False`, then a `~numpy.ndarray` is returned, where masked values have a value of NaN. The default is `False`. Returns ------- result : float, `~numpy.ndarray`, or `~numpy.ma.MaskedArray` The calculated background RMS value. If ``masked`` is `False`, then a `~numpy.ndarray` is returned, otherwise a `~numpy.ma.MaskedArray` is returned. A scalar result is always returned as a float. """ raise NotImplementedError() # pragma: no cover class MeanBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) mean. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MeanBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MeanBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmean(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MedianBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) median. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MedianBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MedianBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmedian(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class ModeEstimatorBackground(BackgroundBase): """ Class to calculate the background in an array using a mode estimator of the form ``(median_factor * median) - (mean_factor * mean)``. Parameters ---------- median_factor : float, optional The multiplicative factor for the data median. Defaults to 3. mean_factor : float, optional The multiplicative factor for the data mean. Defaults to 2. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import ModeEstimatorBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = ModeEstimatorBackground(median_factor=3.0, mean_factor=2.0, ... sigma_clip=sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, median_factor=3.0, mean_factor=2.0, **kwargs): super().__init__(**kwargs) self.median_factor = median_factor self.mean_factor = mean_factor def __repr__(self): params = ('median_factor', 'mean_factor', 'sigma_clip') return make_repr(self, params) def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = ((self.median_factor * nanmedian(data, axis=axis)) - (self.mean_factor * nanmean(data, axis=axis))) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MMMBackground(ModeEstimatorBackground): """ Class to calculate the background in an array using the DAOPHOT MMM algorithm. The background is calculated using a mode estimator of the form ``(3 * median) - (2 * mean)``. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MMMBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MMMBackground(sigma_clip=sigma_clip) The background value can be calculated by using the `~photutils.background.core.ModeEstimatorBackground.calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, **kwargs): kwargs['median_factor'] = 3.0 kwargs['mean_factor'] = 2.0 super().__init__(**kwargs) class SExtractorBackground(BackgroundBase): """ Class to calculate the background in an array using the Source Extractor algorithm. The background is calculated using a mode estimator of the form ``(2.5 * median) - (1.5 * mean)``. If ``(mean - median) / std > 0.3`` then the median is used instead. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import SExtractorBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = SExtractorBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) _median = np.atleast_1d(nanmedian(data, axis=axis)) _mean = np.atleast_1d(nanmean(data, axis=axis)) _std = np.atleast_1d(nanstd(data, axis=axis)) bkg = np.atleast_1d((2.5 * _median) - (1.5 * _mean)) bkg = np.where(_std == 0, _mean, bkg) idx = np.where(_std != 0) condition = (np.abs(_mean[idx] - _median[idx]) / _std[idx]) < 0.3 bkg[idx] = np.where(condition, bkg[idx], _median[idx]) if bkg.size == 1: bkg = bkg[0] result = bkg if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class BiweightLocationBackground(BackgroundBase): """ Class to calculate the background in an array using the biweight location. Parameters ---------- c : float, optional Tuning constant for the biweight estimator. Default value is 6.0. M : float, optional Initial guess for the biweight location. Default value is `None`. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightLocationBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = BiweightLocationBackground(sigma_clip=sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, c=6, M=None, **kwargs): super().__init__(**kwargs) self.c = c self.M = M def __repr__(self): params = ('c', 'M', 'sigma_clip') return make_repr(self, params) def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = biweight_location(data, c=self.c, M=self.M, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class StdBackgroundRMS(BackgroundRMSBase): """ Class to calculate the background RMS in an array as the (sigma-clipped) standard deviation. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import StdBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = StdBackgroundRMS(sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 28.86607004772212 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 28.86607004772212 """ def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanstd(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MADStdBackgroundRMS(BackgroundRMSBase): r""" Class to calculate the background RMS in an array as using the `median absolute deviation (MAD) `_. The standard deviation estimator is given by: .. math:: \sigma \approx \frac{{\textrm{{MAD}}}}{{\Phi^{{-1}}(3/4)}} \approx 1.4826 \ \textrm{{MAD}} where :math:`\Phi^{{-1}}(P)` is the normal inverse cumulative distribution function evaluated at probability :math:`P = 3/4`. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MADStdBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = MADStdBackgroundRMS(sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 37.06505546264005 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 37.06505546264005 """ def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = mad_std(data, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class BiweightScaleBackgroundRMS(BackgroundRMSBase): """ Class to calculate the background RMS in an array as the (sigma-clipped) biweight scale. Parameters ---------- c : float, optional Tuning constant for the biweight estimator. Default value is 9.0. M : float, optional Initial guess for the biweight location. Default value is `None`. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightScaleBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = BiweightScaleBackgroundRMS(sigma_clip=sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 30.09433848589339 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 30.09433848589339 """ def __init__(self, c=9.0, M=None, **kwargs): super().__init__(**kwargs) self.c = c self.M = M def __repr__(self): params = ('c', 'M', 'sigma_clip') return make_repr(self, params) def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) else: # convert to ndarray with masked values as np.nan if isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = biweight_scale(data, c=self.c, M=self.M, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/interpolators.py0000644000175100001770000001453614637570305022710 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines interpolator classes for Background2D. """ import numpy as np from photutils.utils import ShepardIDWInterpolator from photutils.utils._repr import make_repr __all__ = ['BkgZoomInterpolator', 'BkgIDWInterpolator'] __doctest_requires__ = {'BkgZoomInterpolator': ['scipy']} class BkgZoomInterpolator: """ This class generates full-sized background and background RMS images from lower-resolution mesh images using the `~scipy.ndimage.zoom` (spline) interpolator. This class must be used in concert with the `Background2D` class. Parameters ---------- order : int, optional The order of the spline interpolation used to resize the low-resolution background and background RMS mesh images. The value must be an integer in the range 0-5. The default is 3 (bicubic interpolation). mode : {'reflect', 'constant', 'nearest', 'wrap'}, optional Points outside the boundaries of the input are filled according to the given mode. Default is 'reflect'. cval : float, optional The value used for points outside the boundaries of the input if ``mode='constant'``. Default is 0.0. grid_mode : bool, optional If `True` (default), the samples are considered as the centers of regularly-spaced grid elements. If `False`, the samples are treated as isolated points. For zooming 2D images, this keyword should be set to `True`, which makes zoom's behavior consistent with `scipy.ndimage.map_coordinates` and `skimage.transform.resize`. The `False` option is provided only for backwards-compatibility. clip : bool, optional Whether to clip the output to the range of values in the input image. This is enabled by default, since higher order interpolation may produce values outside the given input range. """ def __init__(self, *, order=3, mode='reflect', cval=0.0, grid_mode=True, clip=True): self.order = order self.mode = mode self.cval = cval self.grid_mode = grid_mode self.clip = clip def __repr__(self): params = ('order', 'mode', 'cval', 'grid_mode', 'clip') return make_repr(self, params) def __call__(self, mesh, bkg2d_obj): """ Resize the 2D mesh array. Parameters ---------- mesh : 2D `~numpy.ndarray` The low-resolution 2D mesh array. bkg2d_obj : `Background2D` object The `Background2D` object that prepared the ``mesh`` array. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. """ mesh = np.asanyarray(mesh) if np.ptp(mesh) == 0: return np.zeros_like(bkg2d_obj.data) + np.min(mesh) from scipy.ndimage import zoom if bkg2d_obj.edge_method == 'pad': # The mesh is first resized to the larger padded-data size # (i.e., zoom_factor should be an integer) and then cropped # back to the final data size. zoom_factor = bkg2d_obj.box_size result = zoom(mesh, zoom_factor, order=self.order, mode=self.mode, cval=self.cval, grid_mode=self.grid_mode) result = result[0:bkg2d_obj.data.shape[0], 0:bkg2d_obj.data.shape[1]] else: # The mesh is resized directly to the final data size. zoom_factor = np.array(bkg2d_obj.data.shape) / mesh.shape result = zoom(mesh, zoom_factor, order=self.order, mode=self.mode, cval=self.cval) if self.clip: minval = np.min(mesh) maxval = np.max(mesh) np.clip(result, minval, maxval, out=result) # clip in place return result class BkgIDWInterpolator: """ This class generates full-sized background and background RMS images from lower-resolution mesh images using inverse-distance weighting (IDW) interpolation (`~photutils.utils.ShepardIDWInterpolator`). This class must be used in concert with the `Background2D` class. Parameters ---------- leafsize : float, optional The number of points at which the k-d tree algorithm switches over to brute-force. ``leafsize`` must be positive. See `scipy.spatial.cKDTree` for further information. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. power : float, optional The power of the inverse distance used for the interpolation weights. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. """ def __init__(self, *, leafsize=10, n_neighbors=10, power=1.0, reg=0.0): self.leafsize = leafsize self.n_neighbors = n_neighbors self.power = power self.reg = reg def __repr__(self): params = ('leafsize', 'n_neighbors', 'power', 'reg') return make_repr(self, params) def __call__(self, mesh, bkg2d_obj): """ Resize the 2D mesh array. Parameters ---------- mesh : 2D `~numpy.ndarray` The low-resolution 2D mesh array. bkg2d_obj : `Background2D` object The `Background2D` object that prepared the ``mesh`` array. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. """ mesh = np.asanyarray(mesh) if np.ptp(mesh) == 0: return np.zeros_like(bkg2d_obj.data) + np.min(mesh) yxpos = np.column_stack(bkg2d_obj._mesh_yxpos) mesh1d = mesh[bkg2d_obj._mesh_idx] interp_func = ShepardIDWInterpolator(yxpos, mesh1d, leafsize=self.leafsize) # the position coordinates used when calling the interpolator ny, nx = bkg2d_obj.data.shape yi, xi = np.mgrid[0:ny, 0:nx] yx_indices = np.column_stack((yi.ravel(), xi.ravel())) data = interp_func(yx_indices, n_neighbors=self.n_neighbors, power=self.power, reg=self.reg) return data.reshape(bkg2d_obj.data.shape) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/local_background.py0000644000175100001770000000545314637570305023272 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate local background using a circular annulus aperture. """ import numpy as np from photutils.aperture import CircularAnnulus from photutils.background import MedianBackground from photutils.utils._repr import make_repr __all__ = ['LocalBackground'] class LocalBackground: """ Class to compute a local background using a circular annulus aperture. Parameters ---------- inner_radius : float The inner radius of the circular annulus in pixels. outer_radius : float The outer radius of the circular annulus in pixels. bkg_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundBase` subclass) used to estimate the background in each aperture. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. The default is an instance of `~photutils.background.MedianBackground` with sigma clipping (i.e., sigma-clipped median). """ def __init__(self, inner_radius, outer_radius, bkg_estimator=MedianBackground()): self.inner_radius = inner_radius self.outer_radius = outer_radius self.bkg_estimator = bkg_estimator self._aperture = CircularAnnulus((0, 0), inner_radius, outer_radius) def __repr__(self): params = ('inner_radius', 'outer_radius', 'bkg_estimator') return make_repr(self, params) def __call__(self, data, x, y, mask=None): """ Measure the local background in a circular annulus. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to measure the local background. x, y : float or 1D float `~numpy.ndarray` The aperture center (x, y) position(s) at which to measure the local background. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Returns ------- value : float or 1D float `~numpy.ndarray` The local background values. """ x = np.atleast_1d(x) y = np.atleast_1d(y) self._aperture.positions = np.array(list(zip(x, y))) apermasks = self._aperture.to_mask(method='center') bkg = [] for apermask in apermasks: values = apermask.get_values(data, mask=mask) bkg.append(self.bkg_estimator(values)) bkg = np.array(bkg) if bkg.size == 1: bkg = bkg[0] return bkg ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0188851 photutils-1.13.0/photutils/background/tests/0000755000175100001770000000000014637570322020561 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/tests/__init__.py0000644000175100001770000000000014637570305022661 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/tests/test_background_2d.py0000644000175100001770000004005214637570305024700 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the background_2d module. """ import itertools import astropy.units as u import numpy as np import pytest from astropy.nddata import CCDData, NDData from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.background.background_2d import Background2D from photutils.background.core import MeanBackground from photutils.background.interpolators import (BkgIDWInterpolator, BkgZoomInterpolator) from photutils.utils._optional_deps import HAS_MATPLOTLIB, HAS_SCIPY DATA = np.ones((100, 100)) BKG_RMS = np.zeros((100, 100)) BKG_MESH = np.ones((4, 4)) BKG_RMS_MESH = np.zeros((4, 4)) PADBKG_MESH = np.ones((5, 5)) PADBKG_RMS_MESH = np.zeros((5, 5)) FILTER_SIZES = [(1, 1), (3, 3)] INTERPOLATORS = [BkgZoomInterpolator(), BkgIDWInterpolator()] DATA1 = DATA << u.ct DATA2 = NDData(DATA, unit=None) DATA3 = NDData(DATA, unit=u.ct) DATA4 = CCDData(DATA, unit=u.ct) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestBackground2D: @pytest.mark.parametrize(('filter_size', 'interpolator'), list(itertools.product(FILTER_SIZES, INTERPOLATORS))) def test_background(self, filter_size, interpolator): bkg = Background2D(DATA, (25, 25), filter_size=filter_size, interpolator=interpolator) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_rms, BKG_RMS) assert_allclose(bkg.background_mesh, BKG_MESH) assert_allclose(bkg.background_rms_mesh, BKG_RMS_MESH) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 @pytest.mark.parametrize('data', [DATA1, DATA3, DATA4]) def test_background_nddata(self, data): """Test with NDData and CCDData, and also test units.""" bkg = Background2D(data, (25, 25), filter_size=3) assert isinstance(bkg.background, u.Quantity) assert isinstance(bkg.background_rms, u.Quantity) assert isinstance(bkg.background_median, u.Quantity) assert isinstance(bkg.background_rms_median, u.Quantity) bkg = Background2D(DATA2, (25, 25), filter_size=3) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_rms, BKG_RMS) assert_allclose(bkg.background_mesh, BKG_MESH) assert_allclose(bkg.background_rms_mesh, BKG_RMS_MESH) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 @pytest.mark.parametrize('interpolator', INTERPOLATORS) def test_background_rect(self, interpolator): """ Regression test for interpolators with non-square input data. """ data = np.arange(12).reshape(3, 4) rms = np.zeros((3, 4)) bkg = Background2D(data, (1, 1), filter_size=1, interpolator=interpolator) assert_allclose(bkg.background, data, atol=0.005) assert_allclose(bkg.background_rms, rms) assert_allclose(bkg.background_mesh, data) assert_allclose(bkg.background_rms_mesh, rms) assert bkg.background_median == 5.5 assert bkg.background_rms_median == 0.0 @pytest.mark.parametrize('interpolator', INTERPOLATORS) def test_background_nonconstant(self, interpolator): data = np.copy(DATA) data[25:50, 50:75] = 10.0 bkg_low_res = np.copy(BKG_MESH) bkg_low_res[1, 2] = 10.0 bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), interpolator=interpolator) assert_allclose(bkg1.background_mesh, bkg_low_res) assert bkg1.background.shape == data.shape bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), edge_method='pad', interpolator=interpolator) assert_allclose(bkg2.background_mesh, bkg_low_res) assert bkg2.background.shape == data.shape def test_no_sigma_clipping(self): data = np.copy(DATA) data[10, 10] = 100.0 bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), bkg_estimator=MeanBackground()) bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), sigma_clip=None, bkg_estimator=MeanBackground()) assert bkg2.background_mesh[0, 0] > bkg1.background_mesh[0, 0] @pytest.mark.parametrize('filter_size', FILTER_SIZES) def test_resizing(self, filter_size): bkg1 = Background2D(DATA, (23, 22), filter_size=filter_size, bkg_estimator=MeanBackground(), edge_method='crop') bkg2 = Background2D(DATA, (23, 22), filter_size=filter_size, bkg_estimator=MeanBackground(), edge_method='pad') assert_allclose(bkg1.background, bkg2.background, rtol=2e-6) assert_allclose(bkg1.background_rms, bkg2.background_rms) shape1 = (128, 256) shape2 = (129, 256) box_size = (16, 16) data1 = np.ones(shape1) data2 = np.ones(shape2) bkg1 = Background2D(data1, box_size) bkg2 = Background2D(data2, box_size) assert bkg1.background_mesh.shape == (8, 16) assert bkg2.background_mesh.shape == (9, 16) assert bkg1.background.shape == shape1 assert bkg2.background.shape == shape2 @pytest.mark.parametrize('box_size', ([(25, 25), (23, 22)])) def test_background_mask(self, box_size): """ Test with an input mask. Note that box_size=(23, 22) tests the resizing of the image and mask. """ data = np.copy(DATA) data[25:50, 25:50] = 100.0 mask = np.zeros(DATA.shape, dtype=bool) mask[25:50, 25:50] = True bkg = Background2D(data, box_size, filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground()) assert_allclose(bkg.background, DATA, rtol=2.0e-5) assert_allclose(bkg.background_rms, BKG_RMS) # test edge crop with mask bkg2 = Background2D(data, box_size, filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground(), edge_method='crop') assert_allclose(bkg2.background, DATA, rtol=2.0e-5) def test_mask(self): data = np.copy(DATA) data[25:50, 25:50] = 100.0 mask = np.zeros(DATA.shape, dtype=bool) mask[25:50, 25:50] = True bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), mask=None, bkg_estimator=MeanBackground()) assert_equal(bkg1.background_mesh, bkg1.background_mesh_masked) assert_equal(bkg1.background_rms_mesh, bkg1.background_rms_mesh_masked) assert np.count_nonzero(np.isnan(bkg1.mesh_nmasked)) == 0 bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground()) assert (np.count_nonzero(~np.isnan(bkg2.background_mesh_masked)) < bkg2.nboxes_tot) assert (np.count_nonzero(~np.isnan(bkg2.background_rms_mesh_masked)) < bkg2.nboxes_tot) assert np.count_nonzero(np.isnan(bkg2.mesh_nmasked)) == 1 @pytest.mark.parametrize('fill_value', [0.0, np.nan, -1.0]) def test_coverage_mask(self, fill_value): data = np.copy(DATA) data[:50, :50] = np.nan mask = np.isnan(data) bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), coverage_mask=mask, fill_value=fill_value, bkg_estimator=MeanBackground()) assert_equal(bkg1.background[:50, :50], fill_value) assert_equal(bkg1.background_rms[:50, :50], fill_value) # test combination of masks mask = np.zeros(DATA.shape, dtype=bool) coverage_mask = np.zeros(DATA.shape, dtype=bool) mask[:50, :25] = True coverage_mask[:50, 25:50] = True with pytest.warns(AstropyUserWarning, match='Input data contains invalid values'): bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, coverage_mask=mask, fill_value=0.0, bkg_estimator=MeanBackground()) assert_equal(bkg1.background_mesh, bkg2.background_mesh) assert_equal(bkg1.background_rms_mesh, bkg2.background_rms_mesh) def test_mask_nonfinite(self): data = DATA.copy() data[0, 0:50] = np.nan with pytest.warns(AstropyUserWarning, match='Input data contains invalid values'): bkg = Background2D(data, (25, 25), filter_size=(1, 1)) assert_allclose(bkg.background, DATA, rtol=1e-5) def test_mask_with_already_masked_nans(self): """ Test masked invalid values. These tests should not issue a warning. """ data = DATA.copy() data[50, 25:50] = np.nan mask = np.isnan(data) bkg = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask) assert_equal(bkg.total_mask, mask) assert_allclose(bkg.background, DATA, rtol=1e-5) bkg = Background2D(data, (25, 25), filter_size=(1, 1), coverage_mask=mask) assert_equal(bkg.total_mask, mask) mask = np.zeros(data.shape, dtype=bool) coverage_mask = np.zeros(data.shape, dtype=bool) mask[50, 25:30] = True coverage_mask[50, 30:50] = True bkg = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, coverage_mask=coverage_mask) assert_equal(bkg.total_mask, mask | coverage_mask) def test_masked_array(self): data = DATA.copy() data[0, 0:50] = True mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:50] = True data_ma1 = np.ma.MaskedArray(DATA, mask=mask) data_ma2 = np.ma.MaskedArray(data, mask=mask) bkg1 = Background2D(data, (25, 25), filter_size=(1, 1)) bkg2 = Background2D(data_ma1, (25, 25), filter_size=(1, 1)) bkg3 = Background2D(data_ma2, (25, 25), filter_size=(1, 1)) assert_allclose(bkg1.background, bkg2.background, rtol=1e-5) assert_allclose(bkg2.background, bkg3.background, rtol=1e-5) def test_completely_masked(self): with pytest.raises(ValueError): mask = np.ones(DATA.shape, dtype=bool) Background2D(DATA, (25, 25), mask=mask) def test_zero_padding(self): """Test case where padding is added only on one axis.""" bkg = Background2D(DATA, (25, 22), filter_size=(1, 1)) assert_allclose(bkg.background, DATA, rtol=1e-5) assert_allclose(bkg.background_rms, BKG_RMS) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 def test_exclude_percentile(self): """Only meshes greater than filter_threshold are filtered.""" data = np.copy(DATA) data[0:50, 0:50] = np.nan with pytest.warns(AstropyUserWarning, match='Input data contains invalid values'): bkg = Background2D(data, (25, 25), filter_size=(1, 1), exclude_percentile=100.0) assert len(bkg._box_idx) == 12 def test_filter_threshold(self): """Only meshes greater than filter_threshold are filtered.""" data = np.copy(DATA) data[25:50, 50:75] = 10.0 bkg = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=9.0) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_mesh, BKG_MESH) bkg2 = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=11.0) # no filtering assert bkg2.background_mesh[1, 2] == 10 def test_filter_threshold_high(self): """No filtering because filter_threshold is too large.""" data = np.copy(DATA) data[25:50, 50:75] = 10.0 ref_data = np.copy(BKG_MESH) ref_data[1, 2] = 10.0 b = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=100.0) assert_allclose(b.background_mesh, ref_data) def test_filter_threshold_nofilter(self): """No filtering because filter_size is (1, 1).""" data = np.copy(DATA) data[25:50, 50:75] = 10.0 ref_data = np.copy(BKG_MESH) ref_data[1, 2] = 10.0 b = Background2D(data, (25, 25), filter_size=(1, 1), filter_threshold=1.0) assert_allclose(b.background_mesh, ref_data) def test_scalar_sizes(self): bkg1 = Background2D(DATA, (25, 25), filter_size=(3, 3)) bkg2 = Background2D(DATA, 25, filter_size=3) assert_allclose(bkg1.background, bkg2.background) assert_allclose(bkg1.background_rms, bkg2.background_rms) def test_invalid_box_size(self): with pytest.raises(ValueError): Background2D(DATA, (5, 5, 3)) def test_invalid_filter_size(self): with pytest.raises(ValueError): Background2D(DATA, (5, 5), filter_size=(3, 3, 3)) def test_invalid_exclude_percentile(self): with pytest.raises(ValueError): Background2D(DATA, (5, 5), exclude_percentile=-1) with pytest.raises(ValueError): Background2D(DATA, (5, 5), exclude_percentile=101) def test_mask_nomask(self): bkg = Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.ma.nomask) assert bkg.mask is None bkg = Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.ma.nomask) assert bkg.coverage_mask is None def test_invalid_mask(self): with pytest.raises(ValueError): Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2))) with pytest.raises(ValueError): Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2, 2))) def test_invalid_coverage_mask(self): with pytest.raises(ValueError): Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2))) with pytest.raises(ValueError): Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2, 2))) def test_invalid_edge_method(self): with pytest.raises(ValueError): Background2D(DATA, (23, 22), filter_size=(1, 1), edge_method='not_valid') def test_invalid_mesh_idx_len(self): with pytest.raises(ValueError): bkg = Background2D(DATA, (25, 25), filter_size=(1, 1)) bkg._make_2d_array(np.arange(3)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_meshes(self): """ This test should run without any errors, but there is no return value. """ bkg = Background2D(DATA, (25, 25)) bkg.plot_meshes(outlines=True) def test_crop(self): data = np.ones((300, 500)) bkg = Background2D(data, (74, 99), edge_method='crop') assert_allclose(bkg.background_median, 1.0) assert_allclose(bkg.background_rms_median, 0.0) assert_allclose(bkg.background_mesh.shape, (4, 5)) def test_repr(self): data = np.ones((300, 500)) bkg = Background2D(data, (74, 99), edge_method='crop') cls_repr = repr(bkg) assert cls_repr.startswith(f'{bkg.__class__.__name__}') @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_bkgzoominterp_clip(): bkg = Background2D(np.ones((300, 300)), 100) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) interp1 = BkgZoomInterpolator(clip=False) zoom1 = interp1(mesh, bkg) interp2 = BkgZoomInterpolator(clip=True) zoom2 = interp2(mesh, bkg) minval = np.min(mesh) maxval = np.max(mesh) assert np.min(zoom1) < minval assert np.max(zoom1) > maxval assert np.min(zoom2) == minval assert np.max(zoom2) == maxval ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/tests/test_core.py0000644000175100001770000002003714637570305023125 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose from photutils.background.core import (BiweightLocationBackground, BiweightScaleBackgroundRMS, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) from photutils.datasets import make_noise_image BKG = 0.0 STD = 0.5 DATA = make_noise_image((100, 100), distribution='gaussian', mean=BKG, stddev=STD, seed=0) BKG_CLASS = [MeanBackground, MedianBackground, ModeEstimatorBackground, MMMBackground, SExtractorBackground, BiweightLocationBackground] RMS_CLASS = [StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] SIGMA_CLIP = SigmaClip(sigma=3.0) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_constant_background(bkg_class): data = np.ones((100, 100)) bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, 1.0) assert_allclose(bkg(data), bkg.calc_background(data)) mask = np.zeros(data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(data, mask=mask) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, 1.0) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background(bkg_class): bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(DATA) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.02) assert_allclose(bkg(DATA), bkg.calc_background(DATA)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_nosigmaclip(bkg_class): bkg = bkg_class(sigma_clip=None) bkgval = bkg.calc_background(DATA) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.1) assert_allclose(bkg(DATA), bkg.calc_background(DATA)) # test with masked array mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.1) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_axis(bkg_class): bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkg_arr = bkg.calc_background(DATA, axis=0) bkgi = [] for i in range(100): bkgi.append(bkg.calc_background(DATA[:, i])) bkgi = np.array(bkgi) assert_allclose(bkg_arr, bkgi) bkg_arr = bkg.calc_background(DATA, axis=1) bkgi = [] for i in range(100): bkgi.append(bkg.calc_background(DATA[i, :])) bkgi = np.array(bkgi) assert_allclose(bkg_arr, bkgi) def test_sourceextrator_background_zero_std(): data = np.ones((100, 100)) bkg = SExtractorBackground(sigma_clip=None) assert_allclose(bkg.calc_background(data), 1.0) def test_sourceextrator_background_skew(): data = np.arange(100) data[70:] = 1.0e7 bkg = SExtractorBackground(sigma_clip=None) assert_allclose(bkg.calc_background(data), np.median(data)) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms(rms_class): bkgrms = rms_class(sigma_clip=SIGMA_CLIP) assert_allclose(bkgrms.calc_background_rms(DATA), STD, atol=1.0e-2) assert_allclose(bkgrms(DATA), bkgrms.calc_background_rms(DATA)) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_axis(rms_class): bkgrms = rms_class(sigma_clip=SIGMA_CLIP) rms_arr = bkgrms.calc_background_rms(DATA, axis=0) rmsi = [] for i in range(100): rmsi.append(bkgrms.calc_background_rms(DATA[:, i])) rmsi = np.array(rmsi) assert_allclose(rms_arr, rmsi) rms_arr = bkgrms.calc_background_rms(DATA, axis=1) rmsi = [] for i in range(100): rmsi.append(bkgrms.calc_background_rms(DATA[i, :])) rmsi = np.array(rmsi) assert_allclose(rms_arr, rmsi) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_nosigmaclip(rms_class): bkgrms = rms_class(sigma_clip=None) assert_allclose(bkgrms.calc_background_rms(DATA), STD, atol=1.0e-2) assert_allclose(bkgrms(DATA), bkgrms.calc_background_rms(DATA)) # test with masked array mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) rms = bkgrms.calc_background_rms(data) assert not np.ma.isMaskedArray(bkgrms) assert_allclose(rms, STD, atol=0.01) assert_allclose(bkgrms(data), bkgrms.calc_background_rms(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_masked(bkg_class): bkg = bkg_class(sigma_clip=None) mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) # test masked array with masked=True with axis bkgval1 = bkg(data, masked=True, axis=1) bkgval2 = bkg.calc_background(data, masked=True, axis=1) assert np.ma.isMaskedArray(bkgval1) assert_allclose(np.mean(bkgval1), np.mean(bkgval2)) assert_allclose(np.mean(bkgval1), BKG, atol=0.01) # test masked array with masked=False with axis bkgval2 = bkg.calc_background(data, masked=False, axis=1) assert not np.ma.isMaskedArray(bkgval2) assert_allclose(np.nanmean(bkgval2), BKG, atol=0.01) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_masked(rms_class): bkgrms = rms_class(sigma_clip=None) mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) # test masked array with masked=True with axis rms1 = bkgrms(data, masked=True, axis=1) rms2 = bkgrms.calc_background_rms(data, masked=True, axis=1) assert np.ma.isMaskedArray(rms1) assert_allclose(np.mean(rms1), np.mean(rms2)) assert_allclose(np.mean(rms1), STD, atol=0.01) # test masked array with masked=False with axis rms3 = bkgrms.calc_background_rms(data, masked=False, axis=1) assert not np.ma.isMaskedArray(rms3) assert_allclose(np.nanmean(rms3), STD, atol=0.01) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_axis_tuple(bkg_class): bkg = bkg_class(sigma_clip=None) bkg_val1 = bkg.calc_background(DATA, axis=None) bkg_val2 = bkg.calc_background(DATA, axis=(0, 1)) assert_allclose(bkg_val1, bkg_val2) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_units(bkg_class): data = np.ones((100, 100)) << u.Jy bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(data) assert isinstance(bkgval, u.Quantity) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_units(rms_class): data = np.ones((100, 100)) << u.Jy bkgrms = rms_class(sigma_clip=SIGMA_CLIP) rmsval = bkgrms.calc_background_rms(data) assert isinstance(rmsval, u.Quantity) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_invalid_sigmaclip(bkg_class): with pytest.raises(TypeError): bkg_class(sigma_clip=3) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_invalid_sigmaclip(rms_class): with pytest.raises(TypeError): rms_class(sigma_clip=3) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_repr(bkg_class): bkg = bkg_class() bkg_repr = repr(bkg) assert bkg_repr == str(bkg) assert bkg_repr.startswith(f'{bkg.__class__.__name__}') @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_repr(rms_class): bkgrms = rms_class() rms_repr = repr(bkgrms) assert rms_repr == str(bkgrms) assert rms_repr.startswith(f'{bkgrms.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/tests/test_interpolators.py0000644000175100001770000000243714637570305025106 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolators module. """ import numpy as np import pytest from photutils.background.background_2d import Background2D from photutils.background.interpolators import (BkgIDWInterpolator, BkgZoomInterpolator) from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_zoom_interp(): bkg = Background2D(np.ones((300, 300)), 100) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) interp = BkgZoomInterpolator(clip=False) zoom = interp(mesh, bkg) assert zoom.shape == (300, 300) cls_repr = repr(interp) assert cls_repr.startswith(f'{interp.__class__.__name__}') @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_idw_interp(): bkg = Background2D(np.ones((300, 300)), 100) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) interp = BkgIDWInterpolator() zoom = interp(mesh, bkg) assert zoom.shape == (300, 300) cls_repr = repr(interp) assert cls_repr.startswith(f'{interp.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/background/tests/test_local_background.py0000644000175100001770000000161114637570305025463 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the local_background module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.background import LocalBackground, MedianBackground def test_local_background(): data = np.ones((101, 101)) local_bkg = LocalBackground(5, 10, bkg_estimator=MedianBackground()) x = np.arange(1, 7) * 10 y = np.arange(1, 7) * 10 bkg = local_bkg(data, x, y) assert_allclose(bkg, np.ones(len(x))) # test scalar x and y bkg2 = local_bkg(data, x[2], y[2]) assert not isinstance(bkg2, np.ndarray) assert_allclose(bkg[2], bkg2) bkg3 = local_bkg(data, -100, -100) assert np.isnan(bkg3) with pytest.raises(ValueError): _ = local_bkg(data, x[2], np.inf) cls_repr = repr(local_bkg) assert cls_repr.startswith(local_bkg.__class__.__name__) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0188851 photutils-1.13.0/photutils/centroids/0000755000175100001770000000000014637570322017272 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/__init__.py0000644000175100001770000000032514637570305021404 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for centroiding sources. """ from .core import * # noqa: F401, F403 from .gaussian import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/core.py0000644000175100001770000004470314637570305020605 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ The module contains tools for centroiding sources. """ import inspect import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._parameters import as_pair from photutils.utils._round import py2intround from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['centroid_com', 'centroid_quadratic', 'centroid_sources'] def centroid_com(data, mask=None): """ Calculate the centroid of an n-dimensional array as its "center of mass" determined from moments. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : `~numpy.ndarray` The input n-dimensional array. The image should be a background-subtracted cutout image containing a single source. mask : bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The coordinates of the centroid in pixel order (e.g., ``(x, y)`` or ``(x, y, z)``), not numpy axis order. """ # preserve input data - which should be a small cutout image data = data.copy() if mask is not None and mask is not np.ma.nomask: mask = np.asarray(mask, dtype=bool) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data[mask] = 0.0 badmask = ~np.isfinite(data) if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) data[badmask] = 0.0 total = np.sum(data) if total == 0: return np.array((np.nan, np.nan)) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] # note the output array is reversed to give (x, y) order return np.array([np.sum(indices[axis] * data) / total for axis in range(data.ndim)])[::-1] def centroid_quadratic(data, xpeak=None, ypeak=None, fit_boxsize=5, search_boxsize=None, mask=None): """ Calculate the centroid of an n-dimensional array by fitting a 2D quadratic polynomial. A second degree 2D polynomial is fit within a small region of the data defined by ``fit_boxsize`` to calculate the centroid position. The initial center of the fitting box can specified using the ``xpeak`` and ``ypeak`` keywords. If both ``xpeak`` and ``ypeak`` are `None`, then the box will be centered at the position of the maximum value in the input ``data``. If ``xpeak`` and ``ypeak`` are specified, the ``search_boxsize`` optional keyword can be used to further refine the initial center of the fitting box by searching for the position of the maximum pixel within a box of size ``search_boxsize``. `Vakili & Hogg (2016) `_ demonstrate that 2D quadratic centroiding comes very close to saturating the `CramÊr-Rao lower bound `_ in a wide range of conditions. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. xpeak, ypeak : float or `None`, optional The initial guess of the position of the centroid. If either ``xpeak`` or ``ypeak`` is `None` then the position of the maximum value in the input ``data`` will be used as the initial guess. fit_boxsize : int or tuple of int, optional The size (in pixels) of the box used to define the fitting region. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. If ``fit_boxsize`` is a scalar then a square box of size ``fit_boxsize`` will be used. ``fit_boxsize`` must have odd values for both axes. search_boxsize : int or tuple of int, optional The size (in pixels) of the box used to search for the maximum pixel value if ``xpeak`` and ``ypeak`` are both specified. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. If ``search_boxsize`` is a scalar then a square box of size ``search_boxsize`` will be used. ``search_boxsize`` must have odd values for both axes. This parameter is ignored if either ``xpeak`` or ``ypeak`` is `None`. In that case, the entire array is searched for the maximum value. mask : bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from calculations. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Notes ----- Use ``fit_boxsize = (3, 3)`` to match the work of `Vakili & Hogg (2016) `_ for their 2D second-order polynomial centroiding method. Because this centroid is based on fitting data, it can fail for many reasons, returning (np.nan, np.nan): * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) Also note that a fit is not performed if the maximum data value is at the edge of the data. In this case, the position of the maximum pixel will be returned. References ---------- .. [1] Vakili and Hogg 2016; arXiv:1610.05873 (https://arxiv.org/abs/1610.05873) """ if ((xpeak is None and ypeak is not None) or (xpeak is not None and ypeak is None)): raise ValueError('xpeak and ypeak must both be input or "None"') if xpeak is not None and ((xpeak < 0) or (xpeak > data.shape[1] - 1)): raise ValueError('xpeak is outside of the input data') if ypeak is not None and ((ypeak < 0) or (ypeak > data.shape[0] - 1)): raise ValueError('ypeak is outside of the input data') # preserve input data - which should be a small cutout image data = np.asanyarray(data, dtype=float).copy() ny, nx = data.shape badmask = ~np.isfinite(data) if mask is not None: if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data[mask] = np.nan badmask &= ~mask if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) data[badmask] = np.nan fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(0, 1), upper_bound=data.shape, check_odd=True) if np.prod(fit_boxsize) < 6: raise ValueError('fit_boxsize is too small. 6 values are required ' 'to fit a 2D quadratic polynomial.') if xpeak is None or ypeak is None: yidx, xidx = np.unravel_index(np.nanargmax(data), data.shape) else: xidx = py2intround(xpeak) yidx = py2intround(ypeak) if search_boxsize is not None: search_boxsize = as_pair('search_boxsize', search_boxsize, lower_bound=(0, 1), upper_bound=data.shape, check_odd=True) slc_data, _ = overlap_slices(data.shape, search_boxsize, (yidx, xidx), mode='trim') cutout = data[slc_data] yidx, xidx = np.unravel_index(np.nanargmax(cutout), cutout.shape) xidx += slc_data[1].start yidx += slc_data[0].start # if peak is at the edge of the data, return the position of the maximum if xidx == 0 or xidx == nx - 1 or yidx == 0 or yidx == ny - 1: warnings.warn('maximum value is at the edge of the data and its ' 'position was returned; no quadratic fit was ' 'performed', AstropyUserWarning) return np.array((xidx, yidx), dtype=float) # extract the fitting region slc_data, _ = overlap_slices(data.shape, fit_boxsize, (yidx, xidx), mode='trim') xidx0, xidx1 = (slc_data[1].start, slc_data[1].stop) yidx0, yidx1 = (slc_data[0].start, slc_data[0].stop) # shift the fitting box if it was clipped by the data edge if (xidx1 - xidx0) < fit_boxsize[1]: if xidx0 == 0: xidx1 = min(nx, xidx0 + fit_boxsize[1]) if xidx1 == nx: xidx0 = max(0, xidx1 - fit_boxsize[1]) if (yidx1 - yidx0) < fit_boxsize[0]: if yidx0 == 0: yidx1 = min(ny, yidx0 + fit_boxsize[0]) if yidx1 == ny: yidx0 = max(0, yidx1 - fit_boxsize[0]) cutout = data[yidx0:yidx1, xidx0:xidx1].ravel() if np.count_nonzero(~np.isnan(cutout)) < 6: warnings.warn('at least 6 unmasked data points are required to ' 'perform a 2D quadratic fit', AstropyUserWarning) return np.array((np.nan, np.nan)) # fit a 2D quadratic polynomial to the fitting region xi = np.arange(xidx0, xidx1) yi = np.arange(yidx0, yidx1) x, y = np.meshgrid(xi, yi) x = x.ravel() y = y.ravel() coeff_matrix = np.vstack((np.ones_like(x), x, y, x * y, x * x, y * y)).T # remove NaNs from data to be fit mask = ~np.isnan(cutout) if np.any(mask): coeff_matrix = coeff_matrix[mask] cutout = cutout[mask] try: c = np.linalg.lstsq(coeff_matrix, cutout, rcond=None)[0] except np.linalg.LinAlgError: warnings.warn('quadratic fit failed', AstropyUserWarning) return np.array((np.nan, np.nan)) # analytically find the maximum of the polynomial _, c10, c01, c11, c20, c02 = c det = 4 * c20 * c02 - c11**2 if det <= 0 or ((c20 > 0.0 and c02 >= 0.0) or (c20 >= 0.0 and c02 > 0.0)): warnings.warn('quadratic fit does not have a maximum', AstropyUserWarning) return np.array((np.nan, np.nan)) xm = (c01 * c11 - 2.0 * c02 * c10) / det ym = (c10 * c11 - 2.0 * c20 * c01) / det if 0.0 < xm < (nx - 1.0) and 0.0 < ym < (ny - 1.0): xycen = np.array((xm, ym), dtype=float) else: warnings.warn('quadratic polynomial maximum value falls outside ' 'of the image', AstropyUserWarning) return np.array((np.nan, np.nan)) return xycen def centroid_sources(data, xpos, ypos, box_size=11, footprint=None, mask=None, centroid_func=centroid_com, **kwargs): """ Calculate the centroid of sources at the defined positions. A cutout image centered on each input position will be used to calculate the centroid position. The cutout image is defined either using the ``box_size`` or ``footprint`` keyword. The ``footprint`` keyword can be used to create a non-rectangular cutout image. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be background-subtracted. xpos, ypos : float or array_like of float The initial ``x`` and ``y`` pixel position(s) of the center position. A cutout image centered on this position be used to calculate the centroid. box_size : int or array_like of int, optional The size of the cutout image along each axis. If ``box_size`` is a number, then a square cutout of ``box_size`` will be created. If ``box_size`` has two elements, they must be in ``(ny, nx)`` order. ``box_size`` must have odd values for both axes. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. footprint : bool `~numpy.ndarray`, optional A 2D boolean array where `True` values describe the local footprint region to cutout. ``footprint`` can be used to create a non-rectangular cutout image, in which case the input ``xpos`` and ``ypos`` represent the center of the minimal bounding box for the input ``footprint``. ``box_size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. mask : 2D bool `~numpy.ndarray`, optional A 2D boolean array with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D `~numpy.ndarray`, optional The 2D array of the 1-sigma errors of the input ``data``. ``error`` must have the same shape as ``data``. ``error`` will be used only if supported by the input ``centroid_func``. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The ``centroid_func`` must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray`, representing the x and y centroids. The default is `~photutils.centroids.centroid_com`. **kwargs : `dict` Any additional keyword arguments accepted by the ``centroid_func``. Returns ------- xcentroid, ycentroid : `~numpy.ndarray` The ``x`` and ``y`` pixel position(s) of the centroids. NaNs will be returned where the centroid failed. This is usually due a ``box_size`` that is too small when using a fitting-based centroid function (e.g., `centroid_1dg`, `centroid_2dg`, or `centroid_quadratic`. Examples -------- >>> from photutils.centroids import centroid_com, centroid_sources >>> from photutils.datasets import make_4gaussians_image >>> from photutils.utils import circular_footprint >>> data = make_4gaussians_image() >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> footprint = circular_footprint(5.0) >>> x, y = centroid_sources(data, x_init, y_init, footprint=footprint, ... centroid_func=centroid_com) >>> print(x) # doctest: +FLOAT_CMP [ 24.97314968 90.86925938 150.46696217 159.89689388] >>> print(y) # doctest: +FLOAT_CMP [40.00381216 60.93309607 24.48681544 70.6215995 ] .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.centroids import centroid_com, centroid_sources from photutils.datasets import make_4gaussians_image from photutils.utils import circular_footprint data = make_4gaussians_image() x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) footprint = circular_footprint(5.0) x, y = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) plt.figure(figsize=(8, 4)) plt.imshow(data, origin='lower', interpolation='nearest') plt.scatter(x, y, marker='+', s=80, color='red', label='Centroids') plt.legend() plt.tight_layout() """ xpos = np.atleast_1d(xpos) ypos = np.atleast_1d(ypos) if xpos.ndim != 1: raise ValueError('xpos must be a 1D array.') if ypos.ndim != 1: raise ValueError('ypos must be a 1D array.') if (np.any(np.min(xpos) < 0) or np.any(np.min(ypos) < 0) or np.any(np.max(xpos) > data.shape[1] - 1) or np.any(np.max(ypos) > data.shape[0] - 1)): raise ValueError('xpos, ypos values contains point(s) outside of ' 'input data') if footprint is None: if box_size is None: raise ValueError('box_size or footprint must be defined.') box_size = as_pair('box_size', box_size, lower_bound=(0, 1), check_odd=True) footprint = np.ones(box_size, dtype=bool) else: footprint = np.asanyarray(footprint, dtype=bool) if footprint.ndim != 2: raise ValueError('footprint must be a 2D array.') spec = inspect.signature(centroid_func) if 'mask' not in spec.parameters: raise ValueError('The input "centroid_func" must have a "mask" ' 'keyword.') # drop any **kwargs not supported by the centroid_func centroid_kwargs = {} for key, val in kwargs.items(): if key in spec.parameters: centroid_kwargs[key] = val xcentroids = [] ycentroids = [] for xp, yp in zip(xpos, ypos): slices_large, slices_small = overlap_slices(data.shape, footprint.shape, (yp, xp)) data_cutout = data[slices_large] footprint_mask = np.logical_not(footprint) # trim footprint mask if it has only partial overlap on the data footprint_mask = footprint_mask[slices_small] if mask is not None: # combine the input mask cutout and footprint mask mask_cutout = np.logical_or(mask[slices_large], footprint_mask) else: mask_cutout = footprint_mask if np.all(mask_cutout): raise ValueError(f'The cutout for the source at ({xp, yp}) is ' 'completely masked. Please check your input ' 'mask and footprint. Also note that footprint ' 'must be a small, local footprint.') centroid_kwargs.update({'mask': mask_cutout}) error = centroid_kwargs.get('error', None) if error is not None: centroid_kwargs['error'] = error[slices_large] # remove xpeak and ypeak from the dict and add back only if both # are specified and not None xpeak = centroid_kwargs.pop('xpeak', None) ypeak = centroid_kwargs.pop('ypeak', None) if xpeak is not None and ypeak is not None: centroid_kwargs['xpeak'] = xpeak - slices_large[1].start centroid_kwargs['ypeak'] = ypeak - slices_large[0].start try: xcen, ycen = centroid_func(data_cutout, **centroid_kwargs) except (ValueError, TypeError): xcen, ycen = np.nan, np.nan xcentroids.append(xcen + slices_large[1].start) ycentroids.append(ycen + slices_large[0].start) return np.array(xcentroids), np.array(ycentroids) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/gaussian.py0000644000175100001770000001666514637570305021475 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ The module contains tools for centroiding sources using Gaussians. """ import warnings import numpy as np from astropy.modeling.fitting import LevMarLSQFitter from astropy.modeling.models import Const1D, Const2D, Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning __all__ = ['centroid_1dg', 'centroid_2dg'] def centroid_1dg(data, error=None, mask=None): """ Calculate the centroid of a 2D array by fitting 1D Gaussians to the marginal ``x`` and ``y`` distributions of the array. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. These masks are combined. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. error : 2D `~numpy.ndarray`, optional The 2D array of the 1-sigma errors of the input ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. """ data = np.ma.asanyarray(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) if error is not None: error = np.ma.masked_invalid(error) if data.shape != error.shape: raise ValueError('data and error must have the same shape.') data.mask |= error.mask error.mask = data.mask xy_error = [np.sqrt(np.ma.sum(error**2, axis=i)) for i in (0, 1)] xy_weights = [(1.0 / xy_error[i].clip(min=1.0e-30)) for i in (0, 1)] else: xy_weights = [np.ones(data.shape[i]) for i in (1, 0)] # assign zero weight where an entire row or column is masked if np.any(data.mask): bad_idx = [np.all(data.mask, axis=i) for i in (0, 1)] for i in (0, 1): xy_weights[i][bad_idx[i]] = 0.0 xy_data = [np.ma.sum(data, axis=i).data for i in (0, 1)] constant_init = np.ma.min(data) centroid = [] for (data_i, weights_i) in zip(xy_data, xy_weights): params_init = _gaussian1d_moments(data_i) g_init = Const1D(constant_init) + Gaussian1D(*params_init) fitter = LevMarLSQFitter() x = np.arange(data_i.size) g_fit = fitter(g_init, x, data_i, weights=weights_i) centroid.append(g_fit.mean_1.value) return np.array(centroid) def _gaussian1d_moments(data, mask=None): """ Estimate 1D Gaussian parameters from the moments of 1D data. This function can be useful for providing initial parameter values when fitting a 1D Gaussian to the ``data``. Parameters ---------- data : 1D `~numpy.ndarray` The 1D data array. mask : 1D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- amplitude, mean, stddev : float The estimated parameters of a 1D Gaussian. """ if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) else: data = np.ma.array(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask data.fill_value = 0.0 data = data.filled() x = np.arange(data.size) x_mean = np.sum(x * data) / np.sum(data) x_stddev = np.sqrt(abs(np.sum(data * (x - x_mean) ** 2) / np.sum(data))) amplitude = np.ptp(data) return amplitude, x_mean, x_stddev def centroid_2dg(data, error=None, mask=None): """ Calculate the centroid of a 2D array by fitting a 2D Gaussian (plus a constant) to the array. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. These masks are combined. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. error : 2D `~numpy.ndarray`, optional The 2D array of the 1-sigma errors of the input ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. """ # prevent circular import from photutils.morphology import data_properties data = np.ma.asanyarray(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) if error is not None: error = np.ma.masked_invalid(error) if data.shape != error.shape: raise ValueError('data and error must have the same shape.') data.mask |= error.mask weights = 1.0 / error.clip(min=1.0e-30) else: weights = np.ones(data.shape) if np.ma.count(data) < 7: raise ValueError('Input data must have a least 7 unmasked values to ' 'fit a 2D Gaussian plus a constant.') # assign zero weight to masked pixels if data.mask is not np.ma.nomask: weights[data.mask] = 0.0 mask = data.mask data.fill_value = 0.0 data = data.filled() # Subtract the minimum of the data as a rough background estimate. # This will also make the data values positive, preventing issues with # the moment estimation in data_properties. Moments from negative data # values can yield undefined Gaussian parameters, e.g., x/y_stddev. props = data_properties(data - np.min(data), mask=mask) constant_init = 0.0 # subtracted data minimum above g_init = (Const2D(constant_init) + Gaussian2D(amplitude=np.ptp(data), x_mean=props.xcentroid, y_mean=props.ycentroid, x_stddev=props.semimajor_sigma.value, y_stddev=props.semiminor_sigma.value, theta=props.orientation.value)) fitter = LevMarLSQFitter() y, x = np.indices(data.shape) gfit = fitter(g_init, x, y, data, weights=weights) return np.array([gfit.x_mean_1.value, gfit.y_mean_1.value]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0188851 photutils-1.13.0/photutils/centroids/tests/0000755000175100001770000000000014637570322020434 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/tests/__init__.py0000644000175100001770000000000014637570305022534 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/tests/test_core.py0000644000175100001770000003445114637570305023005 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import itertools from contextlib import nullcontext import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.centroids.core import (centroid_com, centroid_quadratic, centroid_sources) from photutils.centroids.gaussian import centroid_1dg, centroid_2dg from photutils.datasets import make_4gaussians_image, make_noise_image from photutils.utils._optional_deps import HAS_SCIPY XCEN = 25.7 YCEN = 26.2 XSTDS = [3.2, 4.0] YSTDS = [5.7, 4.1] THETAS = np.array([30.0, 45.0]) * np.pi / 180.0 DATA = np.zeros((3, 3)) DATA[0:2, 1] = 1.0 DATA[1, 0:2] = 1.0 DATA[1, 1] = 2.0 CENTROID_FUNCS = (centroid_com, centroid_quadratic, centroid_1dg, centroid_2dg) # NOTE: the fitting routines in astropy use scipy.optimize @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize(('x_std', 'y_std', 'theta'), list(itertools.product(XSTDS, YSTDS, THETAS))) def test_centroid_comquad(x_std, y_std, theta): model = Gaussian2D(2.4, XCEN, YCEN, x_stddev=x_std, y_stddev=y_std, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) xc, yc = centroid_com(data) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) xc, yc = centroid_quadratic(data) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=0.015) # test with mask mask = np.zeros(data.shape, dtype=bool) data[10, 10] = 1.0e5 mask[10, 10] = True xc, yc = centroid_com(data, mask=mask) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) xc, yc = centroid_quadratic(data, mask=mask) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=0.015) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('use_mask', [True, False]) def test_centroid_comquad_nan_withmask(use_mask): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) data[20, :] = np.nan if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True nwarn = 0 ctx = nullcontext() else: mask = None nwarn = 1 ctx = pytest.warns(AstropyUserWarning, match='Input data contains non-finite values') with ctx as warnlist: xc, yc = centroid_com(data, mask=mask) assert_allclose(xc, xc_ref, rtol=0, atol=1.0e-3) assert yc > yc_ref if nwarn == 1: assert len(warnlist) == nwarn with ctx as warnlist: xc, yc = centroid_quadratic(data, mask=mask) assert_allclose(xc, xc_ref, rtol=0, atol=0.15) if nwarn == 1: assert len(warnlist) == nwarn def test_centroid_com_allmask(): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) mask = np.ones(data.shape, dtype=bool) xc, yc = centroid_com(data, mask=mask) assert np.isnan(xc) assert np.isnan(yc) data = np.zeros((25, 25)) xc, yc = centroid_com(data, mask=None) assert np.isnan(xc) assert np.isnan(yc) def test_centroid_com_invalid_inputs(): data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) with pytest.raises(ValueError): centroid_com(data, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_quadratic_xypeak(): data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 xycen1 = centroid_quadratic(data, fit_boxsize=3) assert_allclose(xycen1, (9, 9)) xycen2 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen2, (5, 5)) xycen3 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3, search_boxsize=5) assert_allclose(xycen3, (7, 7)) with pytest.raises(ValueError): centroid_quadratic(data, xpeak=15, ypeak=5) with pytest.raises(ValueError): centroid_quadratic(data, xpeak=5, ypeak=15) with pytest.raises(ValueError): centroid_quadratic(data, xpeak=15, ypeak=15) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_quadratic_nan(): gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error data[50, 50] = np.nan mask = ~np.isfinite(data) xycen = centroid_quadratic(data, xpeak=47, ypeak=52, mask=mask) assert_allclose(xycen, [47.58324, 51.827182]) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_quadratic_npts(): data = np.zeros((3, 3)) data[1, 1] = 1 mask = np.zeros(data.shape, dtype=bool) mask[0, :] = True mask[2, :] = True with pytest.warns(AstropyUserWarning, match='at least 6 unmasked data points'): centroid_quadratic(data, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_quadratic_invalid_inputs(): data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) with pytest.raises(ValueError): centroid_quadratic(data, xpeak=3, ypeak=None) with pytest.raises(ValueError): centroid_quadratic(data, xpeak=None, ypeak=3) with pytest.raises(ValueError): centroid_quadratic(data, fit_boxsize=(2, 2, 2)) with pytest.raises(ValueError): centroid_quadratic(data, fit_boxsize=(-2, 2)) with pytest.raises(ValueError): centroid_quadratic(data, fit_boxsize=(2, 2)) with pytest.raises(ValueError): centroid_quadratic(data, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_quadratic_edge(): data = np.zeros((11, 11)) data[1, 1] = 100 data[9, 9] = 100 xycen = centroid_quadratic(data, xpeak=1, ypeak=1, fit_boxsize=5) assert_allclose(xycen, (0.923077, 0.923077)) xycen = centroid_quadratic(data, xpeak=9, ypeak=9, fit_boxsize=5) assert_allclose(xycen, (9.076923, 9.076923)) data = np.zeros((5, 5)) data[0, 0] = 100 with pytest.warns(AstropyUserWarning, match='maximum value is at the edge'): xycen = centroid_quadratic(data) assert_allclose(xycen, (0, 0)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestCentroidSources: def setup_class(self): ysize = 50 xsize = 47 yy, xx = np.mgrid[0:ysize, 0:xsize] data = np.zeros((ysize, xsize)) xcen = (1, 25, 25, 35, 46) ycen = (1, 25, 12, 35, 49) for xc, yc in zip(xcen, ycen): model = Gaussian2D(10.0, xc, yc, x_stddev=2, y_stddev=2, theta=0) data += model(xx, yy) self.xpos = xcen self.ypos = ycen self.data = data @staticmethod def test_centroid_sources(): theta = np.pi / 6.0 model = Gaussian2D(2.4, XCEN, YCEN, x_stddev=3.2, y_stddev=5.7, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) error = np.ones(data.shape, dtype=float) mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True xpos = [25.0] ypos = [26.0] xc, yc = centroid_sources(data, xpos, ypos, box_size=21, mask=mask) assert_allclose(xc, (25.67,), atol=1e-1) assert_allclose(yc, (26.18,), atol=1e-1) xc, yc = centroid_sources(data, xpos, ypos, error=error, box_size=11, centroid_func=centroid_1dg) assert_allclose(xc, (25.67,), atol=1e-1) assert_allclose(yc, (26.41,), atol=1e-1) with pytest.raises(ValueError): centroid_sources(data, 25, [[26]], box_size=11) with pytest.raises(ValueError): centroid_sources(data, [[25]], 26, box_size=11) with pytest.raises(ValueError): centroid_sources(data, 25, 26, box_size=(1, 2, 3)) with pytest.raises(ValueError): centroid_sources(data, 25, 26, box_size=None, footprint=None) with pytest.raises(ValueError): centroid_sources(data, 25, 26, footprint=np.ones((3, 3, 3))) def test_func(data): return data with pytest.raises(ValueError): centroid_sources(data, [25], 26, centroid_func=test_func) @pytest.mark.parametrize('centroid_func', CENTROID_FUNCS) def test_xypos(self, centroid_func): with pytest.raises(ValueError): centroid_sources(self.data, 47, 50, box_size=5, centroid_func=centroid_func) def test_gaussian_fits_npts(self): xcen, ycen = centroid_sources(self.data, self.xpos, self.ypos, box_size=3, centroid_func=centroid_1dg) assert_allclose(xcen, np.full(5, np.nan)) assert_allclose(ycen, np.full(5, np.nan)) xcen, ycen = centroid_sources(self.data, self.xpos, self.ypos, box_size=3, centroid_func=centroid_2dg) xres = np.copy(self.xpos).astype(float) yres = np.copy(self.ypos).astype(float) xres[-1] = np.nan yres[-1] = np.nan assert_allclose(xcen, xres) assert_allclose(ycen, yres) xcen, ycen = centroid_sources(self.data, self.xpos, self.ypos, box_size=5, centroid_func=centroid_1dg) assert_allclose(xcen, xres) assert_allclose(ycen, yres) xcen, ycen = centroid_sources(self.data, self.xpos, self.ypos, box_size=3, centroid_func=centroid_quadratic) assert_allclose(xcen, xres) assert_allclose(ycen, yres) @pytest.mark.filterwarnings(r'ignore:.*no quadratic fit was performed') def test_centroid_quadratic_kwargs(self): data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 xycen1 = centroid_sources(data, xpos=5, ypos=5, box_size=9, centroid_func=centroid_quadratic, fit_boxsize=3) assert_allclose(xycen1, ([9], [9])) xycen2 = centroid_sources(data, xpos=7, ypos=7, box_size=5, centroid_func=centroid_quadratic, fit_boxsize=3) assert_allclose(xycen2, ([9], [9])) xycen3 = centroid_sources(data, xpos=7, ypos=7, box_size=5, centroid_func=centroid_quadratic, xpeak=7, ypeak=7, fit_boxsize=3) assert_allclose(xycen3, ([7], [7])) xycen4 = centroid_sources(data, xpos=5, ypos=5, box_size=5, centroid_func=centroid_quadratic, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen4, ([5], [5])) xycen5 = centroid_sources(data, xpos=5, ypos=5, box_size=5, centroid_func=centroid_quadratic, fit_boxsize=5) assert_allclose(xycen5, ([7], [7])) def test_centroid_quadratic_mask(self): """ Regression test to check that when a mask is input the original data is not alterned. """ xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:51, 0:51] data = model(x, y) mask = data < 1 xycen = centroid_quadratic(data, mask=mask) assert ~np.any(np.isnan(data)) assert_allclose(xycen, (xc_ref, yc_ref), atol=0.01) def test_mask(self): xcen1, ycen1 = centroid_sources(self.data, 25, 23, box_size=(55, 55)) mask = np.zeros(self.data.shape, dtype=bool) mask[0, 0] = True mask[24, 24] = True mask[11, 24] = True xcen2, ycen2 = centroid_sources(self.data, 25, 23, box_size=(55, 55), mask=mask) assert not np.allclose(xcen1, xcen2) assert not np.allclose(ycen1, ycen2) def test_error_none(self): xycen1 = centroid_sources(self.data, xpos=25, ypos=25, error=None, centroid_func=centroid_1dg) xycen2 = centroid_sources(self.data, xpos=25, ypos=25, error=None, centroid_func=centroid_2dg) assert_allclose(xycen1, ([25], [25]), atol=1.0e-3) assert_allclose(xycen2, ([25], [25]), atol=1.0e-3) def test_xypeaks_none(self): xycen1 = centroid_sources(self.data, xpos=25, ypos=25, error=None, xpeak=None, ypeak=25, centroid_func=centroid_quadratic) xycen2 = centroid_sources(self.data, xpos=25, ypos=25, error=None, xpeak=25, ypeak=None, centroid_func=centroid_quadratic) xycen3 = centroid_sources(self.data, xpos=25, ypos=25, error=None, xpeak=None, ypeak=None, centroid_func=centroid_quadratic) assert_allclose(xycen1, ([25], [25]), atol=1.0e-3) assert_allclose(xycen2, ([25], [25]), atol=1.0e-3) assert_allclose(xycen3, ([25], [25]), atol=1.0e-3) def test_cutout_mask(): """ Test that the cutout is not completely masked (see #1514). """ data = make_4gaussians_image() x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) with pytest.raises(ValueError): footprint = np.zeros((3, 3)) _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) with pytest.raises(ValueError): footprint = np.zeros(data.shape, dtype=bool) _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) with pytest.raises(ValueError): mask = np.ones(data.shape, dtype=bool) _ = centroid_sources(data, x_init, y_init, box_size=11, mask=mask) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/centroids/tests/test_gaussian.py0000644000175100001770000001117614637570305023666 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import itertools from contextlib import nullcontext import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.centroids.gaussian import (_gaussian1d_moments, centroid_1dg, centroid_2dg) from photutils.utils._optional_deps import HAS_SCIPY XCEN = 25.7 YCEN = 26.2 XSTDS = [3.2, 4.0] YSTDS = [5.7, 4.1] THETAS = np.array([30.0, 45.0]) * np.pi / 180.0 DATA = np.zeros((3, 3)) DATA[0:2, 1] = 1.0 DATA[1, 0:2] = 1.0 DATA[1, 1] = 2.0 # NOTE: the fitting routines in astropy use scipy.optimize @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize(('x_std', 'y_std', 'theta'), list(itertools.product(XSTDS, YSTDS, THETAS))) def test_centroids(x_std, y_std, theta): model = Gaussian2D(2.4, XCEN, YCEN, x_stddev=x_std, y_stddev=y_std, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) xc, yc = centroid_1dg(data) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) # test with errors error = np.sqrt(data) xc, yc = centroid_1dg(data, error=error) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, error=error) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) # test with mask mask = np.zeros(data.shape, dtype=bool) data[10, 10] = 1.0e5 mask[10, 10] = True xc, yc = centroid_1dg(data, mask=mask) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, mask=mask) assert_allclose((xc, yc), (XCEN, YCEN), rtol=0, atol=1.0e-3) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('use_mask', [True, False]) def test_centroids_nan_withmask(use_mask): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) data[20, :] = np.nan if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True nwarn = 0 ctx = nullcontext() else: mask = None nwarn = 1 ctx = pytest.warns(AstropyUserWarning, match='Input data contains non-finite values') with ctx as warnlist: xc, yc = centroid_1dg(data, mask=mask) assert_allclose([xc, yc], [xc_ref, yc_ref], rtol=0, atol=1.0e-3) if nwarn == 1: assert len(warnlist) == nwarn with ctx as warnlist: xc, yc = centroid_2dg(data, mask=mask) assert_allclose([xc, yc], [xc_ref, yc_ref], rtol=0, atol=1.0e-3) if nwarn == 1: assert len(warnlist) == nwarn @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_invalid_mask_shape(): data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) with pytest.raises(ValueError): centroid_1dg(data, mask=mask) with pytest.raises(ValueError): centroid_2dg(data, mask=mask) with pytest.raises(ValueError): _gaussian1d_moments(data, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_invalid_error_shape(): error = np.zeros((2, 2), dtype=bool) with pytest.raises(ValueError): centroid_1dg(np.zeros((4, 4)), error=error) with pytest.raises(ValueError): centroid_2dg(np.zeros((4, 4)), error=error) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_2dg_dof(): data = np.ones((2, 2)) with pytest.raises(ValueError): centroid_2dg(data) def test_gaussian1d_moments(): x = np.arange(100) desired = (75, 50, 5) g = Gaussian1D(*desired) data = g(x) result = _gaussian1d_moments(data) assert_allclose(result, desired, rtol=0, atol=1.0e-6) data[0] = 1.0e5 mask = np.zeros(data.shape).astype(bool) mask[0] = True result = _gaussian1d_moments(data, mask=mask) assert_allclose(result, desired, rtol=0, atol=1.0e-6) data[0] = np.nan mask = np.zeros(data.shape).astype(bool) mask[0] = True ctx = pytest.warns(AstropyUserWarning, match='Input data contains non-finite values') with ctx as warnlist: result = _gaussian1d_moments(data, mask=mask) assert_allclose(result, desired, rtol=0, atol=1.0e-6) assert len(warnlist) == 1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/conftest.py0000644000175100001770000000212014637570305017473 0ustar00runnerdocker# This file is used to configure the behavior of pytest when using the Astropy # test infrastructure. It needs to live inside the package in order for it to # get picked up when running the tests inside an interpreter using # packagename.test try: from pytest_astropy_header.display import (PYTEST_HEADER_MODULES, TESTED_VERSIONS) ASTROPY_HEADER = True except ImportError: ASTROPY_HEADER = False def pytest_configure(config): if ASTROPY_HEADER: config.option.astropy_header = True # Customize the following lines to add/remove entries from the # list of packages for which version numbers are displayed when # running the tests. PYTEST_HEADER_MODULES.clear() deps = ['NumPy', 'SciPy', 'Matplotlib', 'Astropy', 'skimage', 'sklearn', 'GWCS', 'Bottleneck', 'tqdm', 'Rasterio', 'Shapely'] for dep in deps: PYTEST_HEADER_MODULES[dep] = dep.lower() from photutils import __version__ TESTED_VERSIONS['photutils'] = __version__ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1719595218.0188851 photutils-1.13.0/photutils/datasets/0000755000175100001770000000000014637570322017110 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/__init__.py0000644000175100001770000000070114637570305021220 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for making or loading datasets for examples and tests. """ from .images import * # noqa: F401, F403 from .load import * # noqa: F401, F403 from .noise import * # noqa: F401, F403 from .sources import * # noqa: F401, F403 from .wcs import * # noqa: F401, F403 # prevent circular imports # isort: off from .examples import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.022885 photutils-1.13.0/photutils/datasets/data/0000755000175100001770000000000014637570322020021 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/data/100gaussians_params.ecsv0000644000175100001770000003170214637570305024470 0ustar00runnerdocker# %ECSV 1.0 # --- # datatype: # - {name: flux, datatype: float64} # - {name: x_mean, datatype: float64} # - {name: y_mean, datatype: float64} # - {name: x_stddev, datatype: float64} # - {name: y_stddev, datatype: float64} # - {name: theta, datatype: float64} # - {name: amplitude, datatype: float64} # schema: astropy-2.0 flux x_mean y_mean x_stddev y_stddev theta amplitude 964.808046408574 97.89749954439819 254.08289907307312 2.1843520590544467 4.675823444033835 1.439513292108698 15.034199467407237 658.1877772908929 147.25066097855222 113.42986110887117 4.194060236863709 1.365066882227572 1.1690269821419244 18.29706161768696 591.9594058385472 313.4999402859645 129.739534929611 2.179113171749347 1.2843284999601239 3.1994697250215594 33.66325845987719 602.2801392765199 43.11155256137605 249.78575288223342 3.7884863733341096 2.6548465445442204 3.333215245206706 9.530452502164367 783.8625145408433 71.47250990610482 111.33958070825817 2.091836915003706 1.8625646771453428 1.775874730526394 32.01996425196006 797.7723514896257 257.91325961546585 12.165981577848662 4.650113805061629 3.0787945261988345 1.948802401816166 8.86859504931396 982.2572598678108 344.670664829149 166.40144579159607 2.1941569350396053 3.234056088514339 1.9470609590238852 22.030793741353982 826.5885484357855 428.3129053949444 135.37387284327605 4.559439088414679 2.8948006556921224 1.6737707338720813 9.967343730088695 874.4533187669559 323.6808416610555 217.59029301801934 4.786889175058894 1.8634884866370744 3.7286378879812636 15.601870730148258 826.7849354258676 290.80933776536995 113.53515669437868 1.6756512914843973 1.957755541198448 1.3642954987175926 40.1116547978098 873.857404635637 355.55797754922986 252.19874871267191 2.4371656229305385 1.866953010885965 2.528246436214826 30.566257767153886 980.6533680364107 126.20842852326241 140.7944080750873 3.7287092114849343 3.622761512536076 1.8130837402407098 11.554133867514777 504.1941489707767 450.07984174130377 168.7930287036521 4.6855678802201695 4.385573801760902 1.0922740560175965 3.905074065981485 553.2221883488596 221.146846453142 198.35980177351772 1.5796973759771369 4.721857573264005 1.4181998931581699 11.804101683061127 649.3518568846907 10.260412328356006 138.67256025740141 1.3994975398327223 4.201222068518245 6.211817229004703 17.577311164422643 828.2055915411349 479.8305069984079 187.09108364661495 2.646411106286612 3.722903380921028 2.7272250830462004 13.378863232837679 904.9062762704674 326.1127112238628 66.56418935560802 4.900501483031972 1.8290278327296852 5.20366669065857 16.06803957862391 936.0879568621415 256.60312505233685 219.8589350265597 1.1660261635947395 3.966638899894948 3.5972042138385207 32.211120137727924 982.3237986859651 341.17819156885196 114.50462660879876 1.7476571780499608 3.787618342783211 4.096010251557252 23.618501574731145 861.8426734599767 244.77019531458555 58.45036253116934 3.4350719519841926 2.5270378970335607 4.585080060663453 15.801580661449423 821.2376639479206 463.2450856287161 81.34883243819053 1.3488748604925718 4.400476498565697 2.458507687663199 22.020015688113233 858.7268104062068 257.9398860843252 74.76751558884376 2.265152615647813 3.112444724438511 2.4684276148447215 19.385456768074707 733.7995036096482 36.07994086846561 45.641718676639975 3.440543106028315 4.572036275950712 5.400719661384855 7.424391661266191 662.792338769689 283.7541490995318 231.4121112603979 1.8047788515925212 4.282237499581191 5.089503319644426 13.649063927454758 719.8223029424048 307.62159186187216 76.62351970919306 3.767590865711192 1.9143144776011658 5.256160243159488 15.884312083467421 864.8445413734257 470.77314723482004 38.263021514560336 1.9785078847694324 4.282495877005472 1.1811629636530672 16.245139362737532 997.007292949981 207.68167736050148 199.55014599680814 3.721486783580557 1.5542457005465353 2.25941299990603 27.43356898820013 838.436855886885 132.2199872877417 123.84148460399483 2.9437509156395083 2.9298699806967528 3.0283946807155906 15.47180862160526 895.4112589238944 48.696582682889286 200.33032993166518 2.0419792466819873 1.5952422256903827 6.070013455692302 43.7486566285601 585.4571288962172 242.92211093900085 197.94405512939792 2.130961276866716 2.79317621972722 3.5394011204605853 15.654576974470553 513.4246378949678 232.33143143047653 91.61330156324993 4.621806479308752 3.663825889615503 5.199366394295275 4.825588144350849 900.1851219627304 14.879658495571102 60.367272308625076 2.1033707497538505 4.156129700731245 0.47819821649968547 16.388795648473245 951.8612691085 347.1387309286877 66.60813794973778 4.364592170031081 3.648083032368938 3.4754682061251727 9.514487585185533 512.3381052146326 358.473556182613 35.99125900317928 1.7856974987427723 3.846049715440203 3.4428114459495074 11.872823326701624 745.8736592229429 364.90571164558474 11.143632309079187 2.3223337178578407 4.5516946485244265 5.689069910871459 11.230204233884345 763.1275836749289 207.17550859477285 10.239475057235781 4.786519540733119 1.5667011649886549 2.3215658114967512 16.19613099468567 798.1830052096902 7.549422397079509 69.14896437436673 3.024287398154893 1.5902804942054503 5.662171871226542 26.413491719398397 525.9787725512668 454.48757874207365 68.50611193964927 2.613663433181723 2.3037233717997245 0.8836107895915326 13.90299474776872 947.5447640269606 394.68935905518 187.47318657744282 1.1082461535074435 1.1274953181946978 0.6231268937198444 120.68933322480586 864.1330901635587 82.59958458158827 267.76835564002835 3.4870947577334044 3.751051578465331 6.114690760677037 10.514387597168403 909.1750056949572 156.39298066313552 233.9184048728827 2.3893940270293155 4.058920180960568 5.919043430533304 14.920017581761048 750.1113764172242 305.472652909798 216.43538058842236 2.107196911218392 2.4000376746989676 3.713029257899366 23.606016353615153 905.0947044016274 182.24514335942953 93.13264771057578 1.256219095731597 3.4640304426724273 0.32646089076436025 33.102977945937276 547.9842628722834 78.01929460074975 108.9250249269539 2.337562075662724 4.833999435269771 0.31511241020444675 7.718243397964587 609.4750218660467 88.6519067122759 58.82453515928686 1.2708397698599714 2.1642104664534423 2.039535629643309 35.26839961391395 629.3595308015563 433.944835523523 280.64753941010434 1.0152969930519142 3.746713850016325 2.36090538291161 26.3314829937531 734.0528769764676 145.04733422224675 168.5202000528061 1.1638712136579428 1.1689129211278324 4.801291776153723 85.873742531488 729.6866013017404 292.5898106573307 245.18746106721326 2.3958194624918416 1.2500145268809866 5.398926522671802 38.778173963780226 854.7548901045895 226.9974379547345 104.6741942841017 1.3678172433456401 4.257594782805457 3.42878621028271 23.35981109171799 589.0265029417691 205.5890660862694 239.91427884708384 3.081262905835392 2.7464734497011043 2.034840871894464 11.077730797034372 765.7249421798169 441.31722258842086 31.231338758202487 1.8629992535288005 2.568273728192377 2.5498100113961435 25.470585905334087 583.8711143932428 346.35400739737446 213.63604953821843 4.966666180423694 2.322590718653218 0.6890629410239101 8.055629142620626 884.4069592029051 139.63667759787523 275.23454877469794 2.068748644205795 2.830635313662304 0.8626267857146525 24.037020619488107 964.0852745576614 32.22011559264176 241.07561058875962 3.720672210165289 4.341408609098313 4.683417017383964 9.499122918678843 804.7468289967518 99.31180687942448 98.13394388968734 3.1049844766732857 2.867075504798007 5.506681247335922 14.387351105520032 575.0917473344408 465.8413723514779 76.53215377915976 2.7596922789218654 2.685143951089968 1.6272780372572588 12.35176542536098 744.8133518469976 427.2067838903452 148.50065230714134 3.646220982904549 3.2834062117540923 4.307762282494921 9.90147701356685 688.6724769166128 477.3673672804626 124.08328628084001 1.5391406984146236 3.9562238267733205 2.256363946629663 18.000048775619845 924.3007059888894 26.12667412158648 127.48112858782564 4.151396019636208 1.2203336343762956 0.03780671426767416 29.037596795647307 955.5486143158364 289.735840295467 22.31349145343068 1.5519108040495366 3.496722596287366 4.782640377951582 28.024958752672504 691.9243605786443 240.2481333245647 181.0171953868571 3.531693190918051 3.484241310776869 2.6013794191971553 8.949268929760564 657.7479516980619 10.854489487782114 224.15992006364175 1.8546614308021399 1.0044421272766613 2.0979051470175456 56.194016706706975 784.197076397712 186.81023186719747 239.272867998246 1.093501258932902 3.4115740256064413 1.74699621435307 33.45578803411642 593.9090175121999 207.0459005882796 114.45015676613785 3.5077358931239027 1.1607416483960464 3.72287625728951 23.215476215111874 562.9207719136851 301.95361695700217 239.14744584612285 3.568654906280811 3.3430014671307333 2.0426675786104207 7.509763942358084 843.797902522246 335.87436367761876 141.53921088425687 3.5684133471288484 2.793517698653144 5.8861215543037835 13.471997414115208 899.8033589737009 419.43285021915824 216.41975134338938 4.458483598696862 2.4149210704756774 0.6030788209984189 13.30079584688517 786.7682825993215 389.763104159437 56.76380678235072 2.3488056138657467 4.734100937496935 5.54991085245916 11.261140723532716 986.6149907997425 200.35052202061559 130.37871281388303 2.643833546992447 2.069023727033364 3.568960673583446 28.70570791636843 817.0271885687393 397.2646156948415 247.04043271564257 3.4412747351003192 2.125197521855424 0.38907666538334523 17.78025535040455 944.2108624057134 446.5621551988015 247.88178835953084 4.763372762954965 3.019623854582616 5.497715925124123 10.447724388607257 747.7073793787322 131.24484542944575 56.85764504448847 1.6083090791701964 3.587224474702067 1.4127916029921659 20.62641402432222 675.8082649451061 494.59850368905296 6.03065104579894 1.7362320240392695 4.8822937160946465 5.022673288427523 12.688549121064984 857.1151842722603 426.65354950786895 211.04741581679082 1.6177531527840023 1.1435019175795258 5.038329110827512 73.74119453584417 751.9645582239577 365.7385780188785 104.18103582568175 3.851297303399289 2.6927994645723836 0.181926612470949 11.540016920517292 612.8188032603198 177.78281235317678 125.85114486703607 4.711932645384994 3.3532483546799967 3.9401792447894834 6.172874110334187 622.4872201209321 441.64474535366827 230.4266709533156 2.6907487199266913 3.9268091302219332 3.7990605196329814 9.37643261476075 896.4003500247952 433.97954541354267 288.8634198912861 3.180709054596129 1.7020442916714096 0.49139237108100303 26.352835222969194 747.5862072556845 477.88322291681897 267.76959207766816 2.2895558612877362 3.0421551384372796 4.856610468324372 17.08239697369921 957.5468366998882 0.05362825986848785 40.48268000221009 1.6428187329380126 3.0011383833576275 0.9004849078854927 30.910390365430022 972.6859169375089 8.270520959487326 239.34141449762154 3.8126080983661867 4.571070527911052 2.9134163150744063 8.882857014999784 766.6161148276078 157.03502862937202 204.62718226211888 3.9209936256680225 2.5307308300222147 0.6101643723612765 12.29577716139544 626.2462972787133 497.65808729635785 159.01586613632531 4.841618150435656 3.6514553472118823 3.5804996367196473 5.637788431860726 860.4310290874239 74.42211157799528 259.7944965618501 2.4819622830675323 1.1872462388082012 2.536942619414388 46.4729472143669 683.7193818972887 83.68855935425218 225.9744286058185 3.866793567273198 4.004893135526702 3.685582699041082 7.026775905598758 749.3242214555266 379.28601845895963 27.960776045775393 4.944244871298163 2.475141169343062 4.704260892024514 9.745182062641872 613.2875237312279 34.76483307716155 98.83182960947107 1.4430702347055497 4.792114546247898 5.589608564767361 14.114633381563364 676.7828233730235 352.73671929813526 123.7088086898568 4.032914195635907 2.3889008726968637 2.3837786502572786 11.180271457013728 825.4258933116027 234.58264088954778 0.28938232983252155 1.6302164853414487 3.7355727953878306 1.837431223348704 21.572264447869767 656.466447652576 5.094108062656422 179.26906893808953 4.265741021633359 4.298539526981184 3.1999649528919267 5.697931916427482 884.3677235846699 387.41193143313103 204.76846262476076 3.2164261849098135 2.9450119769447682 3.347766585491736 14.859095063368128 890.9185516856271 397.100504180237 109.88422846305845 3.326840883437651 4.936703485305969 0.6623835781893743 8.633543097902201 926.2047414703625 74.78472587293278 118.94965722809343 4.744582332803316 3.4152561526530203 0.28865583791802196 9.097160601251925 974.9528700759936 11.85181589504447 142.60774365339384 2.737391020406234 4.038499327940802 3.908098216163365 14.036114441245825 553.6614561005221 381.03188544719495 174.32397016297364 2.9565087454722323 3.7380667814650073 5.167573851519573 7.973301713571764 955.3626779827014 111.83508994667218 41.14113834322911 3.6645218672230655 4.729664908728548 0.34597461338954427 8.772850180362555 668.0275809562431 131.0871988825571 299.8242520392146 3.520616004328165 4.799141886114043 5.013815027029375 6.292631158272047 913.1902134073223 228.43475138774744 152.19816263495727 1.520819002612627 4.9604445122118825 1.534636103923281 19.26563170823333 949.0503175599234 124.9634592535091 147.92001136372195 2.415375884928947 1.504621532348117 5.048776534358012 41.56208936719848 521.3576521681665 284.1417807953734 56.06242650836353 3.5404935385444953 4.906402469007675 1.8734891902803512 4.776710351881127 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/data/4gaussians_params.ecsv0000644000175100001770000000071514637570305024333 0ustar00runnerdocker# %ECSV 1.0 # --- # datatype: # - {name: amplitude, datatype: int64} # - {name: x_mean, datatype: int64} # - {name: y_mean, datatype: int64} # - {name: x_stddev, datatype: float64} # - {name: y_stddev, datatype: float64} # - {name: theta, datatype: float64} # schema: astropy-2.0 amplitude x_mean y_mean x_stddev y_stddev theta 50 160 70 15.2 2.6 2.530727415391778 70 25 40 5.1 2.5 0.3490658503988659 150 150 25 3.0 3.0 0.0 210 90 60 8.1 4.7 1.0471975511965976 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/examples.py0000644000175100001770000000632514637570305021307 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making simulated example images for documentation examples and tests. """ import pathlib import numpy as np from astropy.modeling.models import Gaussian2D from astropy.table import QTable from astropy.utils.data import get_pkg_data_path from photutils.datasets import make_model_image __all__ = ['make_4gaussians_image', 'make_100gaussians_image'] _DATASETS_DATA_DIR = pathlib.Path(get_pkg_data_path('datasets', 'data', package='photutils')) def make_4gaussians_image(noise=True): """ Make an example image containing four 2D Gaussians plus a constant background. The background has a mean of 5. If ``noise`` is `True`, then Gaussian noise with a mean of 0 and a standard deviation of 5 is added to the output image. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` Image containing four 2D Gaussian sources. See Also -------- make_100gaussians_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_4gaussians_image image = make_4gaussians_image() plt.imshow(image, origin='lower', interpolation='nearest') """ shape = (100, 200) model = Gaussian2D() params = QTable.read(_DATASETS_DATA_DIR / '4gaussians_params.ecsv', format='ascii.ecsv') data = make_model_image(shape, model, params, x_name='x_mean', y_name='y_mean') data += 5.0 # background if noise: rng = np.random.default_rng(seed=0) data += rng.normal(loc=0.0, scale=5.0, size=shape) return data def make_100gaussians_image(noise=True): """ Make an example image containing 100 2D Gaussians plus a constant background. The background has a mean of 5. If ``noise`` is `True`, then Gaussian noise with a mean of 0 and a standard deviation of 2 is added to the output image. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` Image containing 100 2D Gaussian sources. See Also -------- make_4gaussians_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_100gaussians_image image = make_100gaussians_image() plt.imshow(image, origin='lower', interpolation='nearest') """ shape = (300, 500) model = Gaussian2D() params = QTable.read(_DATASETS_DATA_DIR / '100gaussians_params.ecsv', format='ascii.ecsv') data = make_model_image(shape, model, params, bbox_factor=6.0, x_name='x_mean', y_name='y_mean') data += 5.0 # background if noise: rng = np.random.default_rng(seed=0) data += rng.normal(loc=0.0, scale=2.0, size=shape) return data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/images.py0000644000175100001770000006053214637570305020736 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making simulated images for documentation examples and tests. """ import math import warnings import astropy.units as u import numpy as np from astropy.convolution import discretize_model from astropy.modeling import Model from astropy.modeling.models import Gaussian2D from astropy.nddata.utils import NoOverlapError from astropy.table import QTable, Table from astropy.utils.decorators import deprecated from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._coords import make_random_xycoords from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['make_model_image', 'make_model_sources_image', 'make_gaussian_sources_image', 'make_gaussian_prf_sources_image', 'make_test_psf_data'] def make_model_image(shape, model, params_table, *, model_shape=None, bbox_factor=None, x_name='x_0', y_name='y_0', discretize_method='center', discretize_oversample=10, progress_bar=False): """ Make a 2D image containing sources generated from a user-specified astropy 2D model. The model parameters for each source are taken from the input ``params_table`` table. Parameters ---------- shape : 2-tuple of int The shape of the output image. model : 2D `astropy.modeling.Model` The 2D model to be used to render the sources. The model must be two-dimensional where it accepts 2 inputs (i.e., (x, y)) and has 1 output. The model must have parameters for the x and y positions of the sources. Typically, these parameters are named 'x_0' and 'y_0', but the parameter names can be specified using the ``x_name`` and ``y_name`` keywords. params_table : `~astropy.table.Table` A table containing the model parameters for each source. Each row of the table corresponds to a source whose model parameters are defined by the column names, which must match the model parameter names. The table must contain columns for the x and y positions of the sources. The column names for the x and y positions can be specified using the ``x_name`` and ``y_name`` keywords. Model parameters not defined in the table will be set to the ``model`` default value. To attach units to model parameters, ``params_table`` must be input as a `~astropy.table.QTable`. If the table contains a column named 'model_shape', then the values in that column will be used to override the ``model_shape`` keyword and model ``bounding_box`` for each source. This can be used to render each source with a different shape. If the table contains a column named 'local_bkg', then the per-pixel local background values in that column will be used to added to each model source over the region defined by its ``model_shape``. The 'local_bkg' column must have the same flux units as the output image (e.g., if the input ``model`` has 'amplitude' or 'flux' parameters with units). Including 'local_bkg' should be used with care, especially in crowded fields where the ``model_shape`` of sources overlap (see Notes below). Except for ``model_shape`` and ``local_bkg`` column names, column names that do not match model parameters will be ignored. model_shape : 2-tuple of int, int, or `None`, optional The shape around the (x, y) center of each source that will used to evaluate the ``model``. If ``model_shape`` is a scalar integer, then a square shape of size ``model_shape`` will be used. If `None`, then the bounding box of the model will be used (which can optionally be scaled using the ``bbox_factor`` keyword). This keyword must be specified if the model does not have a ``bounding_box`` attribute. If specified, this keyword overrides the model ``bounding_box`` attribute. To use a different shape for each source, include a column named ``'model_shape'`` in the ``params_table``. For that case, this keyword is ignored. bbox_factor : `None` or float, optional The multiplicative factor to pass to the model ``bounding_box`` method to determine the model shape. If `None`, the default model bounding box will be used. This keyword is ignored if ``model_shape`` is specified or if the ``params_table`` contains a ``'model_shape'`` column. x_name : str, optional The name of the ``model`` parameter that corresponds to the x position of the sources. This parameter must also be a column name in ``params_table``. y_name : str, optional The name of the ``model`` parameter that corresponds to the y position of the sources. This parameter must also be a column name in ``params_table``. discretize_method : {'center', 'interp', 'oversample', 'integrate'}, optional One of the following methods for discretizing the model on the pixel grid: * ``'center'`` (default) Discretize model by taking the value at the center of the pixel bins. This method should be used for ePSF/PRF single or gridded models. * ``'interp'`` Discretize model by bilinearly interpolating between the values at the corners of the pixel bins. * ``'oversample'`` Discretize model by taking the average of model values in the pixel bins on an oversampled grid. Use the ``discretize_oversample`` keyword to set the integer oversampling factor. * ``'integrate'`` Discretize model by integrating the model over the pixel bins using `scipy.integrate.quad`. This mode conserves the model integral on a subpixel scale, but it is *extremely* slow. discretize_oversample : int, optional The integer oversampling factor used when ``descretize_method='oversample'``. This keyword is ignored otherwise. progress_bar : bool, optional Whether to display a progress bar while adding the sources to the image. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Returns ------- array : 2D `~numpy.ndarray` The rendered image containing the model sources. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.modeling.models import Moffat2D from photutils.datasets import (make_model_image, make_random_models_table) model = Moffat2D() n_sources = 25 shape = (100, 100) param_ranges = {'amplitude': [100, 200], 'x_0': [0, shape[1]], 'y_0': [0, shape[0]], 'gamma': [1, 2], 'alpha': [1, 2]} params = make_random_models_table(n_sources, param_ranges, seed=0) model_shape = (15, 15) data = make_model_image(shape, model, params, model_shape=model_shape) plt.imshow(data, origin='lower') plt.tight_layout() .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.modeling.models import Gaussian2D from photutils.datasets import make_model_image, make_model_params model = Gaussian2D() shape = (500, 500) n_sources = 100 params = make_model_params(shape, n_sources, x_name='x_mean', y_name='y_mean', min_separation=25, amplitude=(100, 500), x_stddev=(1, 3), y_stddev=(1, 3), theta=(0, np.pi)) model_shape = (25, 25) data = make_model_image(shape, model, params, model_shape=model_shape, x_name='x_mean', y_name='y_mean') plt.imshow(data, origin='lower') plt.tight_layout() Notes ----- The local background value around each source is optionally included using the ``local_bkg`` column in the input ``params_table``. This local background added to each source over its ``model_shape`` region. In regions where the ``model_shape`` of source overlap, the local background will be added multiple times. This is not an issue if the sources are well-separated, but for crowded fields, this option should be used with care. """ if not isinstance(shape, tuple) or len(shape) != 2: raise ValueError('shape must be a 2-tuple') if not isinstance(model, Model): raise ValueError('model must be a Model instance') if model.n_inputs != 2 or model.n_outputs != 1: raise ValueError('model must be a 2D model') if x_name not in model.param_names: raise ValueError(f'x_name "{x_name}" not in model parameter names') if y_name not in model.param_names: raise ValueError(f'y_name "{y_name}" not in model parameter names') if not isinstance(params_table, Table): raise ValueError('params_table must be an astropy Table') if x_name not in params_table.colnames: raise ValueError(f'x_name "{x_name}" not in psf_params column names') if y_name not in params_table.colnames: raise ValueError(f'y_name "{y_name}" not in psf_params column names') if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 1)) variable_shape = False if 'model_shape' in params_table.colnames: model_shape = np.array(params_table['model_shape']) if model_shape.ndim == 1: model_shape = np.array([as_pair('model_shape', shape) for shape in model_shape]) variable_shape = True if model_shape is None: try: _ = model.bounding_box except NotImplementedError: raise ValueError('model_shape must be specified if the model ' 'does not have a bounding_box attribute') if 'local_bkg' in params_table.colnames: local_bkg = params_table['local_bkg'] else: local_bkg = np.zeros(len(params_table)) # include only column names that are model parameters params_to_set = set(params_table.colnames) & set(model.param_names) # copy the input model to leave it unchanged model = model.copy() if progress_bar: # pragma: no cover desc = 'Add model sources' params_table = add_progress_bar(params_table, desc=desc) image = np.zeros(shape, dtype=float) for i, source in enumerate(params_table): for param in params_to_set: setattr(model, param, source[param]) x0 = getattr(model, x_name).value y0 = getattr(model, y_name).value if variable_shape: mod_shape = model_shape[i] else: if model_shape is None: # the bounding box size generally depends on model parameters, # so needs to be calculated for each source if bbox_factor is not None: bbox = model.bounding_box(factor=bbox_factor) else: bbox = model.bounding_box.bounding_box() mod_shape = (bbox[0][1] - bbox[0][0], bbox[1][1] - bbox[1][0]) else: mod_shape = model_shape try: slc_lg, _ = overlap_slices(shape, mod_shape, (y0, x0), mode='trim') if discretize_method == 'center': yy, xx = np.mgrid[slc_lg] subimg = model(xx, yy) else: if discretize_method == 'interp': discretize_method = 'linear_interp' x_range = (slc_lg[1].start, slc_lg[1].stop) y_range = (slc_lg[0].start, slc_lg[0].stop) subimg = discretize_model(model, x_range=x_range, y_range=y_range, mode=discretize_method, factor=discretize_oversample) if i == 0 and isinstance(subimg, u.Quantity): image <<= subimg.unit try: image[slc_lg] += subimg + local_bkg[i] except u.UnitConversionError: raise ValueError('The local_bkg column must have the same ' 'flux units as the output image') except NoOverlapError: continue return image @deprecated('1.13.0', alternative='make_model_image') def make_model_sources_image(shape, model, source_table, oversample=1): """ Make an image containing sources generated from a user-specified model. Parameters ---------- shape : 2-tuple of int The shape of the output 2D image. model : 2D astropy.modeling.models object The model to be used for rendering the sources. source_table : `~astropy.table.Table` Table of parameters for the sources. Each row of the table corresponds to a source whose model parameters are defined by the column names, which must match the model parameter names. Column names that do not match model parameters will be ignored. Model parameters not defined in the table will be set to the ``model`` default value. oversample : float, optional The sampling factor used to discretize the models on a pixel grid. If the value is 1.0 (the default), then the models will be discretized by taking the value at the center of the pixel bin. Note that this method will not preserve the total flux of very small sources. Otherwise, the models will be discretized by taking the average over an oversampled grid. The pixels will be oversampled by the ``oversample`` factor. Returns ------- image : 2D `~numpy.ndarray` Image containing model sources. """ image = np.zeros(shape, dtype=float) yidx, xidx = np.indices(shape) params_to_set = [] for param in source_table.colnames: if param in model.param_names: params_to_set.append(param) # Save the initial parameter values so we can set them back when # done with the loop. It's best not to copy a model, because some # models (e.g., PSF models) may have substantial amounts of data in # them. init_params = {param: getattr(model, param) for param in params_to_set} try: for source in source_table: for param in params_to_set: setattr(model, param, source[param]) if oversample == 1: image += model(xidx, yidx) else: image += discretize_model(model, (0, shape[1]), (0, shape[0]), mode='oversample', factor=oversample) finally: for param, value in init_params.items(): setattr(model, param, value) return image @deprecated('1.13.0', alternative='make_model_image') def make_gaussian_sources_image(shape, source_table, oversample=1): r""" Make an image containing 2D Gaussian sources. Parameters ---------- shape : 2-tuple of int The shape of the output 2D image. source_table : `~astropy.table.Table` Table of parameters for the Gaussian sources. Each row of the table corresponds to a Gaussian source whose parameters are defined by the column names. With the exception of ``'flux'``, column names that do not match model parameters will be ignored (flux will be converted to amplitude). If both ``'flux'`` and ``'amplitude'`` are present, then ``'flux'`` will be ignored. Model parameters not defined in the table will be set to the default value. oversample : float, optional The sampling factor used to discretize the models on a pixel grid. If the value is 1.0 (the default), then the models will be discretized by taking the value at the center of the pixel bin. Note that this method will not preserve the total flux of very small sources. Otherwise, the models will be discretized by taking the average over an oversampled grid. The pixels will be oversampled by the ``oversample`` factor. Returns ------- image : 2D `~numpy.ndarray` Image containing 2D Gaussian sources. """ model = Gaussian2D(x_stddev=1, y_stddev=1) if 'x_stddev' in source_table.colnames: xstd = source_table['x_stddev'] else: xstd = model.x_stddev.value # default if 'y_stddev' in source_table.colnames: ystd = source_table['y_stddev'] else: ystd = model.y_stddev.value # default colnames = source_table.colnames if 'flux' in colnames and 'amplitude' not in colnames: source_table = source_table.copy() source_table['amplitude'] = (source_table['flux'] / (2.0 * np.pi * xstd * ystd)) return make_model_image(shape, model, source_table, x_name='x_mean', y_name='y_mean', discretize_oversample=oversample) @deprecated('1.13.0', alternative='make_psf_model_image') def make_gaussian_prf_sources_image(shape, source_table): r""" Make an image containing 2D Gaussian sources. Parameters ---------- shape : 2-tuple of int The shape of the output 2D image. source_table : `~astropy.table.Table` Table of parameters for the Gaussian sources. Each row of the table corresponds to a Gaussian source whose parameters are defined by the column names. With the exception of ``'flux'``, column names that do not match model parameters will be ignored (flux will be converted to amplitude). If both ``'flux'`` and ``'amplitude'`` are present, then ``'flux'`` will be ignored. Model parameters not defined in the table will be set to the default value. Returns ------- image : 2D `~numpy.ndarray` Image containing 2D Gaussian sources. """ from photutils.psf import IntegratedGaussianPRF model = IntegratedGaussianPRF(sigma=1) if 'sigma' in source_table.colnames: sigma = source_table['sigma'] else: sigma = model.sigma.value # default colnames = source_table.colnames if 'flux' not in colnames and 'amplitude' in colnames: source_table = source_table.copy() source_table['flux'] = (source_table['amplitude'] * (2.0 * np.pi * sigma * sigma)) return make_model_image(shape, model, source_table) def _define_psf_shape(psf_model, psf_shape): # pragma: no cover """ Define the shape of the model to evaluate, including the oversampling. Deprecated with make_test_psf_data. """ try: model_ndim = psf_model.data.ndim except AttributeError: model_ndim = None try: model_bbox = psf_model.bounding_box except NotImplementedError: model_bbox = None if model_ndim is not None: if model_ndim == 3: model_shape = psf_model.data.shape[1:] elif model_ndim == 2: model_shape = psf_model.data.shape try: oversampling = psf_model.oversampling except AttributeError: oversampling = 1 oversampling = as_pair('oversampling', oversampling) model_shape = tuple(np.array(model_shape) // oversampling) if np.any(psf_shape > model_shape): psf_shape = tuple(np.min([model_shape, psf_shape], axis=0)) warnings.warn('The input psf_shape is larger than the size of the ' 'evaluated PSF model (including oversampling). The ' f'psf_shape was changed to {psf_shape!r}.', AstropyUserWarning) elif model_bbox is not None: ixmin = math.floor(model_bbox['x'].lower + 0.5) ixmax = math.ceil(model_bbox['x'].upper + 0.5) iymin = math.floor(model_bbox['y'].lower + 0.5) iymax = math.ceil(model_bbox['y'].upper + 0.5) model_shape = (iymax - iymin, ixmax - ixmin) if np.any(psf_shape > model_shape): psf_shape = tuple(np.min([model_shape, psf_shape], axis=0)) warnings.warn('The input psf_shape is larger than the bounding ' 'box size of the PSF model. The psf_shape was ' f'changed to {psf_shape!r}.', AstropyUserWarning) return psf_shape @deprecated('1.13.0', alternative='make_psf_model_image') def make_test_psf_data(shape, psf_model, psf_shape, nsources, *, flux_range=(100, 1000), min_separation=1, seed=0, border_size=None, progress_bar=False): """ Make an example image containing PSF model images. Source positions and fluxes are randomly generated using an optional ``seed``. Parameters ---------- shape : 2-tuple of int The shape of the output image. psf_model : `astropy.modeling.Fittable2DModel` The PSF model. psf_shape : 2-tuple of int The shape around the center of the star that will used to evaluate the ``psf_model``. nsources : int The number of sources to generate. flux_range : tuple, optional The lower and upper bounds of the flux range. min_separation : float, optional The minimum separation between the centers of two sources. Note that if the minimum separation is too large, the number of sources generated may be less than ``nsources``. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. border_size : tuple of 2 int, optional The (ny, nx) size of the border around the image where no sources will be generated (i.e., the source center will not be located within the border). If `None`, then a border size equal to half the (y, x) size of the evaluated PSF model (i.e., taking into account oversampling) will be used. progress_bar : bool, optional Whether to display a progress bar when creating the sources. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Returns ------- data : 2D `~numpy.ndarray` The simulated image. table : `~astropy.table.Table` A table containing the parameters of the generated sources. """ psf_shape = _define_psf_shape(psf_model, psf_shape) if border_size is None: hshape = (np.array(psf_shape) - 1) // 2 else: hshape = border_size xrange = (hshape[1], shape[1] - hshape[1]) yrange = (hshape[0], shape[0] - hshape[0]) xycoords = make_random_xycoords(nsources, xrange, yrange, min_separation=min_separation, seed=seed) x, y = np.transpose(xycoords) rng = np.random.default_rng(seed) flux = rng.uniform(flux_range[0], flux_range[1], nsources) flux = flux[:len(x)] sources = QTable() sources['x_0'] = x sources['y_0'] = y sources['flux'] = flux sources_iter = sources if progress_bar: # pragma: no cover desc = 'Adding sources' sources_iter = add_progress_bar(sources, desc=desc) data = np.zeros(shape, dtype=float) for source in sources_iter: for param in ('x_0', 'y_0', 'flux'): setattr(psf_model, param, source[param]) xcen = source['x_0'] ycen = source['y_0'] slc_lg, _ = overlap_slices(shape, psf_shape, (ycen, xcen), mode='trim') yy, xx = np.mgrid[slc_lg] data[slc_lg] += psf_model(xx, yy) sources.rename_column('x_0', 'x') sources.rename_column('y_0', 'y') sources.rename_column('flux', 'flux') return data, sources ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/load.py0000644000175100001770000002125514637570305020407 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for loading example datasets, from both within photutils and remote servers. """ from urllib.error import HTTPError, URLError from astropy.io import fits from astropy.table import Table from astropy.utils.data import download_file, get_pkg_data_filename __all__ = ['get_path', 'load_spitzer_image', 'load_spitzer_catalog', 'load_irac_psf', 'load_star_image', 'load_simulated_hst_star_image'] def get_path(filename, location='local', cache=True, show_progress=False): """ Get the local path for a given file. Parameters ---------- filename : str File name in the local or remote data folder. location : {'local', 'remote', 'photutils-datasets'} File location. ``'local'`` means bundled with ``photutils``. ``'remote'`` means the astropy data server (or the photutils-datasets repo as a backup) or the Astropy cache on your machine. ``'photutils-datasets'`` means the photutils-datasets repo or the Astropy cache on your machine. cache : bool, optional Whether to cache the contents of remote URLs. Default is `True`. show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). The progress bar is displayed only when outputting to a terminal. Returns ------- path : str The local path of the file. """ datasets_url = ('https://github.com/astropy/photutils-datasets/raw/' f'main/data/{filename}') if location == 'local': path = get_pkg_data_filename('data/' + filename) elif location == 'remote': # pragma: no cover try: url = f'https://data.astropy.org/photometry/{filename}' path = download_file(url, cache=cache, show_progress=show_progress) except (URLError, HTTPError): # timeout or not found path = download_file(datasets_url, cache=cache, show_progress=show_progress) elif location == 'photutils-datasets': # pragma: no cover path = download_file(datasets_url, cache=cache, show_progress=show_progress) else: raise ValueError(f'Invalid location: {location}') return path def load_spitzer_image(show_progress=False): # pragma: no cover """ Load a 4.5 micron Spitzer image. The catalog for this image is returned by :func:`load_spitzer_catalog`. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The 4.5 micron Spitzer image in a FITS image HDU. See Also -------- load_spitzer_catalog Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_spitzer_image hdu = load_spitzer_image() plt.imshow(hdu.data, origin='lower', vmax=50) """ path = get_path('spitzer_example_image.fits', location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_spitzer_catalog(show_progress=False): # pragma: no cover """ Load a 4.5 micron Spitzer catalog. The image from which this catalog was derived is returned by :func:`load_spitzer_image`. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- catalog : `~astropy.table.Table` The catalog of sources. See Also -------- load_spitzer_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_spitzer_catalog catalog = load_spitzer_catalog() plt.scatter(catalog['l'], catalog['b']) plt.xlabel('Galactic l') plt.ylabel('Galactic b') plt.xlim(18.39, 18.05) plt.ylim(0.13, 0.30) """ path = get_path('spitzer_example_catalog.xml', location='remote', show_progress=show_progress) table = Table.read(path) return table def load_irac_psf(channel, show_progress=False): # pragma: no cover """ Load a Spitzer IRAC PSF image. Parameters ---------- channel : int (1-4) The IRAC channel number: * Channel 1: 3.6 microns * Channel 2: 4.5 microns * Channel 3: 5.8 microns * Channel 4: 8.0 microns show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The IRAC PSF in a FITS image HDU. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import ImageNormalize, LogStretch from photutils.datasets import load_irac_psf hdu1 = load_irac_psf(1) hdu2 = load_irac_psf(2) hdu3 = load_irac_psf(3) hdu4 = load_irac_psf(4) norm = ImageNormalize(hdu1.data, stretch=LogStretch()) fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) ax1.imshow(hdu1.data, origin='lower', interpolation='nearest', norm=norm) ax1.set_title('IRAC Ch1 PSF') ax2.imshow(hdu2.data, origin='lower', interpolation='nearest', norm=norm) ax2.set_title('IRAC Ch2 PSF') ax3.imshow(hdu3.data, origin='lower', interpolation='nearest', norm=norm) ax3.set_title('IRAC Ch3 PSF') ax4.imshow(hdu4.data, origin='lower', interpolation='nearest', norm=norm) ax4.set_title('IRAC Ch4 PSF') plt.tight_layout() plt.show() """ channel = int(channel) if channel < 1 or channel > 4: raise ValueError('channel must be 1, 2, 3, or 4') filepath = f'irac_ch{channel}_flight.fits' path = get_path(filepath, location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_star_image(show_progress=False): # pragma: no cover """ Load an optical image of stars. This is an image of M67 from photographic data obtained as part of the National Geographic Society - Palomar Observatory Sky Survey (NGS-POSS). The image was digitized from the POSS-I Red plates as part of the Digitized Sky Survey produced at the Space Telescope Science Institute. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The M67 image in a FITS image HDU. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_star_image hdu = load_star_image() plt.imshow(hdu.data, origin='lower', interpolation='nearest') """ path = get_path('M6707HH.fits', location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_simulated_hst_star_image(show_progress=False): # pragma: no cover """ Load a simulated HST WFC3/IR F160W image of stars. The simulated image does not contain any background or noise. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` A FITS image HDU containing the simulated HST star image. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_simulated_hst_star_image hdu = load_simulated_hst_star_image() plt.imshow(hdu.data, origin='lower', interpolation='nearest') """ path = get_path('hst_wfc3ir_f160w_simulated_starfield.fits', location='photutils-datasets', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/make.py0000644000175100001770000000342514637570305020404 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module was deprecated in version 1.13.0 and will be removed in version 1.15.0 (or 2.0.0). """ import warnings from photutils.datasets import examples, images, noise, sources, wcs __depr__ = {} __depr__[examples] = ['make_4gaussians_image', 'make_100gaussians_image'] __depr__[images] = ['make_model_sources_image', 'make_gaussian_sources_image', 'make_gaussian_prf_sources_image', 'make_test_psf_data'] __depr__[noise] = ['apply_poisson_noise', 'make_noise_image'] __depr__[sources] = ['make_random_models_table', 'make_random_gaussians_table'] __depr__[wcs] = ['make_wcs', 'make_gwcs', 'make_imagehdu'] __depr_mesg__ = ('`photutils.datasets.make.{attr}` is a deprecated alias for ' '`{module}.{attr}` and will be removed in the future. ' 'Instead, please use `from {module} import {attr}` ' 'to silence this warning.') __depr_attrs__ = {} for k, vals in __depr__.items(): for val in vals: __depr_attrs__[val] = (getattr(k, val), __depr_mesg__.format( module='photutils.datasets', attr=val)) del k, val, vals # pylint: disable=W0631 def __getattr__(attr): # pragma: no cover if attr in __depr_attrs__: obj, message = __depr_attrs__[attr] warnings.warn(message, DeprecationWarning, stacklevel=2) return obj raise AttributeError(f'module {__name__!r} has no attribute {attr!r}') message = ('photutils.datasets.make is deprecated and will be removed in ' 'a future version. Instead, please import functions from ' 'photutils.datasets') warnings.warn(message, DeprecationWarning, stacklevel=2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/noise.py0000644000175100001770000001062214637570305020601 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for including noise in simulated data. """ import numpy as np __all__ = ['apply_poisson_noise', 'make_noise_image'] def apply_poisson_noise(data, seed=None): """ Apply Poisson noise to an array, where the value of each element in the input array represents the expected number of counts. Each pixel in the output array is generated by drawing a random sample from a Poisson distribution whose expectation value is given by the pixel value in the input array. Parameters ---------- data : array_like The array on which to apply Poisson noise. Every pixel in the array must have a positive value (i.e., counts). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- result : `~numpy.ndarray` The data array after applying Poisson noise. See Also -------- make_noise_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import (apply_poisson_noise, make_4gaussians_image) data1 = make_4gaussians_image(noise=False) data2 = apply_poisson_noise(data1, seed=0) # plot the images fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 8)) ax1.imshow(data1, origin='lower', interpolation='nearest') ax1.set_title('Original image') ax2.imshow(data2, origin='lower', interpolation='nearest') ax2.set_title('Original image with Poisson noise applied') """ data = np.asanyarray(data) if np.any(data < 0): raise ValueError('data must not contain any negative values') rng = np.random.default_rng(seed) return rng.poisson(data) def make_noise_image(shape, distribution='gaussian', mean=None, stddev=None, seed=None): r""" Make a noise image containing Gaussian or Poisson noise. Parameters ---------- shape : 2-tuple of int The shape of the output 2D image. distribution : {'gaussian', 'poisson'} The distribution used to generate the random noise: * ``'gaussian'``: Gaussian distributed noise. * ``'poisson'``: Poisson distributed noise. mean : float The mean of the random distribution. Required for both Gaussian and Poisson noise. The default is 0. stddev : float, optional The standard deviation of the Gaussian noise to add to the output image. Required for Gaussian noise and ignored for Poisson noise (the variance of the Poisson distribution is equal to its mean). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- image : 2D `~numpy.ndarray` Image containing random noise. See Also -------- apply_poisson_noise Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_noise_image # make Gaussian and Poisson noise images shape = (100, 100) image1 = make_noise_image(shape, distribution='gaussian', mean=0., stddev=5.) image2 = make_noise_image(shape, distribution='poisson', mean=5.) # plot the images fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 4)) ax1.imshow(image1, origin='lower', interpolation='nearest') ax1.set_title(r'Gaussian noise ($\mu=0$, $\sigma=5.$)') ax2.imshow(image2, origin='lower', interpolation='nearest') ax2.set_title(r'Poisson noise ($\mu=5$)') """ if mean is None: raise ValueError('"mean" must be input') rng = np.random.default_rng(seed) if distribution == 'gaussian': if stddev is None: raise ValueError('"stddev" must be input for Gaussian noise') image = rng.normal(loc=mean, scale=stddev, size=shape) elif distribution == 'poisson': image = rng.poisson(lam=mean, size=shape) else: raise ValueError(f'Invalid distribution: {distribution}. Use either ' '"gaussian" or "poisson".') return image ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/sources.py0000644000175100001770000003026014637570305021147 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making tables of sources with random model parameters. """ import numpy as np from astropy.modeling.models import Gaussian2D from astropy.table import QTable from astropy.utils.decorators import deprecated from photutils.utils._coords import make_random_xycoords from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair __all__ = ['make_model_params', 'make_random_models_table', 'make_random_gaussians_table'] __doctest_requires__ = {'make_model_params': ['scipy']} def make_model_params(shape, n_sources, *, x_name='x_0', y_name='y_0', min_separation=1, border_size=(0, 0), seed=0, **kwargs): """ Make a table of randomly generated model positions and additional parameters for simulated sources. By default, this function computes only a table of x_0 and y_0 values. Additional parameters can be specified as keyword arguments with their lower and upper bounds as 2-tuples. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. Parameters ---------- shape : 2-tuple of int The shape of the output image. n_sources : int The number of sources to generate. If ``min_separation`` is too large, the number of requested sources may not fit within the given ``shape`` and therefore the number of sources generated may be less than ``n_sources``. x_name : str, optional The name of the ``model`` parameter that corresponds to the x position of the sources. This will be the column name in the output table. y_name : str, optional The name of the ``model`` parameter that corresponds to the y position of the sources. This will be the column name in the output table. min_separation : float, optional The minimum separation between the centers of two sources. Note that if the minimum separation is too large, the number of sources generated may be less than ``n_sources``. border_size : tuple of 2 int or int, optional The (ny, nx) size of the border around the image where no sources will be generated (i.e., the source center will not be located within the border). If a single integer is provided, it will be used for both dimensions. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. **kwargs Keyword arguments are accepted for additional model parameters. The values should be 2-tuples of the lower and upper bounds for the parameter range. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. Returns ------- table : `~astropy.table.QTable` A table containing the model parameters of the generated sources. The table will also contain an ``'id'`` column with unique source IDs. Examples -------- >>> from photutils.datasets import make_model_params >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id x_0 y_0 flux --- --------- --------- --------- 1 60.956935 72.967865 291.99517 2 31.582937 29.149555 192.94917 3 13.277882 80.118738 420.75223 4 11.322211 14.685443 469.41206 5 75.061619 36.889365 206.45211 >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... x_name='x_mean', y_name='y_mean', ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id x_mean y_mean flux --- --------- --------- --------- 1 60.956935 72.967865 291.99517 2 31.582937 29.149555 192.94917 3 13.277882 80.118738 420.75223 4 11.322211 14.685443 469.41206 5 75.061619 36.889365 206.45211 >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... sigma=(1, 2), alpha=(0, 1), ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.5g' # for consistent table output >>> print(params) id x_0 y_0 flux sigma alpha --- ------ ------ ------ ------ -------- 1 60.957 72.968 292 1.5389 0.61437 2 31.583 29.15 192.95 1.4428 0.028365 3 13.278 80.119 420.75 1.931 0.71922 4 11.322 14.685 469.41 1.0405 0.015992 5 75.062 36.889 206.45 1.732 0.75795 """ shape = as_pair('shape', shape, lower_bound=(0, 1)) border_size = as_pair('border_size', border_size, lower_bound=(0, 0)) xrange = (border_size[1], shape[1] - border_size[1]) yrange = (border_size[0], shape[0] - border_size[0]) if xrange[0] >= xrange[1] or yrange[0] >= yrange[1]: raise ValueError('border_size is too large for the given shape') rng = np.random.default_rng(seed) xycoords = make_random_xycoords(n_sources, xrange, yrange, min_separation=min_separation, seed=rng) x, y = np.transpose(xycoords) model_params = QTable() model_params['id'] = np.arange(len(x)) + 1 model_params[x_name] = x model_params[y_name] = y for param, prange in kwargs.items(): if len(prange) != 2: raise ValueError(f'{param} must be a 2-tuple') vals = rng.uniform(*prange, len(model_params)) model_params[param] = vals return model_params def make_random_models_table(n_sources, param_ranges, seed=None): """ Make a `~astropy.table.QTable` containing randomly generated parameters for an Astropy model to simulate a set of sources. Each row of the table corresponds to a source whose parameters are defined by the column names. The parameters are drawn from a uniform distribution over the specified input ranges, inclusively. The output table can be input into :func:`make_model_sources_image` to create an image containing the model sources. Parameters ---------- n_sources : float The number of random model sources to generate. param_ranges : dict The lower and upper boundaries for each of the model parameters as a dictionary mapping the parameter name to its ``(lower, upper)`` bounds. The parameter values will be uniformly distributed between these bounds, inclusively. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- table : `~astropy.table.QTable` A table of parameters for the randomly generated sources. Each row of the table corresponds to a source whose model parameters are defined by the column names. The column names will be the keys of the dictionary ``param_ranges``. The table will also contain an ``'id'`` column with unique source IDs. See Also -------- make_random_gaussians_table Notes ----- To generate identical parameter values from separate function calls, ``param_ranges`` must have the same parameter ranges and the ``seed`` must be the same. Examples -------- >>> from photutils.datasets import make_random_models_table >>> n_sources = 5 >>> param_ranges = {'amplitude': [500, 1000], ... 'x_mean': [0, 500], ... 'y_mean': [0, 300], ... 'x_stddev': [1, 5], ... 'y_stddev': [1, 5], ... 'theta': [0, np.pi]} >>> params = make_random_models_table(n_sources, param_ranges, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id amplitude x_mean y_mean x_stddev y_stddev theta --- --------- --------- ---------- --------- --------- --------- 1 818.48084 456.37779 244.75607 1.7026225 1.1132787 1.2053586 2 634.89336 303.31789 0.82155005 4.4527157 1.4971331 3.1328274 3 520.48676 364.74828 257.22128 3.1658449 3.6824977 3.0813851 4 508.26382 271.8125 10.075673 2.1988476 3.588758 2.1536937 5 906.63512 467.53621 218.89663 2.6907489 3.4615404 2.0434781 """ rng = np.random.default_rng(seed) sources = QTable() sources.meta.update(_get_meta()) # keep sources.meta type sources['id'] = np.arange(n_sources) + 1 for param_name, (lower, upper) in param_ranges.items(): # Generate a column for every item in param_ranges, even if it # is not in the model (e.g., flux). sources[param_name] = rng.uniform(lower, upper, n_sources) return sources @deprecated('1.13.0', alternative='make_random_models_table') def make_random_gaussians_table(n_sources, param_ranges, seed=None): """ Make a `~astropy.table.QTable` containing randomly generated parameters for 2D Gaussian sources. Each row of the table corresponds to a Gaussian source whose parameters are defined by the column names. The parameters are drawn from a uniform distribution over the specified input ranges. The output table will contain columns for both the Gaussian amplitude and flux. The output table can be input into :func:`make_gaussian_sources_image` to create an image containing the 2D Gaussian sources. Parameters ---------- n_sources : float The number of random 2D Gaussian sources to generate. param_ranges : dict The lower and upper boundaries for each of the `~astropy.modeling.functional_models.Gaussian2D` parameters as a dictionary mapping the parameter name to its ``(lower, upper)`` bounds. The dictionary keys must be valid `~astropy.modeling.functional_models.Gaussian2D` parameter names or ``'flux'``. If ``'flux'`` is specified, but not ``'amplitude'`` then the 2D Gaussian amplitudes will be calculated and placed in the output table. If ``'amplitude'`` is specified, then the 2D Gaussian fluxes will be calculated and placed in the output table. If both ``'flux'`` and ``'amplitude'`` are specified, then ``'flux'`` will be recalculated and overwritten. Model parameters not defined in ``param_ranges`` will be set to the default value. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- table : `~astropy.table.QTable` A table of parameters for the randomly generated Gaussian sources. Each row of the table corresponds to a Gaussian source whose parameters are defined by the column names. See Also -------- make_random_models_table Notes ----- To generate identical parameter values from separate function calls, ``param_ranges`` must have the same parameter ranges and the ``seed`` must be the same. """ sources = make_random_models_table(n_sources, param_ranges, seed=seed) model = Gaussian2D() # compute Gaussian2D amplitude to flux conversion factor if 'x_stddev' in sources.colnames: xstd = sources['x_stddev'] else: xstd = model.x_stddev.value # default if 'y_stddev' in sources.colnames: ystd = sources['y_stddev'] else: ystd = model.y_stddev.value # default gaussian_amplitude_to_flux = 2.0 * np.pi * xstd * ystd if 'amplitude' in param_ranges: sources['flux'] = sources['amplitude'] * gaussian_amplitude_to_flux if 'flux' in param_ranges and 'amplitude' not in param_ranges: sources['amplitude'] = sources['flux'] / gaussian_amplitude_to_flux return sources ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.022885 photutils-1.13.0/photutils/datasets/tests/0000755000175100001770000000000014637570322020252 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/__init__.py0000644000175100001770000000000014637570305022352 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_examples.py0000644000175100001770000000120514637570305023500 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the examples module. """ from numpy.testing import assert_allclose from photutils.datasets import make_4gaussians_image, make_100gaussians_image def test_make_4gaussians_image(): shape = (100, 200) data_sum = 177189.58 image = make_4gaussians_image() assert image.shape == shape assert_allclose(image.sum(), data_sum, rtol=1.0e-6) def test_make_100gaussians_image(): shape = (300, 500) data_sum = 826059.53 image = make_100gaussians_image() assert image.shape == shape assert_allclose(image.sum(), data_sum, rtol=1.0e-6) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_images.py0000644000175100001770000002163614637570305023141 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the images module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Moffat2D from astropy.table import QTable from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose from photutils.datasets import (make_gaussian_prf_sources_image, make_gaussian_sources_image, make_model_image, make_model_sources_image, make_test_psf_data) from photutils.psf import IntegratedGaussianPRF from photutils.utils._optional_deps import HAS_SCIPY @pytest.fixture(name='source_params') def fixture_source_params(): # this can be remove when the image deprecations are removed params = QTable() params['flux'] = [1, 2, 3] params['x_mean'] = [30, 50, 70.5] params['y_mean'] = [50, 50, 50.5] params['x_stddev'] = [1, 2, 3.5] params['y_stddev'] = [2, 1, 3.5] params['theta'] = np.array([0.0, 30, 50]) * np.pi / 180.0 return params @pytest.fixture(name='source_params_prf') def fixture_source_params_prf(): # this can be remove when the image deprecations are removed params = QTable() params['x_0'] = [30, 50, 70.5] params['y_0'] = [50, 50, 50.5] params['amplitude'] = np.array([1, 2, 3]) / (2 * np.pi) # sigma = 1 return params def test_make_model_image(): params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) shape = (300, 500) model_shape = (11, 11) image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 # test variable model shape params['model_shape'] = [9, 7, 11] image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 # test local_bkg params['local_bkg'] = [1, 2, 3] image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_model_image_units(): unit = u.Jy params = QTable() params['x_0'] = [30, 50, 70.5] params['y_0'] = [50, 50, 50.5] params['flux'] = [1, 2, 3] * unit model = IntegratedGaussianPRF(sigma=1.5) shape = (300, 500) model_shape = (11, 11) image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert isinstance(image, u.Quantity) assert image.unit == unit assert model.flux == 1.0 # default flux (unchanged) params['local_bkg'] = [0.1, 0.2, 0.3] * unit image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert isinstance(image, u.Quantity) assert image.unit == unit match = 'The local_bkg column must have the same flux units' with pytest.raises(ValueError, match=match): params['local_bkg'] = [0.1, 0.2, 0.3] make_model_image(shape, model, params, model_shape=model_shape) def test_make_model_image_discretize_method(): params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) shape = (300, 500) model_shape = (11, 11) for method in ('interp', 'oversample'): image = make_model_image(shape, model, params, model_shape=model_shape, discretize_method=method) assert image.shape == shape assert image.sum() > 1 def test_make_model_image_no_overlap(): params = QTable() params['x_0'] = [50] params['y_0'] = [50] params['gamma'] = [1.7] params['alpha'] = [2.9] model = Moffat2D(amplitude=1) shape = (10, 10) model_shape = (3, 3) data = make_model_image(shape, model, params, model_shape=model_shape) assert data.shape == shape assert np.sum(data) == 0 def test_make_model_image_inputs(): match = 'shape must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_image(100, Moffat2D(), QTable()) match = 'model must be a Model instance' with pytest.raises(ValueError, match=match): make_model_image((100, 100), None, QTable()) match = 'model must be a 2D model' with pytest.raises(ValueError, match=match): model = Moffat2D() model.n_inputs = 1 make_model_image((100, 100), model, QTable()) match = 'params_table must be an astropy Table' with pytest.raises(ValueError, match=match): model = Moffat2D() make_model_image((100, 100), model, None) match = 'not in model parameter names' with pytest.raises(ValueError, match=match): model = Moffat2D() make_model_image((100, 100), model, QTable(), x_name='invalid') with pytest.raises(ValueError, match=match): model = Moffat2D() make_model_image((100, 100), model, QTable(), y_name='invalid') match = '"x_0" not in psf_params column names' with pytest.raises(ValueError, match=match): model = Moffat2D() params = QTable() make_model_image((100, 100), model, params) match = '"y_0" not in psf_params column names' with pytest.raises(ValueError, match=match): model = Moffat2D() params = QTable() params['x_0'] = [50, 70, 90] make_model_image((100, 100), model, params) match = 'model_shape must be specified if the model does not have' with pytest.raises(ValueError, match=match): params = QTable() params['x_0'] = [50] params['y_0'] = [50] params['gamma'] = [1.7] params['alpha'] = [2.9] model = Moffat2D(amplitude=1) shape = (100, 100) make_model_image(shape, model, params) def test_make_model_sources_image(): params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) with pytest.warns(AstropyDeprecationWarning): image = make_model_sources_image((300, 500), model, params) assert image.sum() > 1 def test_make_gaussian_sources_image(source_params): with pytest.warns(AstropyDeprecationWarning): shape = (100, 100) image = make_gaussian_sources_image(shape, source_params) assert image.shape == shape assert_allclose(image.sum(), source_params['flux'].sum()) def test_make_gaussian_sources_image_amplitude(source_params): with pytest.warns(AstropyDeprecationWarning): params = source_params.copy() params.remove_column('flux') params['amplitude'] = [1, 2, 3] shape = (100, 100) image = make_gaussian_sources_image(shape, params) assert image.shape == shape def test_make_gaussian_sources_image_desc_oversample(source_params): with pytest.warns(AstropyDeprecationWarning): shape = (100, 100) image = make_gaussian_sources_image(shape, source_params, oversample=10) assert image.shape == shape assert_allclose(image.sum(), source_params['flux'].sum()) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_gaussian_prf_sources_image(source_params_prf): with pytest.warns(AstropyDeprecationWarning): shape = (100, 100) image = make_gaussian_prf_sources_image(shape, source_params_prf) assert image.shape == shape flux = source_params_prf['amplitude'] * (2 * np.pi) # sigma = 1 assert_allclose(image.sum(), flux.sum(), rtol=1.0e-6) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_test_psf_data(): with pytest.warns(AstropyDeprecationWarning): psf_model = IntegratedGaussianPRF(flux=100, sigma=1.5) psf_shape = (5, 5) nsources = 10 shape = (100, 100) data, true_params = make_test_psf_data(shape, psf_model, psf_shape, nsources, flux_range=(500, 1000), min_separation=10, seed=0) assert isinstance(data, np.ndarray) assert data.shape == shape assert isinstance(true_params, QTable) assert len(true_params) == nsources assert true_params['x'].min() >= 0 assert true_params['y'].min() >= 0 match = 'Unable to produce' with pytest.warns(AstropyUserWarning, match=match): nsources = 100 make_test_psf_data(shape, psf_model, psf_shape, nsources, flux_range=(500, 1000), min_separation=100, seed=0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_load.py0000644000175100001770000000066114637570305022606 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the load module. """ import pytest from photutils.datasets import get_path, load def test_get_path(): with pytest.raises(ValueError): get_path('filename', location='invalid') @pytest.mark.remote_data def test_load_star_image(): hdu = load.load_star_image() assert len(hdu.header) == 106 assert hdu.data.shape == (1059, 1059) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_noise.py0000644000175100001770000000267414637570305023012 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the noise module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.datasets import apply_poisson_noise, make_noise_image def test_apply_poisson_noise(): shape = (100, 100) data = np.ones(shape) result = apply_poisson_noise(data) assert result.shape == shape assert_allclose(result.mean(), 1.0, atol=1.0) def test_apply_poisson_noise_negative(): """Test if negative image values raises ValueError.""" with pytest.raises(ValueError): shape = (100, 100) data = np.zeros(shape) - 1.0 apply_poisson_noise(data) def test_make_noise_image(): shape = (100, 100) image = make_noise_image(shape, 'gaussian', mean=0.0, stddev=2.0) assert image.shape == shape assert_allclose(image.mean(), 0.0, atol=1.0) def test_make_noise_image_poisson(): shape = (100, 100) image = make_noise_image(shape, 'poisson', mean=1.0) assert image.shape == shape assert_allclose(image.mean(), 1.0, atol=1.0) def test_make_noise_image_nomean(): """Test invalid inputs.""" shape = (100, 100) with pytest.raises(ValueError): make_noise_image(shape, 'invalid', mean=0, stddev=2.0) with pytest.raises(ValueError): make_noise_image(shape, 'gaussian', stddev=2.0) with pytest.raises(ValueError): make_noise_image(shape, 'gaussian', mean=2.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_sources.py0000644000175100001770000001136214637570305023352 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the sources module. """ import numpy as np import pytest from astropy.table import Table from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from photutils.datasets import (make_model_params, make_random_gaussians_table, make_random_models_table) from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_model_params(): shape = (100, 100) n_sources = 10 flux = (100, 1000) params = make_model_params(shape, n_sources, flux=flux) assert isinstance(params, Table) assert len(params) == 10 cols = ('id', 'x_0', 'y_0', 'flux') for col in cols: assert col in params.colnames assert np.min(params[col]) >= 0 assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] # test extra parameters sigma = (1, 2) alpha = (0, 1) params = make_model_params((120, 100), 5, flux=flux, sigma=sigma, alpha=alpha, min_separation=3, border_size=10, seed=0) cols = ('id', 'x_0', 'y_0', 'flux', 'sigma', 'alpha') for col in cols: assert col in params.colnames assert np.min(params[col]) >= 0 assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] assert np.min(params['sigma']) >= sigma[0] assert np.max(params['sigma']) <= sigma[1] assert np.min(params['alpha']) >= alpha[0] assert np.max(params['alpha']) <= alpha[1] match = 'flux must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=(1, 2, 3)) match = 'must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=(1, 2), alpha=(1, 2, 3)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_model_params_nsources(): """ Test case when the number of the possible sources is less than ``n_sources``. """ with pytest.warns(AstropyUserWarning): shape = (200, 500) n_sources = 100 params = make_model_params(shape, n_sources, min_separation=50, amplitude=(100, 500), x_stddev=(1, 5), y_stddev=(1, 5), theta=(0, np.pi)) assert len(params) < 100 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_model_params_border_size(): shape = (10, 10) n_sources = 10 flux = (100, 1000) with pytest.raises(ValueError): make_model_params(shape, n_sources, flux=flux, border_size=20) def test_make_random_models_table(): param_ranges = {'x_0': (0, 300), 'y_0': (0, 500), 'gamma': (1, 3), 'alpha': (1.5, 3)} source_table = make_random_models_table(10, param_ranges) assert len(source_table) == 10 assert 'id' in source_table.colnames cols = ('x_0', 'y_0', 'gamma', 'alpha') for col in cols: assert col in source_table.colnames assert np.min(source_table[col]) >= param_ranges[col][0] assert np.max(source_table[col]) <= param_ranges[col][1] def test_make_random_gaussians_table(): with pytest.warns(AstropyDeprecationWarning): n_sources = 5 param_ranges = dict([('amplitude', [500, 1000]), ('x_mean', [0, 500]), ('y_mean', [0, 300]), ('x_stddev', [1, 5]), ('y_stddev', [1, 5]), ('theta', [0, np.pi])]) table = make_random_gaussians_table(n_sources, param_ranges, seed=0) assert 'flux' in table.colnames assert len(table) == n_sources def test_make_random_gaussians_table_no_stddev(): with pytest.warns(AstropyDeprecationWarning): n_sources = 5 param_ranges = dict([('amplitude', [500, 1000]), ('x_mean', [0, 500]), ('y_mean', [0, 300])]) table = make_random_gaussians_table(n_sources, param_ranges, seed=0) assert 'flux' in table.colnames assert len(table) == n_sources assert 'x_stddev' not in table.colnames assert 'y_stddev' not in table.colnames def test_make_random_gaussians_table_flux(): with pytest.warns(AstropyDeprecationWarning): n_sources = 5 param_ranges = dict([('flux', [500, 1000]), ('x_mean', [0, 500]), ('y_mean', [0, 300]), ('x_stddev', [1, 5]), ('y_stddev', [1, 5]), ('theta', [0, np.pi])]) table = make_random_gaussians_table(n_sources, param_ranges, seed=0) assert 'amplitude' in table.colnames assert len(table) == n_sources ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/tests/test_wcs.py0000644000175100001770000000264714637570305022471 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the wcs module. """ import pytest from numpy.testing import assert_allclose from photutils.datasets import make_gwcs, make_wcs from photutils.utils._optional_deps import HAS_GWCS def test_make_wcs(): shape = (100, 200) wcs = make_wcs(shape) assert wcs.pixel_shape == shape assert wcs.wcs.radesys == 'ICRS' wcs = make_wcs(shape, galactic=True) assert wcs.wcs.ctype[0] == 'GLON-CAR' assert wcs.wcs.ctype[1] == 'GLAT-CAR' @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_make_gwcs(): shape = (100, 200) wcs = make_gwcs(shape) assert wcs.pixel_n_dim == 2 assert wcs.available_frames == ['detector', 'icrs'] assert wcs.output_frame.name == 'icrs' assert wcs.output_frame.axes_names == ('lon', 'lat') wcs = make_gwcs(shape, galactic=True) assert wcs.pixel_n_dim == 2 assert wcs.available_frames == ['detector', 'galactic'] assert wcs.output_frame.name == 'galactic' assert wcs.output_frame.axes_names == ('lon', 'lat') @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_make_wcs_compare(): shape = (200, 300) wcs = make_wcs(shape) gwcs_obj = make_gwcs(shape) sc1 = wcs.pixel_to_world((50, 75), (50, 100)) sc2 = gwcs_obj.pixel_to_world((50, 75), (50, 100)) assert_allclose(sc1.ra, sc2.ra) assert_allclose(sc1.dec, sc2.dec) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/datasets/wcs.py0000644000175100001770000001332214637570305020260 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making example WCS objects. """ import astropy.units as u import numpy as np from astropy import coordinates as coord from astropy.io import fits from astropy.modeling import models from astropy.utils.decorators import deprecated from astropy.wcs import WCS __all__ = ['make_wcs', 'make_gwcs', 'make_imagehdu'] __doctest_requires__ = {'make_gwcs': ['gwcs']} def make_wcs(shape, galactic=False): """ Create a simple celestial `~astropy.wcs.WCS` object in either the ICRS or Galactic coordinate frame. Parameters ---------- shape : 2-tuple of int The shape of the 2D array to be used with the output `~astropy.wcs.WCS` object. galactic : bool, optional If `True`, then the output WCS will be in the Galactic coordinate frame. If `False` (default), then the output WCS will be in the ICRS coordinate frame. Returns ------- wcs : `astropy.wcs.WCS` object The world coordinate system (WCS) transformation. See Also -------- make_gwcs Notes ----- The `make_gwcs` function returns an equivalent WCS transformation to this one, but as a `gwcs.wcs.WCS` object. Examples -------- >>> from photutils.datasets import make_wcs >>> shape = (100, 100) >>> wcs = make_wcs(shape) >>> print(wcs.wcs.crpix) # doctest: +FLOAT_CMP [50. 50.] >>> print(wcs.wcs.crval) # doctest: +FLOAT_CMP [197.8925 -1.36555556] >>> skycoord = wcs.pixel_to_world(42, 57) >>> print(skycoord) # doctest: +FLOAT_CMP """ wcs = WCS(naxis=2) rho = np.pi / 3.0 scale = 0.1 / 3600.0 # 0.1 arcsec/pixel in deg/pix wcs.pixel_shape = shape wcs.wcs.crpix = [shape[1] / 2, shape[0] / 2] # 1-indexed (x, y) wcs.wcs.crval = [197.8925, -1.36555556] wcs.wcs.cunit = ['deg', 'deg'] wcs.wcs.cd = [[-scale * np.cos(rho), scale * np.sin(rho)], [scale * np.sin(rho), scale * np.cos(rho)]] if not galactic: wcs.wcs.radesys = 'ICRS' wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] else: wcs.wcs.ctype = ['GLON-CAR', 'GLAT-CAR'] return wcs def make_gwcs(shape, galactic=False): """ Create a simple celestial gWCS object in the ICRS coordinate frame. This function requires the `gwcs `_ package. Parameters ---------- shape : 2-tuple of int The shape of the 2D array to be used with the output `~gwcs.wcs.WCS` object. galactic : bool, optional If `True`, then the output WCS will be in the Galactic coordinate frame. If `False` (default), then the output WCS will be in the ICRS coordinate frame. Returns ------- wcs : `gwcs.wcs.WCS` object The generalized world coordinate system (WCS) transformation. See Also -------- make_wcs Notes ----- The `make_wcs` function returns an equivalent WCS transformation to this one, but as an `astropy.wcs.WCS` object. Examples -------- >>> from photutils.datasets import make_gwcs >>> shape = (100, 100) >>> gwcs = make_gwcs(shape) >>> print(gwcs) From Transform -------- ---------------- detector linear_transform icrs None >>> skycoord = gwcs.pixel_to_world(42, 57) >>> print(skycoord) # doctest: +FLOAT_CMP """ from gwcs import coordinate_frames as cf from gwcs import wcs as gwcs_wcs rho = np.pi / 3.0 scale = 0.1 / 3600.0 # 0.1 arcsec/pixel in deg/pix shift_by_crpix = (models.Shift((-shape[1] / 2) + 1) & models.Shift((-shape[0] / 2) + 1)) cd_matrix = np.array([[-scale * np.cos(rho), scale * np.sin(rho)], [scale * np.sin(rho), scale * np.cos(rho)]]) rotation = models.AffineTransformation2D(cd_matrix, translation=[0, 0]) rotation.inverse = models.AffineTransformation2D( np.linalg.inv(cd_matrix), translation=[0, 0]) tan = models.Pix2Sky_TAN() celestial_rotation = models.RotateNative2Celestial(197.8925, -1.36555556, 180.0) det2sky = shift_by_crpix | rotation | tan | celestial_rotation det2sky.name = 'linear_transform' detector_frame = cf.Frame2D(name='detector', axes_names=('x', 'y'), unit=(u.pix, u.pix)) if galactic: sky_frame = cf.CelestialFrame(reference_frame=coord.Galactic(), name='galactic', unit=(u.deg, u.deg)) else: sky_frame = cf.CelestialFrame(reference_frame=coord.ICRS(), name='icrs', unit=(u.deg, u.deg)) pipeline = [(detector_frame, det2sky), (sky_frame, None)] return gwcs_wcs.WCS(pipeline) @deprecated('1.13.0') def make_imagehdu(data, wcs=None): # pragma: no cover """ Create a FITS `~astropy.io.fits.ImageHDU` containing the input 2D image. Parameters ---------- data : 2D array_like The input 2D data. wcs : `None` or `~astropy.wcs.WCS`, optional The world coordinate system (WCS) transformation to include in the output FITS header. Returns ------- image_hdu : `~astropy.io.fits.ImageHDU` The FITS `~astropy.io.fits.ImageHDU`. """ data = np.asanyarray(data) if data.ndim != 2: raise ValueError('data must be a 2D array') if wcs is not None: header = wcs.to_header() else: header = None return fits.ImageHDU(data, header=header) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.022885 photutils-1.13.0/photutils/detection/0000755000175100001770000000000014637570322017256 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/__init__.py0000644000175100001770000000057314637570305021375 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for detecting sources in an astronomical image. """ from .core import * # noqa: F401, F403 from .daofinder import * # noqa: F401, F403 from .irafstarfinder import * # noqa: F401, F403 from .peakfinder import * # noqa: F401, F403 from .starfinder import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/core.py0000644000175100001770000002415014637570305020563 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the base class and star finder kernel for detecting stars in an astronomical image. Each star-finding class should define a method called ``find_stars`` that finds stars in an image. """ import abc import math import warnings import numpy as np from astropy.stats import gaussian_fwhm_to_sigma from photutils.detection.peakfinder import find_peaks from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinderBase'] class StarFinderBase(metaclass=abc.ABCMeta): """ Abstract base class for star finders. """ def __call__(self, data, mask=None): return self.find_stars(data, mask=mask) @staticmethod def _find_stars(convolved_data, kernel, threshold, *, min_separation=0.0, mask=None, exclude_border=False): """ Find stars in an image. Parameters ---------- convolved_data : 2D array_like The convolved 2D array. kernel : `_StarFinderKernel` The convolution kernel. threshold : float The absolute image value above which to select sources. This threshold should be the threshold input to the star finder class multiplied by the kernel relerr. If ``convolved_data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. min_separation : float, optional The minimum separation for detected objects in pixels. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by IRAF's `DAOFIND`_ and `starfind`_ tasks. Returns ------- result : Nx2 `~numpy.ndarray` A Nx2 array containing the (x, y) pixel coordinates. .. _DAOFIND: https://iraf.net/irafhelp.php?val=daofind .. _starfind: https://iraf.net/irafhelp.php?val=starfind """ # define a local footprint for the peak finder if min_separation == 0.0: # DAOStarFinder if isinstance(kernel, np.ndarray): footprint = np.ones(kernel.shape) else: footprint = kernel.mask.astype(bool) else: # define a local circular footprint for the peak finder idx = np.arange(-min_separation, min_separation + 1) xx, yy = np.meshgrid(idx, idx) footprint = np.array((xx**2 + yy**2) <= min_separation**2, dtype=int) # pad the convolved data and mask by half the kernel size (or # x/y radius) to allow for detections near the edges if isinstance(kernel, np.ndarray): ypad = (kernel.shape[0] - 1) // 2 xpad = (kernel.shape[1] - 1) // 2 else: ypad = kernel.yradius xpad = kernel.xradius if not exclude_border: pad = ((ypad, ypad), (xpad, xpad)) pad_mode = 'constant' convolved_data = np.pad(convolved_data, pad, mode=pad_mode, constant_values=0.0) if mask is not None: mask = np.pad(mask, pad, mode=pad_mode, constant_values=False) # find local peaks in the convolved data # suppress any NoDetectionsWarning from find_peaks with warnings.catch_warnings(): warnings.filterwarnings('ignore', category=NoDetectionsWarning) tbl = find_peaks(convolved_data, threshold, footprint=footprint, mask=mask) if tbl is None: return None if exclude_border: xmax = convolved_data.shape[1] - xpad ymax = convolved_data.shape[0] - ypad mask = ((tbl['x_peak'] > xpad) & (tbl['y_peak'] > ypad) & (tbl['x_peak'] < xmax) & (tbl['y_peak'] < ymax)) tbl = tbl[mask] xpos, ypos = tbl['x_peak'], tbl['y_peak'] if not exclude_border: xpos -= xpad ypos -= ypad return np.transpose((xpos, ypos)) @abc.abstractmethod def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.Table` or `None` A table of found stars. If no stars are found then `None` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') class _StarFinderKernel: """ Container class for a 2D Gaussian density enhancement kernel. The kernel has negative wings and sums to zero. It is used by both `DAOStarFinder` and `IRAFStarFinder`. Parameters ---------- fwhm : float The full-width half-maximum (FWHM) of the major axis of the Gaussian kernel in units of pixels. ratio : float, optional The ratio of the minor and major axis standard deviations of the Gaussian kernel. ``ratio`` must be strictly positive and less than or equal to 1.0. The default is 1.0 (i.e., a circular Gaussian kernel). theta : float, optional The position angle (in degrees) of the major axis of the Gaussian kernel, measured counter-clockwise from the positive x axis. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / 2.0*sqrt(2.0*log(2.0))``]. The default is 1.5. normalize_zerosum : bool, optional Whether to normalize the Gaussian kernel to have zero sum, The default is `True`, which generates a density-enhancement kernel. Notes ----- The class attributes include the dimensions of the elliptical kernel and the coefficients of a 2D elliptical Gaussian function expressed as: ``f(x,y) = A * exp(-g(x,y))`` where ``g(x,y) = a*(x-x0)**2 + 2*b*(x-x0)*(y-y0) + c*(y-y0)**2`` References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function """ def __init__(self, fwhm, *, ratio=1.0, theta=0.0, sigma_radius=1.5, normalize_zerosum=True): if fwhm < 0: raise ValueError('fwhm must be positive.') if ratio <= 0 or ratio > 1: raise ValueError('ratio must be positive and less or equal ' 'than 1.') if sigma_radius <= 0: raise ValueError('sigma_radius must be positive.') self.fwhm = fwhm self.ratio = ratio self.theta = theta self.sigma_radius = sigma_radius self.xsigma = self.fwhm * gaussian_fwhm_to_sigma self.ysigma = self.xsigma * self.ratio theta_radians = np.deg2rad(self.theta) cost = np.cos(theta_radians) sint = np.sin(theta_radians) xsigma2 = self.xsigma**2 ysigma2 = self.ysigma**2 self.a = (cost**2 / (2.0 * xsigma2)) + (sint**2 / (2.0 * ysigma2)) # CCW self.b = 0.5 * cost * sint * ((1.0 / xsigma2) - (1.0 / ysigma2)) self.c = (sint**2 / (2.0 * xsigma2)) + (cost**2 / (2.0 * ysigma2)) # find the extent of an ellipse with radius = sigma_radius*sigma; # solve for the horizontal and vertical tangents of an ellipse # defined by g(x,y) = f self.f = self.sigma_radius**2 / 2.0 denom = (self.a * self.c) - self.b**2 # nx and ny are always odd self.nx = 2 * int(max(2, math.sqrt(self.c * self.f / denom))) + 1 self.ny = 2 * int(max(2, math.sqrt(self.a * self.f / denom))) + 1 self.xc = self.xradius = self.nx // 2 self.yc = self.yradius = self.ny // 2 # define the kernel on a 2D grid yy, xx = np.mgrid[0:self.ny, 0:self.nx] self.circular_radius = np.sqrt((xx - self.xc)**2 + (yy - self.yc)**2) self.elliptical_radius = (self.a * (xx - self.xc)**2 + 2.0 * self.b * (xx - self.xc) * (yy - self.yc) + self.c * (yy - self.yc)**2) self.mask = np.where( (self.elliptical_radius <= self.f) | (self.circular_radius <= 2.0), 1, 0).astype(int) self.npixels = self.mask.sum() # NOTE: the central (peak) pixel of gaussian_kernel has a value of 1.0 self.gaussian_kernel_unmasked = np.exp(-self.elliptical_radius) self.gaussian_kernel = self.gaussian_kernel_unmasked * self.mask # denom = variance * npixels denom = ((self.gaussian_kernel**2).sum() - (self.gaussian_kernel.sum()**2 / self.npixels)) self.relerr = 1.0 / np.sqrt(denom) # normalize the kernel to zero sum if normalize_zerosum: self.data = ((self.gaussian_kernel - (self.gaussian_kernel.sum() / self.npixels)) / denom) * self.mask else: # pragma: no cover self.data = self.gaussian_kernel self.shape = self.data.shape def _validate_brightest(brightest): """ Validate the ``brightest`` parameter. It must be >0 and an integer. """ if brightest is not None: if brightest <= 0: raise ValueError('brightest must be >= 0') bright_int = int(brightest) if bright_int != brightest: raise ValueError('brightest must be an integer') brightest = bright_int return brightest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/daofinder.py0000644000175100001770000007412614637570305021576 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the DAOStarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.nddata import extract_array from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.decorators import deprecated_renamed_argument from photutils.detection.core import (StarFinderBase, _StarFinderKernel, _validate_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._quantity_helpers import isscalar, process_quantities from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['DAOStarFinder'] class DAOStarFinder(StarFinderBase): """ Detect stars in an image using the DAOFIND (`Stetson 1987 `_) algorithm. DAOFIND (`Stetson 1987; PASP 99, 191 `_) searches images for local density maxima that have a peak amplitude greater than ``threshold`` (approximately; ``threshold`` is applied to a convolved image) and have a size and shape similar to the defined 2D Gaussian kernel. The Gaussian kernel is defined by the ``fwhm``, ``ratio``, ``theta``, and ``sigma_radius`` input parameters. ``DAOStarFinder`` finds the object centroid by fitting the marginal x and y 1D distributions of the Gaussian kernel to the marginal x and y distributions of the input (unconvolved) ``data`` image. ``DAOStarFinder`` calculates the object roundness using two methods. The ``roundlo`` and ``roundhi`` bounds are applied to both measures of roundness. The first method (``roundness1``; called ``SROUND`` in `DAOFIND`_) is based on the source symmetry and is the ratio of a measure of the object's bilateral (2-fold) to four-fold symmetry. The second roundness statistic (``roundness2``; called ``GROUND`` in `DAOFIND`_) measures the ratio of the difference in the height of the best fitting Gaussian function in x minus the best fitting Gaussian function in y, divided by the average of the best fitting Gaussian functions in x and y. A circular source will have a zero roundness. A source extended in x or y will have a negative or positive roundness, respectively. The sharpness statistic measures the ratio of the difference between the height of the central pixel and the mean of the surrounding non-bad pixels in the convolved image, to the height of the best fitting Gaussian function at that point. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. fwhm : float The full-width half-maximum (FWHM) of the major axis of the Gaussian kernel in units of pixels. ratio : float, optional The ratio of the minor to major axis standard deviations of the Gaussian kernel. ``ratio`` must be strictly positive and less than or equal to 1.0. The default is 1.0 (i.e., a circular Gaussian kernel). theta : float, optional The position angle (in degrees) of the major axis of the Gaussian kernel measured counter-clockwise from the positive x axis. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / (2.0*sqrt(2.0*log(2.0)))``]. sharplo : float, optional The lower bound on sharpness for object detection. sharphi : float, optional The upper bound on sharpness for object detection. roundlo : float, optional The lower bound on roundness for object detection. roundhi : float, optional The upper bound on roundness for object detection. sky : float or `~astropy.units.Quantity`, optional .. deprecated:: 1.13.0 The background sky level of the image. Setting ``sky`` affects only the output values of the object ``peak``, ``flux``, and ``mag`` values. The default is 0.0, which should be used to replicate the results from `DAOFIND`_. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``sky`` must have the same units. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by `DAOFIND`_. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. xycoords : `None` or Nx2 `~numpy.ndarray`, optional The (x, y) pixel coordinates of the approximate centroid positions of identified sources. If ``xycoords`` are input, the algorithm will skip the source-finding step. min_separation : float, optional The minimum separation (in pixels) for detected objects. Note that large values may result in long run times. See Also -------- IRAFStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold``, ``sky``, and ``peakmax`` must all have the same units as the image. For the convolution step, this routine sets pixels beyond the image borders to 0.0. The equivalent parameters in `DAOFIND`_ are ``boundary='constant'`` and ``constant=0.0``. The main differences between `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` are: * `~photutils.detection.IRAFStarFinder` always uses a 2D circular Gaussian kernel, while `~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. References ---------- .. [1] Stetson, P. 1987; PASP 99, 191 (https://ui.adsabs.harvard.edu/abs/1987PASP...99..191S/abstract) .. [2] https://iraf.net/irafhelp.php?val=daofind .. _DAOFIND: https://iraf.net/irafhelp.php?val=daofind """ @deprecated_renamed_argument('sky', None, '1.13.0') def __init__(self, threshold, fwhm, ratio=1.0, theta=0.0, sigma_radius=1.5, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, sky=0.0, exclude_border=False, brightest=None, peakmax=None, xycoords=None, min_separation=0.0): # here we validate the units, but do not strip them # since sky is deprecated, we do not include it in the # validation if it is zero. valid_sky = sky if valid_sky == 0: valid_sky = None inputs = (threshold, valid_sky, peakmax) names = ('threshold', 'sky', 'peakmax') _ = process_quantities(inputs, names) if not isscalar(threshold): raise ValueError('threshold must be a scalar value.') if not np.isscalar(fwhm): raise TypeError('fwhm must be a scalar value.') self.threshold = threshold self.fwhm = fwhm self.ratio = ratio self.theta = theta self.sigma_radius = sigma_radius self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.sky = sky self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: raise ValueError('xycoords must be shaped as a Nx2 array') self.xycoords = xycoords self.kernel = _StarFinderKernel(self.fwhm, ratio=self.ratio, theta=self.theta, sigma_radius=self.sigma_radius) self.threshold_eff = self.threshold * self.kernel.relerr def _get_raw_catalog(self, data, *, mask=None): convolved_data = _filter_data(data, self.kernel.data, mode='constant', fill_value=0.0, check_normalization=False) if self.xycoords is None: xypos = self._find_stars(convolved_data, self.kernel, self.threshold_eff, mask=mask, min_separation=self.min_separation, exclude_border=self.exclude_border) else: xypos = self.xycoords if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None cat = _DAOStarFinderCatalog(data, convolved_data, xypos, self.threshold, self.kernel, sky=self.sky, sharplo=self.sharplo, sharphi=self.sharphi, roundlo=self.roundlo, roundhi=self.roundhi, brightest=self.brightest, peakmax=self.peakmax) return cat def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found stars with the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``sharpness``: object sharpness. * ``roundness1``: object roundness based on symmetry. * ``roundness2``: object roundness based on marginal Gaussian fits. * ``npix``: the total number of pixels in the Gaussian kernel array. * ``sky``: the input ``sky`` parameter. * ``peak``: the peak, sky-subtracted, pixel value of the object. * ``flux``: the object DAOFind "flux" calculated as the peak density in the convolved image divided by the detection threshold. This derivation matches that of `DAOFIND`_ if ``sky`` is 0.0. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. The derivation matches that of `DAOFIND`_ if ``sky`` is 0.0. `None` is returned if no stars are found. """ # here we validate the units, but do not strip them # since sky is deprecated, we do not include it in the # validation if it is zero. valid_sky = self.sky if valid_sky == 0: valid_sky = None inputs = (data, self.threshold, valid_sky, self.peakmax) names = ('data', 'threshold', 'sky', 'peakmax') _ = process_quantities(inputs, names) cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _DAOStarFinderCatalog: """ Class to create a catalog of the properties of each detected star, as defined by `DAOFIND`_. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. convolved_data : 2D `~numpy.ndarray` The convolved 2D image. If ``data`` is a `~astropy.units.Quantity` array, then ``convolved_data`` must have the same units. xypos : Nx2 `numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. threshold : float The absolute image value above which sources were selected. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. kernel : `_StarFinderKernel` The convolution kernel. This kernel must match the kernel used to create the ``convolved_data``. sky : float, optional The local sky level around the source. ``sky`` is used only to calculate the source peak value, flux, and magnitude. If ``data`` is a `~astropy.units.Quantity` array, then ``sky`` must have the same units. sharplo : float, optional The lower bound on sharpness for object detection. sharphi : float, optional The upper bound on sharpness for object detection. roundlo : float, optional The lower bound on roundness for object detection. roundhi : float, optional The upper bound on roundness for object detection. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. References ---------- .. _DAOFIND: https://iraf.net/irafhelp.php?val=daofind """ def __init__(self, data, convolved_data, xypos, threshold, kernel, *, sky=0.0, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, brightest=None, peakmax=None): # here we validate the units, but do not strip them # since sky is deprecated, we do not include it in the # validation if it is zero. valid_sky = sky if valid_sky == 0: valid_sky = None inputs = (data, convolved_data, threshold, valid_sky, peakmax) names = ('data', 'convolved_data', 'threshold', 'sky', 'peakmax') _ = process_quantities(inputs, names) self.data = data if isinstance(data, u.Quantity): unit = data.unit else: unit = None self.unit = unit self.convolved_data = convolved_data self.xypos = np.atleast_2d(xypos) self.kernel = kernel self.threshold = threshold self._sky = sky # DAOFIND has no sky input -> same as sky=0.0 self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.threshold_eff = threshold * kernel.relerr self.cutout_shape = kernel.shape self.cutout_center = tuple((size - 1) // 2 for size in kernel.shape) self.default_columns = ('id', 'xcentroid', 'ycentroid', 'sharpness', 'roundness1', 'roundness2', 'npix', 'sky', 'peak', 'flux', 'mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'convolved_data', 'kernel', 'threshold', '_sky', 'sharplo', 'sharphi', 'roundlo', 'roundhi', 'brightest', 'peakmax', 'threshold_eff', 'cutout_shape', 'cutout_center', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as a 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # slice the other attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue # value is always at least a 1D array, even for a single source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 def make_cutouts(self, data): cutouts = [] for xpos, ypos in self.xypos: cutouts.append(extract_array(data, self.cutout_shape, (ypos, xpos), fill_value=0.0)) value = np.array(cutouts) if self.unit is not None: value <<= self.unit return value @lazyproperty def cutout_data(self): return self.make_cutouts(self.data) @lazyproperty def cutout_convdata(self): return self.make_cutouts(self.convolved_data) @lazyproperty def data_peak(self): return self.cutout_data[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def convdata_peak(self): return self.cutout_convdata[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def roundness1(self): # set the central (peak) pixel to zero for the sum4 calculation cutout_conv = self.cutout_convdata.copy() cutout_conv[:, self.cutout_center[0], self.cutout_center[1]] = 0.0 # calculate the four roundness quadrants. # the cutout size always matches the kernel size, which has odd # dimensions. # quad1 = bottom right # quad2 = bottom left # quad3 = top left # quad4 = top right # 3 3 4 4 4 # 3 3 4 4 4 # 3 3 x 1 1 # 2 2 2 1 1 # 2 2 2 1 1 quad1 = cutout_conv[:, 0:self.cutout_center[0] + 1, self.cutout_center[1] + 1:] quad2 = cutout_conv[:, 0:self.cutout_center[0], 0:self.cutout_center[1] + 1] quad3 = cutout_conv[:, self.cutout_center[0]:, 0:self.cutout_center[1]] quad4 = cutout_conv[:, self.cutout_center[0] + 1:, self.cutout_center[1]:] axis = (1, 2) sum2 = (-quad1.sum(axis=axis) + quad2.sum(axis=axis) - quad3.sum(axis=axis) + quad4.sum(axis=axis)) sum4 = np.abs(cutout_conv).sum(axis=axis) # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) roundness1 = 2.0 * sum2 / sum4 return roundness1 @lazyproperty def sharpness(self): # mean value of the unconvolved data (excluding the peak) cutout_data_masked = self.cutout_data * self.kernel.mask data_mean = ((np.sum(cutout_data_masked, axis=(1, 2)) - self.data_peak) / (self.kernel.npixels - 1)) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return (self.data_peak - data_mean) / self.convdata_peak def daofind_marginal_fit(self, axis=0): """ Fit 1D Gaussians, defined from the marginal x/y kernel distributions, to the marginal x/y distributions of the original (unconvolved) image. These fits are used calculate the star centroid and roundness2 ("GROUND") properties. Parameters ---------- axis : {0, 1}, optional The axis for which the marginal fit is performed: * 0: for the x axis * 1: for the y axis Returns ------- dx : float The fractional shift in x or y (depending on ``axis`` value) of the image centroid relative to the maximum pixel. hx : float The height of the best-fitting Gaussian to the marginal x or y (depending on ``axis`` value) distribution of the unconvolved source data. """ # define triangular weighting functions along each axis, peaked # in the middle and equal to one at the edge ycen, xcen = self.cutout_center xx = xcen - np.abs(np.arange(self.cutout_shape[1]) - xcen) + 1 yy = ycen - np.abs(np.arange(self.cutout_shape[0]) - ycen) + 1 xwt, ywt = np.meshgrid(xx, yy) if axis == 0: # marginal distributions along x axis wt = xwt[0] # 1D wts = ywt # 2D size = self.cutout_shape[1] center = xcen sigma = self.kernel.xsigma dxx = center - np.arange(size) elif axis == 1: # marginal distributions along y axis wt = np.transpose(ywt)[0] # 1D wts = xwt # 2D size = self.cutout_shape[0] center = ycen sigma = self.kernel.ysigma dxx = np.arange(size) - center # compute marginal sums for given axis wt_sum = np.sum(wt) dx = center - np.arange(size) # weighted marginal sums kern_sum_1d = np.sum(self.kernel.gaussian_kernel_unmasked * wts, axis=axis) kern_sum = np.sum(kern_sum_1d * wt) kern2_sum = np.sum(kern_sum_1d**2 * wt) dkern_dx = kern_sum_1d * dx dkern_dx_sum = np.sum(dkern_dx * wt) dkern_dx2_sum = np.sum(dkern_dx**2 * wt) kern_dkern_dx_sum = np.sum(kern_sum_1d * dkern_dx * wt) cutout_data = self.cutout_data if isinstance(cutout_data, u.Quantity): cutout_data = cutout_data.value data_sum_1d = np.sum(cutout_data * wts, axis=axis + 1) data_sum = np.sum(data_sum_1d * wt, axis=1) data_kern_sum = np.sum(data_sum_1d * kern_sum_1d * wt, axis=1) data_dkern_dx_sum = np.sum(data_sum_1d * dkern_dx * wt, axis=1) data_dx_sum = np.sum(data_sum_1d * dxx * wt, axis=1) # perform linear least-squares fit (where data = sky + hx*kernel) # to find the amplitude (hx) hx_numer = data_kern_sum - (data_sum * kern_sum) / wt_sum hx_denom = kern2_sum - (kern_sum**2 / wt_sum) # reject the star if the fit amplitude is not positive mask1 = (hx_numer <= 0.0) | (hx_denom <= 0.0) # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # compute fit amplitude hx = hx_numer / hx_denom # sky = (data_sum - (hx * kern_sum)) / wt_sum # compute centroid shift dx = ((kern_dkern_dx_sum - (data_dkern_dx_sum - dkern_dx_sum * data_sum)) / (hx * dkern_dx2_sum / sigma**2)) dx2 = data_dx_sum / data_sum hsize = size / 2.0 mask2 = (np.abs(dx) > hsize) mask3 = (data_sum == 0.0) mask4 = (mask2 & mask3) mask5 = (mask2 & ~mask3) dx[mask4] = 0.0 dx[mask5] = dx2[mask5] mask6 = (np.abs(dx) > hsize) dx[mask6] = 0.0 hx[mask1] = np.nan dx[mask1] = np.nan return np.transpose((dx, hx)) @lazyproperty def dx_hx(self): return self.daofind_marginal_fit(axis=0) @lazyproperty def dy_hy(self): return self.daofind_marginal_fit(axis=1) @lazyproperty def dx(self): return np.transpose(self.dx_hx)[0] @lazyproperty def dy(self): return np.transpose(self.dy_hy)[0] @lazyproperty def hx(self): return np.transpose(self.dx_hx)[1] @lazyproperty def hy(self): return np.transpose(self.dy_hy)[1] @lazyproperty def xcentroid(self): return np.transpose(self.xypos)[0] + self.dx @lazyproperty def ycentroid(self): return np.transpose(self.xypos)[1] + self.dy @lazyproperty def roundness2(self): """ The star roundness. This roundness parameter represents the ratio of the difference in the height of the best fitting Gaussian function in x minus the best fitting Gaussian function in y, divided by the average of the best fitting Gaussian functions in x and y. A circular source will have a zero roundness. A source extended in x or y will have a negative or positive roundness, respectively. """ return 2.0 * (self.hx - self.hy) / (self.hx + self.hy) @lazyproperty def peak(self): return self.data_peak - self.sky @lazyproperty def flux(self): """ This is DAOStarFinder's definition of "flux", which is the object flux calculated as the peak density in the convolved image divided by the effective detection threshold. This derivation matches that of `DAOFIND`_ if ``sky`` is 0.0. """ flux = self.convdata_peak / self.threshold_eff if self.unit is not None: flux = flux.value * self.unit return flux - (self.sky * self.npix) @lazyproperty def mag(self): # ignore RunTimeWarning if flux is <= 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value mag = -2.5 * np.log10(flux) return mag @lazyproperty def sky(self): sky_vals = np.full(len(self), fill_value=self._sky) if self.unit is not None: sky_vals <<= self.unit return sky_vals @lazyproperty def npix(self): return np.full(len(self), fill_value=self.kernel.data.size) def apply_filters(self): """ Filter the catalog. """ # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'hx', 'hy', 'sharpness', 'roundness1', 'roundness2', 'peak', 'flux') mask = np.ones(len(self), dtype=bool) for attr in attrs: mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # filter based on sharpness, roundness, and peakmax mask = ((newcat.sharpness > newcat.sharplo) & (newcat.sharpness < newcat.sharphi) & (newcat.roundness1 > newcat.roundlo) & (newcat.roundness1 < newcat.roundhi) & (newcat.roundness2 > newcat.roundlo) & (newcat.roundness2 < newcat.roundhi)) if newcat.peakmax is not None: mask &= (newcat.peak < newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the sharpness, ' 'roundness, or peakmax criteria', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source IDs. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/irafstarfinder.py0000644000175100001770000005567314637570305022654 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the IRAFStarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.nddata import extract_array from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.decorators import deprecated_renamed_argument from photutils.detection.core import (StarFinderBase, _StarFinderKernel, _validate_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import isscalar, process_quantities from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['IRAFStarFinder'] class IRAFStarFinder(StarFinderBase): """ Detect stars in an image using IRAF's "starfind" algorithm. `IRAFStarFinder` searches images for local density maxima that have a peak amplitude greater than ``threshold`` above the local background and have a PSF full-width at half-maximum similar to the input ``fwhm``. The objects' centroid, roundness (ellipticity), and sharpness are calculated using image moments. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. fwhm : float The full-width half-maximum (FWHM) of the 2D circular Gaussian kernel in units of pixels. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / 2.0*sqrt(2.0*log(2.0))``]. minsep_fwhm : float, optional The separation (in units of ``fwhm``) for detected objects. The minimum separation is calculated as ``int((fwhm * minsep_fwhm) + 0.5)`` and is clipped to a minimum value of 2. Note that large values may result in long run times. sharplo : float, optional The lower bound on sharpness for object detection. sharphi : float, optional The upper bound on sharpness for object detection. roundlo : float, optional The lower bound on roundness for object detection. roundhi : float, optional The upper bound on roundness for object detection. sky : float, optional .. deprecated:: 1.13.0 The background sky level of the image. Inputing a ``sky`` value will override the background sky estimate. Setting ``sky`` affects only the output values of the object ``peak``, ``flux``, and ``mag`` values. The default is ``None``, which means the sky value will be estimated using the `starfind`_ method. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``sky`` must have the same units. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by `starfind`_. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. xycoords : `None` or Nx2 `~numpy.ndarray`, optional The (x, y) pixel coordinates of the approximate centroid positions of identified sources. If ``xycoords`` are input, the algorithm will skip the source-finding step. min_separation : `None` or float, optional The minimum separation (in pixels) for detected objects. If `None` then ``minsep_fwhm`` will be used, otherwise this keyword overrides ``minsep_fwhm``. Note that large values may result in long run times. See Also -------- DAOStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold``, ``sky``, and ``peakmax`` must all have the same units as the image. For the convolution step, this routine sets pixels beyond the image borders to 0.0. The equivalent parameters in IRAF's `starfind`_ are ``boundary='constant'`` and ``constant=0.0``. IRAF's `starfind`_ uses ``hwhmpsf``, ``fradius``, and ``sepmin`` as input parameters. The equivalent input values for `IRAFStarFinder` are: * ``fwhm = hwhmpsf * 2`` * ``sigma_radius = fradius * sqrt(2.0*log(2.0))`` * ``minsep_fwhm = 0.5 * sepmin`` The main differences between `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` are: * `~photutils.detection.IRAFStarFinder` always uses a 2D circular Gaussian kernel, while `~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. References ---------- .. [1] https://iraf.net/irafhelp.php?val=starfind .. _starfind: https://iraf.net/irafhelp.php?val=starfind """ @deprecated_renamed_argument('sky', None, '1.13.0') def __init__(self, threshold, fwhm, sigma_radius=1.5, minsep_fwhm=2.5, sharplo=0.5, sharphi=2.0, roundlo=0.0, roundhi=0.2, sky=None, exclude_border=False, brightest=None, peakmax=None, xycoords=None, min_separation=None): # here we validate the units, but do not strip them inputs = (threshold, sky, peakmax) names = ('threshold', 'sky', 'peakmax') _ = process_quantities(inputs, names) if not isscalar(threshold): raise TypeError('threshold must be a scalar value.') if not np.isscalar(fwhm): raise TypeError('fwhm must be a scalar value.') self.threshold = threshold self.fwhm = fwhm self.sigma_radius = sigma_radius self.minsep_fwhm = minsep_fwhm self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.sky = sky self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: raise ValueError('xycoords must be shaped as a Nx2 array') self.xycoords = xycoords self.kernel = _StarFinderKernel(self.fwhm, ratio=1.0, theta=0.0, sigma_radius=self.sigma_radius) if min_separation is not None: if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation else: self.min_separation = max(2, int((self.fwhm * self.minsep_fwhm) + 0.5)) def _get_raw_catalog(self, data, *, mask=None): convolved_data = _filter_data(data, self.kernel.data, mode='constant', fill_value=0.0, check_normalization=False) if self.xycoords is None: xypos = self._find_stars(convolved_data, self.kernel, self.threshold, min_separation=self.min_separation, mask=mask, exclude_border=self.exclude_border) else: xypos = self.xycoords if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None cat = _IRAFStarFinderCatalog(data, convolved_data, xypos, self.kernel, sky=self.sky, sharplo=self.sharplo, sharphi=self.sharphi, roundlo=self.roundlo, roundhi=self.roundhi, brightest=self.brightest, peakmax=self.peakmax) return cat def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found objects with the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``fwhm``: object FWHM. * ``sharpness``: object sharpness. * ``roundness``: object roundness. * ``pa``: object position angle (degrees counter clockwise from the positive x axis). * ``npix``: the total number of (positive) unmasked pixels. * ``sky``: the local ``sky`` value. * ``peak``: the peak, sky-subtracted, pixel value of the object. * ``flux``: the object instrumental flux. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. `None` is returned if no stars are found. """ inputs = (data, self.threshold, self.sky, self.peakmax) names = ('data', 'threshold', 'sky', 'peakmax') _ = process_quantities(inputs, names) cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _IRAFStarFinderCatalog: """ Class to create a catalog of the properties of each detected star, as defined by IRAF's ``starfind`` task. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. convolved_data : 2D `~numpy.ndarray` The convolved 2D image. If ``data`` is a `~astropy.units.Quantity` array, then ``convolved_data`` must have the same units. xypos : Nx2 `numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. kernel : `_StarFinderKernel` The convolution kernel. This kernel must match the kernel used to create the ``convolved_data``. sky : `None` or float, optional The local sky level around the source. If sky is ``None``, then a local sky level will be (crudely) estimated using the IRAF ``starfind`` calculation. If ``data`` is a `~astropy.units.Quantity` array, then ``sky`` must have the same units. sharplo : float, optional The lower bound on sharpness for object detection. sharphi : float, optional The upper bound on sharpness for object detection. roundlo : float, optional The lower bound on roundness for object detection. roundhi : float, optional The upper bound on roundness for object detection. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, convolved_data, xypos, kernel, *, sky=None, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (data, convolved_data, sky, peakmax) names = ('data', 'convolved_data', 'sky', 'peakmax') _ = process_quantities(inputs, names) self.data = data if isinstance(data, u.Quantity): unit = data.unit else: unit = None self.unit = unit self.convolved_data = convolved_data self.xypos = xypos self.kernel = kernel self._sky = sky self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.cutout_shape = kernel.shape self.default_columns = ('id', 'xcentroid', 'ycentroid', 'fwhm', 'sharpness', 'roundness', 'pa', 'npix', 'sky', 'peak', 'flux', 'mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'convolved_data', 'kernel', '_sky', 'sharplo', 'sharphi', 'roundlo', 'roundhi', 'brightest', 'peakmax', 'cutout_shape', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as a 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # index/slice the remaining attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue # value is always at least a 1D array, even for a single source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 @lazyproperty def sky(self): if self._sky is None: skymask = ~self.kernel.mask.astype(bool) # 1=sky, 0=obj nsky = np.count_nonzero(skymask) axis = (1, 2) if nsky == 0.0: sky = (np.max(self.cutout_data_nosub, axis=axis) - np.max(self.cutout_convdata, axis=axis)) else: sky = (np.sum(self.cutout_data_nosub * skymask, axis=axis) / nsky) else: sky = np.full(len(self), fill_value=self._sky) if self.unit is not None: sky <<= self.unit return sky def make_cutouts(self, data): cutouts = [] for xpos, ypos in self.xypos: cutouts.append(extract_array(data, self.cutout_shape, (ypos, xpos), fill_value=0.0)) value = np.array(cutouts) if self.unit is not None: value <<= self.unit return value @lazyproperty def cutout_data_nosub(self): return self.make_cutouts(self.data) @lazyproperty def cutout_data(self): data = ((self.cutout_data_nosub - self.sky[:, np.newaxis, np.newaxis]) * self.kernel.mask) # IRAF starfind discards negative pixels data[data < 0] = 0.0 return data @lazyproperty def cutout_convdata(self): return self.make_cutouts(self.convolved_data) @lazyproperty def npix(self): return np.count_nonzero(self.cutout_data, axis=(1, 2)) @lazyproperty def moments(self): return np.array([_moments(arr, order=1) for arr in self.cutout_data]) @lazyproperty def cutout_centroid(self): moments = self.moments # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((ycentroid, xcentroid)) @lazyproperty def cutout_xcentroid(self): return np.transpose(self.cutout_centroid)[1] @lazyproperty def cutout_ycentroid(self): return np.transpose(self.cutout_centroid)[0] @lazyproperty def cutout_xorigin(self): return np.transpose(self.xypos)[0] - self.kernel.xradius @lazyproperty def cutout_yorigin(self): return np.transpose(self.xypos)[1] - self.kernel.yradius @lazyproperty def xcentroid(self): return self.cutout_xcentroid + self.cutout_xorigin @lazyproperty def ycentroid(self): return self.cutout_ycentroid + self.cutout_yorigin @lazyproperty def peak(self): peaks = [np.max(arr) for arr in self.cutout_data] if self.unit is not None: peaks = u.Quantity(peaks) else: peaks = np.array(peaks) return peaks @lazyproperty def flux(self): fluxes = [np.sum(arr) for arr in self.cutout_data] if self.unit is not None: fluxes = u.Quantity(fluxes) else: fluxes = np.array(fluxes) return fluxes @lazyproperty def mag(self): flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) @lazyproperty def moments_central(self): moments = np.array([_moments_central(arr, center=(xcen_, ycen_), order=2) for arr, xcen_, ycen_ in zip(self.cutout_data, self.cutout_xcentroid, self.cutout_ycentroid)]) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return moments / self.moments[:, 0, 0][:, np.newaxis, np.newaxis] @lazyproperty def mu_sum(self): return self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0] @lazyproperty def mu_diff(self): return self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0] @lazyproperty def fwhm(self): return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @lazyproperty def roundness(self): # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return (np.sqrt(self.mu_diff**2 + 4.0 * self.moments_central[:, 1, 1]**2) / self.mu_sum) @lazyproperty def sharpness(self): return self.fwhm / self.kernel.fwhm @lazyproperty def pa(self): pa = np.rad2deg(0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff)) pa = np.where(pa < 0, pa + 180, pa) return pa def apply_filters(self): """ Filter the catalog. """ # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'sharpness', 'roundness', 'pa', 'sky', 'peak', 'flux') mask = np.count_nonzero(self.cutout_data, axis=(1, 2)) > 1 for attr in attrs: mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # filter based on sharpness, roundness, and peakmax mask = ((newcat.sharpness > newcat.sharplo) & (newcat.sharpness < newcat.sharphi) & (newcat.roundness > newcat.roundlo) & (newcat.roundness < newcat.roundhi)) if newcat.peakmax is not None: mask &= (newcat.peak < newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the sharpness, ' 'roundness, or peakmax criteria', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source IDs. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/peakfinder.py0000644000175100001770000002135214637570305021744 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for finding local peaks in an astronomical image. """ import warnings import numpy as np from astropy.table import QTable from photutils.utils._misc import _get_meta from photutils.utils._quantity_helpers import process_quantities from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['find_peaks'] def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None, border_width=None, npeaks=np.inf, centroid_func=None, error=None, wcs=None): """ Find local peaks in an image that are above a specified threshold value. Peaks are the maxima above the ``threshold`` within a local region. The local regions are defined by either the ``box_size`` or ``footprint`` parameters. ``box_size`` defines the local region around each pixel as a square box. ``footprint`` is a boolean array where `True` values specify the region shape. If multiple pixels within a local region have identical intensities, then the coordinates of all such pixels are returned. Otherwise, there will be only one peak pixel per local region. Thus, the defined region effectively imposes a minimum separation between peaks unless there are identical peaks within the region. If ``centroid_func`` is input, then it will be used to calculate a centroid within the defined local region centered on each detected peak pixel. In this case, the centroid will also be returned in the output table. Parameters ---------- data : array_like The 2D array of the image. threshold : float, scalar `~astropy.units.Quantity` or array_like The data value or pixel-wise data values to be used for the detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` must have the same shape as ``data``. See `~photutils.segmentation.detect_threshold` for one way to create a ``threshold`` image. box_size : scalar or tuple, optional The size of the local region to search for peaks at every point in ``data``. If ``box_size`` is a scalar, then the region shape will be ``(box_size, box_size)``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. footprint : `~numpy.ndarray` of bools, optional A boolean array where `True` values describe the local footprint region within which to search for peaks at every point in ``data``. ``box_size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. mask : array_like, bool, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. border_width : bool, optional The width in pixels to exclude around the border of the ``data``. npeaks : int, optional The maximum number of peaks to return. When the number of detected peaks exceeds ``npeaks``, the peaks with the highest peak intensities will be returned. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The ``centroid_func`` must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword, and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray` objects, representing the x and y centroids, respectively. error : array_like, optional The 2D array of the 1-sigma errors of the input ``data``. ``error`` is used only if ``centroid_func`` is input (the ``error`` array is passed directly to the ``centroid_func``). If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must have the same units as ``data``. wcs : `None` or WCS object, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then the sky coordinates will not be returned in the output `~astropy.table.Table`. Returns ------- output : `~astropy.table.Table` or `None` A table containing the x and y pixel location of the peaks and their values. If ``centroid_func`` is input, then the table will also contain the centroid position. If no peaks are found then `None` is returned. Notes ----- By default, the returned pixel coordinates are the integer indices of the maximum pixel value within the input ``box_size`` or ``footprint`` (i.e., only the peak pixel is identified). However, a centroiding function can be input via the ``centroid_func`` keyword to compute centroid coordinates with subpixel precision within the input ``box_size`` or ``footprint``. """ from scipy.ndimage import maximum_filter arrays, unit = process_quantities((data, threshold, error), ('data', 'threshold', 'error')) data, threshold, error = arrays data = np.asanyarray(data) if np.all(data == data.flat[0]): warnings.warn('Input data is constant. No local peaks can be found.', NoDetectionsWarning) return None if not np.isscalar(threshold): threshold = np.asanyarray(threshold) if data.shape != threshold.shape: raise ValueError('A threshold array must have the same shape as ' 'the input data.') # remove NaN values to avoid runtime warnings nan_mask = np.isnan(data) if np.any(nan_mask): data = np.copy(data) # ndarray data[nan_mask] = np.nanmin(data) if footprint is not None: data_max = maximum_filter(data, footprint=footprint, mode='constant', cval=0.0) else: data_max = maximum_filter(data, size=box_size, mode='constant', cval=0.0) peak_goodmask = (data == data_max) # good pixels are True if mask is not None: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape') peak_goodmask = np.logical_and(peak_goodmask, ~mask) if border_width is not None: for i in range(peak_goodmask.ndim): peak_goodmask = peak_goodmask.swapaxes(0, i) peak_goodmask[:border_width] = False peak_goodmask[-border_width:] = False peak_goodmask = peak_goodmask.swapaxes(0, i) peak_goodmask = np.logical_and(peak_goodmask, (data > threshold)) y_peaks, x_peaks = peak_goodmask.nonzero() peak_values = data[y_peaks, x_peaks] if unit is not None: peak_values <<= unit nxpeaks = len(x_peaks) if nxpeaks > npeaks: idx = np.argsort(peak_values)[::-1][:npeaks] x_peaks = x_peaks[idx] y_peaks = y_peaks[idx] peak_values = peak_values[idx] if nxpeaks == 0: warnings.warn('No local peaks were found.', NoDetectionsWarning) return None # construct the output table ids = np.arange(len(x_peaks)) + 1 colnames = ['id', 'x_peak', 'y_peak', 'peak_value'] coldata = [ids, x_peaks, y_peaks, peak_values] table = QTable(coldata, names=colnames) table.meta.update(_get_meta()) # keep table.meta type if wcs is not None: skycoord_peaks = wcs.pixel_to_world(x_peaks, y_peaks) idx = table.colnames.index('y_peak') + 1 table.add_column(skycoord_peaks, name='skycoord_peak', index=idx) # perform centroiding if centroid_func is not None: # prevent circular import from photutils.centroids import centroid_sources if not callable(centroid_func): raise TypeError('centroid_func must be a callable object') x_centroids, y_centroids = centroid_sources( data, x_peaks, y_peaks, box_size=box_size, footprint=footprint, error=error, mask=mask, centroid_func=centroid_func) table['x_centroid'] = x_centroids table['y_centroid'] = y_centroids if wcs is not None: skycoord_centroids = wcs.pixel_to_world(x_centroids, y_centroids) idx = table.colnames.index('y_centroid') + 1 table.add_column(skycoord_centroids, name='skycoord_centroid', index=idx) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/starfinder.py0000644000175100001770000003706314637570305022003 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the StarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.table import QTable from astropy.utils import lazyproperty from photutils.detection.core import StarFinderBase, _validate_brightest from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import _overlap_slices as overlap_slices from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinder'] class StarFinder(StarFinderBase): """ Detect stars in an image using a user-defined kernel. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. kernel : `~numpy.ndarray` A 2D array of the PSF kernel. min_separation : float, optional The minimum separation (in pixels) for detected objects. Note that large values may result in long run times. exclude_border : bool, optional Whether to exclude sources found within half the size of the convolution kernel from the image borders. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. See Also -------- DAOStarFinder, IRAFStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peakmax`` must all have the same units as the image. For the convolution step, this routine sets pixels beyond the image borders to 0.0. The source properties are calculated using image moments. """ def __init__(self, threshold, kernel, min_separation=5.0, exclude_border=False, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (threshold, peakmax) names = ('threshold', 'peakmax') _ = process_quantities(inputs, names) self.threshold = threshold self.kernel = kernel if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax def _get_raw_catalog(self, data, *, mask=None): kernel = self.kernel kernel /= np.max(kernel) # normalize max value to 1.0 denom = np.sum(kernel**2) - (np.sum(kernel)**2 / kernel.size) kernel = (kernel - np.sum(kernel) / kernel.size) / denom convolved_data = _filter_data(data, kernel, mode='constant', fill_value=0.0, check_normalization=False) xypos = self._find_stars(convolved_data, kernel, self.threshold, min_separation=self.min_separation, mask=mask, exclude_border=self.exclude_border) if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None cat = _StarFinderCatalog(data, xypos, self.kernel.shape, brightest=self.brightest, peakmax=self.peakmax) return cat def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found objects with the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``fwhm``: object FWHM. * ``roundness``: object roundness. * ``pa``: object position angle (degrees counter clockwise from the positive x axis). * ``max_value``: the maximum pixel value in the source * ``flux``: the source instrumental flux. * ``mag``: the source instrumental magnitude calculated as ``-2.5 * log10(flux)``. `None` is returned if no stars are found or no stars meet the roundness and peakmax criteria. """ cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _StarFinderCatalog: """ Class to calculate the properties of each detected star. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. xypos : Nx2 `numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. shape : tuple of int The shape of the stars cutouts. The shape in both dimensions must be odd and match the shape of the smoothing kernel. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Only objects whose peak pixel values are strictly smaller than ``peakmax`` will be selected. This may be used, for example, to exclude saturated sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, xypos, shape, *, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (data, peakmax) names = ('data', 'peakmax') _ = process_quantities(inputs, names) self.data = data if isinstance(data, u.Quantity): unit = data.unit else: unit = None self.unit = unit self.xypos = np.atleast_2d(xypos) self.shape = shape self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.default_columns = ('id', 'xcentroid', 'ycentroid', 'fwhm', 'roundness', 'pa', 'max_value', 'flux', 'mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'shape', 'brightest', 'peakmax', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # index/slice the remaining attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') scalar_index = np.isscalar(index) for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue if key in ('slices', 'cutout_data'): # lists instead of arrays # apply fancy indices to list properties value = np.array(value + [None], dtype=object)[:-1][index] if scalar_index: value = [value] else: value = value.tolist() else: # value is always at least a 1D array, even for a single # source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """Reset the ID column to be consecutive integers.""" self.id = np.arange(len(self)) + 1 @lazyproperty def slices(self): slices = [] for xpos, ypos in self.xypos: slc, _ = overlap_slices(self.data.shape, self.shape, (ypos, xpos), mode='trim') slices.append(slc) return slices @lazyproperty def bbox_xmin(self): return np.array([slc[1].start for slc in self.slices]) @lazyproperty def bbox_ymin(self): return np.array([slc[0].start for slc in self.slices]) @lazyproperty def cutout_data(self): cutout = [] for slc in self.slices: cdata = self.data[slc] cdata[cdata < 0] = 0.0 # exclude negative pixels cutout.append(cdata) return cutout @lazyproperty def moments(self): return np.array([_moments(arr, order=1) for arr in self.cutout_data]) @lazyproperty def cutout_centroid(self): moments = self.moments # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((ycentroid, xcentroid)) @lazyproperty def cutout_xcentroid(self): return np.transpose(self.cutout_centroid)[1] @lazyproperty def cutout_ycentroid(self): return np.transpose(self.cutout_centroid)[0] @lazyproperty def xcentroid(self): return self.cutout_xcentroid + self.bbox_xmin @lazyproperty def ycentroid(self): return self.cutout_ycentroid + self.bbox_ymin @lazyproperty def max_value(self): peaks = [np.max(arr) for arr in self.cutout_data] if self.unit is not None: peaks = u.Quantity(peaks) else: peaks = np.array(peaks) return peaks @lazyproperty def flux(self): fluxes = [np.sum(arr) for arr in self.cutout_data] if self.unit is not None: fluxes = u.Quantity(fluxes) else: fluxes = np.array(fluxes) return fluxes @lazyproperty def mag(self): flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) @lazyproperty def moments_central(self): moments = np.array([_moments_central(arr, center=(xcen_, ycen_), order=2) for arr, xcen_, ycen_ in zip(self.cutout_data, self.cutout_xcentroid, self.cutout_ycentroid)]) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return moments / self.moments[:, 0, 0][:, np.newaxis, np.newaxis] @lazyproperty def mu_sum(self): return self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0] @lazyproperty def mu_diff(self): return self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0] @lazyproperty def fwhm(self): return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @lazyproperty def roundness(self): with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) factor = self.mu_diff**2 + 4.0 * self.moments_central[:, 1, 1]**2 return np.sqrt(factor) / self.mu_sum @lazyproperty def pa(self): pa = np.rad2deg(0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff)) pa = np.where(pa < 0, pa + 180, pa) return pa def apply_filters(self): """Filter the catalog.""" # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'fwhm', 'roundness', 'pa', 'max_value', 'flux') mask = np.ones(len(self), dtype=bool) for attr in attrs: mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # filter based on peakmax if newcat.peakmax is not None: mask = (newcat.max_value < newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the peakmax ' 'criterion.', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source ids. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.022885 photutils-1.13.0/photutils/detection/tests/0000755000175100001770000000000014637570322020420 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/__init__.py0000644000175100001770000000000014637570305022520 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/conftest.py0000644000175100001770000000200114637570305022611 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Fixtures used in tests. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from photutils.psf import IntegratedGaussianPRF, make_psf_model_image @pytest.fixture(name='kernel') def fixture_kernel(): size = 5 cen = (size - 1) / 2 y, x = np.mgrid[0:size, 0:size] g = Gaussian2D(1, cen, cen, 1.2, 1.2, theta=0) return g(x, y) @pytest.fixture(name='data') def fixture_data(): shape = (101, 101) model_shape = (11, 11) psf_model = IntegratedGaussianPRF(flux=1, sigma=1.5) n_sources = 25 data, _ = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(100, 200), min_separation=10, seed=0, border_size=(10, 10), progress_bar=False) return data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/test_daofinder.py0000644000175100001770000001525514637570305023775 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for DAOStarFinder. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.detection.daofinder import DAOStarFinder from photutils.utils._optional_deps import HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestDAOStarFinder: def test_daostarfind(self, data): units = u.Jy threshold = 5.0 fwhm = 1.0 finder0 = DAOStarFinder(threshold, fwhm) finder1 = DAOStarFinder(threshold * units, fwhm) tbl0 = finder0(data) tbl1 = finder1(data << units) assert_array_equal(tbl0, tbl1) def test_daofind_inputs(self): with pytest.raises(ValueError): DAOStarFinder(threshold=np.ones((2, 2)), fwhm=3.0) with pytest.raises(TypeError): DAOStarFinder(threshold=3.0, fwhm=np.ones((2, 2))) with pytest.raises(ValueError): DAOStarFinder(threshold=3.0, fwhm=-10) with pytest.raises(ValueError): DAOStarFinder(threshold=3.0, fwhm=2, ratio=-10) with pytest.raises(ValueError): DAOStarFinder(threshold=3.0, fwhm=2, sigma_radius=-10) with pytest.raises(ValueError): DAOStarFinder(threshold=10, fwhm=1.5, brightest=-1) with pytest.raises(ValueError): DAOStarFinder(threshold=10, fwhm=1.5, brightest=3.1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) with pytest.raises(ValueError): DAOStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) def test_daofind_nosources(self, data): match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=100, fwhm=2) tbl = finder(data) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=2) tbl = finder(-data) assert tbl is None def test_daofind_exclude_border(self): data = np.zeros((9, 9)) data[0, 0] = 1 data[2, 2] = 1 data[4, 4] = 1 data[6, 6] = 1 finder0 = DAOStarFinder(threshold=0.1, fwhm=0.5, exclude_border=False) finder1 = DAOStarFinder(threshold=0.1, fwhm=0.5, exclude_border=True) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) def test_daofind_sharpness(self, data): """Sources found, but none pass the sharpness criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, sharplo=1.0) tbl = finder(data) assert tbl is None def test_daofind_roundness(self, data): """Sources found, but none pass the roundness criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, roundlo=1.0) tbl = finder(data) assert tbl is None def test_daofind_peakmax(self, data): """Sources found, but none pass the peakmax criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, peakmax=1.0) tbl = finder(data) assert tbl is None def test_daofind_peakmax_filtering(self, data): """ Regression test that objects with peak >= peakmax are filtered out. """ peakmax = 8 finder0 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf) finder1 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, peakmax=peakmax) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] < peakmax) def test_daofind_brightest_filtering(self, data): """ Regression test that only top ``brightest`` objects are selected. """ brightest = 10 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest) tbl = finder(data) assert len(tbl) == brightest # combined with peakmax peakmax = 8 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest, peakmax=peakmax) tbl = finder(data) assert len(tbl) == 5 def test_daofind_mask(self, data): """Test DAOStarFinder with a mask.""" finder = DAOStarFinder(threshold=1.0, fwhm=1.5) mask = np.zeros(data.shape, dtype=bool) mask[0:50, :] = True tbl0 = finder(data) tbl1 = finder(data, mask=mask) assert len(tbl0) > len(tbl1) def test_xycoords(self, data): finder0 = DAOStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['xcentroid'], tbl0['ycentroid'])) xycoords = np.round(xycoords).astype(int) finder1 = DAOStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) def test_min_separation(self, data): threshold = 1.0 fwhm = 1.0 finder1 = DAOStarFinder(threshold, fwhm) tbl1 = finder1(data) finder2 = DAOStarFinder(threshold, fwhm, min_separation=10.0) tbl2 = finder2(data) assert len(tbl1) > len(tbl2) match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, min_separation=-1.0) def test_single_detected_source(self, data): finder = DAOStarFinder(7.9, 2, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/test_irafstarfinder.py0000644000175100001770000001440714637570305025043 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for IRAFStarFinder. """ import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_array_equal from photutils.detection import IRAFStarFinder from photutils.utils._optional_deps import HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestIRAFStarFinder: def test_irafstarfind(self, data): units = u.Jy threshold = 5.0 fwhm = 1.0 finder0 = IRAFStarFinder(threshold, fwhm) finder1 = IRAFStarFinder(threshold * units, fwhm) tbl0 = finder0(data) tbl1 = finder1(data << units) assert_array_equal(tbl0, tbl1) def test_irafstarfind_inputs(self): with pytest.raises(TypeError): IRAFStarFinder(threshold=np.ones((2, 2)), fwhm=3.0) with pytest.raises(TypeError): IRAFStarFinder(threshold=3.0, fwhm=np.ones((2, 2))) with pytest.raises(ValueError): IRAFStarFinder(10, 1.5, brightest=-1) with pytest.raises(ValueError): IRAFStarFinder(10, 1.5, brightest=3.1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) with pytest.raises(ValueError): IRAFStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) def test_irafstarfind_nosources(self): data = np.ones((3, 3)) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=10, fwhm=1) tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=0.1, fwhm=0.1) tbl = finder(-data) assert tbl is None def test_irafstarfind_sharpness(self, data): """Sources found, but none pass the sharpness criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, sharplo=2.0) tbl = finder(data) assert tbl is None def test_irafstarfind_roundness(self, data): """Sources found, but none pass the roundness criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, roundlo=1.0) tbl = finder(data) assert tbl is None def test_irafstarfind_peakmax(self, data): """Sources found, but none pass the peakmax criteria.""" match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, peakmax=1.0) tbl = finder(data) assert tbl is None def test_irafstarfind_sky(self, data): with pytest.warns(AstropyDeprecationWarning): finder0 = IRAFStarFinder(threshold=1.0, fwhm=2.0, sky=0.0) finder1 = IRAFStarFinder(threshold=1.0, fwhm=2.0, sky=2.0) tbl0 = finder0(data) tbl1 = finder1(data) assert np.all(tbl0['flux'] > tbl1['flux']) def test_irafstarfind_peakmax_filtering(self, data): """ Regression test that objects with peak >= peakmax are filtered out. """ peakmax = 8 finder0 = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf) finder1 = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, peakmax=peakmax) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] < peakmax) def test_irafstarfind_brightest_filtering(self, data): """ Regression test that only top ``brightest`` objects are selected. """ brightest = 10 finder = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest) tbl = finder(data) assert len(tbl) == brightest def test_irafstarfind_mask(self, data): """Test IRAFStarFinder with a mask.""" finder = IRAFStarFinder(threshold=1.0, fwhm=1.5) mask = np.zeros(data.shape, dtype=bool) mask[0:50, :] = True tbl0 = finder(data) tbl1 = finder(data, mask=mask) assert len(tbl0) > len(tbl1) def test_xycoords(self, data): finder0 = IRAFStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['xcentroid'], tbl0['ycentroid'])) xycoords = np.round(xycoords).astype(int) finder1 = IRAFStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) def test_min_separation(self, data): threshold = 1.0 fwhm = 1.0 finder1 = IRAFStarFinder(threshold, fwhm) tbl1 = finder1(data) finder2 = IRAFStarFinder(threshold, fwhm, min_separation=3.0) tbl2 = finder2(data) assert np.all(tbl1 == tbl2) finder3 = IRAFStarFinder(threshold, fwhm, min_separation=10.0) tbl3 = finder3(data) assert len(tbl2) > len(tbl3) match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=10, fwhm=1.5, min_separation=-1.0) def test_single_detected_source(self, data): finder = IRAFStarFinder(8.4, 2, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/test_peakfinder.py0000644000175100001770000001357214637570305024152 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the peakfinder module. """ import astropy.units as u import numpy as np import pytest from astropy.tests.helper import assert_quantity_allclose from numpy.testing import assert_array_equal, assert_equal from photutils.centroids import centroid_com from photutils.datasets import make_gwcs, make_wcs from photutils.detection import find_peaks from photutils.utils._optional_deps import HAS_GWCS, HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestFindPeaks: def test_box_size(self, data): """Test with box_size.""" tbl = find_peaks(data, 0.1, box_size=3) assert tbl['id'][0] == 1 assert len(tbl) == 25 columns = ['id', 'x_peak', 'y_peak', 'peak_value'] assert all(column in tbl.colnames for column in columns) assert np.min(tbl['x_peak']) > 0 assert np.max(tbl['x_peak']) < 101 assert np.min(tbl['y_peak']) > 0 assert np.max(tbl['y_peak']) < 101 assert np.max(tbl['peak_value']) < 13.2 # test with units unit = u.Jy tbl2 = find_peaks(data << unit, 0.1 << unit, box_size=3) columns = ['id', 'x_peak', 'y_peak'] for column in columns: assert_equal(tbl[column], tbl2[column]) col = 'peak_value' assert tbl2[col].unit == unit assert_equal(tbl[col], tbl2[col].value) def test_footprint(self, data): """Test with footprint.""" tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, footprint=np.ones((3, 3))) assert_array_equal(tbl0, tbl1) def test_mask(self, data): """Test with mask.""" mask = np.zeros(data.shape, dtype=bool) mask[0:50, :] = True tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, box_size=3, mask=mask) assert len(tbl1) < len(tbl0) def test_maskshape(self, data): """Test if make shape doesn't match data shape.""" with pytest.raises(ValueError): find_peaks(data, 0.1, mask=np.ones((5, 5))) def test_thresholdshape(self, data): """Test if threshold shape doesn't match data shape.""" with pytest.raises(ValueError): find_peaks(data, np.ones((2, 2))) def test_npeaks(self, data): """Test npeaks.""" tbl = find_peaks(data, 0.1, box_size=3, npeaks=1) assert len(tbl) == 1 def test_border_width(self, data): """Test border exclusion.""" tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, box_size=3, border_width=25) assert len(tbl1) < len(tbl0) def test_box_size_int(self, data): """Test non-integer box_size.""" tbl1 = find_peaks(data, 0.1, box_size=5.0) tbl2 = find_peaks(data, 0.1, box_size=5.5) assert_array_equal(tbl1, tbl2) def test_centroid_func_callable(self, data): """Test that centroid_func is callable.""" with pytest.raises(TypeError): find_peaks(data, 0.1, box_size=2, centroid_func=True) def test_wcs(self, data): """Test with astropy WCS.""" columns = ['skycoord_peak', 'skycoord_centroid'] fits_wcs = make_wcs(data.shape) tbl = find_peaks(data, 1, wcs=fits_wcs, centroid_func=centroid_com) for column in columns: assert column in tbl.colnames assert tbl.colnames == ['id', 'x_peak', 'y_peak', 'skycoord_peak', 'peak_value', 'x_centroid', 'y_centroid', 'skycoord_centroid'] @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_gwcs(self, data): """Test with gwcs.""" columns = ['skycoord_peak', 'skycoord_centroid'] gwcs_obj = make_gwcs(data.shape) tbl = find_peaks(data, 1, wcs=gwcs_obj, centroid_func=centroid_com) for column in columns: assert column in tbl.colnames @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_wcs_values(self, data): fits_wcs = make_wcs(data.shape) gwcs_obj = make_gwcs(data.shape) tbl1 = find_peaks(data, 1, wcs=fits_wcs, centroid_func=centroid_com) tbl2 = find_peaks(data, 1, wcs=gwcs_obj, centroid_func=centroid_com) columns = ['skycoord_peak', 'skycoord_centroid'] for column in columns: assert_quantity_allclose(tbl1[column].ra, tbl2[column].ra) assert_quantity_allclose(tbl1[column].dec, tbl2[column].dec) def test_constant_array(self): """Test for empty output table when data is constant.""" data = np.ones((10, 10)) match = 'Input data is constant' with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 0.0) assert tbl is None def test_no_peaks(self, data): """ Tests for when no peaks are found. """ fits_wcs = make_wcs(data.shape) match = 'No local peaks were found' with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 10000) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, centroid_func=centroid_com) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, wcs=fits_wcs) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, wcs=fits_wcs, centroid_func=centroid_com) assert tbl is None def test_data_nans(self, data): """Test that data with NaNs does not issue Runtime warning.""" data = np.copy(data) data[50:, :] = np.nan find_peaks(data, 0.1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/detection/tests/test_starfinder.py0000644000175100001770000000735314637570305024203 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for StarFinder. """ import astropy.units as u import numpy as np import pytest from astropy.table import Table from numpy.testing import assert_equal from photutils.detection import StarFinder from photutils.utils._optional_deps import HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestStarFinder: def test_starfind(self, data, kernel): finder1 = StarFinder(1, kernel) finder2 = StarFinder(10, kernel) tbl1 = finder1(data) tbl2 = finder2(data) assert isinstance(tbl1, Table) assert len(tbl1) == 25 assert len(tbl2) == 9 # test with units unit = u.Jy finder3 = StarFinder(1 * unit, kernel) tbl3 = finder3(data << unit) assert isinstance(tbl3, Table) assert len(tbl3) == 25 assert tbl3['flux'].unit == unit assert tbl3['max_value'].unit == unit for col in tbl3.colnames: if col not in ('flux', 'max_value'): assert_equal(tbl3[col], tbl1[col]) def test_inputs(self, kernel): with pytest.raises(ValueError): StarFinder(1, kernel, min_separation=-1) with pytest.raises(ValueError): StarFinder(1, kernel, brightest=-1) with pytest.raises(ValueError): StarFinder(1, kernel, brightest=3.1) def test_nosources(self, data, kernel): match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = StarFinder(100, kernel) tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 with pytest.warns(NoDetectionsWarning, match=match): finder = StarFinder(1, kernel) tbl = finder(-data) assert tbl is None def test_min_separation(self, data, kernel): finder1 = StarFinder(1, kernel, min_separation=0) finder2 = StarFinder(1, kernel, min_separation=10) tbl1 = finder1(data) tbl2 = finder2(data) assert len(tbl1) == 25 assert len(tbl2) == 20 def test_peakmax(self, data, kernel): finder1 = StarFinder(1, kernel, peakmax=None) finder2 = StarFinder(1, kernel, peakmax=11) tbl1 = finder1(data) tbl2 = finder2(data) assert len(tbl1) == 25 assert len(tbl2) == 16 match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): starfinder = StarFinder(10, kernel, peakmax=5) tbl = starfinder(data) assert tbl is None def test_brightest(self, data, kernel): finder = StarFinder(1, kernel, brightest=10) tbl = finder(data) assert len(tbl) == 10 fluxes = tbl['flux'] assert fluxes[0] == np.max(fluxes) def test_single_detected_source(self, data, kernel): finder = StarFinder(11.5, kernel, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux def test_mask(self, data, kernel): starfinder = StarFinder(1, kernel) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl1 = starfinder(data) tbl2 = starfinder(data, mask=mask) assert len(tbl1) == 25 assert len(tbl2) == 13 assert min(tbl2['ycentroid']) > 50 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.022885 photutils-1.13.0/photutils/extern/0000755000175100001770000000000014637570322016605 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/extern/__init__.py0000644000175100001770000000024114637570305020714 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools that are bundled with the package but are external to it. """ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.026885 photutils-1.13.0/photutils/geometry/0000755000175100001770000000000014637570322017133 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/__init__.py0000644000175100001770000000044114637570305021244 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage provides low-level geometry functions. """ from .circular_overlap import * # noqa: F401, F403 from .elliptical_overlap import * # noqa: F401, F403 from .rectangular_overlap import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/circular_overlap.pyx0000644000175100001770000002031314637570305023231 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions defined here allow one to determine the exact area of overlap of a rectangle and a circle (written by Thomas Robitaille). """ import numpy as np cimport numpy as np __all__ = ['circular_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double sqrt(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t # NOTE: Here we need to make sure we use cimport to import the C functions from # core (since these were defined with cdef). This also requires the core.pxd # file to exist with the function signatures. from .core cimport area_arc, area_triangle, floor_sqrt def circular_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double r, int use_exact, int subpixels): """ circular_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, r, use_exact, subpixels) Area of overlap between a circle and a pixel grid. The circle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. r : float The radius of the circle. use_exact : 0 or 1 If ``1`` calculates exact overlap, if ``0`` uses ``subpixel`` number of subpixels to calculate the overlap. subpixels : int Each pixel resampled by this factor in each dimension, thus each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` (float) 2-d array of shape (ny, nx) giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy, d, pixel_radius cdef double bxmin, bxmax, bymin, bymax cdef double pxmin, pxcen, pxmax, pymin, pycen, pymax # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny # Find the radius of a single pixel pixel_radius = 0.5 * sqrt(dx * dx + dy * dy) # Define bounding box bxmin = -r - 0.5 * dx bxmax = +r + 0.5 * dx bymin = -r - 0.5 * dy bymax = +r + 0.5 * dy for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxcen = pxmin + dx * 0.5 pxmax = pxmin + dx # upper end of pixel if pxmax > bxmin and pxmin < bxmax: for j in range(ny): pymin = ymin + j * dy pycen = pymin + dy * 0.5 pymax = pymin + dy if pymax > bymin and pymin < bymax: # Distance from circle center to pixel center. d = sqrt(pxcen * pxcen + pycen * pycen) # If pixel center is "well within" circle, count full # pixel. if d < r - pixel_radius: frac[j, i] = 1.0 # If pixel center is "close" to circle border, find # overlap. elif d < r + pixel_radius: # Either do exact calculation or use subpixel # sampling: if use_exact: frac[j, i] = circular_overlap_single_exact( pxmin, pymin, pxmax, pymax, r) / (dx * dy) else: frac[j, i] = circular_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, r, subpixels) # Otherwise, it is fully outside circle. # No action needed. return frac # NOTE: The following two functions use cdef because they are not # intended to be called from the Python code. Using def makes them # callable from outside, but also slower. In any case, these aren't useful # to call from outside because they only operate on a single pixel. cdef double circular_overlap_single_subpixel(double x0, double y0, double x1, double y1, double r, int subpixels): """Return the fraction of overlap between a circle and a single pixel with given extent, using a sub-pixel sampling method.""" cdef unsigned int i, j cdef double x, y, dx, dy, r_squared cdef double frac = 0.0 # Accumulator. dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels r_squared = r ** 2 x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy if x * x + y * y < r_squared: frac += 1.0 return frac / (subpixels * subpixels) cdef double circular_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double r): """ Area of overlap of a rectangle and a circle """ if 0.0 <= xmin: if 0.0 <= ymin: return circular_overlap_core(xmin, ymin, xmax, ymax, r) elif 0.0 >= ymax: return circular_overlap_core(-ymax, xmin, -ymin, xmax, r) else: return circular_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, xmax, ymax, r) elif 0.0 >= xmax: if 0.0 <= ymin: return circular_overlap_core(-xmax, ymin, -xmin, ymax, r) elif 0.0 >= ymax: return circular_overlap_core(-xmax, -ymax, -xmin, -ymin, r) else: return circular_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, xmax, ymax, r) else: if 0.0 <= ymin: return circular_overlap_single_exact(xmin, ymin, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, ymax, r) if 0.0 >= ymax: return circular_overlap_single_exact(xmin, ymin, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, ymax, r) else: return circular_overlap_single_exact(xmin, ymin, 0.0, 0.0, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, 0.0, xmax, ymax, r) cdef double circular_overlap_core(double xmin, double ymin, double xmax, double ymax, double r): """ Assumes that the center of the circle is <= xmin, ymin (can always modify input to conform to this). """ cdef double area, d1, d2, x1, x2, y1, y2 if xmin * xmin + ymin * ymin > r * r: area = 0.0 elif xmax * xmax + ymax * ymax < r * r: area = (xmax - xmin) * (ymax - ymin) else: area = 0.0 d1 = floor_sqrt(xmax * xmax + ymin * ymin) d2 = floor_sqrt(xmin * xmin + ymax * ymax) if d1 < r and d2 < r: x1, y1 = floor_sqrt(r * r - ymax * ymax), ymax x2, y2 = xmax, floor_sqrt(r * r - xmax * xmax) area = ((xmax - xmin) * (ymax - ymin) - area_triangle(x1, y1, x2, y2, xmax, ymax) + area_arc(x1, y1, x2, y2, r)) elif d1 < r: x1, y1 = xmin, floor_sqrt(r * r - xmin * xmin) x2, y2 = xmax, floor_sqrt(r * r - xmax * xmax) area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, x1, ymin, xmax, ymin) + area_triangle(x1, y1, x2, ymin, x2, y2)) elif d2 < r: x1, y1 = floor_sqrt(r * r - ymin * ymin), ymin x2, y2 = floor_sqrt(r * r - ymax * ymax), ymax area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, xmin, y1, xmin, ymax) + area_triangle(x1, y1, xmin, y2, x2, y2)) else: x1, y1 = floor_sqrt(r * r - ymin * ymin), ymin x2, y2 = xmin, floor_sqrt(r * r - xmin * xmin) area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, x2, y2, xmin, ymin)) return area ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/core.pxd0000644000175100001770000000133514637570305020603 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst #cython: language_level=3 # This file is needed in order to be able to cimport functions into other Cython files cdef double distance(double x1, double y1, double x2, double y2) cdef double area_arc(double x1, double y1, double x2, double y2, double R) cdef double area_triangle(double x1, double y1, double x2, double y2, double x3, double y3) cdef double area_arc_unit(double x1, double y1, double x2, double y2) cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3) cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3) cdef double floor_sqrt(double x) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/core.pyx0000644000175100001770000002751414637570305020637 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions here are the core geometry functions. """ import numpy as np cimport numpy as np cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython ctypedef struct point: double x double y ctypedef struct intersections: point p1 point p2 cdef double floor_sqrt(double x): """ In some of the geometrical functions, we have to take the sqrt of a number and we know that the number should be >= 0. However, in some cases the value is e.g. -1e-10, but we want to treat it as zero, which is what this function does. Note that this does **not** check whether negative values are close or not to zero, so this should be used only in cases where the value is expected to be positive on paper. """ if x > 0: return sqrt(x) else: return 0 # NOTE: The following two functions use cdef because they are not intended to be # called from the Python code. Using def makes them callable from outside, but # also slower. Some functions currently return multiple values, and for those we # still use 'def' for now. cdef double distance(double x1, double y1, double x2, double y2): """ Distance between two points in two dimensions. Parameters ---------- x1, y1 : float The coordinates of the first point x2, y2 : float The coordinates of the second point Returns ------- d : float The Euclidean distance between the two points """ return sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) cdef double area_arc(double x1, double y1, double x2, double y2, double r): """ Area of a circle arc with radius r between points (x1, y1) and (x2, y2). References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double a, theta a = distance(x1, y1, x2, y2) theta = 2.0 * asin(0.5 * a / r) return 0.5 * r * r * (theta - sin(theta)) cdef double area_triangle(double x1, double y1, double x2, double y2, double x3, double y3): """ Area of a triangle defined by three vertices. """ return 0.5 * abs(x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) cdef double area_arc_unit(double x1, double y1, double x2, double y2): """ Area of a circle arc with radius R between points (x1, y1) and (x2, y2) References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double a, theta a = distance(x1, y1, x2, y2) theta = 2.0 * asin(0.5 * a) return 0.5 * (theta - sin(theta)) cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3): """ Check if a point (x,y) is inside a triangle """ cdef int c = 0 c += ((y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1) c += ((y2 > y) != (y3 > y) and x < (x3 - x2) * (y - y2) / (y3 - y2) + x2) c += ((y3 > y) != (y1 > y) and x < (x1 - x3) * (y - y3) / (y1 - y3) + x3) return c % 2 == 1 cdef intersections circle_line(double x1, double y1, double x2, double y2): """Intersection of a line defined by two points with a unit circle""" cdef double a, b, delta, dx, dy cdef double tolerance = 1.e-10 cdef intersections inter dx = x2 - x1 dy = y2 - y1 if fabs(dx) < tolerance and fabs(dy) < tolerance: inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. elif fabs(dx) > fabs(dy): # Find the slope and intercept of the line a = dy / dx b = y1 - a * x1 # Find the determinant of the quadratic equation delta = 1. + a * a - b * b if delta > 0.: # solutions exist delta = sqrt(delta) inter.p1.x = (- a * b - delta) / (1. + a * a) inter.p1.y = a * inter.p1.x + b inter.p2.x = (- a * b + delta) / (1. + a * a) inter.p2.y = a * inter.p2.x + b else: # no solution, return values > 1 inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. else: # Find the slope and intercept of the line a = dx / dy b = x1 - a * y1 # Find the determinant of the quadratic equation delta = 1. + a * a - b * b if delta > 0.: # solutions exist delta = sqrt(delta) inter.p1.y = (- a * b - delta) / (1. + a * a) inter.p1.x = a * inter.p1.y + b inter.p2.y = (- a * b + delta) / (1. + a * a) inter.p2.x = a * inter.p2.y + b else: # no solution, return values > 1 inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. return inter cdef point circle_segment_single2(double x1, double y1, double x2, double y2): """ The intersection of a line with the unit circle. The intersection the closest to (x2, y2) is chosen. """ cdef double dx1, dy1, dx2, dy2 cdef intersections inter cdef point pt1, pt2, pt inter = circle_line(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 # Can be optimized, but just checking for correctness right now dx1 = fabs(pt1.x - x2) dy1 = fabs(pt1.y - y2) dx2 = fabs(pt2.x - x2) dy2 = fabs(pt2.y - y2) if dx1 > dy1: # compare based on x-axis if dx1 > dx2: pt = pt2 else: pt = pt1 else: if dy1 > dy2: pt = pt2 else: pt = pt1 return pt cdef intersections circle_segment(double x1, double y1, double x2, double y2): """ Intersection(s) of a segment with the unit circle. Discard any solution not on the segment. """ cdef intersections inter, inter_new cdef point pt1, pt2 inter = circle_line(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 if (pt1.x > x1 and pt1.x > x2) or (pt1.x < x1 and pt1.x < x2) or (pt1.y > y1 and pt1.y > y2) or (pt1.y < y1 and pt1.y < y2): pt1.x, pt1.y = 2., 2. if (pt2.x > x1 and pt2.x > x2) or (pt2.x < x1 and pt2.x < x2) or (pt2.y > y1 and pt2.y > y2) or (pt2.y < y1 and pt2.y < y2): pt2.x, pt2.y = 2., 2. if pt1.x > 1. and pt2.x < 2.: inter_new.p1 = pt1 inter_new.p2 = pt2 else: inter_new.p1 = pt2 inter_new.p2 = pt1 return inter_new cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3): """ Given a triangle defined by three points (x1, y1), (x2, y2), and (x3, y3), find the area of overlap with the unit circle. """ cdef double d1, d2, d3 cdef bool in1, in2, in3 cdef bool on1, on2, on3 cdef double area cdef double PI = np.pi cdef intersections inter cdef point pt1, pt2, pt3, pt4, pt5, pt6, pt_tmp # Find distance of all vertices to circle center d1 = x1 * x1 + y1 * y1 d2 = x2 * x2 + y2 * y2 d3 = x3 * x3 + y3 * y3 # Order vertices by distance from origin if d1 < d2: if d2 < d3: pass elif d1 < d3: x2, y2, d2, x3, y3, d3 = x3, y3, d3, x2, y2, d2 else: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x3, y3, d3, x1, y1, d1, x2, y2, d2 else: if d1 < d3: x1, y1, d1, x2, y2, d2 = x2, y2, d2, x1, y1, d1 elif d2 < d3: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x2, y2, d2, x3, y3, d3, x1, y1, d1 else: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x3, y3, d3, x2, y2, d2, x1, y1, d1 if d1 > d2 or d2 > d3 or d1 > d3: raise Exception("ERROR: vertices did not sort correctly") # Determine number of vertices inside circle in1 = d1 < 1 in2 = d2 < 1 in3 = d3 < 1 # Determine which vertices are on the circle on1 = fabs(d1 - 1) < 1.e-10 on2 = fabs(d2 - 1) < 1.e-10 on3 = fabs(d3 - 1) < 1.e-10 if on3 or in3: # triangle is completely in circle area = area_triangle(x1, y1, x2, y2, x3, y3) elif in2 or on2: # If vertex 1 or 2 are on the edge of the circle, then we use the dot # product to vertex 3 to determine whether an intersection takes place. intersect13 = not on1 or x1 * (x3 - x1) + y1 * (y3 - y1) < 0. intersect23 = not on2 or x2 * (x3 - x2) + y2 * (y3 - y2) < 0. if intersect13 and intersect23 and not on2: pt1 = circle_segment_single2(x1, y1, x3, y3) pt2 = circle_segment_single2(x2, y2, x3, y3) area = area_triangle(x1, y1, x2, y2, pt1.x, pt1.y) \ + area_triangle(x2, y2, pt1.x, pt1.y, pt2.x, pt2.y) \ + area_arc_unit(pt1.x, pt1.y, pt2.x, pt2.y) elif intersect13: pt1 = circle_segment_single2(x1, y1, x3, y3) area = area_triangle(x1, y1, x2, y2, pt1.x, pt1.y) \ + area_arc_unit(x2, y2, pt1.x, pt1.y) elif intersect23: pt2 = circle_segment_single2(x2, y2, x3, y3) area = area_triangle(x1, y1, x2, y2, pt2.x, pt2.y) \ + area_arc_unit(x1, y1, pt2.x, pt2.y) else: area = area_arc_unit(x1, y1, x2, y2) elif on1: # The triangle is outside the circle area = 0.0 elif in1: # Check for intersections of far side with circle inter = circle_segment(x2, y2, x3, y3) pt1 = inter.p1 pt2 = inter.p2 pt3 = circle_segment_single2(x1, y1, x2, y2) pt4 = circle_segment_single2(x1, y1, x3, y3) if pt1.x > 1.: # indicates no intersection # Code taken from `sep.h`. # TODO: use `sep` and get rid of this Cython code. if (((0.-pt3.y) * (pt4.x-pt3.x) > (pt4.y-pt3.y) * (0.-pt3.x)) != ((y1-pt3.y) * (pt4.x-pt3.x) > (pt4.y-pt3.y) * (x1-pt3.x))): area = area_triangle(x1, y1, pt3.x, pt3.y, pt4.x, pt4.y) \ + (PI - area_arc_unit(pt3.x, pt3.y, pt4.x, pt4.y)) else: area = area_triangle(x1, y1, pt3.x, pt3.y, pt4.x, pt4.y) \ + area_arc_unit(pt3.x, pt3.y, pt4.x, pt4.y) else: if (pt2.x - x2)**2 + (pt2.y - y2)**2 < (pt1.x - x2)**2 + (pt1.y - y2)**2: pt1, pt2 = pt2, pt1 area = area_triangle(x1, y1, pt3.x, pt3.y, pt1.x, pt1.y) \ + area_triangle(x1, y1, pt1.x, pt1.y, pt2.x, pt2.y) \ + area_triangle(x1, y1, pt2.x, pt2.y, pt4.x, pt4.y) \ + area_arc_unit(pt1.x, pt1.y, pt3.x, pt3.y) \ + area_arc_unit(pt2.x, pt2.y, pt4.x, pt4.y) else: inter = circle_segment(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 inter = circle_segment(x2, y2, x3, y3) pt3 = inter.p1 pt4 = inter.p2 inter = circle_segment(x3, y3, x1, y1) pt5 = inter.p1 pt6 = inter.p2 if pt1.x <= 1.: xp, yp = 0.5 * (pt1.x + pt2.x), 0.5 * (pt1.y + pt2.y) area = overlap_area_triangle_unit_circle(x1, y1, x3, y3, xp, yp) \ + overlap_area_triangle_unit_circle(x2, y2, x3, y3, xp, yp) elif pt3.x <= 1.: xp, yp = 0.5 * (pt3.x + pt4.x), 0.5 * (pt3.y + pt4.y) area = overlap_area_triangle_unit_circle(x3, y3, x1, y1, xp, yp) \ + overlap_area_triangle_unit_circle(x2, y2, x1, y1, xp, yp) elif pt5.x <= 1.: xp, yp = 0.5 * (pt5.x + pt6.x), 0.5 * (pt5.y + pt6.y) area = overlap_area_triangle_unit_circle(x1, y1, x2, y2, xp, yp) \ + overlap_area_triangle_unit_circle(x3, y3, x2, y2, xp, yp) else: # no intersections if in_triangle(0., 0., x1, y1, x2, y2, x3, y3): return PI else: return 0. return area ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/elliptical_overlap.pyx0000644000175100001770000001525414637570305023557 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions defined here allow one to determine the exact area of overlap of an ellipse and a triangle (written by Thomas Robitaille). The approach is to divide the rectangle into two triangles, and reproject these so that the ellipse is a unit circle, then compute the intersection of a triangle with a unit circle. """ import numpy as np cimport numpy as np __all__ = ['elliptical_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython # NOTE: Here we need to make sure we use cimport to import the C functions from # core (since these were defined with cdef). This also requires the core.pxd # file to exist with the function signatures. from .core cimport area_triangle, distance, overlap_area_triangle_unit_circle def elliptical_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double rx, double ry, double theta, int use_exact, int subpixels): """ elliptical_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, rx, ry, use_exact, subpixels) Area of overlap between an ellipse and a pixel grid. The ellipse is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. rx : float The semimajor axis of the ellipse. ry : float The semiminor axis of the ellipse. theta : float The position angle of the semimajor axis in radians (counterclockwise). use_exact : 0 or 1 If set to 1, calculates the exact overlap, while if set to 0, uses a subpixel sampling method with ``subpixel`` subpixels in each direction. subpixels : int If ``use_exact`` is 0, each pixel is resampled by this factor in each dimension. Thus, each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` 2-d array giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy cdef double bxmin, bxmax, bymin, bymax cdef double pxmin, pxmax, pymin, pymax cdef double norm # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny norm = 1.0 / (dx * dy) # For now we use a bounding circle and then use that to find a bounding box # but of course this is inefficient and could be done better. # Find bounding circle radius r = max(rx, ry) # Define bounding box bxmin = -r - 0.5 * dx bxmax = +r + 0.5 * dx bymin = -r - 0.5 * dy bymax = +r + 0.5 * dy for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxmax = pxmin + dx # upper end of pixel if pxmax > bxmin and pxmin < bxmax: for j in range(ny): pymin = ymin + j * dy pymax = pymin + dy if pymax > bymin and pymin < bymax: if use_exact: frac[j, i] = elliptical_overlap_single_exact( pxmin, pymin, pxmax, pymax, rx, ry, theta) * norm else: frac[j, i] = elliptical_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, rx, ry, theta, subpixels) return frac # NOTE: The following two functions use cdef because they are not # intended to be called from the Python code. Using def makes them # callable from outside, but also slower. In any case, these aren't useful # to call from outside because they only operate on a single pixel. cdef double elliptical_overlap_single_subpixel(double x0, double y0, double x1, double y1, double rx, double ry, double theta, int subpixels): """ Return the fraction of overlap between a ellipse and a single pixel with given extent, using a sub-pixel sampling method. """ cdef unsigned int i, j cdef double x, y cdef double frac = 0.0 # Accumulator. cdef double inv_rx_sq, inv_ry_sq cdef double cos_theta = cos(theta) cdef double sin_theta = sin(theta) cdef double dx, dy cdef double x_tr, y_tr dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels inv_rx_sq = 1.0 / (rx * rx) inv_ry_sq = 1.0 / (ry * ry) x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy # Transform into frame of rotated ellipse x_tr = y * sin_theta + x * cos_theta y_tr = y * cos_theta - x * sin_theta if x_tr * x_tr * inv_rx_sq + y_tr * y_tr * inv_ry_sq < 1.: frac += 1.0 return frac / (subpixels * subpixels) cdef double elliptical_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double rx, double ry, double theta): """ Given a rectangle defined by (xmin, ymin, xmax, ymax) and an ellipse with major and minor axes rx and ry respectively, position angle theta, and centered at the origin, find the area of overlap. """ cdef double cos_m_theta = cos(-theta) cdef double sin_m_theta = sin(-theta) cdef double scale # Find scale by which the areas will be shrunk scale = rx * ry # Reproject rectangle to frame of reference in which ellipse is a # unit circle x1, y1 = ((xmin * cos_m_theta - ymin * sin_m_theta) / rx, (xmin * sin_m_theta + ymin * cos_m_theta) / ry) x2, y2 = ((xmax * cos_m_theta - ymin * sin_m_theta) / rx, (xmax * sin_m_theta + ymin * cos_m_theta) / ry) x3, y3 = ((xmax * cos_m_theta - ymax * sin_m_theta) / rx, (xmax * sin_m_theta + ymax * cos_m_theta) / ry) x4, y4 = ((xmin * cos_m_theta - ymax * sin_m_theta) / rx, (xmin * sin_m_theta + ymax * cos_m_theta) / ry) # Divide resulting quadrilateral into two triangles and find # intersection with unit circle return (overlap_area_triangle_unit_circle(x1, y1, x2, y2, x3, y3) + overlap_area_triangle_unit_circle(x1, y1, x4, y4, x3, y3)) * scale ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/rectangular_overlap.pyx0000644000175100001770000000776214637570305023751 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ This module provides tools to calculate the area of overlap between a rectangle and a pixel grid. """ import numpy as np cimport numpy as np __all__ = ['rectangular_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython def rectangular_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double width, double height, double theta, int use_exact, int subpixels): """ rectangular_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, width, height, use_exact, subpixels) Area of overlap between a rectangle and a pixel grid. The rectangle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. width : float The width of the rectangle height : float The height of the rectangle theta : float The position angle of the rectangle in radians (counterclockwise). use_exact : 0 or 1 If set to 1, calculates the exact overlap, while if set to 0, uses a subpixel sampling method with ``subpixel`` subpixels in each direction. subpixels : int If ``use_exact`` is 0, each pixel is resampled by this factor in each dimension. Thus, each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` 2-d array giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy cdef double pxmin, pxmax, pymin, pymax # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) if use_exact == 1: raise NotImplementedError("Exact mode has not been implemented for " "rectangular apertures") # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny # TODO: can implement a bounding box here for efficiency (as for the # circular and elliptical aperture photometry) for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxmax = pxmin + dx # upper end of pixel for j in range(ny): pymin = ymin + j * dy pymax = pymin + dy frac[j, i] = rectangular_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, width, height, theta, subpixels) return frac cdef double rectangular_overlap_single_subpixel(double x0, double y0, double x1, double y1, double width, double height, double theta, int subpixels): """ Return the fraction of overlap between a rectangle and a single pixel with given extent, using a sub-pixel sampling method. """ cdef unsigned int i, j cdef double x, y cdef double frac = 0.0 # Accumulator. cdef double cos_theta = cos(theta) cdef double sin_theta = sin(theta) cdef double half_width, half_height half_width = width / 2.0 half_height = height / 2.0 dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy # Transform into frame of rotated rectangle x_tr = y * sin_theta + x * cos_theta y_tr = y * cos_theta - x * sin_theta if fabs(x_tr) < half_width and fabs(y_tr) < half_height: frac += 1.0 return frac / (subpixels * subpixels) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.026885 photutils-1.13.0/photutils/geometry/tests/0000755000175100001770000000000014637570322020275 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/tests/__init__.py0000644000175100001770000000000014637570305022375 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/tests/test_circular_overlap_grid.py0000644000175100001770000000166214637570305026255 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circular_overlap_grid module. """ import itertools import pytest from numpy.testing import assert_allclose from photutils.geometry import circular_overlap_grid grid_sizes = [50, 500, 1000] circ_sizes = [0.2, 0.4, 0.8] use_exacts = [0, 1] subsamples = [1, 5, 10] @pytest.mark.parametrize(('grid_size', 'circ_size', 'use_exact', 'subsample'), list(itertools.product(grid_sizes, circ_sizes, use_exacts, subsamples))) def test_circular_overlap_grid(grid_size, circ_size, use_exact, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = circular_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, circ_size, use_exact, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/tests/test_elliptical_overlap_grid.py0000644000175100001770000000230114637570305026562 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the elliptical_overlap_grid module. """ import itertools import pytest from numpy.testing import assert_allclose from photutils.geometry import elliptical_overlap_grid grid_sizes = [50, 500, 1000] maj_sizes = [0.2, 0.4, 0.8] min_sizes = [0.2, 0.4, 0.8] angles = [0.0, 0.5, 1.0] use_exacts = [0, 1] subsamples = [1, 5, 10] @pytest.mark.parametrize(('grid_size', 'maj_size', 'min_size', 'angle', 'use_exact', 'subsample'), list(itertools.product(grid_sizes, maj_sizes, min_sizes, angles, use_exacts, subsamples))) def test_elliptical_overlap_grid(grid_size, maj_size, min_size, angle, use_exact, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = elliptical_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, maj_size, min_size, angle, use_exact, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/geometry/tests/test_rectangular_overlap_grid.py0000644000175100001770000000170414637570305026755 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangular_overlap_grid module. """ import itertools import pytest from numpy.testing import assert_allclose from photutils.geometry import rectangular_overlap_grid grid_sizes = [50, 500, 1000] rect_sizes = [0.2, 0.4, 0.8] angles = [0.0, 0.5, 1.0] subsamples = [1, 5, 10] @pytest.mark.parametrize(('grid_size', 'rect_size', 'angle', 'subsample'), list(itertools.product(grid_sizes, rect_sizes, angles, subsamples))) def test_rectangular_overlap_grid(grid_size, rect_size, angle, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = rectangular_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, rect_size, rect_size, angle, 0, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.026885 photutils-1.13.0/photutils/isophote/0000755000175100001770000000000014637570322017132 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/__init__.py0000644000175100001770000000076614637570305021255 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for fitting elliptical isophotes to galaxy images. """ from .ellipse import * # noqa: F401, F403 from .fitter import * # noqa: F401, F403 from .geometry import * # noqa: F401, F403 from .harmonics import * # noqa: F401, F403 from .integrator import * # noqa: F401, F403 from .isophote import * # noqa: F401, F403 from .model import * # noqa: F401, F403 from .sample import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/ellipse.py0000644000175100001770000010233014637570305021141 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to fit elliptical isophotes. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.isophote.fitter import (DEFAULT_CONVERGENCE, DEFAULT_FFLAG, DEFAULT_MAXGERR, DEFAULT_MAXIT, DEFAULT_MINIT, CentralEllipseFitter, EllipseFitter) from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.integrator import BILINEAR from photutils.isophote.isophote import Isophote, IsophoteList from photutils.isophote.sample import CentralEllipseSample, EllipseSample __all__ = ['Ellipse'] class Ellipse: r""" Class to fit elliptical isophotes to a galaxy image. The isophotes in the image are measured using an iterative method described by `Jedrzejewski (1987; MNRAS 226, 747) `_. See the **Notes** section below for details about the algorithm. Parameters ---------- image : 2D `~numpy.ndarray` The image array. geometry : `~photutils.isophote.EllipseGeometry` instance or `None`, optional The optional geometry that describes the first ellipse to be fitted. If `None`, a default `~photutils.isophote.EllipseGeometry` instance is created centered on the image frame with ellipticity of 0.2 and a position angle of 90 degrees. threshold : float, optional The threshold for the object centerer algorithm. By lowering this value the object centerer becomes less strict, in the sense that it will accept lower signal-to-noise data. If set to a very large value, the centerer is effectively shut off. In this case, either the geometry information supplied by the ``geometry`` parameter is used as is, or the fit algorithm will terminate prematurely. Note that once the object centerer runs successfully, the (x, y) coordinates in the ``geometry`` attribute (an `~photutils.isophote.EllipseGeometry` instance) are modified in place. The default is 0.1. Notes ----- The image is measured using an iterative method described by `Jedrzejewski (1987; MNRAS 226, 747) `_. Each isophote is fitted at a pre-defined, fixed semimajor axis length. The algorithm starts from a first-guess elliptical isophote defined by approximate values for the (x, y) center coordinates, ellipticity, and position angle. Using these values, the image is sampled along an elliptical path, producing a 1-dimensional function that describes the dependence of intensity (pixel value) with angle (E). The function is stored as a set of 1D numpy arrays. The harmonic content of this function is analyzed by least-squares fitting to the function: .. math:: y = y0 + (A1 * \sin(E)) + (B1 * \cos(E)) + (A2 * \sin(2 * E)) + (B2 * \cos(2 * E)) Each one of the harmonic amplitudes (A1, B1, A2, and B2) is related to a specific ellipse geometric parameter in the sense that it conveys information regarding how much the parameter's current value deviates from the "true" one. To compute this deviation, the image's local radial gradient has to be taken into account too. The algorithm picks up the largest amplitude among the four, estimates the local gradient, and computes the corresponding increment in the associated ellipse parameter. That parameter is updated, and the image is resampled. This process is repeated until any one of the following criteria are met: 1. the largest harmonic amplitude is less than a given fraction of the rms residual of the intensity data around the harmonic fit. 2. a user-specified maximum number of iterations is reached. 3. more than a given fraction of the elliptical sample points have no valid data in then, either because they lie outside the image boundaries or because they were flagged out from the fit by sigma-clipping. In any case, a minimum number of iterations is always performed. If iterations stop because of reasons 2 or 3 above, then those ellipse parameters that generated the lowest absolute values for harmonic amplitudes will be used. At this point, the image data sample coming from the best fit ellipse is fitted by the following function: .. math:: y = y0 + (An * sin(n * E)) + (Bn * cos(n * E)) with :math:`n = 3` and :math:`n = 4`. The corresponding amplitudes (A3, B3, A4, and B4), divided by the semimajor axis length and local intensity gradient, measure the isophote's deviations from perfect ellipticity (these amplitudes, divided by semimajor axis and gradient, are the actual quantities stored in the output `~photutils.isophote.Isophote` instance). The algorithm then measures the integrated intensity and the number of non-flagged pixels inside the elliptical isophote, and also inside the corresponding circle with same center and radius equal to the semimajor axis length. These parameters, their errors, other associated parameters, and auxiliary information, are stored in the `~photutils.isophote.Isophote` instance. Errors in intensity and local gradient are obtained directly from the rms scatter of intensity data along the fitted ellipse. Ellipse geometry errors are obtained from the errors in the coefficients of the first and second simultaneous harmonic fit. Third and fourth harmonic amplitude errors are obtained in the same way, but only after the first and second harmonics are subtracted from the raw data. For more details, see the error analysis in `Busko (1996; ASPC 101, 139) `_. After fitting the ellipse that corresponds to a given value of the semimajor axis (by the process described above), the axis length is incremented/decremented following a pre-defined rule. At each step, the starting, first-guess, ellipse parameters are taken from the previously fitted ellipse that has the closest semimajor axis length to the current one. On low surface brightness regions (those having large radii), the small values of the image radial gradient can induce large corrections and meaningless values for the ellipse parameters. The algorithm has the ability to stop increasing semimajor axis based on several criteria, including signal-to-noise ratio. See the `~photutils.isophote.Isophote` documentation for the meaning of the stop code reported after each fit. The fit algorithm provides a k-sigma clipping algorithm for cleaning deviant sample points at each isophote, thus improving convergence stability against any non-elliptical structure such as stars, spiral arms, HII regions, defects, etc. The fit algorithm has no way of finding where, in the input image frame, the galaxy to be measured is located. The center (x, y) coordinates need to be close to the actual center for the fit to work. An "object centerer" function helps to verify that the selected position can be used as starting point. This function scans a 10x10 window centered either on the (x, y) coordinates in the `~photutils.isophote.EllipseGeometry` instance passed to the constructor of the `~photutils.isophote.Ellipse` class, or, if any one of them, or both, are set to `None`, on the input image frame center. In case a successful acquisition takes place, the `~photutils.isophote.EllipseGeometry` instance is modified in place to reflect the solution of the object centerer algorithm. In some cases the object centerer algorithm may fail, even though there is enough signal-to-noise to start a fit (e.g., in objects with very high ellipticity). In those cases the sensitivity of the algorithm can be decreased by decreasing the value of the object centerer threshold parameter. The centerer works by looking to where a quantity akin to a signal-to-noise ratio is maximized within the 10x10 window. The centerer can thus be shut off entirely by setting the threshold to a large value >> 1 (meaning, no location inside the search window will achieve that signal-to-noise ratio). A note of caution: the ellipse fitting algorithm was designed explicitly with an elliptical galaxy brightness distribution in mind. In particular, a well defined negative radial intensity gradient across the region being fitted is paramount for the achievement of stable solutions. Use of the algorithm in other types of images (e.g., planetary nebulae) may lead to inability to converge to any acceptable solution. """ def __init__(self, image, geometry=None, threshold=0.1): self.image = image if geometry is not None: self._geometry = geometry else: _x0 = image.shape[1] / 2 _y0 = image.shape[0] / 2 self._geometry = EllipseGeometry(_x0, _y0, 10.0, eps=0.2, pa=np.pi / 2) self.set_threshold(threshold) def set_threshold(self, threshold): """ Modify the threshold value used by the centerer. Parameters ---------- threshold : float The new threshold value to use. """ self._geometry.centerer_threshold = threshold def fit_image(self, sma0=None, minsma=0.0, maxsma=None, step=0.1, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, sclip=3.0, nclip=0, integrmode=BILINEAR, linear=None, maxrit=None, fix_center=False, fix_pa=False, fix_eps=False): # This parameter list is quite large and should in principle be # simplified by re-distributing these controls to somewhere else. # We keep this design though because it better mimics the flat # architecture used in the original STSDAS task `ellipse`. """ Fit multiple isophotes to the image array. This method loops over each value of the semimajor axis (sma) length (constructed from the input parameters), fitting a single isophote at each sma. The entire set of isophotes is returned in an `~photutils.isophote.IsophoteList` instance. Note that the fix_XXX parameters act in unison. Meaning, if one of them is set via this call, the others will assume their default (False) values. This effectively overrides any settings that are present in the internal `~photutils.isophote.EllipseGeometry` instance that is carried along as a property of this class. If an instance of `~photutils.isophote.EllipseGeometry` was passed to this class' constructor, that instance will be effectively overridden by the fix_XXX parameters in this call. Parameters ---------- sma0 : float, optional The starting value for the semimajor axis length (pixels). This value must not be the minimum or maximum semimajor axis length, but something in between. The algorithm can't start from the very center of the galaxy image because the modelling of elliptical isophotes on that region is poor and it will diverge very easily if not tied to other previously fit isophotes. It can't start from the maximum value either because the maximum is not known beforehand, depending on signal-to-noise. The ``sma0`` value should be selected such that the corresponding isophote has a good signal-to-noise ratio and a clearly defined geometry. If set to `None` (the default), one of two actions will be taken: if a `~photutils.isophote.EllipseGeometry` instance was input to the `~photutils.isophote.Ellipse` constructor, its ``sma`` value will be used. Otherwise, a default value of 10. will be used. minsma : float, optional The minimum value for the semimajor axis length (pixels). The default is 0. maxsma : float or `None`, optional The maximum value for the semimajor axis length (pixels). When set to `None` (default), the algorithm will increase the semimajor axis until one of several conditions will cause it to stop and revert to fit ellipses with sma < ``sma0``. step : float, optional The step value used to grow/shrink the semimajor axis length (pixels if ``linear=True``, or a relative value if ``linear=False``). See the ``linear`` parameter. The default is 0.1. conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. This is the main control for preventing ellipses to grow to regions of too low signal-to-noise ratio. It specifies the maximum acceptable relative error in the local radial intensity gradient. `Busko (1996; ASPC 101, 139) `_ showed that the fitting precision relates to that relative error. The usual behavior of the gradient relative error is to increase with semimajor axis, being larger in outer, fainter regions of a galaxy image. In the current implementation, the ``maxgerr`` criterion is triggered only when two consecutive isophotes exceed the value specified by the parameter. This prevents premature stopping caused by contamination such as stars and HII regions. A number of actions may happen when the gradient error exceeds ``maxgerr`` (or becomes non-significant and is set to `None`). If the maximum semimajor axis specified by ``maxsma`` is set to `None`, semimajor axis growth is stopped and the algorithm proceeds inwards to the galaxy center. If ``maxsma`` is set to some finite value, and this value is larger than the current semimajor axis length, the algorithm enters non-iterative mode and proceeds outwards until reaching ``maxsma``. The default is 0.5. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, optional The area integration mode. The default is 'bilinear'. linear : bool, optional The semimajor axis growing/shrinking mode. If `False` (default), the geometric growing mode is chosen, thus the semimajor axis length is increased by a factor of (1. + ``step``), and the process is repeated until either the semimajor axis value reaches the value of parameter ``maxsma``, or the last fitted ellipse has more than a given fraction of its sampled points flagged out (see ``fflag``). The process then resumes from the first fitted ellipse (at ``sma0``) inwards, in steps of (1./(1. + ``step``)), until the semimajor axis length reaches the value ``minsma``. In case of linear growing, the increment or decrement value is given directly by ``step`` in pixels. If ``maxsma`` is set to `None`, the semimajor axis will grow until a low signal-to-noise criterion is met. See ``maxgerr``. maxrit : float or `None`, optional The maximum value of semimajor axis to perform an actual fit. Whenever the current semimajor axis length is larger than ``maxrit``, the isophotes will be extracted using the current geometry, without being fitted. This non-iterative mode may be useful for sampling regions of very low surface brightness, where the algorithm may become unstable and unable to recover reliable geometry information. Non-iterative mode can also be entered automatically whenever the ellipticity exceeds 1.0 or the ellipse center crosses the image boundaries. If `None` (default), then no maximum value is used. fix_center : bool, optional Keep center of ellipse fixed during fit? The default is False. fix_pa : bool, optional Keep position angle of semi-major axis of ellipse fixed during fit? The default is False. fix_eps : bool, optional Keep ellipticity of ellipse fixed during fit? The default is False. Returns ------- result : `~photutils.isophote.IsophoteList` instance A list-like object of `~photutils.isophote.Isophote` instances, sorted by increasing semimajor axis length. """ # multiple fitted isophotes will be stored here isophote_list = [] # get starting sma from appropriate source: keyword parameter, # internal EllipseGeometry instance, or fixed default value. if not sma0: if self._geometry: sma = self._geometry.sma else: sma = 10.0 else: sma = sma0 # Override geometry instance with parameters set at the call. if isinstance(linear, bool): self._geometry.linear_growth = linear else: linear = self._geometry.linear_growth if fix_center and fix_pa and fix_eps: warnings.warn(': Everything is fixed. Fit not possible.', AstropyUserWarning) return IsophoteList([]) if fix_center or fix_pa or fix_eps: # Note that this overrides the geometry instance for good. self._geometry.fix = np.array([fix_center, fix_center, fix_pa, fix_eps]) # first, go from initial sma outwards until # hitting one of several stopping criteria. noiter = False first_isophote = True while True: # first isophote runs longer minit_a = 2 * minit if first_isophote else minit first_isophote = False isophote = self.fit_isophote(sma, step, conver, minit_a, maxit, fflag, maxgerr, sclip, nclip, integrmode, linear, maxrit, noniterate=noiter, isophote_list=isophote_list) # check for failed fit. if isophote.stop_code < 0 or isophote.stop_code == 1: # in case the fit failed right at the outset, return an # empty list. This is the usual case when the user # provides initial guesses that are too way off to enable # the fitting algorithm to find any meaningful solution. if len(isophote_list) == 1: warnings.warn('No meaningful fit was possible.', AstropyUserWarning) return IsophoteList([]) self._fix_last_isophote(isophote_list, -1) # get last isophote from the actual list, since the last # `isophote` instance in this context may no longer be OK. isophote = isophote_list[-1] # if two consecutive isophotes failed to fit, # shut off iterative mode. Or, bail out and # change to go inwards. if len(isophote_list) > 2: if ((isophote.stop_code == 5 and isophote_list[-2].stop_code == 5) or isophote.stop_code == 1): if maxsma and maxsma > isophote.sma: # if a maximum sma value was provided by # user, and the current sma is smaller than # maxsma, keep growing sma in non-iterative # mode until reaching it. noiter = True else: # if no maximum sma, stop growing and change # to go inwards. break # reset variable from the actual list, since the last # `isophote` instance may no longer be OK. isophote = isophote_list[-1] # update sma. If exceeded user-defined # maximum, bail out from this loop. sma = isophote.sample.geometry.update_sma(step) if maxsma and sma >= maxsma: break # reset sma so as to go inwards. first_isophote = isophote_list[0] sma, step = first_isophote.sample.geometry.reset_sma(step) # now, go from initial sma inwards towards center. while True: isophote = self.fit_isophote(sma, step, conver, minit, maxit, fflag, maxgerr, sclip, nclip, integrmode, linear, maxrit, going_inwards=True, isophote_list=isophote_list) # if abnormal condition, fix isophote but keep going. if isophote.stop_code < 0: self._fix_last_isophote(isophote_list, 0) # but if we get an error from the scipy fitter, bail out # immediately. This usually happens at very small radii # when the number of data points is too small. if isophote.stop_code == 3: break # reset variable from the actual list, since the last # `isophote` instance may no longer be OK. isophote = isophote_list[-1] # figure out next sma; if exceeded user-defined # minimum, or too small, bail out from this loop sma = isophote.sample.geometry.update_sma(step) if sma <= max(minsma, 0.5): break # if user asked for minsma=0, extract special isophote there if minsma == 0.0: # isophote is appended to isophote_list _ = self.fit_isophote(0.0, isophote_list=isophote_list) # sort list of isophotes according to sma isophote_list.sort() return IsophoteList(isophote_list) def fit_isophote(self, sma, step=0.1, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, sclip=3.0, nclip=0, integrmode=BILINEAR, linear=False, maxrit=None, noniterate=False, going_inwards=False, isophote_list=None): """ Fit a single isophote with a given semimajor axis length. The ``step`` and ``linear`` parameters are not used to actually grow or shrink the current fitting semimajor axis length. They are necessary so the sampling algorithm can know where to start the gradient computation and also how to compute the elliptical sector areas (when area integration mode is selected). Parameters ---------- sma : float The semimajor axis length (pixels). step : float, optional The step value used to grow/shrink the semimajor axis length (pixels if ``linear=True``, or a relative value if ``linear=False``). See the ``linear`` parameter. The default is 0.1. conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. When fitting a single isophote by itself this parameter doesn't have any effect on the outcome. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, optional The area integration mode. The default is 'bilinear'. linear : bool, optional The semimajor axis growing/shrinking mode. When fitting just one isophote, this parameter is used only by the code that define the details of how elliptical arc segments ("sectors") are extracted from the image when using area extraction modes (see the ``integrmode`` parameter). maxrit : float or `None`, optional The maximum value of semimajor axis to perform an actual fit. Whenever the current semimajor axis length is larger than ``maxrit``, the isophotes will be extracted using the current geometry, without being fitted. This non-iterative mode may be useful for sampling regions of very low surface brightness, where the algorithm may become unstable and unable to recover reliable geometry information. Non-iterative mode can also be entered automatically whenever the ellipticity exceeds 1.0 or the ellipse center crosses the image boundaries. If `None` (default), then no maximum value is used. noniterate : bool, optional Whether the fitting algorithm should be bypassed and an isophote should be extracted with the geometry taken directly from the most recent `~photutils.isophote.Isophote` instance stored in the ``isophote_list`` parameter. This parameter is mainly used when running the method in a loop over different values of semimajor axis length, and we want to change from iterative to non-iterative mode somewhere along the sequence of isophotes. When set to `True`, this parameter overrides the behavior associated with parameter ``maxrit``. The default is `False`. going_inwards : bool, optional Parameter to define the sense of SMA growth. When fitting just one isophote, this parameter is used only by the code that defines the details of how elliptical arc segments ("sectors") are extracted from the image, when using area extraction modes (see the ``integrmode`` parameter). The default is `False`. isophote_list : list or `None`, optional If not `None` (the default), the fitted `~photutils.isophote.Isophote` instance is appended to this list. It must be created and managed by the caller. Returns ------- result : `~photutils.isophote.Isophote` instance The fitted isophote. The fitted isophote is also appended to the input list input to the ``isophote_list`` parameter. """ geometry = self._geometry # if available, geometry from last fitted isophote will be # used as initial guess for next isophote. if isophote_list: geometry = isophote_list[-1].sample.geometry # do the fit if noniterate or (maxrit and sma > maxrit): isophote = self._non_iterative(sma, step, linear, geometry, sclip, nclip, integrmode) else: isophote = self._iterative(sma, step, linear, geometry, sclip, nclip, integrmode, conver, minit, maxit, fflag, maxgerr, going_inwards) # store result in list if isophote_list is not None and isophote.valid: isophote_list.append(isophote) return isophote def _iterative(self, sma, step, linear, geometry, sclip, nclip, integrmode, conver, minit, maxit, fflag, maxgerr, going_inwards=False): if sma > 0.0: # iterative fitter sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, nclip=nclip, linear_growth=linear, geometry=geometry, integrmode=integrmode) fitter = EllipseFitter(sample) else: # sma == 0 requires special handling sample = CentralEllipseSample(self.image, 0.0, geometry=geometry) fitter = CentralEllipseFitter(sample) isophote = fitter.fit(conver, minit, maxit, fflag, maxgerr, going_inwards) return isophote def _non_iterative(self, sma, step, linear, geometry, sclip, nclip, integrmode): sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, nclip=nclip, linear_growth=linear, geometry=geometry, integrmode=integrmode) sample.update(geometry.fix) # build isophote without iterating with an EllipseFitter isophote = Isophote(sample, 0, True, stop_code=4) return isophote @staticmethod def _fix_last_isophote(isophote_list, index): if isophote_list: isophote = isophote_list.pop() # check if isophote is bad; if so, fix its geometry # to be like the geometry of the index-th isophote # in list. isophote.fix_geometry(isophote_list[index]) # force new extraction of raw data, since # geometry changed. isophote.sample.values = None isophote.sample.update(isophote.sample.geometry.fix) # we take the opportunity to change an eventual # negative stop code to its' positive equivalent. code = 5 if isophote.stop_code < 0 else isophote.stop_code # build new instance so it can have its attributes # populated from the updated sample attributes. new_isophote = Isophote(isophote.sample, isophote.niter, isophote.valid, code) # add new isophote to list isophote_list.append(new_isophote) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/fitter.py0000644000175100001770000004114114637570305021003 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to fit ellipses. """ import math import numpy as np from astropy import log from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics) from photutils.isophote.isophote import CentralPixel, Isophote from photutils.isophote.sample import EllipseSample __all__ = ['EllipseFitter'] __doctest_skip__ = ['EllipseFitter.fit'] PI2 = np.pi / 2 MAX_EPS = 0.95 MIN_EPS = 0.05 DEFAULT_CONVERGENCE = 0.05 DEFAULT_MINIT = 10 DEFAULT_MAXIT = 50 DEFAULT_FFLAG = 0.7 DEFAULT_MAXGERR = 0.5 class EllipseFitter: """ Class to fit ellipses. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample data to be fitted. """ def __init__(self, sample): self._sample = sample def fit(self, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, going_inwards=False): """ Fit an elliptical isophote. Parameters ---------- conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. This is the main control for preventing ellipses to grow to regions of too low signal-to-noise ratio. It specifies the maximum acceptable relative error in the local radial intensity gradient. `Busko (1996; ASPC 101, 139) `_ showed that the fitting precision relates to that relative error. The usual behavior of the gradient relative error is to increase with semimajor axis, being larger in outer, fainter regions of a galaxy image. In the current implementation, the ``maxgerr`` criterion is triggered only when two consecutive isophotes exceed the value specified by the parameter. This prevents premature stopping caused by contamination such as stars and HII regions. A number of actions may happen when the gradient error exceeds ``maxgerr`` (or becomes non-significant and is set to `None`). If the maximum semimajor axis specified by ``maxsma`` is set to `None`, semimajor axis growth is stopped and the algorithm proceeds inwards to the galaxy center. If ``maxsma`` is set to some finite value, and this value is larger than the current semimajor axis length, the algorithm enters non-iterative mode and proceeds outwards until reaching ``maxsma``. The default is 0.5. going_inwards : bool, optional Parameter to define the sense of SMA growth. When fitting just one isophote, this parameter is used only by the code that defines the details of how elliptical arc segments ("sectors") are extracted from the image, when using area extraction modes (see the ``integrmode`` parameter in the `~photutils.isophote.EllipseSample` class). The default is `False`. Returns ------- result : `~photutils.isophote.Isophote` instance The fitted isophote, which also contains fit status information. Examples -------- >>> from photutils.isophote import EllipseSample, EllipseFitter >>> sample = EllipseSample(data, sma=10.0) >>> fitter = EllipseFitter(sample) >>> isophote = fitter.fit() """ sample = self._sample # this flag signals that limiting gradient error (`maxgerr`) # wasn't exceeded yet. lexceed = False # here we keep track of the sample that caused the minimum harmonic # amplitude(in absolute value). This will eventually be used to # build the resulting Isophote in cases where iterations run to # the maximum allowed (maxit), or the maximum number of flagged # data points (fflag) is reached. minimum_amplitude_value = np.inf minimum_amplitude_sample = None # these must be passed throughout the execution chain. fixed_parameters = self._sample.geometry.fix for i in range(maxit): # Force the sample to compute its gradient and associated values. sample.update(fixed_parameters) # The extract() method returns sampled values as a 2-d numpy # array with the following structure: # values[0] = 1-d array with angles # values[1] = 1-d array with radii # values[2] = 1-d array with intensity values = sample.extract() # We have to check for a zero-length condition here, and # bail out in case it is detected. The scipy fitter won't # raise an exception for zero-length input arrays, but just # prints an "INFO" message. This may result in an infinite # loop. if len(values[2]) < 1: s = str(sample.geometry.sma) log.warning('Too small sample to warrant a fit. SMA is ' + s) sample.geometry.fix = fixed_parameters return Isophote(sample, i + 1, False, 3) # Fit harmonic coefficients. Failure in fitting is # a fatal error; terminate immediately with sample # marked as invalid. try: coeffs = fit_first_and_second_harmonics(values[0], values[2]) coeffs = coeffs[0] except Exception as e: log.warning(e) sample.geometry.fix = fixed_parameters return Isophote(sample, i + 1, False, 3) # Mask out coefficients that control fixed ellipse parameters. free_coeffs = np.ma.masked_array(coeffs[1:], mask=fixed_parameters) # Largest non-masked harmonic in absolute value drives the # correction. largest_harmonic_index = np.argmax(np.abs(free_coeffs)) largest_harmonic = free_coeffs[largest_harmonic_index] # see if the amplitude decreased; if yes, keep the # corresponding sample for eventual later use. if abs(largest_harmonic) < minimum_amplitude_value: minimum_amplitude_value = abs(largest_harmonic) minimum_amplitude_sample = sample # check if converged model = first_and_second_harmonic_function(values[0], coeffs) residual = values[2] - model if ((conver * sample.sector_area * np.std(residual)) > np.abs(largest_harmonic)): # Got a valid solution. But before returning, ensure # that a minimum of iterations has run. if i >= minit - 1: sample.update(fixed_parameters) return Isophote(sample, i + 1, True, 0) # it may not have converged yet, but the sample contains too # many invalid data points: return. if sample.actual_points < (sample.total_points * fflag): # when too many data points were flagged, return the # best fit sample instead of the current one. minimum_amplitude_sample.update(fixed_parameters) return Isophote(minimum_amplitude_sample, i + 1, True, 1) # pick appropriate corrector code. corrector = _CORRECTORS[largest_harmonic_index] # generate *NEW* EllipseSample instance with corrected # parameter. Note that this instance is still devoid of # other information besides its geometry. It needs to be # explicitly updated for computations to proceed. We have to # build a new EllipseSample instance every time because of # the lazy extraction process used by EllipseSample code. To # minimize the number of calls to the area integrators, we # pay a (hopefully smaller) price here, by having multiple # calls to the EllipseSample constructor. sample = corrector.correct(sample, largest_harmonic) sample.update(fixed_parameters) # see if any abnormal (or unusual) conditions warrant # the change to non-iterative mode, or go-inwards mode. proceed, lexceed = self._check_conditions( sample, maxgerr, going_inwards, lexceed) if not proceed: sample.update(fixed_parameters) return Isophote(sample, i + 1, True, -1) # Got to the maximum number of iterations. Return with # code 2, and handle it as a valid isophote. Use the # best fit sample instead of the current one. minimum_amplitude_sample.update(fixed_parameters) return Isophote(minimum_amplitude_sample, maxit, True, 2) @staticmethod def _check_conditions(sample, maxgerr, going_inwards, lexceed): proceed = True # If center wandered more than allowed, put it back # in place and signal the end of iterative mode. # if wander: # if abs(dx) > WANDER(al)) or abs(dy) > WANDER(al): # sample.geometry.x0 -= dx # sample.geometry.y0 -= dy # STOP(al) = ST_NONITERATE # proceed = False # check if an acceptable gradient value could be computed. if sample.gradient_error and sample.gradient_relative_error: if not going_inwards and ( sample.gradient_relative_error > maxgerr or sample.gradient >= 0.0): if lexceed: proceed = False else: lexceed = True else: proceed = False # check if ellipse geometry diverged. if abs(sample.geometry.eps > MAX_EPS): proceed = False if (sample.geometry.x0 < 1.0 or sample.geometry.x0 > sample.image.shape[1] or sample.geometry.y0 < 1.0 or sample.geometry.y0 > sample.image.shape[0]): proceed = False # See if eps == 0 (round isophote) was crossed. # If so, fix it but still proceed if sample.geometry.eps < 0.0: sample.geometry.eps = min(-sample.geometry.eps, MAX_EPS) if sample.geometry.pa < PI2: sample.geometry.pa += PI2 else: sample.geometry.pa -= PI2 # If ellipse is an exact circle, computations will diverge. # Make it slightly flat, but still proceed if sample.geometry.eps == 0.0: sample.geometry.eps = MIN_EPS return proceed, lexceed class _ParameterCorrector: def correct(self, sample, harmonic): raise NotImplementedError class _PositionCorrector(_ParameterCorrector): @staticmethod def finalize_correction(dx, dy, sample): new_x0 = sample.geometry.x0 + dx new_y0 = sample.geometry.y0 + dy return EllipseSample(sample.image, sample.geometry.sma, x0=new_x0, y0=new_y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=sample.geometry.eps, position_angle=sample.geometry.pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) class _PositionCorrector0(_PositionCorrector): def correct(self, sample, harmonic): aux = -harmonic * (1.0 - sample.geometry.eps) / sample.gradient dx = -aux * math.sin(sample.geometry.pa) dy = aux * math.cos(sample.geometry.pa) return self.finalize_correction(dx, dy, sample) class _PositionCorrector1(_PositionCorrector): def correct(self, sample, harmonic): aux = -harmonic / sample.gradient dx = aux * math.cos(sample.geometry.pa) dy = aux * math.sin(sample.geometry.pa) return self.finalize_correction(dx, dy, sample) class _AngleCorrector(_ParameterCorrector): def correct(self, sample, harmonic): eps = sample.geometry.eps sma = sample.geometry.sma gradient = sample.gradient correction = (harmonic * 2.0 * (1.0 - eps) / sma / gradient / ((1.0 - eps)**2 - 1.0)) # '% np.pi' to make angle lie between 0 and np.pi radians new_pa = (sample.geometry.pa + correction) % np.pi return EllipseSample(sample.image, sample.geometry.sma, x0=sample.geometry.x0, y0=sample.geometry.y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=sample.geometry.eps, position_angle=new_pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) class _EllipticityCorrector(_ParameterCorrector): def correct(self, sample, harmonic): eps = sample.geometry.eps sma = sample.geometry.sma gradient = sample.gradient correction = harmonic * 2.0 * (1.0 - eps) / sma / gradient new_eps = min((sample.geometry.eps - correction), MAX_EPS) return EllipseSample(sample.image, sample.geometry.sma, x0=sample.geometry.x0, y0=sample.geometry.y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=new_eps, position_angle=sample.geometry.pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) # instances of corrector code live here: _CORRECTORS = [_PositionCorrector0(), _PositionCorrector1(), _AngleCorrector(), _EllipticityCorrector()] class CentralEllipseFitter(EllipseFitter): """ A special Fitter class to handle the case of the central pixel in the galaxy image. """ def fit(self, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, going_inwards=False): """ Perform just a simple 1-pixel extraction at the current (x0, y0) position using bilinear interpolation. The input parameters are ignored, but included simple to match the calling signature of the parent class. Returns ------- result : `~photutils.isophote.CentralEllipsePixel` instance The central pixel value. For convenience, the `~photutils.isophote.CentralEllipsePixel` class inherits from the `~photutils.isophote.Isophote` class, although it's not really a true isophote but just a single intensity value at the central position. Thus, most of its attributes are hardcoded to `None` or other default value when appropriate. """ # default values fixed_parameters = np.array([False, False, False, False]) self._sample.update(fixed_parameters) return CentralPixel(self._sample) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/geometry.py0000644000175100001770000004735714637570305021360 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a container class to store parameters for the geometry of an ellipse. """ import math import numpy as np from astropy import log __all__ = ['EllipseGeometry'] IN_MASK = [ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ] OUT_MASK = [ [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1], [1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], [1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], [1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1], ] def _area(sma, eps, phi, r): """ Compute elliptical sector area. """ aux = r * math.cos(phi) / sma signal = aux / abs(aux) if abs(aux) >= 1.0: aux = signal return abs(sma**2 * (1.0 - eps) / 2.0 * math.acos(aux)) class EllipseGeometry: r""" Container class to store parameters for the geometry of an ellipse. Parameters that describe the relationship of a given ellipse with other associated ellipses are also encapsulated in this container. These associated ellipses may include, e.g., the two (inner and outer) bounding ellipses that are used to build sectors along the elliptical path. These sectors are used as areas for integrating pixel values, when the area integration mode (mean or median) is used. This class also keeps track of where in the ellipse we are when performing an 'extract' operation. This is mostly relevant when using an area integration mode (as opposed to a pixel integration mode) Parameters ---------- x0, y0 : float The center pixel coordinate of the ellipse. sma : float The semimajor axis of the ellipse in pixels. eps : float The ellipticity of the ellipse. The ellipticity is defined as .. math:: \epsilon = 1 - \frac{b}{a} where a and b are the lengths of the semimajor and semimior axes, respectively. pa : float The position angle (in radians) of the semimajor axis in relation to the positive x axis of the image array (rotating towards the positive y axis). Position angles are defined in the range :math:`0 < PA <= \pi`. Avoid using as starting position angle of 0., since the fit algorithm may not work properly. When the ellipses are such that position angles are near either extreme of the range, noise can make the solution jump back and forth between successive isophotes, by amounts close to 180 degrees. astep : float, optional The step value for growing/shrinking the semimajor axis. It can be expressed either in pixels (when ``linear_growth=True``) or as a relative value (when ``linear_growth=False``). The default is 0.1. linear_growth : bool, optional The semimajor axis growing/shrinking mode. The default is `False`. fix_center : bool, optional Keep center of ellipse fixed during fit? The default is False. fix_pa : bool, optional Keep position angle of semi-major axis of ellipse fixed during fit? The default is False. fix_eps : bool, optional Keep ellipticity of ellipse fixed during fit? The default is False. """ def __init__(self, x0, y0, sma, eps, pa, astep=0.1, linear_growth=False, fix_center=False, fix_pa=False, fix_eps=False): self.x0 = x0 self.y0 = y0 self.sma = sma self.eps = eps self.pa = pa self.astep = astep self.linear_growth = linear_growth # Fixed parameters are flagged in here. Note that the # ordering must follow the same ordering used in the # fitter._CORRECTORS list. self.fix = np.array([fix_center, fix_center, fix_pa, fix_eps]) # limits for sector angular width self._phi_min = 0.05 self._phi_max = 0.2 # variables used in the calculation of the sector angular width sma1, sma2 = self.bounding_ellipses() inner_sma = min((sma2 - sma1), 3.0) self._area_factor = (sma2 - sma1) * inner_sma # sma can eventually be zero! if self.sma > 0.0: self.sector_angular_width = max(min((inner_sma / self.sma), self._phi_max), self._phi_min) self.initial_polar_angle = self.sector_angular_width / 2.0 self.initial_polar_radius = self.radius(self.initial_polar_angle) def find_center(self, image, threshold=0.1, verbose=True): """ Find the center of a galaxy. If the algorithm is successful the (x, y) coordinates in this `~photutils.isophote.EllipseGeometry` (i.e., the ``x0`` and ``y0`` attributes) instance will be modified. The isophote fit algorithm requires an initial guess for the galaxy center (x, y) coordinates and these coordinates must be close to the actual galaxy center for the isophote fit to work. This method provides can provide an initial guess for the galaxy center coordinates. See the **Notes** section below for more details. Parameters ---------- image : 2D `~numpy.ndarray` The image array. Masked arrays are not recognized here. This assumes that centering should always be done on valid pixels. threshold : float, optional The centerer threshold. To turn off the centerer, set this to a large value (i.e., >> 1). The default is 0.1. verbose : bool, optional Whether to print object centering information. The default is `True`. Notes ----- The centerer function scans a 10x10 window centered on the (x, y) coordinates in the `~photutils.isophote.EllipseGeometry` instance passed to the constructor of the `~photutils.isophote.Ellipse` class. If any of the `~photutils.isophote.EllipseGeometry` (x, y) coordinates are `None`, the center of the input image frame is used. If the center acquisition is successful, the `~photutils.isophote.EllipseGeometry` instance is modified in place to reflect the solution of the object centerer algorithm. In some cases the object centerer algorithm may fail even though there is enough signal-to-noise to start a fit (e.g., objects with very high ellipticity). In those cases the sensitivity of the algorithm can be decreased by decreasing the value of the object centerer threshold parameter. The centerer works by looking where a quantity akin to a signal-to-noise ratio is maximized within the 10x10 window. The centerer can thus be shut off entirely by setting the threshold to a large value (i.e., >> 1; meaning no location inside the search window will achieve that signal-to-noise ratio). """ self._centerer_mask_half_size = len(IN_MASK) / 2 self.centerer_threshold = threshold # number of pixels in each mask sz = len(IN_MASK) self._centerer_ones_in = np.ma.masked_array(np.ones(shape=(sz, sz)), mask=IN_MASK) self._centerer_ones_out = np.ma.masked_array(np.ones(shape=(sz, sz)), mask=OUT_MASK) self._centerer_in_mask_npix = np.sum(self._centerer_ones_in) self._centerer_out_mask_npix = np.sum(self._centerer_ones_out) # Check if center coordinates point to somewhere inside the frame. # If not, set then to frame center. shape = image.shape _x0 = self.x0 _y0 = self.y0 if (_x0 is None or _x0 < 0 or _x0 >= shape[1] or _y0 is None or _y0 < 0 or _y0 >= shape[0]): _x0 = shape[1] / 2 _y0 = shape[0] / 2 max_fom = 0.0 max_i = 0 max_j = 0 # scan all positions inside window window_half_size = 5 for i in range(int(_x0 - window_half_size), int(_x0 + window_half_size) + 1): for j in range(int(_y0 - window_half_size), int(_y0 + window_half_size) + 1): # ensure that it stays inside image frame i1 = int(max(0, i - self._centerer_mask_half_size)) j1 = int(max(0, j - self._centerer_mask_half_size)) i2 = int(min(shape[1] - 1, i + self._centerer_mask_half_size)) j2 = int(min(shape[0] - 1, j + self._centerer_mask_half_size)) window = image[j1:j2, i1:i2] # averages in inner and outer regions. inner = np.ma.masked_array(window, mask=IN_MASK) outer = np.ma.masked_array(window, mask=OUT_MASK) inner_avg = np.sum(inner) / self._centerer_in_mask_npix outer_avg = np.sum(outer) / self._centerer_out_mask_npix # standard deviation and figure of merit inner_std = np.std(inner) outer_std = np.std(outer) stddev = np.sqrt(inner_std**2 + outer_std**2) fom = (inner_avg - outer_avg) / stddev if fom > max_fom: max_fom = fom max_i = i max_j = j # figure of merit > threshold: update geometry with new coordinates. if max_fom > threshold: self.x0 = float(max_i) self.y0 = float(max_j) if verbose: log.info(f'Found center at x0 = {self.x0:5.1f}, ' f'y0 = {self.y0:5.1f}') else: if verbose: log.info('Result is below the threshold -- keeping the ' 'original coordinates.') def radius(self, angle): """ Calculate the polar radius for a given polar angle. Parameters ---------- angle : float The polar angle (radians). Returns ------- radius : float The polar radius (pixels). """ return (self.sma * (1.0 - self.eps) / np.sqrt(((1.0 - self.eps) * np.cos(angle))**2 + (np.sin(angle))**2)) def initialize_sector_geometry(self, phi): """ Initialize geometry attributes associated with an elliptical sector at the given polar angle ``phi``. This function computes: * the four vertices that define the elliptical sector on the pixel array. * the sector area (saved in the ``sector_area`` attribute) * the sector angular width (saved in ``sector_angular_width`` attribute) Parameters ---------- phi : float The polar angle (radians) where the sector is located. Returns ------- x, y : 1D `~numpy.ndarray` The x and y coordinates of each vertex as 1D arrays. """ # These polar radii bound the region between the inner # and outer ellipses that define the sector. sma1, sma2 = self.bounding_ellipses() eps_ = 1.0 - self.eps # polar vector at one side of the elliptical sector self._phi1 = phi - self.sector_angular_width / 2.0 r1 = (sma1 * eps_ / math.sqrt((eps_ * math.cos(self._phi1))**2 + (math.sin(self._phi1))**2)) r2 = (sma2 * eps_ / math.sqrt((eps_ * math.cos(self._phi1))**2 + (math.sin(self._phi1))**2)) # polar vector at the other side of the elliptical sector self._phi2 = phi + self.sector_angular_width / 2.0 r3 = (sma2 * eps_ / math.sqrt((eps_ * math.cos(self._phi2))**2 + (math.sin(self._phi2))**2)) r4 = (sma1 * eps_ / math.sqrt((eps_ * math.cos(self._phi2))**2 + (math.sin(self._phi2))**2)) # sector area sa1 = _area(sma1, self.eps, self._phi1, r1) sa2 = _area(sma2, self.eps, self._phi1, r2) sa3 = _area(sma2, self.eps, self._phi2, r3) sa4 = _area(sma1, self.eps, self._phi2, r4) self.sector_area = abs((sa3 - sa2) - (sa4 - sa1)) # angular width of sector. It is calculated such that the sectors # come out with roughly constant area along the ellipse. self.sector_angular_width = max(min((self._area_factor / (r3 - r4) / r4), self._phi_max), self._phi_min) # compute the 4 vertices that define the elliptical sector. vertex_x = np.zeros(shape=4, dtype=float) vertex_y = np.zeros(shape=4, dtype=float) # vertices are labelled in counterclockwise sequence vertex_x[0:2] = np.array([r1, r2]) * math.cos(self._phi1 + self.pa) vertex_x[2:4] = np.array([r4, r3]) * math.cos(self._phi2 + self.pa) vertex_y[0:2] = np.array([r1, r2]) * math.sin(self._phi1 + self.pa) vertex_y[2:4] = np.array([r4, r3]) * math.sin(self._phi2 + self.pa) vertex_x += self.x0 vertex_y += self.y0 return vertex_x, vertex_y def bounding_ellipses(self): """ Compute the semimajor axis of the two ellipses that bound the annulus where integrations take place. Returns ------- sma1, sma2 : float The smaller and larger values of semimajor axis length that define the annulus bounding ellipses. """ if self.linear_growth: a1 = self.sma - self.astep / 2.0 a2 = self.sma + self.astep / 2.0 else: a1 = self.sma * (1.0 - self.astep / 2.0) a2 = self.sma * (1.0 + self.astep / 2.0) return a1, a2 def polar_angle_sector_limits(self): """ Return the two polar angles that bound the sector. The two bounding polar angles become available only after calling the :meth:`~photutils.isophote.EllipseGeometry.initialize_sector_geometry` method. Returns ------- phi1, phi2 : float The smaller and larger values of polar angle that bound the current sector. """ return self._phi1, self._phi2 def to_polar(self, x, y): r""" Return the radius and polar angle in the ellipse coordinate system given (x, y) pixel image coordinates. This function takes care of the different definitions for position angle (PA) and polar angle (phi): .. math:: -\pi < PA < \pi 0 < phi < 2 \pi Note that radius can be anything. The solution is not tied to the semimajor axis length, but to the center position and tilt angle. Parameters ---------- x, y : float The (x, y) image coordinates. Returns ------- radius, angle : float The ellipse radius and polar angle. """ # We split in between a scalar version and a # vectorized version. This is necessary for # now so we don't pay a heavy speed penalty # that is incurred when using vectorized code. # The split in two separate functions helps in # the profiling analysis: most of the time is # spent in the scalar function. if isinstance(x, (int, float)): return self._to_polar_scalar(x, y) else: return self._to_polar_vectorized(x, y) def _to_polar_scalar(self, x, y): x1 = x - self.x0 y1 = y - self.y0 radius = x1**2 + y1**2 if radius > 0.0: radius = math.sqrt(radius) angle = math.asin(abs(y1) / radius) else: radius = 0.0 angle = 1.0 if x1 >= 0.0 and y1 < 0.0: angle = 2 * np.pi - angle elif x1 < 0.0 and y1 >= 0.0: angle = np.pi - angle elif x1 < 0.0 and y1 < 0.0: angle = np.pi + angle pa1 = self.pa if self.pa < 0.0: pa1 = self.pa + 2 * np.pi angle = angle - pa1 if angle < 0.0: angle = angle + 2 * np.pi return radius, angle def _to_polar_vectorized(self, x, y): x1 = np.atleast_2d(x) - self.x0 y1 = np.atleast_2d(y) - self.y0 radius = x1**2 + y1**2 angle = np.ones(radius.shape) imask = (radius > 0.0) radius[imask] = np.sqrt(radius[imask]) angle[imask] = np.arcsin(np.abs(y1[imask]) / radius[imask]) radius[~imask] = 0.0 angle[~imask] = 1.0 idx = (x1 >= 0.0) & (y1 < 0.0) angle[idx] = 2 * np.pi - angle[idx] idx = (x1 < 0.0) & (y1 >= 0.0) angle[idx] = np.pi - angle[idx] idx = (x1 < 0.0) & (y1 < 0.0) angle[idx] = np.pi + angle[idx] pa1 = self.pa if self.pa < 0.0: pa1 = self.pa + 2 * np.pi angle = angle - pa1 angle[angle < 0] += 2 * np.pi return radius, angle def update_sma(self, step): """ Calculate an updated value for the semimajor axis, given the current value and the step value. The step value must be managed by the caller to support both modes: grow outwards and shrink inwards. Parameters ---------- step : float The step value. Returns ------- sma : float The new semimajor axis length. """ if self.linear_growth: sma = self.sma + step else: sma = self.sma * (1.0 + step) return sma def reset_sma(self, step): """ Change the direction of semimajor axis growth, from outwards to inwards. Parameters ---------- step : float The current step value. Returns ------- sma, new_step : float The new semimajor axis length and the new step value to initiate the shrinking of the semimajor axis length. This is the step value that should be used when calling the :meth:`~photutils.isophote.EllipseGeometry.update_sma` method. """ if self.linear_growth: sma = self.sma - step step = -step else: aux = 1.0 / (1.0 + step) sma = self.sma * aux step = aux - 1.0 return sma, step ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/harmonics.py0000644000175100001770000001024514637570305021472 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for computing and fitting harmonic functions. """ import numpy as np __all__ = ['first_and_second_harmonic_function', 'fit_first_and_second_harmonics', 'fit_upper_harmonic'] def _least_squares_fit(optimize_func, parameters): # call the least squares fitting # function and handle the result. from scipy.optimize import leastsq solution = leastsq(optimize_func, parameters, full_output=True) if solution[4] > 4: raise RuntimeError('Error in least squares fit: ' + solution[3]) # return coefficients and covariance matrix return (solution[0], solution[1]) def first_and_second_harmonic_function(phi, c): r""" Compute the harmonic function value used to calculate the corrections for ellipse fitting. This function includes simultaneously both the first and second order harmonics: .. math:: f(phi) = c[0] + c[1]*\sin(phi) + c[2]*\cos(phi) + c[3]*\sin(2*phi) + c[4]*\cos(2*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. c : `~numpy.ndarray` of shape (5,) Array containing the five harmonic coefficients. Returns ------- result : float or `~numpy.ndarray` The function value(s) at the given input angle(s). """ return (c[0] + c[1] * np.sin(phi) + c[2] * np.cos(phi) + c[3] * np.sin(2 * phi) + c[4] * np.cos(2 * phi)) def fit_first_and_second_harmonics(phi, intensities): r""" Fit the first and second harmonic function values to a set of (angle, intensity) pairs. This function is used to compute corrections for ellipse fitting: .. math:: f(phi) = y0 + a1*\sin(phi) + b1*\cos(phi) + a2*\sin(2*phi) + b2*\cos(2*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. intensities : `~numpy.ndarray` The intensities measured along the elliptical path, at the angles defined by the ``phi`` parameter. Returns ------- y0, a1, b1, a2, b2 : float The fitted harmonic coefficient values. """ a1 = b1 = a2 = b2 = 1.0 def optimize_func(x): return first_and_second_harmonic_function( phi, np.array([x[0], x[1], x[2], x[3], x[4]])) - intensities return _least_squares_fit(optimize_func, [np.mean(intensities), a1, b1, a2, b2]) def fit_upper_harmonic(phi, intensities, order): r""" Fit upper harmonic function to a set of (angle, intensity) pairs. With ``order`` set to 3 or 4, the resulting amplitudes, divided by the semimajor axis length and local gradient, measure the deviations from perfect ellipticity. The harmonic function that is fit is: .. math:: y(phi, order) = y0 + An*\sin(order*phi) + Bn*\cos(order*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. intensities : `~numpy.ndarray` The intensities measured along the elliptical path, at the angles defined by the ``phi`` parameter. order : int The order of the harmonic to be fitted. Returns ------- y0, An, Bn : float The fitted harmonic values. """ an = bn = 1.0 def optimize_func(x): return (x[0] + x[1] * np.sin(order * phi) + x[2] * np.cos(order * phi) - intensities) return _least_squares_fit(optimize_func, [np.mean(intensities), an, bn]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/integrator.py0000644000175100001770000002667714637570305021705 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for pixel integration. """ import math import numpy as np __all__ = ['INTEGRATORS', 'NEAREST_NEIGHBOR', 'BILINEAR', 'MEAN', 'MEDIAN'] # integration modes NEAREST_NEIGHBOR = 'nearest_neighbor' BILINEAR = 'bilinear' MEAN = 'mean' MEDIAN = 'median' class _Integrator: """ Base class that supports different kinds of pixel integration methods. Parameters ---------- image : 2D `~numpy.ndarray` The image array. geometry : `~photutils.isophote.EllipseGeometry` instance Object that encapsulates geometry information about current ellipse. angles : list Output list; contains the angle values along the elliptical path. radii : list Output list; contains the radius values along the elliptical path. intensities : list Output list; contains the extracted intensity values along the elliptical path. """ def __init__(self, image, geometry, angles, radii, intensities): self._image = image self._geometry = geometry self._angles = angles self._radii = radii self._intensities = intensities # for bounds checking self._i_range = range(0, self._image.shape[1] - 1) self._j_range = range(0, self._image.shape[0] - 1) def integrate(self, radius, phi): """ The three input lists (angles, radii, intensities) are appended with one sample point taken from the image by a chosen integration method. Sub classes should implement the actual integration method. Parameters ---------- radius : float The length of the radius vector in pixels. phi : float The polar angle of radius vector. """ raise NotImplementedError def _reset(self): """ Reset the lists containing results. This method is for internal use and shouldn't be used by external callers. """ self._angles = [] self._radii = [] self._intensities = [] def _store_results(self, phi, radius, sample): self._angles.append(phi) self._radii.append(radius) self._intensities.append(sample) def get_polar_angle_step(self): """ Return the polar angle step used to walk over the elliptical path. The polar angle step is defined by the actual integrator subclass. Returns ------- result : float The polar angle step. """ raise NotImplementedError def get_sector_area(self): """ Return the area of elliptical sectors where the integration takes place. This area is defined and managed by the actual integrator subclass. Depending on the integrator, the area may be a fixed constant, or may change along the elliptical path, so it's up to the caller to use this information in a correct way. Returns ------- result : float The sector area. """ raise NotImplementedError def is_area(self): """ Return the type of the integrator. An area integrator gets it's value from operating over a (generally variable) number of pixels that define a finite area that lays around the elliptical path, at a certain point on the image defined by a polar angle and radius values. A pixel integrator, by contrast, integrates over a fixed and normally small area related to a single pixel on the image. An example is the bilinear integrator, which integrates over a small, fixed, 5-pixel area. This method checks if the integrator is of the first type or not. Returns ------- result : boolean True if this is an area integrator, False otherwise. """ raise NotImplementedError class _NearestNeighborIntegrator(_Integrator): def integrate(self, radius, phi): self._r = radius # Get image coordinates of (radius, phi) pixel i = int(radius * math.cos(phi + self._geometry.pa) + self._geometry.x0) j = int(radius * math.sin(phi + self._geometry.pa) + self._geometry.y0) # ignore data point if outside image boundaries if (i in self._i_range) and (j in self._j_range): sample = self._image[j][i] if sample is not np.ma.masked: self._store_results(phi, radius, sample) def get_polar_angle_step(self): return 1.0 / self._r def get_sector_area(self): return 1.0 def is_area(self): return False class _BiLinearIntegrator(_Integrator): def integrate(self, radius, phi): self._r = radius # Get image coordinates of (radius, phi) pixel x_ = radius * math.cos(phi + self._geometry.pa) + self._geometry.x0 y_ = radius * math.sin(phi + self._geometry.pa) + self._geometry.y0 i = int(x_) j = int(y_) fx = x_ - i fy = y_ - j # ignore data point if outside image boundaries if (i in self._i_range) and (j in self._j_range): # in the future, will need to handle masked pixels here qx = 1.0 - fx qy = 1.0 - fy if (self._image[j][i] is not np.ma.masked and self._image[j + 1][i] is not np.ma.masked and self._image[j][i + 1] is not np.ma.masked and self._image[j + 1][i + 1] is not np.ma.masked): sample = (self._image[j][i] * qx * qy + self._image[j + 1][i] * qx * fy + self._image[j][i + 1] * fx * qy + self._image[j + 1][i + 1] * fy * fx) self._store_results(phi, radius, sample) def get_polar_angle_step(self): return 1.0 / self._r def get_sector_area(self): return 2.0 def is_area(self): return False class _AreaIntegrator(_Integrator): def __init__(self, image, geometry, angles, radii, intensities): super().__init__(image, geometry, angles, radii, intensities) # build auxiliary bilinear integrator to be used when # sector areas contain a too small number of valid pixels. self._bilinear_integrator = INTEGRATORS[BILINEAR](image, geometry, angles, radii, intensities) def integrate(self, radius, phi): self._phi = phi # Get image coordinates of the four vertices of the elliptical sector. vertex_x, vertex_y = self._geometry.initialize_sector_geometry(phi) self._sector_area = self._geometry.sector_area # step in polar angle to be used by caller next time # when updating the current polar angle `phi` to point # to the next sector. self._phistep = self._geometry.sector_angular_width # define rectangular image area that encompasses the elliptical # sector. We have to account for rounding of pixel indices. i1 = int(min(vertex_x)) - 1 j1 = int(min(vertex_y)) - 1 i2 = int(max(vertex_x)) + 1 j2 = int(max(vertex_y)) + 1 # polar angle limits for this sector phi1, phi2 = self._geometry.polar_angle_sector_limits() # ignore data point if the elliptical sector lies # partially, ou totally, outside image boundaries if (i1 in self._i_range) and (j1 in self._j_range) and \ (i2 in self._i_range) and (j2 in self._j_range): # Scan rectangular image area, compute sample value. npix = 0 accumulator = self.initialize_accumulator() for j in range(j1, j2): for i in range(i1, i2): # Check if polar coordinates of each pixel # put it inside elliptical sector. rp, phip = self._geometry.to_polar(i, j) # check if inside angular limits if phip < phi2 and phip >= phi1: # check if radius is inside bounding ellipses sma1, sma2 = self._geometry.bounding_ellipses() aux = ((1.0 - self._geometry.eps) / math.sqrt(((1.0 - self._geometry.eps) * math.cos(phip))**2 + (math.sin(phip))**2)) r1 = sma1 * aux r2 = sma2 * aux if rp < r2 and rp >= r1: # update accumulator with pixel value pix_value = self._image[j][i] if pix_value is not np.ma.masked: accumulator, npix = self.accumulate( pix_value, accumulator) # If 6 or less pixels were sampled, get the bilinear # interpolated value instead. if npix in range(0, 7): # must reset integrator to remove older samples. self._bilinear_integrator._reset() self._bilinear_integrator.integrate(radius, phi) # because it was reset, current value is the only one stored # internally in the bilinear integrator instance. Move it # from the internal integrator to this instance. if len(self._bilinear_integrator._intensities) > 0: sample_value = self._bilinear_integrator._intensities[0] self._store_results(phi, radius, sample_value) elif npix > 6: sample_value = self.compute_sample_value(accumulator) self._store_results(phi, radius, sample_value) def get_polar_angle_step(self): _, phi2 = self._geometry.polar_angle_sector_limits() phistep = self._geometry.sector_angular_width / 2.0 + phi2 - self._phi return phistep def get_sector_area(self): return self._sector_area def is_area(self): return True def initialize_accumulator(self): raise NotImplementedError def accumulate(self, pixel_value, accumulator): raise NotImplementedError def compute_sample_value(self, accumulator): raise NotImplementedError class _MeanIntegrator(_AreaIntegrator): def initialize_accumulator(self): accumulator = 0.0 self._npix = 0 return accumulator def accumulate(self, pixel_value, accumulator): accumulator += pixel_value self._npix += 1 return accumulator, self._npix def compute_sample_value(self, accumulator): return accumulator / self._npix class _MedianIntegrator(_AreaIntegrator): def initialize_accumulator(self): accumulator = [] self._npix = 0 return accumulator def accumulate(self, pixel_value, accumulator): accumulator.append(pixel_value) self._npix += 1 return accumulator, self._npix def compute_sample_value(self, accumulator): accumulator.sort() return accumulator[int(self._npix / 2)] # Specific integrator subclasses can be instantiated from here. INTEGRATORS = { NEAREST_NEIGHBOR: _NearestNeighborIntegrator, BILINEAR: _BiLinearIntegrator, MEAN: _MeanIntegrator, MEDIAN: _MedianIntegrator } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/isophote.py0000644000175100001770000007200114637570305021337 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to store the results of isophote fits. """ import astropy.units as u import numpy as np from astropy.table import QTable from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics, fit_upper_harmonic) from photutils.utils._misc import _get_meta __all__ = ['Isophote', 'IsophoteList'] class Isophote: """ Container class to store the results of single isophote fit. The extracted data sample at the given isophote (sampled intensities along the elliptical path on the image) is also kept as an attribute of this class. The container concept helps in segregating information directly related to the sample, from information that more closely relates to the fitting process, such as status codes, errors for isophote parameters, and the like. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample information. niter : int The number of iterations used to fit the isophote. valid : bool The status of the fitting operation. stop_code : int The fitting stop code: * 0: Normal. * 1: Fewer than the pre-specified fraction of the extracted data points are valid. * 2: Exceeded maximum number of iterations. * 3: Singular matrix in harmonic fit, results may not be valid. This also signals an insufficient number of data points to fit. * 4: Small or wrong gradient, or ellipse diverged. Subsequent ellipses at larger or smaller semimajor axis may have the same constant geometric parameters. It's also used when the user turns off the fitting algorithm via the ``maxrit`` fitting parameter (see the `~photutils.isophote.Ellipse` class). * 5: Ellipse diverged; not even the minimum number of iterations could be executed. Subsequent ellipses at larger or smaller semimajor axis may have the same constant geometric parameters. * -1: Internal use. Attributes ---------- rms : float The root-mean-square of intensity values along the elliptical path. int_err : float The error of the mean (rms / sqrt(# data points)). ellip_err : float The ellipticity error. pa_err : float The position angle error (radians). x0_err : float The error associated with the center x coordinate. y0_err : float The error associated with the center y coordinate. pix_stddev : float The estimate of pixel standard deviation (rms * sqrt(average sector integration area)). grad : float The local radial intensity gradient. grad_error : float The measurement error of the local radial intensity gradient. grad_r_error : float The relative error of local radial intensity gradient. tflux_e : float The sum of all pixels inside the ellipse. npix_e : int The total number of valid pixels inside the ellipse. tflux_c : float The sum of all pixels inside a circle with the same ``sma`` as the ellipse. npix_c : int The total number of valid pixels inside a circle with the same ``sma`` as the ellipse. sarea : float The average sector area on the isophote (pixel**2). ndata : int The number of extracted data points. nflag : int The number of discarded data points. Data points can be discarded either because they are physically outside the image frame boundaries, because they were rejected by sigma-clipping, or they are masked. a3, b3, a4, b4 : float The higher order harmonics that measure the deviations from a perfect ellipse. These values are actually the raw harmonic amplitudes divided by the local radial gradient and the semimajor axis length, so they can directly be compared with each other. The ``b4`` parameter is positive for galaxies with disky (kite-like) isophotes and negative for galaxies with boxy isophotes. a3_err, b3_err, a4_err, b4_err : float The errors associated with the ``a3``, ``b3``, ``a4``, and ``b4`` attributes. """ def __init__(self, sample, niter, valid, stop_code): self.sample = sample self.niter = niter self.valid = valid self.stop_code = stop_code if sample.geometry.sma > 0: self.intens = sample.mean self.rms = np.std(sample.values[2]) self.int_err = self.rms / np.sqrt(sample.actual_points) self.pix_stddev = self.rms * np.sqrt(sample.sector_area) self.grad = sample.gradient self.grad_error = sample.gradient_error self.grad_r_error = sample.gradient_relative_error self.sarea = sample.sector_area self.ndata = sample.actual_points self.nflag = sample.total_points - sample.actual_points # flux contained inside ellipse and circle (self.tflux_e, self.tflux_c, self.npix_e, self.npix_c) = self._compute_fluxes() self._compute_errors() # deviations from a perfect ellipse (self.a3, self.b3, self.a3_err, self.b3_err) = self._compute_deviations(sample, 3) (self.a4, self.b4, self.a4_err, self.b4_err) = self._compute_deviations(sample, 4) # This method is useful for sorting lists of instances. Note # that __lt__ is the python3 way of supporting sorting. def __lt__(self, other): try: return self.sma < other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __gt__(self, other): try: return self.sma > other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __le__(self, other): try: return self.sma <= other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __ge__(self, other): try: return self.sma >= other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __eq__(self, other): try: return self.sma == other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __ne__(self, other): try: return self.sma != other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __str__(self): return str(self.to_table()) @property def sma(self): """The semimajor axis length (pixels).""" return self.sample.geometry.sma @property def eps(self): """The ellipticity of the ellipse.""" return self.sample.geometry.eps @property def pa(self): """The position angle (radians) of the ellipse.""" return self.sample.geometry.pa @property def x0(self): """The center x coordinate (pixel).""" return self.sample.geometry.x0 @property def y0(self): """The center y coordinate (pixel).""" return self.sample.geometry.y0 def _compute_fluxes(self): """ Compute integrated flux inside ellipse, as well as inside a circle defined with the same semimajor axis. Pixels in a square section enclosing circle are scanned; the distance of each pixel to the isophote center is compared both with the semimajor axis length and with the length of the ellipse radius vector, and integrals are updated if the pixel distance is smaller. """ # Compute limits of square array that encloses circle. sma = self.sample.geometry.sma x0 = self.sample.geometry.x0 y0 = self.sample.geometry.y0 xsize = self.sample.image.shape[1] ysize = self.sample.image.shape[0] imin = max(0, int(x0 - sma - 0.5) - 1) jmin = max(0, int(y0 - sma - 0.5) - 1) imax = min(xsize, int(x0 + sma + 0.5) + 1) jmax = min(ysize, int(y0 + sma + 0.5) + 1) # Integrate if (jmax - jmin > 1) and (imax - imin) > 1: y, x = np.mgrid[jmin:jmax, imin:imax] radius, angle = self.sample.geometry.to_polar(x, y) radius_e = self.sample.geometry.radius(angle) midx = (radius <= sma) values = self.sample.image[y[midx], x[midx]] tflux_c = np.ma.sum(values) npix_c = np.ma.count(values) midx2 = (radius <= radius_e) values = self.sample.image[y[midx2], x[midx2]] tflux_e = np.ma.sum(values) npix_e = np.ma.count(values) else: tflux_e = 0.0 tflux_c = 0.0 npix_e = 0 npix_c = 0 return tflux_e, tflux_c, npix_e, npix_c def _compute_deviations(self, sample, n): """ Compute deviations from a perfect ellipse, based on the amplitudes and errors for harmonic "n". Note that we first subtract the first and second harmonics from the raw data. """ try: # upper (third and fourth) harmonics up_coeffs, up_inv_hessian = fit_upper_harmonic(sample.values[0], sample.values[2], n) a = up_coeffs[1] / self.sma / abs(sample.gradient) b = up_coeffs[2] / self.sma / abs(sample.gradient) def errfunc(x, phi, order, intensities): return (x[0] + x[1] * np.sin(order * phi) + x[2] * np.cos(order * phi) - intensities) up_var_residual = np.std(errfunc(up_coeffs, self.sample.values[0], n, self.sample.values[2]), ddof=len(up_coeffs))**2 up_covariance = up_inv_hessian * up_var_residual ce = np.sqrt(np.diag(up_covariance)) # this comes from the old code. Likely it was based on # empirical experience with the STSDAS task, so we leave # it here without too much thought. gre = self.grad_r_error if self.grad_r_error is not None else 0.8 a_err = abs(a) * np.sqrt((ce[1] / up_coeffs[1])**2 + gre**2) b_err = abs(b) * np.sqrt((ce[2] / up_coeffs[2])**2 + gre**2) except Exception: # we want to catch everything a = b = a_err = b_err = None return a, b, a_err, b_err def _compute_errors(self): """ Compute parameter errors based on the diagonal of the covariance matrix of the four harmonic coefficients for harmonics n=1 and n=2.0. """ try: coeffs, covariance = fit_first_and_second_harmonics( self.sample.values[0], self.sample.values[2]) model = first_and_second_harmonic_function(self.sample.values[0], coeffs) var_residual = np.std(self.sample.values[2] - model, ddof=len(coeffs)) ** 2 errors = np.sqrt(np.diagonal(covariance * var_residual)) eps = self.sample.geometry.eps pa = self.sample.geometry.pa # parameter errors result from direct projection of # coefficient errors. These showed to be the error estimators # that best convey the errors measured in Monte Carlo # experiments (see Busko 1996; ASPC 101, 139). ea = abs(errors[2] / self.grad) eb = abs(errors[1] * (1.0 - eps) / self.grad) self.x0_err = np.sqrt((ea * np.cos(pa))**2 + (eb * np.sin(pa))**2) self.y0_err = np.sqrt((ea * np.sin(pa))**2 + (eb * np.cos(pa))**2) self.ellip_err = (abs(2.0 * errors[4] * (1.0 - eps) / self.sma / self.grad)) if abs(eps) > np.finfo(float).resolution: self.pa_err = (abs(2.0 * errors[3] * (1.0 - eps) / self.sma / self.grad / (1.0 - (1.0 - eps)**2))) else: self.pa_err = 0.0 except Exception: # we want to catch everything self.x0_err = self.y0_err = self.pa_err = self.ellip_err = 0.0 def fix_geometry(self, isophote): """ Fix the geometry of a problematic isophote to be identical to the input isophote. This method should be called when the fitting goes berserk and delivers an isophote with bad geometry, such as ellipticity > 1 or another meaningless situation. This is not a problem in itself when fitting any given isophote, but will create an error when the affected isophote is used as starting guess for the next fit. Parameters ---------- isophote : `~photutils.isophote.Isophote` instance The isophote from which to take the geometry information. """ self.sample.geometry.eps = isophote.sample.geometry.eps self.sample.geometry.pa = isophote.sample.geometry.pa self.sample.geometry.x0 = isophote.sample.geometry.x0 self.sample.geometry.y0 = isophote.sample.geometry.y0 def sampled_coordinates(self): """ Return the (x, y) coordinates where the image was sampled in order to get the intensities associated with this isophote. Returns ------- x, y : 1D `~numpy.ndarray` The x and y coordinates as 1D arrays. """ return self.sample.coordinates() def to_table(self): """ Return the main isophote parameters as an astropy `~astropy.table.QTable`. Returns ------- result : `~astropy.table.QTable` An astropy `~astropy.table.QTable` containing the main isophote parameters. """ return _isophote_list_to_table([self]) class CentralPixel(Isophote): """ Specialized Isophote class for the galaxy central pixel. This class holds only a single intensity value at the central position. Thus, most of its attributes are hardcoded to `None` or a default value when appropriate. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample information. """ def __init__(self, sample): super().__init__(sample, 0, True, 0) self.intens = sample.mean # some values are set to zero to ease certain tasks # such as model building and plotting magnitude errors self.rms = None self.int_err = 0.0 self.pix_stddev = None self.grad = 0.0 self.grad_error = None self.grad_r_error = None self.sarea = None self.ndata = sample.actual_points self.nflag = sample.total_points - sample.actual_points self.tflux_e = self.tflux_c = self.npix_e = self.npix_c = None self.a3 = self.b3 = 0.0 self.a4 = self.b4 = 0.0 self.a3_err = self.b3_err = 0.0 self.a4_err = self.b4_err = 0.0 self.ellip_err = 0.0 self.pa_err = 0.0 self.x0_err = 0.0 self.y0_err = 0.0 def __eq__(self, other): try: return self.sma == other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err @property def eps(self): return 0.0 @property def pa(self): return 0.0 @property def x0(self): return self.sample.geometry.x0 class IsophoteList: """ Container class that provides the same attributes as the `~photutils.isophote.Isophote` class, but for a list of isophotes. The attributes of this class are arrays representing the values of the attributes for the entire list of `~photutils.isophote.Isophote` instances. See the `~photutils.isophote.Isophote` class for a description of the attributes. The class extends the `list` functionality, thus provides basic list behavior such as slicing, appending, and support for '+' and '+=' operators. Parameters ---------- iso_list : list of `~photutils.isophote.Isophote` A list of `~photutils.isophote.Isophote` instances. """ def __init__(self, iso_list): self._list = iso_list def __len__(self): return len(self._list) def __delitem__(self, index): self._list.__delitem__(index) def __setitem__(self, index, value): self._list.__setitem__(index, value) def __getitem__(self, index): if isinstance(index, slice): return IsophoteList(self._list[index]) return self._list.__getitem__(index) def __iter__(self): return self._list.__iter__() def sort(self): self._list.sort() def insert(self, index, value): self._list.insert(index, value) def append(self, value): self.insert(len(self) + 1, value) def extend(self, value): self._list.extend(value._list) def __iadd__(self, value): self.extend(value) return self def __add__(self, value): temp = self._list[:] # shallow copy temp.extend(value._list) return IsophoteList(temp) def get_closest(self, sma): """ Return the `~photutils.isophote.Isophote` instance that has the closest semimajor axis length to the input semimajor axis. Parameters ---------- sma : float The semimajor axis length. Returns ------- isophote : `~photutils.isophote.Isophote` instance The isophote with the closest semimajor axis value. """ index = (np.abs(self.sma - sma)).argmin() return self._list[index] def _collect_as_array(self, attr_name): return np.array(self._collect_as_list(attr_name), dtype=float) def _collect_as_list(self, attr_name): return [getattr(iso, attr_name) for iso in self._list] @property def sample(self): """ The isophote `~photutils.isophote.EllipseSample` information. """ return self._collect_as_list('sample') @property def sma(self): """The semimajor axis length (pixels).""" return self._collect_as_array('sma') @property def intens(self): """The mean intensity value along the elliptical path.""" return self._collect_as_array('intens') @property def int_err(self): """The error of the mean intensity (rms / sqrt(# data points)).""" return self._collect_as_array('int_err') @property def eps(self): """The ellipticity of the ellipse.""" return self._collect_as_array('eps') @property def ellip_err(self): """The ellipticity error.""" return self._collect_as_array('ellip_err') @property def pa(self): """The position angle (radians) of the ellipse.""" return self._collect_as_array('pa') @property def pa_err(self): """The position angle error (radians).""" return self._collect_as_array('pa_err') @property def x0(self): """The center x coordinate (pixel).""" return self._collect_as_array('x0') @property def x0_err(self): """The error associated with the center x coordinate.""" return self._collect_as_array('x0_err') @property def y0(self): """The center y coordinate (pixel).""" return self._collect_as_array('y0') @property def y0_err(self): """The error associated with the center y coordinate.""" return self._collect_as_array('y0_err') @property def rms(self): """ The root-mean-square of intensity values along the elliptical path. """ return self._collect_as_array('rms') @property def pix_stddev(self): """ The estimate of pixel standard deviation (rms * sqrt(average sector integration area)). """ return self._collect_as_array('pix_stddev') @property def grad(self): """The local radial intensity gradient.""" return self._collect_as_array('grad') @property def grad_error(self): """ The measurement error of the local radial intensity gradient. """ return self._collect_as_array('grad_error') @property def grad_r_error(self): """ The relative error of local radial intensity gradient. """ return self._collect_as_array('grad_r_error') @property def sarea(self): """The average sector area on the isophote (pixel**2).""" return self._collect_as_array('sarea') @property def ndata(self): """The number of extracted data points.""" return self._collect_as_array('ndata') @property def nflag(self): """ The number of discarded data points. Data points can be discarded either because they are physically outside the image frame boundaries, because they were rejected by sigma-clipping, or they are masked. """ return self._collect_as_array('nflag') @property def niter(self): """The number of iterations used to fit the isophote.""" return self._collect_as_array('niter') @property def valid(self): """The status of the fitting operation.""" return self._collect_as_array('valid') @property def stop_code(self): """The fitting stop code.""" return self._collect_as_array('stop_code') @property def tflux_e(self): """The sum of all pixels inside the ellipse.""" return self._collect_as_array('tflux_e') @property def tflux_c(self): """ The sum of all pixels inside a circle with the same ``sma`` as the ellipse. """ return self._collect_as_array('tflux_c') @property def npix_e(self): """The total number of valid pixels inside the ellipse.""" return self._collect_as_array('npix_e') @property def npix_c(self): """ The total number of valid pixels inside a circle with the same ``sma`` as the ellipse. """ return self._collect_as_array('npix_c') @property def a3(self): """ A third-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('a3') @property def b3(self): """ A third-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('b3') @property def a4(self): """ A fourth-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('a4') @property def b4(self): """ A fourth-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('b4') @property def a3_err(self): """ The error associated with `~photutils.isophote.IsophoteList.a3`. """ return self._collect_as_array('a3_err') @property def b3_err(self): """ The error associated with `~photutils.isophote.IsophoteList.b3`. """ return self._collect_as_array('b3_err') @property def a4_err(self): """ The error associated with `~photutils.isophote.IsophoteList.a4`. """ return self._collect_as_array('a4_err') @property def b4_err(self): """ The error associated with `~photutils.isophote.IsophoteList.b3`. """ return self._collect_as_array('b4_err') def to_table(self, columns='main'): """ Convert an `~photutils.isophote.IsophoteList` instance to a `~astropy.table.QTable` with the main isophote parameters. Parameters ---------- columns : list of str A list of properties to export from the isophote list. If ``columns`` is 'all' or 'main', it will pick all or few of the main properties. Returns ------- result : `~astropy.table.QTable` An astropy QTable with the main isophote parameters. """ return _isophote_list_to_table(self, columns) def get_names(self): """ Print the names of the properties of an `~photutils.isophote.IsophoteList` instance. """ list_names = list(_get_properties(self).keys()) return list_names def _get_properties(isophote_list): """ Return the properties of an `~photutils.isophote.IsophoteList` instance. Parameters ---------- isophote_list : `~photutils.isophote.IsophoteList` instance A list of isophotes. Returns ------- result : `dict` An dictionary with the list of the isophote_list properties. """ properties = {} for an_item in isophote_list.__class__.__dict__: p_type = isophote_list.__class__.__dict__[an_item] # Exclude the sample property if isinstance(p_type, property) and 'sample' not in an_item: properties[str(an_item)] = str(an_item) return properties def _isophote_list_to_table(isophote_list, columns='main'): """ Convert an `~photutils.isophote.IsophoteList` instance to a `~astropy.table.QTable`. Parameters ---------- isophote_list : list of `~photutils.isophote.Isophote` or \ `~photutils.isophote.IsophoteList` instance A list of isophotes. columns : list of str A list of properties to export from the ``isophote_list``. If ``columns`` is 'all' or 'main', it will pick all or few of the main properties. Returns ------- result : `~astropy.table.QTable` An astropy QTable with the selected or all isophote parameters. """ properties = {} isotable = QTable() isotable.meta.update(_get_meta()) # keep isotable.meta type # main_properties: `List` # A list of main parameters matching the original names of # the isophote_list parameters def __rename_properties(properties, orig_names=('int_err', 'eps', 'ellip_err', 'grad_r_error', 'nflag'), new_names=('intens_err', 'ellipticity', 'ellipticity_err', 'grad_rerror', 'nflag')): """ Simple renaming for some of the isophote_list parameters. Parameters ---------- properties : `dict` A dictionary with the list of the isophote_list parameters. orig_names : list A list of original names in the isophote_list parameters to be renamed. new_names : list A list of new names matching in length of the orig_names. Returns ------- properties: `dict` A dictionary with the list of the renamed isophote_list parameters. """ main_properties = ['sma', 'intens', 'int_err', 'eps', 'ellip_err', 'pa', 'pa_err', 'grad', 'grad_error', 'grad_r_error', 'x0', 'x0_err', 'y0', 'y0_err', 'ndata', 'nflag', 'niter', 'stop_code'] for an_item in main_properties: if an_item in orig_names: properties[an_item] = new_names[orig_names.index(an_item)] else: properties[an_item] = an_item return properties if columns == 'all': properties = _get_properties(isophote_list) properties = __rename_properties(properties) elif columns == 'main': properties = __rename_properties(properties) else: for an_item in columns: properties[an_item] = an_item for k, v in properties.items(): isotable[v] = np.array([getattr(iso, k) for iso in isophote_list]) if k in ('pa', 'pa_err'): isotable[v] = isotable[v] * 180.0 / np.pi * u.deg return isotable ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/model.py0000644000175100001770000001413714637570305020613 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module profiles tools for building a model elliptical galaxy image from a list of isophotes. """ import numpy as np from photutils.isophote.geometry import EllipseGeometry __all__ = ['build_ellipse_model'] def build_ellipse_model(shape, isolist, fill=0.0, high_harmonics=False): """ Build a model elliptical galaxy image from a list of isophotes. For each ellipse in the input isophote list the algorithm fills the output image array with the corresponding isophotal intensity. Pixels in the output array are in general only partially covered by the isophote "pixel". The algorithm takes care of this partial pixel coverage by keeping track of how much intensity was added to each pixel by storing the partial area information in an auxiliary array. The information in this array is then used to normalize the pixel intensities. Parameters ---------- shape : 2-tuple The (ny, nx) shape of the array used to generate the input ``isolist``. isolist : `~photutils.isophote.IsophoteList` instance The isophote list created by the `~photutils.isophote.Ellipse` class. fill : float, optional The constant value to fill empty pixels. If an output pixel has no contribution from any isophote, it will be assigned this value. The default is 0. high_harmonics : bool, optional Whether to add the higher-order harmonics (i.e., ``a3``, ``b3``, ``a4``, and ``b4``; see `~photutils.isophote.Isophote` for details) to the result. Returns ------- result : 2D `~numpy.ndarray` The image with the model galaxy. """ from scipy.interpolate import LSQUnivariateSpline # the target grid is spaced in 0.1 pixel intervals so as # to ensure no gaps will result on the output array. finely_spaced_sma = np.arange(isolist[0].sma, isolist[-1].sma, 0.1) # interpolate ellipse parameters # End points must be discarded, but how many? # This seems to work so far nodes = isolist.sma[2:-2] intens_array = LSQUnivariateSpline( isolist.sma, isolist.intens, nodes)(finely_spaced_sma) eps_array = LSQUnivariateSpline( isolist.sma, isolist.eps, nodes)(finely_spaced_sma) pa_array = LSQUnivariateSpline( isolist.sma, isolist.pa, nodes)(finely_spaced_sma) x0_array = LSQUnivariateSpline( isolist.sma, isolist.x0, nodes)(finely_spaced_sma) y0_array = LSQUnivariateSpline( isolist.sma, isolist.y0, nodes)(finely_spaced_sma) grad_array = LSQUnivariateSpline( isolist.sma, isolist.grad, nodes)(finely_spaced_sma) a3_array = LSQUnivariateSpline( isolist.sma, isolist.a3, nodes)(finely_spaced_sma) b3_array = LSQUnivariateSpline( isolist.sma, isolist.b3, nodes)(finely_spaced_sma) a4_array = LSQUnivariateSpline( isolist.sma, isolist.a4, nodes)(finely_spaced_sma) b4_array = LSQUnivariateSpline( isolist.sma, isolist.b4, nodes)(finely_spaced_sma) # Return deviations from ellipticity to their original amplitude meaning a3_array = -a3_array * grad_array * finely_spaced_sma b3_array = -b3_array * grad_array * finely_spaced_sma a4_array = -a4_array * grad_array * finely_spaced_sma b4_array = -b4_array * grad_array * finely_spaced_sma # correct deviations cased by fluctuations in spline solution eps_array[np.where(eps_array < 0.0)] = 0.0 result = np.zeros(shape=shape) weight = np.zeros(shape=shape) eps_array[np.where(eps_array < 0.0)] = 0.05 # for each interpolated isophote, generate intensity values on the # output image array # for index in range(len(finely_spaced_sma)): for index in range(1, len(finely_spaced_sma)): sma0 = finely_spaced_sma[index] eps = eps_array[index] pa = pa_array[index] x0 = x0_array[index] y0 = y0_array[index] geometry = EllipseGeometry(x0, y0, sma0, eps, pa) intens = intens_array[index] # scan angles. Need to go a bit beyond full circle to ensure # full coverage. r = sma0 phi = 0.0 while phi <= 2 * np.pi + geometry._phi_min: # we might want to add the third and fourth harmonics # to the basic isophotal intensity. harm = 0.0 if high_harmonics: harm = (a3_array[index] * np.sin(3.0 * phi) + b3_array[index] * np.cos(3.0 * phi) + a4_array[index] * np.sin(4.0 * phi) + b4_array[index] * np.cos(4.0 * phi)) / 4.0 # get image coordinates of (r, phi) pixel x = r * np.cos(phi + pa) + x0 y = r * np.sin(phi + pa) + y0 i = int(x) j = int(y) if i > 0 and i < shape[1] - 1 and j > 0 and j < shape[0] - 1: # get fractional deviations relative to target array fx = x - float(i) fy = y - float(j) # add up the isophote contribution to the overlapping pixels result[j, i] += (intens + harm) * (1.0 - fy) * (1.0 - fx) result[j, i + 1] += (intens + harm) * (1.0 - fy) * fx result[j + 1, i] += (intens + harm) * fy * (1.0 - fx) result[j + 1, i + 1] += (intens + harm) * fy * fx # add up the fractional area contribution to the # overlapping pixels weight[j, i] += (1.0 - fy) * (1.0 - fx) weight[j, i + 1] += (1.0 - fy) * fx weight[j + 1, i] += fy * (1.0 - fx) weight[j + 1, i + 1] += fy * fx # step towards next pixel on ellipse phi = max((phi + 0.75 / r), geometry._phi_min) r = max(geometry.radius(phi), 0.5) # if outside image boundaries, ignore. else: break # zero weight values must be set to 1.0 weight[np.where(weight <= 0.0)] = 1.0 # normalize result /= weight # fill value result[np.where(result == 0.0)] = fill return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/sample.py0000644000175100001770000003733614637570305021002 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to sample data along an elliptical path. """ import copy import numpy as np from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.integrator import INTEGRATORS __all__ = ['EllipseSample'] class EllipseSample: """ Class to sample image data along an elliptical path. The image intensities along the elliptical path can be extracted using a selection of integration algorithms. The ``geometry`` attribute describes the geometry of the elliptical path. Parameters ---------- image : 2D `~numpy.ndarray` The input image. sma : float The semimajor axis length in pixels. x0, y0 : float, optional The (x, y) coordinate of the ellipse center. astep : float, optional The step value for growing/shrinking the semimajor axis. It can be expressed either in pixels (when ``linear_growth=True``) or as a relative value (when ``linear_growth=False``). The default is 0.1. eps : float, optional The ellipticity of the ellipse. The default is 0.2. position_angle : float, optional The position angle of ellipse in relation to the positive x axis of the image array (rotating towards the positive y axis). The default is 0. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. Set to zero to skip sigma-clipping. The default is 0. linear_growth : bool, optional The semimajor axis growing/shrinking mode. The default is `False`. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, optional The area integration mode. The default is 'bilinear'. geometry : `~photutils.isophote.EllipseGeometry` instance or `None` The geometry that describes the ellipse. This can be used in lieu of the explicit specification of parameters ``sma``, ``x0``, ``y0``, ``eps``, etc. In any case, the `~photutils.isophote.EllipseGeometry` instance becomes an attribute of the `~photutils.isophote.EllipseSample` object. The default is `None`. Attributes ---------- values : 2D `~numpy.ndarray` The sampled values as a 2D array, where the rows contain the angles, radii, and extracted intensity values, respectively. mean : float The mean intensity along the elliptical path. geometry : `~photutils.isophote.EllipseGeometry` instance The geometry of the elliptical path. gradient : float The local radial intensity gradient. gradient_error : float The error associated with the local radial intensity gradient. gradient_relative_error : float The relative error associated with the local radial intensity gradient. sector_area : float The average area of the sectors along the elliptical path from which the sample values were integrated. total_points : int The total number of sample values that would cover the entire elliptical path. actual_points : int The actual number of sample values that were taken from the image. It can be smaller than ``total_points`` when the ellipse encompasses regions outside the image, or when sigma-clipping removed some of the points. """ def __init__(self, image, sma, x0=None, y0=None, astep=0.1, eps=0.2, position_angle=0.0, sclip=3.0, nclip=0, linear_growth=False, integrmode='bilinear', geometry=None): self.image = image self.integrmode = integrmode if geometry: # when the geometry is inherited from somewhere else, # its sma attribute must be replaced by the value # explicitly passed to the constructor. self.geometry = copy.deepcopy(geometry) self.geometry.sma = sma else: # if no center was specified, assume it's roughly # coincident with the image center _x0 = x0 _y0 = y0 if not _x0 or not _y0: _x0 = image.shape[1] / 2 _y0 = image.shape[0] / 2 self.geometry = EllipseGeometry(_x0, _y0, sma, eps, position_angle, astep, linear_growth) # sigma-clip parameters self.sclip = sclip self.nclip = nclip # extracted values associated with this sample. self.values = None self.mean = None self.gradient = None self.gradient_error = None self.gradient_relative_error = None self.sector_area = None # total_points reports the total number of pairs angle-radius that # were attempted. actual_points reports the actual number of sampled # pairs angle-radius that resulted in valid values. self.total_points = 0 self.actual_points = 0 def extract(self): """ Extract sample data by scanning an elliptical path over the image array. Returns ------- result : 2D `~numpy.ndarray` The rows of the array contain the angles, radii, and extracted intensity values, respectively. """ # the sample values themselves are kept cached to prevent # multiple calls to the integrator code. if self.values is not None: return self.values else: s = self._extract() self.values = s return s def _extract(self, phi_min=0.05): # Here the actual sampling takes place. This is called only once # during the life of an EllipseSample instance, because it's an # expensive calculation. This method should not be called from # external code. # If one wants to force it to re-run, then do: # # sample.values = None # # before calling sample.extract() # individual extracted sample points will be stored in here angles = [] radii = [] intensities = [] sector_areas = [] # reset counters self.total_points = 0 self.actual_points = 0 # build integrator integrator = INTEGRATORS[self.integrmode](self.image, self.geometry, angles, radii, intensities) # initialize walk along elliptical path radius = self.geometry.initial_polar_radius phi = self.geometry.initial_polar_angle # In case of an area integrator, ask the integrator to deliver a # hint of how much area the sectors will have. In case of too # small areas, tests showed that the area integrators (mean, # median) won't perform properly. In that case, we override the # caller's selection and use the bilinear integrator regardless. if integrator.is_area(): integrator.integrate(radius, phi) area = integrator.get_sector_area() # this integration that just took place messes up with the # storage arrays and the constructors. We have to build a new # integrator instance from scratch, even if it is the same # kind as originally selected by the caller. angles = [] radii = [] intensities = [] if area < 1.0: integrator = INTEGRATORS['bilinear']( self.image, self.geometry, angles, radii, intensities) else: integrator = INTEGRATORS[self.integrmode](self.image, self.geometry, angles, radii, intensities) # walk along elliptical path, integrating at specified # places defined by polar vector. Need to go a bit beyond # full circle to ensure full coverage. while phi <= np.pi * 2.0 + phi_min: # do the integration at phi-radius position, and append # results to the angles, radii, and intensities lists. integrator.integrate(radius, phi) # store sector area locally sector_areas.append(integrator.get_sector_area()) # update total number of points self.total_points += 1 # update angle and radius to be used to define # next polar vector along the elliptical path phistep_ = integrator.get_polar_angle_step() phi += min(phistep_, 0.5) radius = self.geometry.radius(phi) # average sector area is calculated after the integrator had # the opportunity to step over the entire elliptical path. self.sector_area = np.mean(np.array(sector_areas)) # apply sigma-clipping. angles, radii, intensities = self._sigma_clip(angles, radii, intensities) # actual number of sampled points, after sigma-clip removed outliers. self.actual_points = len(angles) # pack results in 2-d array result = np.array([np.array(angles), np.array(radii), np.array(intensities)]) return result def _sigma_clip(self, angles, radii, intensities): if self.nclip > 0: for _ in range(self.nclip): # do not use list.copy()! must be python2-compliant. angles, radii, intensities = self._iter_sigma_clip( angles[:], radii[:], intensities[:]) return np.array(angles), np.array(radii), np.array(intensities) def _iter_sigma_clip(self, angles, radii, intensities): # Can't use scipy or astropy tools because they use masked arrays. # Also, they operate on a single array, and we need to operate on # three arrays simultaneously. We need something that physically # removes the clipped points from the arrays, since that is what # the remaining of the `ellipse` code expects. r_angles = [] r_radii = [] r_intensities = [] values = np.array(intensities) mean = np.mean(values) sig = np.std(values) lower = mean - self.sclip * sig upper = mean + self.sclip * sig count = 0 for k in range(len(intensities)): if intensities[k] >= lower and intensities[k] < upper: r_angles.append(angles[k]) r_radii.append(radii[k]) r_intensities.append(intensities[k]) count += 1 return r_angles, r_radii, r_intensities def update(self, fixed_parameters=None): """ Update this `~photutils.isophote.EllipseSample` instance. This method calls the :meth:`~photutils.isophote.EllipseSample.extract` method to get the values that match the current ``geometry`` attribute, and then computes the mean intensity, local gradient, and other associated quantities. """ if fixed_parameters is None: fixed_parameters = np.array([False, False, False, False]) self.geometry.fix = fixed_parameters step = self.geometry.astep # Update the mean value first, using extraction from main sample. s = self.extract() self.mean = np.mean(s[2]) # Get sample with same geometry but at a different distance from # center. Estimate gradient from there. gradient, gradient_error = self._get_gradient(step) # Check for meaningful gradient. If no meaningful gradient, try # another sample, this time using larger radius. Meaningful # gradient means something shallower, but still close to within # a factor 3 from previous gradient estimate. If no previous # estimate is available, guess it by adding the error to the # current gradient. previous_gradient = self.gradient if not previous_gradient: previous_gradient = gradient + gradient_error # solution adopted before 08/12/2019 # previous_gradient = -0.05 # good enough, based on usage if gradient >= (previous_gradient / 3.0): # gradient is negative! gradient, gradient_error = self._get_gradient(2 * step) # If still no meaningful gradient can be measured, try with # previous one, slightly shallower. A factor 0.8 is not too far # from what is expected from geometrical sampling steps of 10-20% # and a deVaucouleurs law or an exponential disk (at least at its # inner parts, r <~ 5 req). Gradient error is meaningless in this # case. if gradient >= (previous_gradient / 3.0): gradient = previous_gradient * 0.8 gradient_error = None self.gradient = gradient self.gradient_error = gradient_error if gradient_error and gradient < 0.0: self.gradient_relative_error = gradient_error / np.abs(gradient) else: self.gradient_relative_error = None def _get_gradient(self, step): gradient_sma = (1.0 + step) * self.geometry.sma gradient_sample = EllipseSample( self.image, gradient_sma, x0=self.geometry.x0, y0=self.geometry.y0, astep=self.geometry.astep, sclip=self.sclip, nclip=self.nclip, eps=self.geometry.eps, position_angle=self.geometry.pa, linear_growth=self.geometry.linear_growth, integrmode=self.integrmode) sg = gradient_sample.extract() mean_g = np.mean(sg[2]) gradient = (mean_g - self.mean) / self.geometry.sma / step s = self.extract() sigma = np.std(s[2]) sigma_g = np.std(sg[2]) gradient_error = (np.sqrt(sigma**2 / len(s[2]) + sigma_g**2 / len(sg[2])) / self.geometry.sma / step) return gradient, gradient_error def coordinates(self): """ Return the (x, y) coordinates associated with each sampled point. Returns ------- x, y : 1D `~numpy.ndarray` The x and y coordinate arrays. """ angles = self.values[0] radii = self.values[1] x = np.zeros(len(angles)) y = np.zeros(len(angles)) for i in range(len(x)): x[i] = (radii[i] * np.cos(angles[i] + self.geometry.pa) + self.geometry.x0) y[i] = (radii[i] * np.sin(angles[i] + self.geometry.pa) + self.geometry.y0) return x, y class CentralEllipseSample(EllipseSample): """ An `~photutils.isophote.EllipseSample` subclass designed to handle the special case of the central pixel in the galaxy image. """ def update(self, fixed_parameters): """ Update this `~photutils.isophote.EllipseSample` instance with the intensity integrated at the (x0, y0) center position using bilinear integration. The local gradient is set to `None`. 'fixed_parameters' is ignored in this subclass. """ s = self.extract() self.mean = s[2][0] self.gradient = None self.gradient_error = None self.gradient_relative_error = None def _extract(self): angles = [] radii = [] intensities = [] integrator = INTEGRATORS['bilinear'](self.image, self.geometry, angles, radii, intensities) integrator.integrate(0.0, 0.0) self.total_points = 1 self.actual_points = 1 return np.array([np.array(angles), np.array(radii), np.array(intensities)]) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.030885 photutils-1.13.0/photutils/isophote/tests/0000755000175100001770000000000014637570322020274 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/__init__.py0000644000175100001770000000000014637570305022374 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.030885 photutils-1.13.0/photutils/isophote/tests/data/0000755000175100001770000000000014637570322021205 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/data/M51_table.fits0000644000175100001770000006250014637570305023611 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'M51_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 52 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'dev$pix ' END Eņ°˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€ņ)˙˙˙˙C~§˙˙˙˙ÄĪļ˙˙˙˙˙˙˙˙Á‰B˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__EÖĨnAˇ÷ŒBęƒGBĨĶZ=J‹=ŗ ´BSäB“°ņC€ņ)<ÁQ&C~§<Á[ÄĪ‚ĮD Ҍ>Æe§?Y~`Áy•;nĄh;mŅĄEņ°Eņ°Á‰BÁ‰B=¨i=8 –ŧÔÛē=›‰=H?į<ĖhŦŊ‡Ā„<õ¸! @)kM@?ĩĩEÔ AÂģoBø<\B¯‡z=#Ąb=­ kB@^-BÅLC€đ=<Íđ¤C}Ā<ÍŊ)Ä͆YDÆ7>ĀÉ?^ŧüÁDE;Ŧž;~ējEņ°Eņ°Á‰BÁ‰B=—”™=.v;ŧÎ'9=Ą&;úsx<‰ŅŊĒ;F= H @}“Ä@?!azEŅ‘ĀAäRžC‡BÍέ=J[=škÆB[wšB_ |C€íT<ôš~C~U<ķ ’ÄĘLTD#ŧ6>Ī3>?dųÁ";—ž;—Eņ°Eņ°Á‰BÁ‰B=´í%=G+ÛŧĪok=d=†›==Ô Ŋf*<<ã׀ A:Ē@?1„ĶEÎ} AâãŪCYBĖ„=eāa=¨iB=ĨB2Š5C€í<ôčC}<ô;uÄČ‚DŽĸ>ž!?iœÁÍE;™ {;˜]LEņ°Eņ°Á‰BÁ‰B=••]=+ļŽŧÉyų=MŒ:Ö<‡†ņŊŠÂ—= Šü @Ŗ2@?CEOEËSĖAņxiCčcBŲ¨=„Á•=ŖĖ)BHÉBC€ęu=ÕËC}=˙ˆÄÅŊD>šžË?o>6ÁˆŠ;Ĩm;¤ŠúEņ°Eņ°Á‰BÁ‰B= pD=(&CŧĪ(ä<õ×<ČĘ7<—ÛŊ2<ôÔ6 @š*ū@?VĖ>EĮįB\DC# ČBįg¯=•Û*=–¤SB86éA÷PíC€é=­ļC}=vÄÍm,DČZ>ώã?uÁ=*;˛ė ;˛Eņ°Eņ°Á‰BÁ‰B=‚l¤=ą7ŧÎYc<ņĪĢŧ …‰ĩ?zë’ÁÜÜ;­¤;Ŧ.ĸEņ°Eņ°Á‰BÁ‰B<ĮM;<ėˆ÷ŧģßÄ<ã&ŊaM<|“ôŊ“ã<ļ ? 2@ëė@?ķãEŋuAëÖCQBԔd=ŠHÆ=<ūĄAáZuAŠđC€į<ČÂēC~<ÎėÄõĶ^CÁä’>Ięô?€|CÁua;Ģéƒ;Ģ˜Eņ°GČÁ‰BÁ4šEŧAg<ĄÔžŊįŪ<ĨŒ…ŊCr-Bz?ƒ•kÁīą;•zŒ;”ÔPGČGČÁ4šEÁ4šEŧF”ŗ%E˛6oA¯Œ4BßĮžBž<=ž&M<Õ<–Aį)÷A _/C€é<‰ƒ‰C}´<Ži´Åš%Cs")=ę{&?†Á˛Á>Y;‰,Œ;ˆž@GČGČÁ4šEÁ4šEŧI‘ŸŖyÖ@?Ŧ÷EŠû9A™ō­BÄ>ôBŠÄN=ˇ'‘<ŠÃķAđ@åûBC€ęĐŲ"@?ŅJEE—žAŖmļBĐTÔB“P=ŧ]<‘_Aķ*@‰C€í#GR˙GR˙Á=LōÁ=Lō ŧ\=;îs“<(Á;äN‰<o~;Ô ģēŋø;ÉĮë ? 2č@?æ8E‹ƒA”ŧ|BŊš/B†Ž=Žu\}A >LB[lB'Ģ=Âc°<dAŽs @+J[C€îĮ<(*Ct›<,]ÄĨūøB–Ėæ=hļ?Ÿ7[Á <ü;R$Ŗ;QŽG°‡GŊ.ÁF>GÁGrēRv|;-WGå(HĻÁJÆŦÁMQņ%ŧ Û;_Ã<Í"8;sYĀ;‰ú ;QC@:$;Q ‹ ?ׯ@@`PæE ~f@ī „BAĻØBîĩ=Ęļw<2XAņî@UuÉC€đÉ<ĸa?Ch<¨cĨÄ ÜAķøG=`žœ?¯#KÁ2;l×|;l÷Ģ@@vŋdEž @čOyBDķeB Cå=ЧŨ„@@‡ļDíJ@ŲĸžB@]WBˇ=ÚøE<[4øBĢ6@q$›C€é×<ōQgCjå<ųT6ÃĻFˇAÁÜj=•<?ˇ¯§ÁÅ;’•;~ļ‡H9@H*”@ÁP)pÁQ°d19ŧ¸cš;ŸúģaõC~Z=?û5Ã`î;A°ĸ=ČiK?ĀĻĶÁ…Ą;ŸMt;ž’āHEH^@ÁT<+ÁVEĘIYŧæ_<ëŧFģĄ;îLŧÆÚ;Ŋ’ŧ7Ũ;­dÕ =éũ@@´Ą˛DŊ*ĩ@ū¤ŲBƒ=B9š!=ŋ‹€<ϐ€BđK@ŌĶ)C€Ø’=v—pCZ=yæÃ<ØjA§‡7=ã/?ÅLĀūe;ģŅ;ē˜6Hd €HiÔ@ÁVģ­ÁW*ū]aŊ<€ŧeą6<Krŧūęî;ūzį9ū`;â-)" >¸čŸ@@ÆąŪD¯•=A=(Bė¯BHļ,=¤S<ŗíOB ÕČA×5C€Ũą=‘ÃXCĨ>=”’Ã#%ƒA›C=ķ ­?ĘĀûÎH;ÎÃČ;͓@HzˇH…l`ÁXaÁYvdmyŊ <4¸*ģ1ĩ´<%^ŧúŸ<gGŲ@@ڐuDŖŲAĩIB™œwBY=1=–88<Øb{Aõŗ×A*2jC€æ(=ŧ€ŽCŧ=ÂeųÃUIAíz> Yä?ÎíSĀų=÷;čß;æ‰įHŽ” H”ē ÁZ™Á[YZ‡‘ŧꉃ<\Cä<3”īedÂÉIkA‚¯S>&5?ĶęĩĀ÷í;ū‡5;üˇaHŸß@H¨ĀÁ\šĻÁ]z]ŖąŧŠ<†Q<ĻiT<†ãø;{€<}ŧ<Ęô<„oã- >TčĄ@A;=DÛÆA|BŠ[ BoG={Đ÷=-ŨAVÕJAĸYˇCČ>4ŽaCø¸>=aũ† Au<˙>jh?ŲåĀôáÍ<]ĩ<hVHˇ HĀÅ@Á^õ†Á_ÚÖËŨŧ‰îq<ĩÛw9mY}<´(Q="Éč<ģš,ŧ2Î<ŽmM2 >įV@At]D‹ØßA6īBĀ˙ũBˆxš=ƒz=KÎÁŲBAļ¯ņCl÷>kNßC‚1Ģ>rŪĮÂ`@ A]Ş>}+Ø?ŪB ĀķæE<æÂ<˛sHĘē€H֚ Á`ēÔÁaˇũīŧŨx<ŌĖ5=A Š<áLJŊ|Īo<äGÃģĀ(M<Ā9Ā7 >xķŲ@A Dˆ0TA"BēRšBƒĀ=ĪęĖ=÷4Áž’úA3=ÃCŖœ>Eđ•C‚\˙>OÛzÂną€A9ĀÜ>G8ˇ?㞊Āōú< e5< Q&HáYĀHô Áb‘KÁcķÁ=ŧŠ_e<—ë‹={—<š%îŊ(xŪ<›Ŋ¯ģÞĩ<ŒsK<>%Ė>@A0D‚¨@ûüâB˛.ĢB{üâ>r˜<é/Á‰\@ŅW?CÄß>$|‘C‚kw>4OĐÂŽAīn> |¯?éČĀņ‰Ĩ<†'<‚áHųw@I đÁdUiÁeöÕGyŊ;G PĨ<Ī6fÁ­[8@ĄŌÕCĐ/>$&C‚jH>3‚.ÂkxADũ> ]˙?îēÉĀī—á<Žä<ŗæI 80Ik Áf>Áh`yŅŊ/žL<\Õg={C PĨ=$ h ëA CĐ/>“ZõC‚jH>˜5ž”AŠG>uĢU?ô|}ĀíRz< Ž<˙IlāI0épÁh|ÅÁjg™Ķ)<\’¨<¯ęl=°ŊU<ō\æŊARū<‘ŽKžØæ= !M2ACÂÎ@AjAŠDcßûAcäB˲B‹k>râÍ=&ÃÛÂ"R)@ŗĖÂCĢ{>Ž™qC‚?m>´SÁëĢB@˙„đ>ŠČ ?úaēĀėĮĐ<K]<ë I)`IJ*āÁiŸ Álš Ą<+\;<ˏĘ=¨ã =T$ŊG6<Ũģ‘ž "Ų=$˛ÕP2AÜ@A€×?D_îAÔ/BØĶØB™Qų>“ōf=†Â"R)@‹MÆCĪÕ>ˇ19C‚ K>žœ1Áã0°@ôįG>‰úĪ@5ŽĀė,Ž<&äļ<%VČI8ZĀIeG0ÁkÜÁnčŽI%ŊĖt<úÖ}=<.1<ā}RŧØ}™<Ē´OžB)=" ‹T2@ßy‚@Aš’D]ļÆAŗ>Bß_ūBķ0>ˇ <šÔ&¨Ę?īåĮCö8>W@7C‚DĨ>e"†Â9ŽG@Ö˛Ļ>O@M"ĀëÔ<)Ŧ<(äICÁIƒĢĐÁl)°ÁqP˛}ÕŊa9 ‘d@A›åēD<û'@ķ(đBájOBŸd†>6L <”7{Â[ˇ@KáÎCzã>M—C‚?m>BâEÂLyČ@ĩié=ã u@wĢĀæG<<3¸T<1ėúI„\(I–8ÁqgįÁsšĮ׹Ŋ!đˇ<+Šā=É<*ŸÁŧbš°<ėđŧĒđ<zÔn @O @AĢ|ŗD*ņ @÷ÛBí¤uB¨ ß>Zŗ¤<ŧ;•ÂY7Ę@\ūCB>“‚C‚%>ŠûģÂd¯@­ž…>Ī@ ĩĀĀâĘéô<{X2ŧ|É Ķč<šˇÂKā@YēC€ĀŸ>‹ņũCŽH>„î×Â1rØ@×ķ*>Ŏ@ ŲĀáõķ<üô<|\2I›Į˜IŧÁ Áti>a^ĘC;ø>XĄŒÂ?@ŽûÄ=ė7@nrĀŲŋü<]–‚Ÿ></ `4?öLš)ZC‚eV>Ą•ŲÁęÂŋ@Œ1ņ>āú@ę ĀÚ^?hûę@{ ĀĖŌ<ˆŊv<†ĢŠIúv`Ju<Á||=Á}Ü K ŧv;M<ûB=H7<¨mÄŧKÄ.^wŗ@";ĀȅÖ<Ž´J<ĢV\J 7¤JKČÁ~ēÁ~´[ ™Ą=`vÚ<ĪvŪ=˛øČ<ņ.ŧ„=<ˇäŊw< ÆĐ2A J @BæbCÍŅ@χĶBŪžŽB =ŠY=2ڔÂsčíA˜}ēCV¤?^œCđß?WūŋĀö{?a=éŧn@ßãĀÄ7Ŧ<¤¸ī<Ąē~Jé J›PÁšĪÁ€!‡ŧŖ-ä<ēÎę=Œ×ė<Å´ĸŊŠÜ<ĀáĐē‡û<ˇ:Čå2Bã@B'C‚0@Ĩn–BčB¤"k=ŠY= 5QÂsčíAo.VC€?@÷C„<›?:PBÁ 3o@‰>ovÉ@"´ŖĀÁ?{<˛_}<ŽŪ*J'<øJ,u(Á€ÁOÁĨûaŊu7}<ĩĸ–Ŋ“ķ5<ÆEŊ?ũ×<™Ŋū=đQX=¸Xü2B%@B7ˇCwŨu@z.ëB˛ #B|Z>-åX<ÉŊfBĄr8@éFC|Éa?*;+C‚û?\fÁļP?‰7Ô>^õ@&Ą Āŋ‰ˆ<dĀ<‹-˜J.48J<ĄhÁ ÁĖ÷Õŧ-)ļ-åX<ˇoÂąØg@ƒã#C|Éa?*tķC‚û?äz‡wž?nÎ> 2@*Ĩ¨Ā¸ōh<1’ŠJMšÜÁŨkÁ‚ßû]<ôĻ˙`HĩĪuC‚’Ĩ>¯%0Ā•¯>Ü%u=ŧAT@.ÃĀļ§™<„Z;˙3ÅJIžŦJ`‹Á‚a ÁƒP}%ų;d]#;ķöb<¤4 ;ûb[ŧ‰`ä;ë‘Ļ=! %<S3 ?poņ@BtĸėC?Ų?fōĨAÆöVAŒ°C~'f?[ĖC‚’Ĩ?XĶĘŋą‹˙˙˙˙˙˙˙˙@2ųįĀŽÔø;Ō;ĐŅJrļJtmŧÁƒũ}Á„ +-=-ņ;ßO'FæFähFߞzFŪ‘FÛĢFØuŽF՝ÆFŌA4FΚæFˆDFÉ\ŧFŝiFž’¨FŧĻrFēâcFˇWF˛˜IFŽMŦFŠjBF¨ :FŖFŸŅĶF›ôŖF–‘hF“ÃjF3ŦFŽ‚dFˆäĩF‡&ÅFƒ‘ĩF€W‹F "ÆF&˙F*ŸâF+ąF1H¸F34ēF6ąūF:íbF>7{FA rFFæöFJŗÆFO,˛FT3FVzÎF]ITF`ą¤FhyFlĐXFsāFy(ĐFFē_F„3tF‡ÖęF‰QÂF `FēBFœ™ÎFĸwÜF¤°FŠ€F­/™F°0æF˛|äFļæ’F¸ļDFŋŸZFÃ8FÅđĄFĘ ’F˜ĄFĪÔ¤FÕŌrF×YāFÛH-FÜ´UFáÜÜFäAgFåĢ…FæÛEFéXŽFíKcFī?'Fī2Fņ÷fFņ…YFōFíAŠFíQFčHSFį2:Fåo×Fã’LFā,FÚú%FÖ+áFԙüFŅ$âFĐXÔFÉėFٝFÃX‹FĀĸ9Fē°ĒFˇ9Fą€#F¯ÁIFĒ1ŽF§ éFĄø˛FžīģFšč F—ˇ-F“\F{ŊFŠ}­F‡‚šF„A8F‚wF$áyF'îbF*ÅF-ÃõF1D™F3•ŸF:ŽĄF=¨FBŽFEĖĪFGÔ[FOnÁFRLøFU‰F[ųŒFb$FcļFkŌÃFnÜŽFv­F}ÂÂFF„;F…•WF‰Ŗ‚F](F‘Í÷F’˛UF–ļlFšmžFÁEFĸiFĨ3FŠ‘+FރiFąúŒF´1áFšcčFŧįäFĀD%FÄ^=FĮĻÎFËŋFĪŊFŌžRFÖaFÛ FßŋĨFāšņFåQ$FįJ8Fė Fė$FņœŽFņFļFô@ōFö¯TFųNFû5aFũ!ĪFú wFų`ÖFũœ˙Fü™ĸFú¨šFųÁ)FúEŨFú 4F÷[Fķ:ĻFō˜ĢFđĢFėÚâFęĀÛFæø,FãŨíFá5÷FÛ˛F×CFÔøFÎP,FËÕDFÆIôFÂHnFžŋeFšČėFļ˜DF˛ÂFŽFŠ|0FĻŗ‹FĄ_FžÁ™F˜I†F–<_F’NFŽˆ’F‰ōF‡ôIF„@F$Ā\F(+ŦF,kĢF2"JF4O’F6*ŖF?jpF=ī|FEŗFGĀcFL#yFRú|FUāĸFZ+EFaŒWFdmĨFnéžFnø^FqūĸF|ÄņF€õčFƒûčF‡KÁFŠyĀFŒå#F‘uF’íœF˜oōFšÃFžĨ­F ­ŖFĻn´FĒspF­<F˛šôFĩ‰‚FģzQFž¸4FÃ\ũFĮ¨NFĘB[FĐyfFĶ~‰FÔ-CFŲģ#FßlFä HFæūFęŪėFîŽFFņđŅFōaÔFôúF÷œdFû(ķFũe‹Fū†qGŨ8GÃGērGSGpGĖ_Gw]G˙ÕG…CGŠ(G4ĐFū\yFûgšF÷ģ|F÷×FōĖÅFīķ¸FîāƒFéĒÅFäUPFâq FÛÂJFڒvFĶáFŅî‹FĪ$úFĮ[ÖFÄë>FŊŒ°Fŧ@IF´ˆ F´OˆF­õ•F¨ōęF¤‡%FĄ´ßFCÁ›FHø—FKešFTČ;FS1AF[Ž#FbZŽFc}~Fj0ŅFrPôFvüF|ŧF›pFƒŦ?FˆAFŒ€cFŽSAF“P¯F–$uFš•øFœĒčFĄ‹õFĻÖAFĒ}/FŽwiF´ pF¸}ūFŊHFiFĮlĄFËqŖFĐ7FÖGļFÚqFÜūFāĒFč FëƒÄFņãJFõãÔFøé FūõGHuGÅG¤ņGÜKG {G ~-G 8'G ZŠGØG’ÖGxŒG¯G[zGruGĀG`›GrGpBGüGG1"G ?%G ļG dGHGŠēGįúGÜFũ֝FųĶFôį|FīĨĖFęôFįõîFās@FۙF×ŋFŅķFÎOFɂFÃĖŌFžĸFšü…F˛ÖF¯y=FŠ6FĨžļFĸԎFŗF˜ĐĶF•­FúFô FëZFæŠ7Fß-ŦFŲڀFĶÆˇFÍkbFÅ*ÚF€NFŧŸ^FˇfũF°ļ.F­ŗ FĻmFĄŗžFvĸF˜ÅôF”i1F4ŅSF8¨æF@yFDZFF3FLOFQ,RFWh\F[ęuF`Ą‚FeōFmŒcFrŽ]Fu¸F€yF‚ÁÕF…xRF‰¨ĩFŽƒFŒ;F–>’F™oŸFž:FŖ„ÔF§ ~FŠũ„F°c=FļhĄFēZ_FĀ@|FÄÍVFĘĩôFŅá#FÖgŽFŨQFæC$FęTęFđ(Fö†EFü šGĒGu÷GõG GÛZG÷ũGéŊGGĒG?ĻGŲPG!‰ĢG$‰´G&ŧíG)H=G*xžG,kžG.îŒG/JG0ũG1jG1HG2LõG0|ČG/ũ2G/9~G-ŪYG,ŒTG*RéG&ĸæG#ŪĀG"BGÜPGŒ:G0ĪGGŅGdG{dG é˜G Ŗ.GH'GzŠF˙ļ˛Föƒ—FđÂFę(ˆFäĩ"FŪ FÖ­EFŌ…žFËŋ FÄp Fžę€F¸ƒ÷FĩĄeF¯‹ĮFŠ’ŦF¤‹íF 1qF›ĀgF– F7ĮqF;öFCŧOFDŠFM¨rFP7°FTā‘FX Fa mFbĨoFi‡&Fp+Fx.F}jFƒ F„Ũ2F‰…BFŒ„$FŽ6ČF• ŋF™ŅXF]­F [zFĨŖeFĢÆF°-F˛ÕÛFšNQFŋ;IFÄážFĖˆFŌ>=FÖt˜FŨ”FãÎÂFęč|FīWGuĒąGtƒûG@ڇG@AG?I/G>e4G<õÄG;öŪG:ėDG8/ŒG4;ÕG/ä1G-ĩâG*Ģ'G'‚G$­$G ‘Gų GėGXžGÁEG îG`ĖG-YGÉĮFûN°FôFFëm5FįDaFāĨOFÚ?›FŅ|ŲF͞îFÆPaFĀAFģOŌF´FŽ"YFŠr´FĨ”ûFŸ%Fš8Gw„ƒG{XÖGuĄÄGwQGMĐČGHąˆGFĨGEų`GC#°GAÜ}G?ĪG;GvG:G5ũ~G1ÄQG-ĨG)NˇôG@ÍFGEÎhGI PGu¸GxuđGoģGnk[Gt™Gx{”Gxz!GS:ãGS‘GOûøGO-ßGKĐmGKbúGGÔGCnG@ĻĖG<lG8}G2ã]G.ëG*ĢÅG%ã9G!ėSG{G ĖGī§GôĪG ĨĪGäGFūMäF÷ˆFîŠĸFæõ˜Fāe{FŲ.ËFŅÆÎFˎFČ‚FŋĀ5F¸áFŗhÂFŽŪ7F¨ķŖF¤-FßTF?ŖFD”^FHĸ$FOĻŗFU?ĩFZâ–F^ÜØFfôFké_FpĢiFxnyF€ĨoF‚iF‡ĐF‹3ļFåF‘.;F—áFšÎ%F 3ŗFŖiFŠđ_FŽ ŠFŗ×F¸üFžíFÄäÂFË~+FĶ+FØBŠFā ‘FæúoFėąF÷îFũu†G­cG›šG Ķ’GŦ_G,G —GÄ!G!ĖG'R@G,/G0G1G5M+G9Ā(G>ĒķGC1qGFuŖGJžFGO ŋGB;†GI‹GO˜uGS–JGY2QG]fōGmûGvlųGvGƒGr;´Gsr4GxzÉGx|6GxzZGx{…Gx{ˆGx|/GnâHGn&GhęŊGcü´GaŊ,G] ĘGW>˜GSU˜GJšGH7GB=!G<„G6ŋ§G1G,)G%GŽG ÆGN¨GFäG2ŗG ‰GčÍGÆ5Fũ !FôƒšFëcķFäQáFÜëFÔjÍFÍŨDFÆJĻFÁĶĖFšbŦF´EžF­&F¨÷xFĨFųFE/üFH°°FN8ŽFT—ĶFY5‘F`˙öFeÚˇFkėDFrîÃF{ĐuF€ēŦF„b0F‡ėkFŒŌFú5F”û!Fš>€FœØũFĄ/1F§8JFŦëFŗĐŸF¸ZZFž1^FÄëčFĖrFŅU‡FÚAšFā{ĨFéGFī4ZFų%GW%G˛ĩG ū¤GbcGËGօGøˆG"g°G(G,õ–G4<ŨG8Ė­GB pGF)ĀGLT&GR/7GXÃfG\¸¸GcŖËGiđßG{âGtQŌGwr†Gq÷9GyÅGx|aGx{šGx{GxzŲGxzGwĖGtˇšGpÜĢGqO-GpF‹GiėdGe>GaŽąGY}ÔGUCŽGNåŨGH´ĐGAīFG;G6G/ÔųG)“$G$2pGļŌG͈Gą8G ā§G š•Gz„F˙íæFöpŠFīĩŠFåÔëFá0fFØBFŅDŽFÉ FÁ×SFģđ3FĩČF°‘ÚFĢqîF¤=ŽFGFKŒīFRÔÖFVqĖF]Õ/F`)IFlvOFn͝FxšŊF}‡F‚iF†m4F‹ē˛FŒī'F“ĄpF—5F›,­F 3FĻē\FĢËvF°•ĻF´•7Fģ€FĀŌTFČbËFÍŲAFÔ¨„FŪ‹FåããFėŒ"FôÍFūĖGWyGēĮG å‘Gž=GbĀG+cG#HķG'ûØG.ŽG4<ļG;UGAEŲGGÆúGNëĢGUrĘGX˙sGbęĨGhx8Gn mGtl,Gr› GvuGv„=Gn3GwĸGkrãGx}ØGx{´Gx{=GvqGr`^GuđÂGrS}Gu<ĒGu‹Gs•™GpÂGiđ1Gd! G]GWöGPX/GHņGB/qG;Æ G46ôG.ė¯G(ŋwG!yøGA€G¸ŽGQæG õíG~;G›ßFūœĩFôÉFé2KFāņéFŲSÄFĶ*FËFÆFVFŋÁFˇ[F˛WFŦr˙F§ đFI+FO*ëFP§rFW0ØF^|ĻFf­ŸFjęPFpwúFwa”FžŽFƒÂBF‰ ŧF‹ŠFUĸF”ŠF™]uFūFĄ×¯FĻ’ŧF­ĘmF´íFš—SFžœwFÅŖžFËÅFŅëÉFÚ××Fâ|ĢFę¯FōôAFúĖīGëG)ÄG ËöG8G˜ęGØĪG!ĘnG(čÕG.ˆOG4š*G:æNGA;GIĘrGN˞GVîzG^ÃŅGg/æGlہGrõŽGxr:GrGtaBGpčwGs¤ëGo}öGsQGyAMGz;Gv#—GnėÛGwFGwFGv‚[GprSGnâGy,GqĨGtG;Gq‘-GmJČGdĀŊG^,RGW2!GM÷ĸGFįÅG@šNG8˙ÎG2žG-¨žG$s.GĶpGW.GgųGG {nGí’G FōčkFí ¨Få2úFŪhŠFÕēFĪ5FȔ•FĀčÍFēFĩ8šFŽF¨ FHLÜFL¯eFVC>FZĖĮFb6ĮFfõ¨Fl×bFramF{ÂÄF‚īųF„Š=FŠ\8FŒĒíF’ÆÔF˜×"F™‡˜FŸôrFĻž÷F¨KLF¯QōFĩ€Fēš^FĀčāFÉ2ĶFĪÛ¨F×ņˇFÜFåëíFîFök=F˙•GRœG ŗG#GÚGÛG ÖG'm]G,ĢcG2H G;}G?čGHŽtGOč™GXŽ–G_{ĀGgÍīGpÃ0GwtGwĪQGplŲGq“ĻGvÅGu?QGsĢ!GsņFGtoOGoxßGo¸3GsÎüGxtXGnÚGqøâGpŋąGpN›GwĘįGyViGwTGw<ĢGmœįGkō0GmŅGd^úG[`GV…ˇGMŽžGFÚĖG>–G6,ˇG/¤VG*bVG#˛žGu5GŪ…Gˆ"G X8GHˇGJFųãFōē†Fį҃Fß|SFÖÄFЄFČŲ*FÁžëF¸cQFˇŠķF¯9ŌFŠ2ˆFLA†FPXFW>ÉF\‡Fg&ũFlfŧFmõ•Fyė#F~SF‚ F…YF‹9FÅ$F”w0F•Ü F•‚F ŽōFĻåâFŦ´ÂF˛…ãF¸šÎFžÄGFÄYRFË9 FԁũFÜŅ˙F㙠Fę}‡FôģFūX’GžÂG}áGČYG|øGČGđG#´G)‹G1“ G8ëG?oãGGžŗGP:HGY G``ĩGg]ÚGp~KGt?GtJČGoųõGo‚SGs+‘GrQŽGpĄGqrnGzžûGrĢļGv¨€GsŽEGsZāGoy!GqwaGw\íGu˜ĄGx–GvswGrxÉGnŧãGrĸĀGp GkŠÎGtûŪGlß GbĐöG[CGS1ĪGKx9GC§G;4G4ŧëG-"ÖG%fGåÎG‰xGEG DGĮ‰GÛˇFũKāFôÅÎFëdˇFãęÚFŲžœFŅáFÍYÂFÄØƒFŊđFļÜbF°ÕžFŠ#øFJę”FTiûFYq]F`-ÅFdė˙FmcFt1‚Fz‚ūF~ũåFƒĨFˆŊ/F‹KdF‘|‡F”øüF—Ú/FŸD&FĸäÎFĒ5öFŽĶ‹F´øâFģļFÁ![FĮOFÎPÖFÕķjFá~HFæ™ŪFFųoCGyJG÷šG AzG˜ÃG‹GŌ]G!ÚÉG*#7G/ã?G7ŌŨG?āGEômGO‹GVÉJG_įGllGqÉ{GošÖGww0GnąGv*ŅGq\BGod0GsŅGw‚ĩGsŲģGsŒ¤GrL—Gna‚GqéēGs pGxĶGniGu…GqáĖGsuŌGz}Gs4Gv(GvŌGyvôGv~hGwŽGsÁGkn"GaŽ8GYOGNÚgGH@G@ûG8~AG0ZG(ŖÄG"#9GŦƒG>ĐGjFæ†ŧFņ=F÷eG<G2ŋG ˙yGG?īGōXG#ãōG+™ģG2Ņ‘G;8GDGJņĘGTžG^lGhölGo‘Gq*Gs•mGr˙gGrAsGp Gs—Guķ“GqGvž}Gq‡GrHâGsŠGu×)Gu_7Goĩ˛GsÉâGtģŋGxō|GuĖGnŨčGv×ôGrLįGrięGsYG{r`Gn…ķGskGvôGwí GuŨųGn‡&GcX7GZ-ķGQ´ÖGFķG?ĒÍG71øG/EG'LŅG‰AGBíGxGŠĩGĪÔGBôFûFđ;FęQäFßËqF؞ôFĐnFČA9FÁ…āFšûžFĩąFŽ#…FRüsFYņF^7ŖFa™ÁFlĖFpØFz˙ŸF}u˜F„Č´F‡ÕkFŒZũF‘ųGF•ÅēF™™šFŸFFĻUÅFĢ äF¯ė2F¸*ĢFŊQęFÄØ_FĘaFŌ!FÚΊFāTõFëQFõüŦF˙YãGo,G qŲGødG &G=ƒG"G(âÃG/ņÁG6vG@S=GJŧXGQ#æG[øGdžÜGpŠHGv‹GxØÍGx|{Gs$2GzŲ6GsÆ Gu´GGwläGw×ÕGv]˛GuĪGrסGsĩŦGrú{GsÂĮGxGp^GvŽ^GmOÄGppGvđGwDGvũėGtiÃGrRžGpŸGr‘hGpp GueîGtjG}Gs3ķGheGG_gGT$GJĶ[GBH4G:ĪG2ZwG)_ G"„CGŖ_G‘GæG s´GĖfG‹Fô÷0Fí+cFã‡ÜFÚßQFŅ1›FČĀRFÂ>‰FģēFĩ sF­?§FUĮaFUũ:F\LFi4ĖFjÉĸFt4F|æ”F€ņæF„ę÷FˆŌØFŽæF‘Ų|F–y\F›Ų FĄ"æF§ûFŦ/FąŨČFšE­Fŧ‹OFà FĖ&|FĶÛßFŨ?GrÁZGwũ¤GwĨGsÉ˙GuŗXGtĀGv]yGpÔŦGvOĮGp]Gb?GXŸGH†G?2/G58ôG/žŲG(kG~‘G ›G¯G gGggGąÃFøR!FîĶFãŋ*FÜãFŌißFËwsFÃĀFžã—FļWTF˛–|FUw&F\käF`gĘFj7õFo­FwÛãF}$JFáŧF‡yÜF‹@FŲ3F“į|F™2Fž=BFŖb'FŠqKF­×ĶFˇaŨFģēFœFÉ߅FĐõVFŲîFâúFęyļFô\F˙uûGōļG ΤG¯æGS"GįäG#ō:G+ĄG2ßÛG;ønGDPuGN°4GX¤jGdj´Gp%Gsf‚GsŸGsöpGoØųGvÔVGonGtLqGsšFšuÆFŸBFĨX`FĒbGF¯ĻķFļÜFŊ‘ÉFÆ1kFĖRČFĶ .FÛ]FäͤFíPFøŨ9GđÉGIāG s™G lGŗ G´LG&´ĀG.ûG7ãGAŖ)GJ;hGTÛTG_9œGiũ&Gtd GrŨKGt GoĄ>GtĨ GvÎ:GuÕGu•ŠGu”GuÖ%GuGvܰGu#AGv¯9GsTĸGqf Gx4ûGx@7GvQZGv5rGs„ŠGwåŌGs)lGvž|GsžĖGo•GpĶ,GrącGsSOGnąGqu˜GtŒDGz‘GwŠgGoœnGuš›GoOžGcVļGXZÅGM‚GEA‰G:U¨G3¯ėG*EĪG"YGü4G]øGĖLGÄGŨ`FúŽŖFņ+˜Fæ =FŨ FדFͰbFĮņÛFžĒFˇųČFŗĻFYĨôF_OčFeĻEFk÷VFqĪnF|O‹F€WØF„ūF‰éØFŒĻČF‘äãF•ëF›ÜËFĸ>F§2ÜFŦvFą`‚FˇJcFžņÛFĮŲ­FÎ"}FÖėFŪ1=FætCFīžFũúGŒÛG gG †ˇGGÚ-G"‰ČG*w'G3ĮG:5GC'~GMņįGX…ˆGcW˛GpËōGreGlŊÆGu2ŽGrW˜GjŪēGqØGqŦ?GrϜGuIGpGuâqGušāGm¨/Gs,ĻGnœRGr`ˇGv"÷Gm6‘Gk‹6GoakGnŗLGs;wGuĄGu|îGoˇęGuB[G{, Gu˛GvTGqsWGo">GtaøGr…GvmGp=?Gpū Go{jGgGZĀņGPW—GEėG=iŊG3‹|G,‹hG#üųGŅGTŽG´ņGUG\GFû{1FôCoFémbFßZxFÖxkFÎ}FÆÛxFžŌ„F¸rËFŗ]eFZlbF_˛1FeäTFnŽFveęF|mFŧ˛F…ÉĶF‰ŧiFŽiFk.F—<ŖFž=ÁFĸ[öFĨāíFŦ÷˛F˛ė-FšÕUFĀhFÉuļFĐĄ5F×ĀFá‹ĸFëXFō÷¸FũoČGŠĶG @‡GãšGü“GŅ'G%wĘG,ĒG5_ƒG>\¤GFŊPGRēŨG^u;GjÜLGsū>GqŲHGvƗGvģGzšGrˇVGqküGtGwķ Gw"ŊGlđMGvĢŲGx‹ŽGv[ Gu–ĻGx™]GwÂGr˟GtĨGx:Gs:ÉGp0ŖGríG{GthOGsYļGt)GvČ÷Gu:GuņŦGsÍGr‘dGo]GxĻGtĸŋGxáPG{ŽGGtŗ‡Gg—G^}=GRÖGF@(G?qG4•G,ŪšG$ãäG[8G“GGŦG ‘GĖFũĻ FōáuFéRČFßytFØÕéFĪw;FĮüĒFŋ‹gFēiÂFą‡ÍF\œ8Fa+’Fh՚FlņžFtFŪF}PĩF‚u¯F†ßœFЇFōcF‘,ÁF™WFœwļFŖŲNF¨YfFŽH‘FļMXFģƒ=FÂ.,FĘ9FԌ"FÚÛąFäJžFīk°FōÆFūĘVGÅîG ĐŽG0ŪGĻdGbVG&ņĸG/§'G8‚ˇGA\[GK´|GV DGb¤ GnÍGtGwÅëGwÄGqeGvOĢGtæGrqcGr—Gvl^Gp…&Guĩ Guī Gv÷LGv(—Gs0ĖGsįDGxQGn ĢGv=°Gs¤Gv‡ĪGpÂlGvږGuXGGz6GGrMÔGp—@Gs\˜Gu2ĐGxGqܰGvĖžGs´÷G|~GpōKGt=GsčŊGwgŲGlÍÜG^KGS!dGJ9G?ā¯G6ĄG-Ĩ¯G$×PGĀęG'GVųG ‡;G“F˙§æFôJ¤FęHFâ}FØ.MFĐEUFÉǰFĀĨÄFē—FŗT\F]ļ‚FbÛFiđûFmÎFvĶF{XDFƒÛF†¯wFŠīUFhãF”7sF™uļFŸėF¤ ûFĒõ[F¯™@F¸”kFžŊFÅ AFÎWĨFĶM”FÛ˛FæHšFî‰UFųČÕGÔÉG͚G ‘'GtÄGxŗEGt’ˆGw~Gv„ßGv?ƒGp˙LGvĢíGwP¨Gt]ĩGs:ÍGz*ķGnņ,Gu$GsyúGsũ^GuÆpGsÛ9GpėÁGrPAGuŒ–Gs¯ŒGv›ĘGsęļGq4{Gz8`Gwû\Gv]KGviGqŒôGx œGl%G`Û¸GUœEGIķJG@j”G7.ÛG.0G&ĢcGŽ4GĒtG€cG ĶG)ëFũEįFö#zFëúīFāõ…FŲ#|FŅhŠFȓSFÁŖŠFē%Fŗë5F^_ÍFdJšFiõ­FpX7Fw2‘F}x‡Fƒ›NF‡FŒ’oFã˜F– hFšXDFŸÖwF¤ī”FĢEcFąÖ&FˇCŌFĀRFÅÔFĪį FÕ ¨FÛ¯CFæE FīŋÎFújëGüÅG bGØGŋžG`G$(-G+AīG3z G>DdGEūGQ>G]ŧ‰Gi“Gv>¤GtĘÛGuZĐGp–úGvĐmGpÉoGw§‹GqwGpöĨGpÎDGohęGsĢuGoņųGsõG{˛)Goö™GpõīGqŧGsrGp ¨Gz|ėGrÎčGtŧ4GwΡGs ƒGsæíGy6įGsČGp—™Gol7GmîĒG`íĀGTļ7GKm7G@GtļôGqžYGr¤UGnæ°Gv¨GmGPGmāÄGbxRGTŧGJĀuG@Š”G7HËG.G'|éGâGCũGjūG ĸG33G­FķØûFė ņFãŸßFØd×FŅ"fFČņ˙FĀoVFŧŒ9FąIF_–FdˆŦFk†3FrĖMF~™pFĪF„ܧF‰ŲčFŒÖĖF’ēŖF˜xšFœ†F #LFĻG&FŦr-F´z×FšãFÁ`GFÉĶÜFĪ(™FŲ¤Fáy‘FéķēFôhYGG”‚G 0nGíGŲG!R[G(÷īG/3íG8ōyGBįŗGKCœGVʂGa6GqÁgGvŗnGpĮŅGow˜Gk‹ėGuæGtm’Gq/TGzšŸGu“Gt/7G~}7GrEGqÆ8Gwũ‰GuâÂGvŠĨGtåÔGoŸ.GsƒgGpŌ,Gz7ņGw2ĀGw—GnhōGplĶGoûGr§÷Gr{\GqŗÃGsŌGsßGv4(GoŦfGm°ĘGwUGvząGv˚Gq°šGsÚ Gn^‚GbrGV ģGJ<ÚG@ƒ(G7XĶG0,ųG&ũhG¨Gë G4ķG GÅG—ôFū)ļFķž0FégFā#[FØáØFĖĨyFĮyFÁéúF¸ŧĢF˛žéF]ÛFfm€FjsøFsĪFz2bFųŋF…žčF‰îÚFë$F’[ŒF—ėuFœ_žF¤ƒFĨhFގKFŗÉčFģaįFÁžFÉ~>FĐĢFFØ+¤FâĶFėĢAFõ‰GA°GašG Ĩ7GŲĪGÚ(G"ŨG+6¨G2X‰G;C^GD6GMrĖGWq~GeI[Gq—KGx“ãGmÔūGnŨGuCUGqŅēGv,GnōGr8dGr-QGk’kGq!ÕGsÉGqxGsÁžGn)6GtˇPGu’íGuļĄGv@Gt–nGqe¨GwGpä GtuGtÚGqäAGq’ĖGq[GrGw“†GoßEGo9ĶGyíGn¨1GwņG{ÚpGsæĨG}ÅÁGvęHGn{°G`ą GSÕ¤GJŌëG?}G7sŅG-“ĩG&šGŒ GĐņG{ŋG JGŠõFūōFFķØĮFé‹Fá¤ĨF×wÍFĐ+0FČqšFÁ€ųF¸#2FŗŊVF`OÕFi'…Fl8ŽFtęF{€ĀFƒ;œF†ßTF‰ã]FŽMÃF’ĘF—…‹Fœ'ûFĄÔ¸F¨JöFŽ>AF´%_FŧŨœFÁįFĘ xFҏĀFŲ§ĘFáT–FėŧhF÷eÆGUœGßG 4GšīGžG$$&G,vG3HŋG<ųšGDĶŠGNbŗG[ĸGezGqė_GuķSGu'UGvīCGzsrG~S|G|qåGv%GvɯGE+ŋGQ7&G[Q…Gg'‡GrŲGx’GtŊŠGq9„Goķ×Gt÷…Guƒ"GsŽ"GqĐGq™×Gxb}GußĶGsåGqSvGrĮļGqŖMGvWGt’YGt¤GvŖ2GsŦüGto>GrtBGuģGvVGsšūGrîĄGtŽģGv)GpäOGrĨāGv] G{šGo. !G5_˙G-<ĻG#pĘGÂhGî~GÃČG¨GūôFütEFō`hFéBjFß BFÖ¤FÎ÷ßFÆđGyē­GsgGx&uGrJąGtŸˆGvrvGw—MGsÎÅGqGsGwWĪGrÆĸGu?Gtō Gw~ļGxŽŪGtjÁGoT¯Go_GuÖFGpÔWGu*°Gt(ŪGqƒĢGmQDGx›qGpĮâGiĀG[ø)GQĐFGF!GG=eĀG4ĒiG+x”G#úęGT÷GøGæG †Gņ6Fų5Fō8fFįmŨFßŋ•FÕ#!FÍSFĮ‰ŦFŊŦFˇ\ĶF˛F^ŪĖFiGXFoÚCF{0úF×ŅFƒĐ[F‡”F‹æßF.F“+ûF™ŨFœ˛ŖFĨöŨFĒä‡F¯EšFļ VFŊŽĪFÂpæFĖ!ÆFŌaFÚˇFå=FđĸVF÷$¤Gb@GiG GÍ4GĨG#Ķ‚G+đåG5IēG>ãGE׉GQą G[ŨGgåGtöGrŸ€GpËŌGyS…GrŅĮGlœ˜Gy#…Gp%ëGsÉ FædƒFíįÜF÷îÃGˇGwųG ņGü?GâG"KÁG)=‹G2x-G;ĀjGD UGLôpGW‘EGdHÖGoAĮGv]ãGqÜ3GvÚģGrÔÃGv!ŖGqˇDGrĢAGxĻåGqūNGtuôGtŪŠGu.FGsøäGxGs››Gyá/Gt'‘Gq&CGt¸#Gu´ėGszGuƒGqrŋG{~,Gp3GpņôGpäpG{¯'GwG|GqgĶGuÚ;GtGtīŖGpmĄGtZGxjŋGyi\GmzGbˆëGYh GOųGEĀøG;ŲXG1i~G)ÛG"‘˜GîmGÍxG _‘G›¯G$FøëØFīō_FįIßFŨ÷,FÕ§{FĖÍŨFÆĶFž^F¸@ÕF˛âÎFLJ÷F`‚7FfėFpHFtj F}ū1F‚ë›F†- FˆāņF]ĒF•0ķF—PGFzFŖ×ŌFŠÄyFŽŽ‹FˇåFžŪ>FÄ0¤FĖFŌ¸FÛâØFᮗFí|}F÷ˇlGß~GTëG %kG• GžG _G*)áG0ÉūG:xËGBÕWGM6!GW[ŦGbúAGmėúGqMÕGtnGwJŊGs9Gt”Gq&Gxė6Gtš Go ÉGyōdGrÁ9GsuGzEGuÍ+GtRđGyĩŧGp`Gt‡Gr‚čG|ÎF†‰FĘ FÔ FÛn FåS‚Fė<^Fõ˜EF˙ĻGÖG ģ'Gŋ^Gb}G Ÿ¤G'žG/c‘G7 3GA8nGJ¤GUÃÉG^ČTGkxƒGu˛–GuyÕGqnÆGuĒũGt#´Gu(qGsĸGx¸@GlM.Gu%RGn.ÆGr˙•Gv&ņGqŨIGs.GqV–GnFöGqΚGuú?Gw)ÛGu|ËGsČŋGx~6GvœžGlbGyįGpLGp:GxâGu¤JGsÉGy0KGu×G|ąxGsßāGlé‚GrW1Ggā“G[;_GPQģGF„ˆG=ĸ\G5ˇ G,îļG%¯›Gé GįG”ôG 2G¨đFū’ąFõ~kFė“wFâéŖFÛ´ÆFŌFÉīKFŝFŧ]FļīoF­ÄF¨‘F]ÄjFfÁFlFu DF}[ũFF† Fˆ,&FŽ?F’5ĖF—FũÛFĄĘ´FŠFŦ3Fļ2ēFŊ‘ FÂČFĘōPFŅ[FŲ ŠFāsFęQXFķūG8ˇGč‹G :’G•?GLOG„jG%õG."áG5.āG>6‡GI^LGRįÚG\\_GioAGtŠ4GtÃ$GwŊŋGoŖGt“;Gqŧ0Gp OGs™¯GwŒGq¸öGrãÄGr)ßGtGqŅÃGuå=GuúķGsGs2(Gu˜‚Gq0’GwęĖGt‹-GnęYGtëŧGvXGukÛGqRÃGs™8GyEĄGs˜%Gq–”GqÄEGu=ˆGs‡¤GqļˇGrt¨Gkf?Gag`GVqFGLž‹GBģ?G9ų›G1;ŖG)õÜG!rĐGĶøGxG‰íG H GfĨFũÄāFķ…’Fę˙FFߙGF×FĐēĘFČą@FÁtëFšÎ¸F˛Ę'FŽgžFНF_Fgæ7Fm) Fu§F|î¨F‚$F…Y•F‰ŸæF%F”ŠņF˜CGF›RxFĸŠBFĻĐlF­‚úFŗíĸF¸~ŸG4ŖG.LG&?F]Ū’Fe šFn2IFrNgFyĢĢF€"ÍF…&Fˆ4ŋFF’†F–&ĸFšŒ-FĄ&hFĨ?ÖF­sF´ĨĩF¸=FĀ÷FĮjiF΀ĐFÖ(åFáPFįRČFņ+‚Fú6“G‹†G’öG ÆGœÍGJ‘G§6G'ĒĶG/ŖdG6ĐŠG?SėGIyFGRŸĢG\p\GgU0GqÛëGv2Gs„ĸGu‘ûGvã-Gp™§GsØ>GqĸvGsø}Gp>TGpJšGpü-Gq] Gs,MGu\CGsĸGxÕGpëMGsĪĮGpmÚGqfGyV…/G5€ÂG.|ŨG'ŽG„’GbÃG×tG tCG*ęGŖöFú‘FķŦíFé{FáÕđF֙FΈÅFÉ\:F Fš„RF´ŖDFއ‘FĒ$;FŖČ’F^ķüFgŠäFiå˙Fno}Fy yF€_F„.FˆRFFbF–šaF›ƒ+Fĸ FĨ—NFĒÃ˙FąsšFē“FŋFÄ5FĖū˜FԙFŪ–0Fåi†Fđļ(Føy#G)GuKG ĢGHGZåGŨ…G%kŌG+veG5o™G<ĀĘGCЛGOJGVG`<GkīGrÅCGpNæGt=lGtšĖGpĄŽGw*áGsęžGyŲcGvkčGrōųGzŋGr. GzkJGu ˙Gs/GshHGr[BGqKįGwVGwwJGuˆ!Gu@ÍGrķ­Gu%GuķGsĶ"GuPčGsFGt ‘GwLqGté×Go$“GeŽG]ÚJGS"äGJ`[GArĮG:?eG0ŊØG*ƒG$€ŸGąGG´uG äGԎF˙ÜJFøzFíĢxFäzôFŨ=ĪFÖsFĖšĻFÅ~ŽFŊ°ōF¸ąnF˛ ûFŽ#ĮFĻIFĄ`jF[Ô1Fa’ FiÛFqÎĘFxlČF€™ūFƒ%FˆÖ"FŒlŨFÛÂF•÷Fš˜ėFŸģF¤,øFŠsFąōFĩŅFŊųŸFÅú‹Fˤ`F͚^FÜKĄFâˇFFõf^F˙Ÿ_GnÖG 3_GØSGeeGĨ[G$?G*%0G1BG8†kG@ē˙GI˜øGRЎG[{ÆGeŲYGp*.Gs(čGt"‚Gvq+Gw¨ƒGuŖXGo×ßG|īqGpÅŊGoœ3Gsp]GtBGvëÉGt@úGuĄ#GuūG|„āGyÍ}GtrGt sGqŊGsPpGqFGnĐbGsÆGwUíGkˇ°GsâÖGw~öGr@ GqD=Gh{ôG^dšGTô‘GLĖßGE,G=yG6†ĢG/=´G(mŒG!´EGc/G$G&ËG qG7ÍGXFöę‘FėۏFæ„hFŨnĀFÖ§đFÎÆĸFĮƒ|FŋūSFēé’F˛‚FŽfFŠ–đFŖģßFŸI^FY ?Fc3éFiÂNFl´(FrúĀF|ŠF‚ŒF†(#F‹ĐŖFÕĀF•#ŊF˜ģČFFBFŖŸNF§ŲŽF­f|FŗMhFēÁ÷FÁęÔFČa FÎ^đF×M‰FßéPFéSFņ˙ŗFúŊČGÎüG°TG h“GíGחGšG$:¨G+.´G2UVG8ĀĪG@ķ.GH×ĮGQ!QGXéiGa-Gl_ØGuŊ–Gt͜GsxGxĄhGvÂûGrč+GsN‘Gtk™GwjGwr‘Gsã‚Gup$GqQEGqgGqZŽGo‰^Gup1GvkGv÷GyķėGtoėGvfGqP|Gu„īGv9–Gr€^GqŽGgpWG_ęGW1^GO fGG”˛G?ā}G7Ŧ­G2TdG*P˜G$ ˛GiĸGœčGŪėG ˛G?G ĄFú…đFķē!FérŸFáFRFÚ÷LFҟúFĖ”FíčFŋ–>Fļ5F˛-FĒø§FĨũ†Fĸ$“FšãFYpF_ÄPFf Fk´ŅFt™žFyˆUF5F…iFАFöÃF“Y(F˜ãFžpF ÁFĨÖĻFŦÃÚF˛tF¸ČFÁC–FĮ\ FÎnûFÕ jFŨ°ŊFáøĶFîÂFųÆFūƝGĩxG ŦŠGUüGíG9XG!^šG([ G-°ŅG4ų5G<˜GD“ĮGKCGT IG[ë[GbãGkfGqČãGu °GočGr>ōGtO…GrßŧGw€@Gp@&GtħGmļãGvą¤Gt˂Gp†˜GwGuō¯Gt`GqĒ™Gs_ĶGs‰×Gm šGzFÖGwÁōGp&€Gv(Gp‚GhNöG_'GV>GPãÚGHŗÕG@߇G:& G2˙†G+‡€G%/žGGė¯G†„GĄG uGž˜FūuFô1•FîœFåÄ FÜBÔF×yFĪŗFȏqFÁTŅFˇÕ0FĩíFŽdzF¨_ĩFĸ ĀFž~F™Ø2FX‹ëF_ˆFdļOFkFs0F|:xFlōF„€~FО*FŒJžF’ ßF–…F›­ F “iF¤ībFĒĪ;Fą|Fˇ´FžŠüFÅ\+FË[.FĶøšFÛF☸FéS>FôÔĮFû‚ĐGĨGjG …°G@@GYGČG%ZSG,&G1ÎZG7Ą|G=¯:GGW#GLMGU-ūG\É×Gd´Gjã,Gs1OGsSÉGr¸ūGqžGoÂŊGqø)Gu+0GrœGoÜŗGq€ËGvĢžGu­ûGxMŨGv”mGs78Gr#EGlÔnGx*´GpUDGo¤GsfŨGo Gnö‘GeábG\ЖGVÜ"GO:dGHi¯GB+ĐG9ļG4LEG-ŋJG&EG!GëŊG6ķG€õG ž6GsuGeFųđĮFņmōFę)QFá FÛ-ÂFŌbOFĖfŅFÅvôFŧČÎF¸¸ F°ēąFŦŧĖFĻ3ģF …•F›´F˜@ŽFX3ŦF]šÕFa§ŗFlDMFoظFy"F~#FƒWČF†ĐŠFОßFûJF–…Fš^FžO@Fϝ¯FĒģ/FąíFĩŅ{Fģ^ZFÃ\ŽFČÍÍFĐ×xFŲp@FāwmFį÷FņČæF÷€ĢG=VGų¸G č|GÛgGæ]G+XG!ąG&ČĒG-žîG3 ĩG9'ŦG>ĻôGGV–GM“5GV:[G]gČGb_Gk´GpčGw¨GtĀīGyûĒGt÷}GsНGvŽGt2ŋGwlGtÎōGu:´GyēHGs•žGq—Gy>Gt4*Gz”WGrCXGugŽGoÃGiŪ÷Gc…G]2GV×GMļTGF¨ũG@XŧG:KÂG4jâG.\G(ŨŊG!ėlGSąGgĢGö€G "G{ėG´īF˙ŗĐFô6xF몈FåFÜāÚFÖĸFÍbĩFĮXFĀ6FēŦ×FŗŦF°7˙F§•FŖˇCFŸĒnF™uF–ÃFUmNFZē Fd\ŅFiųÕFq´FuuĘFŌFƒĘXF‡‰F‰CFã^F“ÂpF˜`kFžHßFŖ´äF§ŠBF­˛ŽFĩÖDFēFĀęFĮzīF͍jFÕ`sFÜŠtFãųúFėŠÎFö#[FüįáGƒG¤OG ķG UGGØyG#‰G(ĶnG. ĘG5Ø G;ĒAGBRĸGHK‚GNQŅGTNūG\"+GaGg–#GnEGrˇRGs¤Gx 7Gs.šGpÅĪGyÍĪGrŠmGpļcGuDÖGr@Gq1ÛGq ÷Gsc‡GsĐ1GqЁGrÍGl,Gfé=G_ÔÕG[tfGSœīGLGG×GAũđG:G4tAG/¤ūG(;ZG"ؐGC\GÔÁGŗDG##Gĸ GŒF˙ƒFøXėFėû†FįĩļFŨKęFØNšFŅĐŽFÉ<FÃÞFŧ­Fļ<îFąÄ2FŦØFĻøFĄCqF7QF™tZF“2?FT_ĢF[’ĀFbaâFfĄ…Fk%ÕFvš!F} HF€ÖíF†-FŠEĒFžIF’2æF˜FFœCiFĸøF§% FĢ›`Fą3ĪFˇÔöFŋ= FÅE/FËFĶëFŲĸFßå\FéäFđRFøjGÆaGUĩG ؘGY€GŦ‚G¸ÁGųĖG$áfG*SčG/ëāG6]ĪG;PĒG@˜IGGĒĄGN;GRĩGY.oG[Ô%Gb˙xGdū‚GlibGn(oGpœ$GnŋGrNlGvG­GvZĶGxycGqvŗGt§GoüĒGnãÅGt‚oGsŒûGe¸Gaķ*G[sÆGV_¸GO/ØGIėģGC´ĻG@MG8f1G4ƒLG-ĸ'G(G"ĐŪG†G!GΑG}G 1îGŪ.G!ĨFú‰FōíÜFęįūFáWŠFÜÉFÖ ĖFĪĀFĮFƒFĀ`čFšœjF´'FŽ]ĘFĒ)>F¤šFŸ4:FšCF•‰F’PFR•vFZtĖF_›ÕFgõ FkŌFrēģF{KmF‚,›F„kŅFˆWIF!QF‘įįF–!1Fœ´FžƒąFĨm…FИöFąÅ Fļ¨äFŊ ũFŸĐFČ{OFĐqFÕŨrFŨÎrFä ÎFíøFöē0FũūčGCíG­^G ž(GōėG˙DGNG ÔRG$žWG+´ŦG0’ūG5;›G:…kG@ÆHGFŌķGISRGPÄ:GT´LGYr.G]&ÕGa>yGe@ŦGhũGhšÕGq>Gu¤JGr9/Gt(ęGqåGsVGwŸŧGx¸žGw´ōGlkŗG[LrGUĖGPذGM÷éGGŨęGA˞G=\G8A¯G1 ęG-ŒëG(WíG"bËG‚öGNRGėåGGG ö>GqG›Fû—}FōsĢFëŠFä§éFÜVģFÖĒöFĪąûFÉ}XFÄ3hFŧ-™FˇÆFąđ7FĢgFĻ;VF iŠFœ ˆF——‚F”Ũ†FŨ/FSŧŠF[ąģF_Ō‘Fc[ąFkôŗFsŌŠFzDņF€¨9FƒŽúF‡žšFŠ{ FŠČF”âNF˜ž8FĸdFŖ‚ĒF¨ū_FŽzFĩíķFšA§Fŋ@áFÅĐZFĖ]+FÔ¤˛FØÅŲFâfÄFčņŪFņ LFú÷ŋGVŲGƒG øÜG NčGšOGfG^G!8˙G%ÜëG*ÍrG0JyG5ZG9w5G?Ĩ%GCEŖGGĮGL „GOÜāGSxaG9{kG=ŋXG@\æGD˛ŸGFŗ§GG“ZGJh~GMJ'GLŨGMęGyNôGo(GuŪ˛GuûCGr×IGrŠúGDËVGA<ØGAhG:ũÎG8(ŪG4¸ÍG/Ņ-G,~`G'*[G#ãĮGö¸Gë‘G‚ŅG-šGqG FæGšGÆÖG‰F÷č˜FđĮÍFë ˛FßĮZFÜ=šFÔ~ŦFÎļŊFȊrFÄ;ĘFŊŨFˇĒĸF˛r†FŦøŒF¨ĐBFĸ5LFŸFš‡ąF”ųčFīŒFĸpF‰FLËFU'ĸFXZûF]ŋ?FböHFk*yFq3™FzúSF}8—F„q€FˆNņFŒNēFDîF‘ÍžF™Ã8FĢĩF¤gFĻ„AFŦ{ļFąˇŽFˇlëFŋāØFÅzĶFĘĒAFĪÉF׊ņFß~ąFäŸJFęđ€Fô]FúRõGGhúGŋíG MGˆ7G.ŽG%ĖGėG ęzG$Ø G(ëžG-G/ {F“`ŲFŒåĖFЎûF†ã–FLņFOÜÄFY‡9F^Z4FcÜZFk”ãFoĩ Fv›íF~5’F‚ąĘF†ęFŠHmFŽUÔF‘’ÍF—ėLFšÆEFĄeF¤°2FŠčF¯ũ'F´ƒąFŧ-FÁ‹åFÅíõF͈RFÔŲÎFŲ‰ FāĐSFåû?FęjFôRVFûĀG,âGlhG !IG ’RGķËGžkG÷”GĒLGí|G"n%G'9œG)o;G-­ôG/°ˆG2üG5ę1G6ĩČG8„eG:ʃG:ĒDG<ōĘG<’:GŦFĸŠĒFqF—9,F“=ĻFsÉFRAF‰'ŗF† \FLĄMFQÎ:FTĩæFZļoFa”>Fh€‘Fn4ãFtŊ*F{F€˜YF„TÆF‰ÜFŒˆdFš§F•āF™ŽPFÕ™F¤^gF¨M–FŦ,íFąĻFˇžĻFŋéFÂÍZFËJ3FĪĪŠFÕpFÜPÄFãnŗFéMFņ6F÷ŸFũāGUßGUG yąG ‚"GZœGŖņG ¨GÜ!GÜG!ØÂG$ÛgG&X˙G(h G+­G-ôOG0‡qG0ØXG3ĪG4bāG4FG4mŸG5ApG5g G[øēGpōūG0ˆĸG.fÍG-aRG+ÅG(Á)G%™īG"ĸUGmÔGË!G€žG/G¸„GG  GÍ~GøMGgJFũšFøFōÚFéũUFåCFŪš?FŲŧ0FĐ8åFĖvšFƋÖFĀVFģtÃFļØŋFą•VF­sF¨eĨF íFŸ"F™UF•~F‘¯FŽFŠÅ8F‡dFƒîWFG`åFNŌfFT+ŧFX'0F_ÜÚFekFmęJFp‰bFwN‡F}ĪŖFƒF†ĨOFŠ—&F3'F”~F–FšōƒFŸzæF¤´bFŦ F¯ŽFĩ:ZFē2FĀūFĮnoFˏQFŅ8FÖÉxFŪ™-Få§ĀFë'FōļFøOFüģ4GŠGæ[G˙G +æGcJGGŽÁG7JG>ĮGģBG!ž G"øŧG%}G&ŸG(ü1G*ßëG+jG+QĐG,*G,=$G,׉G+øG,OåG*´ëG)‡ŽG(j~G%#ŸG#ŨäG#_ŨGN\Gu1Gú:GpÂGDbGåČG­G ‰fG“#GGGĪFūWŸFõrīFđŠ Fę’FãGņFŪ>F×ũĸFĶšFĘ÷CFĮiĸFÃ7ļFž^xFˇ­ĨFąČ…F­nF¨HaFĸșF NAFœ˜äF–žF’@ FGF‹ŧF‰/IF„‹ĮFQxFHāFLģFPŽ FVĻF[œFc>wFičøFnuĩFxŦFzëˆF‚dDF„čyF‰‘FŽYĒF‘ˆ•F–Q:F›vĩFžhîFŖúsF§hiF­á(Fą˙9FˇrĖFž —F ŖFȸ0FÎV„FŌ¤šFØN(FāDŗFå!âFíFņËÛF÷čF˙†-G7=GíãG%ēG ƒeGÜžGdįGQ˛G™“GxGc~GC G oõG ™€G"G#éĪG#úmG$b­G%ǎG%å“G%‡{G&zŋG%ã¨G#PG#€G"h^G ŌUGˇG_:G*ÆG˙UGÛõG:GŠéG Á˜G +jGeTGø‹GMÂFû5PFôÛģFīénFęřFâ›ãFßG†F×vâFŌ„uFÎĨ+FČëōFšcFŧ˙Fļ1=FŗáÄF¯iĒFŠŗFĨŋIF Ė[FœŽōF˜ĢF”aFOōF‹ŖFˆ;’F†$æFgF}÷ŲFJ4ĩFIōĖFO"FTÔâFX|qF_ķÃFeŨđFmŅFrĄFw ĄF~KÁF…?ĀF†ĸëF‹ūžFJF‘øŒF˜TžF›Ķ>FŸ(vFĨžFĒ. FŽÍF´ŋÎFšBMFž:öFÄõmFȖ FÎėÎFĶŪĮF܍FáP Fæ˛îFęFsFņSÄF÷/éFũ}Gg_GAZGf.G G ÚÆGáhGHėGŨG^KGJGŊ¯GųGå›GWķG"įGęGÎŌG ØŋG‚xG VG˜€G6§G×ĨGcĸGˇZGXG3…GōG.GŸmG \†G E–G\ûGøGūuFūõF÷´MFõēFíųŖFęÂĀFáėFŨ´wF× ÄFŌÛõF͘æFÆŪFÃ9Fģ ĮFļ°ˇFŗÂ!F­>%FĢ|nFĻ÷FĄ–˜F^ŪF˜į0F—”§F‘:äF­†FŠ‹ĘF†đžFƒŲ’F€ŦšFzú_FD8…FJSÄFM˛FPTîFYy9F_ƨFeJŖFk†FĐ]F•×7F™Q"FKŧFĸ4ĀF§p,FŦf/F¯‚œFŗ÷9FˇņšFŋģ FÁė|FÆãeFÍģ FĐFÕAFÛ%FáŗFäSFé™.FîÍoFķĻĶFø.¤Fũ,ÃGæįGīuGĐ`G"ŊGƒBG Å`G OG hG ^ÜG ŨũG á†GÃ0Gå‰GļôGSäG ų+G KņG XĘG :qG >ĀG‚ Gķ Gt:GöGëąGvÍFüU™Fø#gFõŒ8Fņ‘{Fë˙2F掉FâCÕFŨĀSF×-FÔxÛFÎëŗFĘõįFÆÕ$FĀ ŧFŊ*CF¸ žFŗ_lF° ëFŦŒšF§˛{FĸėFŸfëF›jÍF™ ŠF”¸F_°˛FeųXFkĖäFpĘ*FxÂF€0=F‚˙ØF†čF‹šF0ŽF’ž~F–€.FšjŒFžÕF¤3F¨lFŦ F¯G–F´ĸüF¸CFŊגFÂ4ĶFƕFÍ{~FĐ~VFÖ ]FÛĀŧFßá›FåĢ-FčÖĖFí;Fō 'FõĢwFú_aFũÖŠG ŅGūGâGđĘGK`G˙ŧGũBGá#G 3üG ŅÃG B G÷KG Z˜G gYGã:G+ČGcG<ÎGÔŽGĸGÍF˙”FûâŗFövĐFõĄĻFōĨ;FíÅoFęN˙FäšYFß3—FŨBķFŲ FÔFÍ6bFĘž|FÆ]FÁ.ŅFģËaFˇiF´hF¯4BFĒ'FĻ^åFĸÜ$FŸŠ2F›˙ŌF–œF“‘>F‡zFŒ FŠØOF‡jF„v|FŊmFy^FtlĶFošFj;F>UYF@mÄFDĖīFK9“FL+nFSFX|žF^ÉxFc[ĘFjŅ,FnžTFx/×F~ëF‚ îF…¸÷F‰XíFŒüĻFäôF”1ũF™BFZF G2FϏĸFŠčkFŽŽįF°Ģ Fĩ¸ōFēģßFž6ĶFÄ"5FɨFËÜfFŅušFԍFÚ?ßFßL4FájFčZņFęâéFîˉFōÅHFöGošGöGˆGŌŖGĒnGˇ.GüËGÛtGĖ&GæäGųGū/G—‚Fū'Fû%‹FøŖXFõIËFō:FīvFëŅ Fč˜FäAQFáåęFÚéjF×%!FĶ ŧFŅ*FĖĩ¸FĮĪUFÃ×kFĀFēæMF´ģbFąėF¯4FŠŗåFˆ'ąFƒ#$F‚+/F|özFxV:FqĶ"Flõ“FdéRFeļF7ãrF¸FeėF]9•F7ĒOF=F@+FB&VFGŅFJš˙FRžFUĒbFYĄÃF`4FeddFjŪÔFs"ƒFvŠôFzEjFƒ+ōF„^CF‡'ĐFŒ‚„F!F“${F—%­F™DŨF’FŖRQFĨÔ F¨Ņ¯FŽĐčFątCFĩÖĩF¸E5FŊĪeFÁËFÆČšFĘPņFÎk$FĐ~RFԝčFØjFÚÕFFßFۈAFäp´Fäô,FæÖÚFčk˜Fí ¨Fė­$FíHūFīēāFíåŊFîŖŸFîÍqFîÚWFī…Fîģ,FęŨVFëyËFęIáF菨FåõWFã~ĒFāŧŦF܂uFڀF×ģWFÔ<ŦFŌ“FĐc:FĖPdFÅĢAFÆ@zFÁbFž ĀFēŠFĩ‹]F˛k?F°{ûFŦsüF¨Š)FĨ2ÄFĸ™GFŸ]ęF›ždF—2ŋF”)PFIņFŽAFЉFˆ¤đF…’•F‚zF}ķ—FwmZFsΖFnøđFj:†FdęF]ЙFXŦ3F5î4F:4F<*zF@ŗKFG$F‰i;F‹öĮFŒČđF‘¯ĘF”0)F˜Fœ-QFŸāF \‘F¨õFĒ1ŌF­ĨF°ÎōF´uœFˇ-8FēTŋFŋ!ŅFÂĀFÄ­5FČXŒFĘÚŗFͯzFŅd6F͇*FÕČ,FØō“FŲō=Fۏ~FŨXF߉FۘŦF߯ØFŨŗFŪ&ķFߐFßÛæFāOãFŨĶHFÜSøFÛ~ŅFږF×Ģ_FÖ¸÷FŌŦFŅģūFĪpØFË­ĒFĘN¨FČŽFÆVĮFÃüFŋÜÂFžeÉFšĻ÷Fļ‘›F˛ŖSF¯ …FŽ8ŗFĢmËFĨ]„F¤FŸĶĮFĨîF™ōĘF˜,F’ôFonFéF‹ F‡>^F…ËęFĮF~ˆFxđdFpךFnč'FkՕFfÔF`åņF^(ĀFUŽ(FUsÃF1ņ’F4]ūF9ōF;­FA‡^FCОFNā–FP,OFTļpFZđF^ųFd.FhŸÔFl–ģFrrBFuE[F€ĖF€˙€F…é'FˆWųFІ0FĐúF‘¨F•O=F˜ }F›ŋųFŸĒiFĄ;FĨ›FŠë;FĒŖÚFŽÚ"F˛cģFļÔoFš"įFŧIFŋĘ[FÂdŖFÆ.FČiFÉäÃFĘbˆFФBFĪå4ĄF@ÃFJˇĩFM]ĢFQBÔFXfæF^ō„FcvČFeLõFeQ´FoFsÜĶFwߖF|ƆF‚ F„Ė„F‡žĐF‰€›FŽßF@‚F•ËœF—€lFšĶíFá$FĄĩ FĨ­ĩF¨ŋ›FĒŽF­ôÍFą§ĮFĩ3ąFˇkÁFē2•FŊFĀ wFÂĻüFƑŦFĮ¸]FÉXËFĖŅcFĖ€‹FÍ}ÂFÎô˛FĪ”bFŌTôFĐ)FŅßJFĐāDFŅÃjFĐoFĪøÍFΉčFĪ|ŠFÍØžFĖårFĘŠFČoÜFÉĸ§FÅ5ÅFÁĐ FÁ HFŧLÕFŊE0Fē>fFļŅüFŗĖF°ęáFŽøxF¨üF§ŗF¤˛šFŖ ōF 6áFœíF™ĒÚF˜&÷F”tFKFqœFŠ6%FˆöØF‡Š­F…ņÕF‚Ų2F||F{” Frŧ?FmA¯FirŪFeIbFaĐÔF_ dFUÍ1FU!FRvFJúÔF)đtF.°ņF1¤HF: ˆF;­đF<^”FDrLFJŋ5FQĮ)FX?ˆFXnAF\*VFb _Ff‹FißFm7ŖFv˜œFzsEF}ŅeF‚AeFƒŋĄFˆZĖF‹ŨŽFjF‘ĪģF“ŪˇF˜h-F™ƒFÛFĄĖaF¤€FŠŌ¯F¨˛œFŦžãF¯ąĻF˛Õ(Fĩ4ņF¸JUFša=FŊ˜QFžPFˆFÂq‘FÄf FÆ`üFĮŽeFȀŅFË[FÉčuFÉ FËĀžF˓nFËĻ3FÉįīFʨäFĘ˙9FĮĢFĮ,’FÄÎWFÂĐFÂqFĀķ?Fŋ–ØFŧ‚¤Fē?FˇfhF´ÆvFąÕ­FądF­ÄeFĒČëF¨8iF¨|ŽFĸk†FĄ‹éFŸ*FœL‘F—~:F–HįF“äßFŨ)FŽÔPF‹nKF‰$ãF…æŒFƒƒíFÜâF}"ŠFyĮNFt `FoyÂFh\FeÄ8F`-F\:ÂF\^FT€FQÖVFJĸ FJ1 F(ĘHF-Â÷F2b“F4ŧQF7F?xFA"ŽFIĩFMú–FSĄĐFW?FYjF^í1Fc˛FdĨˆFl‡•Fm YFtžFzMaF~č´F‚͡F…,F‰fÖFŠæjFĘČF´úF”cšF–>'F™€’FœÁ€F _ŖFĄëĖFĻÅHF¨ĐnFĒFŽ(čFŽæ’F˛Û¤F´‹\FļWFš}FģFž>FžwzFĀÅŊFÂĘ4FјFÄrœFÃnkFÄo1FÄC6FÂØ˙FÂe¯FÄz8FÂ%ķFĀí¨FÃ+ŧFŋŦÚFŊĮFžĖKFēģĄFšžqFšGFĩVūFŗ1%FąŌF°,¨F­bĀFŦĶ’FŠe'FĻąjFĨqŨFĄļbFžx˙F6ĸF˜ī—F—†ļF”‹āF“÷.F’StFŒQĸFŠą:Fˆŗ¨F†¨`F…XkF}ũ*F{4xFxëfFtx°FnQ“Fiu-FfŽÃF`•ķF]đ@FY, FVeöFPĢąFL‹FEĀ\FCÁF(’õF+'\F0‡KF37F5_JF9Ō¤F?č]FE6pFL FN;ZFS!FVŪF[-PF`>F^§,Fd‘XFk‘CFroXFu0Fx‚ĢFvĢFj9F…rBF‡ņvFŠōJFüŦF‘NĄF”ëF•–/F— F0ŒF"]FĄyįF¤*ėFĻÔDF¨´FĒ´ĪF¯Ā F° Fŗ24F˛9jFļ7čF¸ĪFēÅFē›ãFģŅzFšVsFģŊ]Fž…0Fž_…FŊ‰ØFžG>FŊOjFģë Fģ' FģnšFģUF¸—FˇzĖF¸°8Fĩ<ūF˛¨ÔFŗ'öFąåßF­ßzF­Ĩ{FŠĀ–F¨ë"F§žēFĨšÅFŖ*EF û:F8ôFšj§F™F…F•ųÃF“ĶFŲ FęF‹ūĀFŠæôF†KōF„IˇF‚R=F.×F˙aFxˇFsČFmđ0FkŲ%Ff sFaûVF]&5FY+ĀFTyÜFQ āFL¯vFK@]FC&ŒF@ō›F%ąčF)wGF-7hF0á˜F4ĩœF91õF:‡pFCoéFDŖFIčũFL?ųFQeFV ¯FW&ėF^!¨F_áęFgsĮFj Fo=qFt…ôF{ üF~ˆF‚ ÃF„œÂF†aõFŠŠMFŒĪF †F†5F•‚F˜wyFšeFnœFŸ„ŨFĄŅŽFĨFĨžÅF¨ņīF&J^F(F)F/@uF1čöF8<0F9_@F=‰éFA‰.FGFKSĖFMU×FP›MFSˇ,FYVuF\ĄFb$ĒFf?öFkŒ{Fm“ÕFuuŊFyî‡F{ĶUF‚dŅF…Fˆ‚—F‰GZFŒēĀFōF‘ŖcF“dČF–Ŋ…F—īÉF›ŦuFĶôFŸ¤sFĄ3ÕFĨČFĨXFĻaF¨˛ūFĢÎvFŦŠFŦšFŽæ’F°˙ŌF°|ŅF¯)ŠFąËF°ČįFą˛ģFą" F˛CčF¯*F°PNFާ]FŽõF­ÜFŦŒéFĢŖFĒđF¨ŠF§:FĨW—FŖ˜FĄĮ$FŸ0ZFŨFœņ=FšĄF—R(F—˛ƒF•˜F‘ĩáF‘FWF‹ŪēF‰ÃnF†įgF†ëF‚ãF‚ ĖFŖFw Fs~ßFnšFl”ÕFj/“FhŠNFbuļF\RFYwXFYŖíFQÔDFL|sFI[ÕFDFCJĢF>bņF<9eF!F&zF(ĖF*ž”F.B’F4^F7v˛F9rhF=ĐbFAj[FDŲ¨FGô>FJ’rFN<FQ›FT×ÁF]§ÔFa}FfQ‘Fj9IFlí.Ft FwãžF|ĢF€@SF„‚’F†ÜĶFˆļF‹ˆFHūFŽ”˜F“)F“8ĖF•O(F˜ũIFš›F:ÍFŸÉĮF !ËFŖĸAFŖCáFĨ%qFϤFĒ{bFĒnFĢžûFĒ`üFĢF̞đFĒėGFĢųFSFœéâF›ĖōF›t™FšŸēF˜?F•áQF”õmF“Ū2F”ęžFbžF§FŽzgF‹^åF‰YĒFˆ+ Fˆ,sFƒėTFƒŒF‚áF}goF~ĩ¯FxÅFsؚFon¤FluaFh¨EFf`ƒF`}#F]äXFZkFFXZFU.AFPÔzFMÛįFNaũFG„iFDŽxF>~F;ÛxF7ŦéF7F2ĄČFuĨF!;ÛF!‚uF$ô”F(īeF*ÚâF//ģF0\yF5é8F7ŊŊF;¤F>–FAÅtFEæFJwcFMVFQÎ5FRāXFYâĄF_ßF_ FažŊFiq;FkģFo79FtmÚFxiFF|aTF€š;FƒoF„ēaF…ŠųFˆ›áF‡đUFŒ¸ĻFŽ(FîF‘˙NF’ŪâF“š•F”÷¨F—ÉÎF˜F™|†F—Ú*FšrTFœĪÜFšßĖFœ‘ Fœ?žF›9+Fœy-Fœ!3FœAF›áTF™nVF˜ęZF™‘2F—ā˜F–ŪjF•ˆÜF”x F•PŲF’ęÄF’FíFŽyFoąFŒĘFŠzœF‰]ŲF‡„#F†)ĮF„áąF‚Ã4F€Ä—F}ČFFzZ]FxaJFt}2Fp¸SFn™ÉFgu(FfļZFcŽFdDF\!ôFZ;ķFTž)FRzFP—FMs>FI3FEÚŲFD1ĩF@KôF9FF8,F4ØtF5īF/Ķ-F žFąŸF ’-F"ėF&oßF,7íF+x F0‡yF0+5F3ˆ7F9\ÔF<âF=äFAīeFC×{FKÆFJ} FMá FU߁FWšFYÆFaqĮFb˙zFg›šFh؟Fm–#FrˇFtņŧF}AF~(yF€1×F‚ÔŠF„‹KF†CFˆņeFˆāF‹ŽĸFœķFûF‚ F‘‡ņF’ÃF’æŽF“ˆF•ŽFF–Á/F•ˇ6F—*xF–SėF—uŽF–‚ÔF—¯?F•M™F–—ÁF–CF•@ëF”˨F’žF’îbF’–fF’}¤FėWFôF&tF'ÜF‹XFŠ0˜FˆōĮF‰gF†Ņ?F„x FĪņF€ņ†F€äFx™9FwDŠFuLJFvVšFm5ŋFkō˙Ff´Ff•FaÁ2F^ä5F\OFWĄõFUų%FPHFKVņFJ FHĪTFE2ãF?ø˜F?ŒyF;ūWF8AŗF2sĖF1ĢF/†F+ĀFųĻF+˜FËFTáF"´9F'ø´F*Ģ[F,#ąF/yF1åPF5CÁF7ZF:F={mFC$FE‡0FG9FKĒŠFNV¤FQMŖFUЍFWŒúF_ėF`õäFdɉFfėãFmEFqAFrö”Fw…ÃF|ĒF×ÅF€œöFƒÉ8F„rF…ÎF……ŠF‰ 3F‰ā“FŒPmF‹÷ FįFāDF‡ęF”RFtlF‘F‘ŖdF“Ø˛F‘ūpF‘ÁÁF‘§QF’ĨF‘õ‰F‘áHF>Fޞ^FöōFŽĀFŽ@FŲāF‹k`FŠB.FŠ FŠQF‡F†Ļ2FƒäFƒ„FF€ĐFž,F|ôFu˙ÁFvŲFsü!Fq Fm˜ÎFg*FgįrFaŌFcFjF\RKFX *FU‚āFS%!FR´RFMs‰FH*0FIŊƒFB:FB>F=“F:°ũF8,F3\F2ĻøF0]F,éqF'ĶĢFā'FFņFnĀF>F!6˛F"jšF&ÃF*‰ņF-ûF/F„]ÄF„ÜđF†ÉÚFˆ;F‡Â„FˆúöF‰(ōF‰LˆFŠ;tF‰æLFŠĀF‰ØĮF‰/âFˆ?šF‰NIF‰{F†˛đF…ė,F…ƒåF…ßėF„ŗ4Fƒ|xFē F~įYF€„˛F~ëæFzvXFvLĮFw0íFsūæFoá_FoÍ5FlæFi„YFdžÃFaõ>FaœF[‰ŨF[3\FW‹FWÚ>FRlōFM8ĢFO\ŨFMøFF4ˆFDÉëFBė–F>°čF=¨øF:4úF5E‚F1 F.?qF/‚¨F+JDF'ŒĻF'[ F ėČFŲ§FņFu/Fx€FsqFŪ.F!l[F$)ŧF)LF)eŅF-ŋ F/ŊSF2@F5ĻŅF:=ÖF;N;F=ģF?æĸFC<ŸFD^FFHņDFMÃFO>=FSÂöFQØFY[,F[,•F^áôFdpuFfÂĐFg*‚Fkđ>FmmēFnaYFrEFu!ģFx{ F{Ē‹F.ÍF€>ŠF‚€‹F‚7īF€üFƒ 9F„ûFƒ*>FƒîF†˜sF„´˜F…ą•F„rF…›ÎF„ŪF…ZĻFƒ›rF„ ÉF‚åˇFƒË.FĢsFLFøF|īLF~^öFyP9Fxå}FwÆuFv„`FrvžFqĩîFtŖ*Fm9ŖFkÄéFlČ)FgZFbXFFaÕOF^ĨŧF[ęFUJFUČÚFSn"FQ*FMæąFKĪ:FIœFF2FEÁ˛FA)nF;ŖÄF:rF5ŸųF7N­F4ėÉF1œF/ŧF)x—F(­F%F$[JFęã././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/data/synth_highsnr_table.fits0000644000175100001770000007020014637570305026132 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_highsnr_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth_highsnr.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€˙˙˙˙˙C€~|˙˙˙˙Ãv˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘dA’ļ{BģÜB„>Â>“û3>)ÕbÂ> Ažũ6C€˙=]UžC€~|=WŸcÄĸQD Xd?‰ŠŌ?Y~`Á‡;'; áĸF’*F’*ÁÅ+ÁÅ+ŧÖŲ=ä%.ģŽŊ=ÔnFŊ•wˇ=§ÔDž ŊŒ>­Aō =Fâ:@?ĩĩF%ĒAą[HBâBŸŨ÷>“û3>)ÂÂ=ûpAžëC€€=sZĻC€~S=mËÄ ˆžD?‰ŽI?^ŧüÁz;,a×;+˙/F’*F’*ÁÅ+ÁÅ+ŧ×P÷=ä§ģŽQD=ÔSĀŊ•=ŗ=§uđž Ŧļ>­ . =´dö@?!azFŖdAÖ§¸CĐüBÁ|Ī>“û3>)žļÂ>ĀAžįūC€€=…ÖŨC€~(=‚ažÄ˛D'V]?‰’/?dųÁjŠ;QvA;PŌįF’*F’*ÁÅ+ÁÅ+ŧ× Ģ=äˆģ)ü=ÔO'Ŋ•ˆ|=§ÃÍž ¨ƒ>­ _ =Ķ9'@?1„ĶFĩBØ!C%…Bę>“û3>)Ā,Â=ū­ S =@?CEOF GB'CHTžC §Ę>“û3>)ĘjÂ>ûAžōōC€ū=ĄüŨC€}Č=ÎQÄ6Á@;šė;šBŖF’*F’*ÁÅ+ÁÅ+ŧÕÕd=äģ =Ô_•Ŋ•}Č=§Į¤ž ŗœ>­$Ž =Ūm.@?VĖ>F `B>&Crd‰C+eÅ>“û3>)ÆÖÂ=ųvAžīšC€€=˛*NC€}Œ=­”?ÄO6YD^Ŋ‰?‰—’?uÁ#‹;ŧÆ;ģŊĨF’*F’*ÁÅ+ÁÅ+ŧ׃=ä!ģ­š =ü™š@?lGF HÎBfŪC’¤ĪCObš>“û3>)ÅĐÂ=ũpAžîĨC€€=Ãû#C€}N=žíŗÄcîXDuO?‰—.?zë’ÁØ;æM;äĶÂF’*F’*ÁÅ+ÁÅ+ŧÖö=äˇģŽ@=ÔXŪŊ•Z =§iž ¯ú>­Đ >&•ˇ@?ķãF ÷B‹6îCąvįCzø˙>“û3>)ȆÂ>Ažņ.C€ũ=ךC€} =ŌdÄz˜D†Įi?‰™}?€|CÁփ< É< ¯ôF’*G5ö´ÁÅ+Á:ēŸŧ՝=ä ģŒ“=Ô\ÎŊ•ŧM=¨Qž ¯ë>­Ž >‰“č@?Žō­FX÷B¨Č÷C×(×C˜$ >“Ką=ë7éÂ=eA] C€õ=ŖđC€|ŧ=ŸûÚÄĮۄDŽZO?6Wk?ƒ•kÁĸ<,ßf<+6-F’*G5ö´ÁÅ+Á:ēŸŧ—D7=šíŠģ\Ņî=’ŊÍŊFŋ=Yž^dD>7ŋ ?Ô=į@?>%F´BĻ͑CÔĸC–ZŦ>wdĒ=…"Â7'úAžC€€Ô=Bn§C€~ĩ=A&ĢÅ(čÉD`}">ĒI?†Á˛Áļ<0uÕ<.š1G5ö´G5ö´Á:ēŸÁ:ēŸŧ,Ž="š‹ģ´ƒō=ā‹ŧˇrãSC=(ÉĶÂ;Ôl@Ķ'OC€=VC€Z=’uÅH¤īDį>JŊ÷?АÁ,Z<û˙<ʋG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ ī<ÄĸËģ‹Zė<Ŋ -ŧ€2y<+ĩ Ŋ…*j<ƒP+ >LDų@?žC„Eëü;BNxdCƒ™xC:>Gęv<íģâÂ:cö@œ-;C€:<ËũC€R<Čö]ÅMU CãBˇ> Ģu?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ēí<ˆ§­ģņÚ<ƒ\qŧRč< ƆŊ-xÂ<#~į ={Ō@?ŅJEEÜ6DB*ĀNCYĒyCéŊ>C%6<ˇŲîÂ9ī@vĶ~C€€č<Ŧ ĒC€(<Ēv*ÅHÅCĨËâ=ĶgÛ?ŊöÁëm;Ø*Â;Öá3Grâ‡G’ŽÁ?žáÁC‘ ģoŖa<ũ“<„WÂ8ōČ@6‚ŌC€&<‡A]C€S<†C:Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5KĮ<ˇBÂ7Δ?Öŋ^C€<<'ĐC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:ŲAĒVBØČâB™J9>*üû5z;×ZÜÂënú@@(ˆ-EŒãÕA-2ũBm)¨B'ŗ >?<‚ĪÂ1Ü?ŽŠKC€€.<@¤pC€€üI`@@9b˜EËč@ô9¤B2AûŊÕ>D›ä;ŸäüÂ6Xî?WQˇC€é<ÂC€€f<6xÄŽĸAAūe<ēv>?ĻüÆÁŧ;äb;˜dH Ē3H?QÁN5ÁNåv9Îļž;=rUēŗa;5ē¨ųĄ;ø˙ģÍÁ&;-7 > mŗ@@KėtEiŅ@™xAÔf4A–0^>AN‚;Y?Â3î?Õ C€ũ;Á¸3C€ų;Áų˛Ä‰šA´#<¨'×?Ģ›Áëz:¨w:¨0÷H Ē3H9´ÁN5ÁSI%¸Ŗnž:ųë7¸¨įs:ųŽf:ŊÍ:į1‹;M":įô =¸ķ´@@`PæESĖĮ@Ą:äAūíUA´Bģ>AĖr;‹›ũÂ8ļÅ?90 C€€š< 8HC€ų< bkÄW˙§A’ž<­/R4-@@vŋdEABû@'šõAŠø’ADˆŪ>FŸ…;BÂ4ŧZ>ŋĩ:C€ģ; š C€ũ; ’YÄ=ŅAÔ F÷D:ÖŦWÂ18>ŠjsC€€;€ īC€€;€ZÄ&‘-@Ų<&Õq?ˇ¯§Á â¯:<LJ:>G˛b; 9Â3îĻ>°XjC€ü;ļ ÁC€€ ;ļrąô@Ŗ5 <"Šī?ŧ‘Á Î:gÚg:gÃNHtDKH…”ÁWí4ÁYjŽ;E9īģ:ŖôxšĀ¨o:Ĩ0Ø7Yŋ:”§;'ą:•Ė´2?Q÷ @@¤5įE–—?Šæ A:@@ē˙|>I­:”{‹Â35ô>=E*C€ō;W2C€>;WnÃä ī@HI:ŒXāÂ2ëŖ>4bĘC€€T;_˛ÖC€€H;`2Ãļם@ \Ë;Ć?ÅLÁn'9ũX 9ũEH“¸5H e˜Á[;Á\Š9Sa9ĩxm:)2škK:'c[š5:Í:˛Ã:Œö =<‘@@ÆąŪDæ7?%‰M@­>@tÔß>Jdų:R’*Â49j>ˆC€€ĸ;8­†C€ē;8žļÛV‘?Ąx9;… i?ĘÁ›ã9ĮĒ]9Č hHĄįH´^Á\ēÁ^ŗ ayē2yž9÷ũz8:âē9ö´ļ¸3Ã9æē‚+ŋ9äį # <$YH@@ڐuDÍũÚ>É0@^ø@ ]>I¸": āųÂ3žŲ=ļ” C€€+; ~aC€Ŋ; ‰üÃ}æ–?<>§;=Í?ÎíSÁ­v9‡Ę9‡­BHŗĖHÆgãÁ^žÁ`Zúw‘ēi›9ĻŠMš=)ö9ŖÄÆˇœ¯ŋ9ĄN†9örš9žŒ“' ;ŌÃ}@@đk´D¸{g>_$á?˙¤Å?´Äq>Jë¤9Š)#Â4’°=TOC€å:˛ü¤C€ų:˛Ö:ÃRną?īÃ;Ę?ĶęĩĀũ†9(.č9'üƒHÂÖųHÛ´=Á` QÁb q‹ą9Ēž9Kā 7ƒš–9L{ŪÚI9@|,šĘ€ƒ9A$ * ‰¤„@%?ébl>Kee9æ/sÂ3“ĸ=ķC€€;¨ÃC€×;žÃ./>ˁÖ;y?ŲåĀų™,9h7Û9h˙HŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤˜9‰Fž7ā"9Š<ú9ŒŠÔ9đįš˙–Y9‚į. ;~Į@At]D’ĀŠ>9î?깚?ĨųÄ>K9¯UÎÂ3ā’=\ēųC€é:āŋsC€ĩ:āɖà °Ø>„Na:ī X?ŪB Āõ’î9/÷$90ķHđËKI(äÁc¸Áex%Ķšo{9P 39=A9OäŊĩ,N]9IČĩ9Žšv9Iš 3 ;˜K@A D‚ŦX>°Ÿ? s?‰7C>KFļ9—$Â4ķ==ĄĀC€đ:Ô× C€€:ÔÕŌÂč€>:•Ņ:ÍĮ+?㞊ĀņŠÉ94e9đŸIĨIFáÁeECÁg÷ũ=8q*98СãČô98ūú67Q947š9”°Z94wˇ8:ˆQq@A0Dhh~=Ņęã?’Ž?Nœ>K°:9tĀøÂ4C*=iāC€€:ž^C€€:žL9ÂŊ¨û=ö$ž:ĻÆ?éČĀíw8ú›ž8ûdYIŦYIÍåÁfĘÁh‡e/y86ė9q÷¸įœ‘9“ų¸ƒģu9Õj8šmR9ß> ;:ĖĒ@AA™šDNXy=Ļ´ ?sz?+Õs>KÅû9OũÂ42=ĖīC€€ :ąļÎC€˙:ą°qÂ™ČÆ=Š :Œ´+?îēÉĀéU8āmÍ8āĖI÷¯I/9ÁhS¨Áj8°mҏC9ZVˇ“Ø8˙ö˛¸ŸĒĀ8ø19{ˈ8÷}D :öLĀ@ATõÃD7z=kŽI?3>ũJ|>Kց9.&@Â4)s<؞žC€€5:ĸé>C€ë:ĸßÂyBb=PŋÛ:Ve?ô|}Āå-Õ8˛“¯8˛ė“I,) I=PÁiî”Ák™hŊ)š‘­8ŌĀŌˇŪĀā8Ôkޏ"û8Ę]9{`Ė8˯„J :oĮŠ@AjAŠD"bŦ= x>áä÷>ŸģA>Lõ8×E’Â47<†ĖxC€÷:^E_C€ü:^C ÂIĶ<íÅV:_f?úaēĀá18p¤‚8r ’IÔË>7›ė>LMÍ8Ŧ:Â4ƒ§›>lS€>LDŧ8ēŲfÂ4á1%Ą=ú†>LĨ8~û Â4˙<.C€Ų:/0 C€ü:/.•ÁĪÄt<ķ¸9ĻGß@wĢĀԃÅ7ëī[7ęėˇIqÁtI…j¨ÁoÔXÁq‹Cĩą8—mx8€ˇ„g8QZ82¸¸Ŧ8uqm :Rŗ@AĢ|ŗCÉ`E<%×o> “L=ã>L‰8Q‹ēÂ4:<čC€ü:oÃC€€:lBÁ§?#jD<úaŦ@ ĩĀĀĐg-7æbų7ãÚãI„5{Iœ^ÁqbĶÁrņ’Š5XO8萡Z9˙8üļŠ{u7ûNf¸ąŗ7üvhx 9×lm@AŧĸÅCŗ2x< ˜B>ĘH=ËY™>L…s8?SÂ4Š;ģģÄC€õ9ųčŌC€ô9ųčCÁ„Û,>Ô;C<ĖyZ@ ŲĀĖYQ7Ũ 7ŲęJIIœĩ%ÁrÂ~ÁtVÖ{Õ5àÁ7ē]ļA*#7ēŠˇR67ēHU6ŖM7ēnđ„ :bX@AĪŲCŸâK:žu¤<¨§€LŸū8ōÂ3ūÂ;ŗ‹C€Ü:íC€ß:ž+ÁKe>”r”<ē×@nrĀČc6Ĩ6†lI›ßIŠ*tÁt'ÁuĢ -ļĸ p7˛V¸7wé‘7˛Žã6žĨ7ŽDb¸`67Žņú‘ 9–Å@Aä?ÕCä:„$Å<“GVLЁ7ÛiäÂ4ą;ˆbeC€€9ÜaëC€ô9Üa]Á#Ęæ>Jķ§<žš@ę Āćs6uŨ6…œøI¨I¸[ÎÁuŒrÁw)‚ũ ˇQâ/7‡”üļ#đœ7ˆ=6lm7ƒ’ƒ¸Ké‡7„ęŸ 9Q:¸@AûC€‡;ĮW=:Ōö=Å>LĢ7´ÖßÂ3ũŗ;`đæC€ķ9ĮÚ/C€ō9ĮÚßÁ Ú> Di<‰@{ ĀĀÍY70P˜7)ąĀIļXÆIĮãdÁvøļÁx‘- ¯ ĩԚ7`(kļͧ7a"ī6ˆ¯ę7[›]¸dO7\Üf¯ 9Tž@B BCgôĄ;#Å=GÂņ= @ą>LĒ7‹ō^Â4;-?C€€9Ší–C€û9Šė‰ĀÆcņ=Ŧ­<^@";ĀŊ;~7F7@žŨIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒÄæ7*å-ˇ3y7, ëļ™0đ7*n7Ŧ7+ąüĀ 7ų¤Û@BæbCRi°>L°Ã7_ī‡Â3ūË; mŊC€€9•ÎŨC€€ 9•Ī!šYŒ=€ËpLŧ7;ÅŗÂ3ũ—:é•5C€ü9Š!C€ũ9Š!”Āpã°=ī°<(įö@"´ŖĀļŠÍ6œīø6Ą|IéÎJqÁ{Lŧ­7(yÂ3ũ]:ļÖûC€ū9nĢC€˙9n›Ā7ö<ŌØ7<´3@&Ą Āŗŗ6īüĖ6īEˆIũ™âJ .Á|ŗŸÁ~n$ĢÕ6ŒĒÍ6ļP:ļJ¨6ˇB=6žbm6ŗ” ˇm°6´  8\A@BJ-ČC#o:€+Ų<ž>c<†…Ę>Lžč6Ū՟Â4ģ:Š ņC€ö9F_C€ķ9F^ÎĀBD<Ŧ<Cĩ@*Ĩ¨Ā°ú6ÛE´6Ų<J aŠJz˜Á~8IÁ€f ]5Ÿ—ˆ6Š|`5­‹36‹ģĩęË6‰*ûˇ~Ņ6‰Áũ 7˙ĄÖ@B^eCĘ$:~į¤<ÆXŖ<Œ@>LŋŨ6ļŅãÂ3˙:cQOC€€92øīC€ų92ų ŋŲ&ŋ>LÂã6˜ņÂ4°:>šC€ü9$ęC€û9$éåŋ¤×<[ĸ;ČGM@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋ-ƒ6=šĐ´VĄö6=ŨæĩŠ Ë6=Sjļ÷Ų6=­KV 7žf:@B†ŒĪC~>LÃá6pŸÂ3˙Ō:Ą›C€ū9]ŦC€€9]ļŋw’”;¤Žz;ĒI@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛įs6ö%ļ|āj6?ųĩTŋg6ã¤4ܰ6={x 7pé@@B”JB˙âh:„n<í8Ų<§ŊČ>LÄđ6RÂ3˙ō:žMC€€9ōņC€€9ōķŋ;é;YęJ;•Ė@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3“-(6Đ^4§+Û6CĀ4di66É4a3°6IF 7ā–d@BĸÎ8Bõ ô9Õ}LÆY6ôôÂ4e9Æ GC€˙8äqC€€8äPŋ |;˸;ƒR@@<ũ§•6<ˇ6aŗ*J_ĪįJ~ĩÁƒI<Á„cA Qiŗ ^5Æ/EĩŠŦ5Ə{4†e$5ÂÆļÁŋâ5Ã=éĮ 7@N°@Bŗ Bė˜>LĮ76 Î Â3˙Ō9Ģ–+C€˙8ŲjŧC€ü8ŲjËžŅƒÃ:ˇõ‹;`Æ@@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩĩĻ5́°ĩČĩ—5ŦĩŽ>5ĢÁ5¯§b5Ģ­jô 7cR@BÄūĻBäÍÚ>LČ5ÜzhÂ4"9‰BũC€€8ŋQĘC€˙8ŋQÁžš…œ:hŲ;@áå@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛†˙ã5‰ecĩmĨ5‰Ë#´ z5ˆē6/6Y5‰:& 6׆ē@BØąˇBŪŲ>LČŋ5Ŋ,UÂ49kŠäC€€8´’C€˙8´’ žc=h:†k;%Ø@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Y…5kiíĩ’‘L5l-´†iå5jÜN5ģ)ã5kĄ‡] 6 lļ@Bî]BÚŋ>LÉË5š[Â49@ ŖC€˙8ĸC€€8ĸž&Ř9Ŋåë;ŋņ@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4ž‰ģ5@.˛ĩ@(Ä5@âŗ˜5@‚4Âh35@á‘™ 6H”Ë@C™BÖ">LĘc5„BĸÂ49$ĒC€ū8˜ĀõC€ũ8˜ĀđŊķ‡Ũ9už[;)Ā@XĒĀĸ_d4*F´*FJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ2û)û5$ŖOĩŠHõ5% 1Œ[¯5$/ƒĩŸ`!5$ŧÜ 6Ø@C5ÂBĶt>LĘŖ5^f7Â3˙˙9 hC€˙8AĐC€ū8AĐŊ¯øy9Få:Ũ‡Ū@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗ…Į§5 –ĩ_Z5 Ŋ4„ŗģ5 uĩ¨ō5 †c% 68Ŋ‰@CĄ‰BЊÆ9ÄgŌ<3<6–ß>LË5BPįÂ3˙˙8ņ¸āC€€8‡ēQC€˙8‡ēQŊ}Tæ8ž~Ž: *@c!ĀĄsū6Ū$6…L]Kˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´sĶø4ņÚŠĩ9Ø]4ōÄ´¤4ņ@ĩqå.4ō@ u 5Į8ā@C.~}BΔz9FIû<žÚ;Ác*>LË5#ŗäÂ3˙˙8ËŋC€ū8{ÍC€ũ8{ÎŊ5ƒ8•G’:Ķ@h›¸ĀĄę6ļį5úÂöKąēK8´ˆÁŒ|Á¨Ā*Ķuĩŗ%-4Ëøĩ`:4ĖĄã2š.á4Ëļ05~94ĖsÆÎ 5ˇ@í@C?ņŠBÍ m9ņĮ<;đr<äŖ>LË~5ŪyÂ3˙˙8˛ūC€ũ8s4=C€ū8s4>Ŋ§8dB‰:ã @n7ĸĀ Ũ96256)“K3öKWøļÁnåÁ}iŗÄ4V™4ŗ\ˆ´ đŨ4´ŗļOx4˛âKĩMˆđ4ŗš&/ 4ĮŦe@CS#KBËÔŽ>L˔4˙öĮÂ3˙˙8Ÿ6}C€ū8mų?C€˙8mų?ŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4Bje4Ÿ~,´š_ 4 ŠŗNO¤4Ÿuü2%„E4 ūš 5.†„@Ch@lBĘäõ>LËÆ4ãŸÂ3˙˙8C˜C€˙8h=ŸC€ū8h=Ÿŧ~Ķr7¸@i:šē@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´”'Ŗ4yŽ´4úaŗ‰É4hú´c‚›4÷o 3ˇōJ@CzBĘ,:{<ŨR<œHT>LĖ4ΜoÂ3˙˙8ãC€˙8i†“C€ū8i†”ŧ1bN7Ĩđ­:ī{ø@Ū~Ā `6ŧ+œ6ŽĮĄK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´ˆųP4O|´“ÉÚ4Ķ'ŗ€…u4:Ž´čŌ4ĘË‘ 4yNÖ@CŒƒ#Bɞm>LĖ4ŧÎÂ3˙õ8pmC€ū8kÛģC€˙8kÛŋģõŒH7€N;ħ@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5ĄcÂ3˙õ9c C€ū8đÉFC€˙8đÉQ썐=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅc˛ĄĢs4툠´ģ×Ú4´úĀ´}v]4Îĸaĩ›?4ÉÔî,‘5eß\@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/data/synth_lowsnr_table.fits0000644000175100001770000007020014637570305026014 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_lowsnr_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 55 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth_lowsnr.fits' END FGŸ˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€}ā˙˙˙˙C€}r˙˙˙˙ÃX‘đ˙˙˙˙˙˙˙˙ÁŧC˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F„MA’§āBēķ™—>2EžÂHĻ AĄÔŽC€}ā=nMC€}r=adÃņwlD Ε?“)[?Y~`Á…ø;/; ØQFGŸFGŸÁŧCÁŧC;†mh=čdž=c&>ž)>¤bžŖ +>ģʃ >_¨@?ĩĩFĖAŗ"ABäZBĄx>˜đ7>1G@ÂF AĄ~HC€~=Ą|C€}G=vŨ€Ä˙¨DŽ)?‘˜:?^ŧüÁy‹;.,C;-škFGŸFGŸÁŧCÁŧCģžAŌ=į`j=jÕ>dŊîĄ> lŋž¤ $>ģ >ōw@?!azFŸžAŲ`0C ŒæBÃđ™>˜ x>0ëxÂEņëAĄōC€~!=Ž—C€}E=‡RŠÄwD)rį?÷å?dųÁj7;Tî;S€RFGŸFGŸÁŧCÁŧCģŌ‚‹=įC‡=LK9=ú2YŊî!>Î žŖíU>šū =“Hi@?1„ĶF„B˛VC'á„Bíkb>—†Ä>0?uÂE]UAĄÉĶC€~3=›^C€}0=”IGÄ%ĨŨD:Ĩ?9~?iœÁWƒ;Æ;€ŖëFGŸFGŸÁŧCÁŧCŧ \—=æåå=4P=ķ]ŦŊįų>žÜžŖÎĻ>¸įn >ƒō@?CEOF KëB×:CKÁüC>–Ų>0žÂDų1Aĸ*rC€~M=Ē[C€}2=ĸÍoÄ7]/DM÷Ļ?ĮT?o>6Á@Ą;Œđ;œā„FGŸFGŸÁŧCÁŧCŧ"ļP=æîĩ=ų(=í8ŌŊäĢŊ>‡*žŖĐK>¸Wļ >Ĩéķ@?VĖ>F jáBAs~CvšPC._ß>–Ų>/UÆÂD…9AĄ‹˙C€~m=ē‘ĻC€}=˛‚ëÄJˆ’Dbĸ7?;.?uÁ$ā;ŋ÷Ã;žõ'FGŸFGŸÁŧCÁŧCŧ@ž=æĪo=ØŦ=čŊßķ=ūâAžŖ°ŋ>ˇ' ?Ÿu@?lGF VYBjöųC•ÂāCSË[>–8>/^ÂD?AĸHĖC€~„=ĖŌ`C€}=ÄEUÄ`[Dz8 ?Žđ?zë’Áˆ;ëj;é˜ÜFGŸFGŸÁŧCÁŧCŧS¸E=æņ.<ßû =äāįŊÛÄ=ųÖížŖÅú>ˇ9û ?7/@?ķãF ŒBŽ[LCĩxEC€Q‹>–8>/rÂD$Aĸ’C€~ž=ā÷ÍC€|ū=ׇÄw8DD‰ĪČ?Ž´Å?€|CÁØų<æØ<žeFGŸG6 ÁŧCÁ:ŧxŧ`Tõ=æīy<Į¸˜=âáąŊÜŧ~=úz“žŖŒ>ļ­- >aMY@?Žō­FtmBŦŌ˙CÜNúC›Č>•‚Ę=÷ŌiÂD‹AfšC€~ŗ=Žđ~C€|í=§¤ļÄÁ/D“Æ?C 8?ƒ•kÁĨ‘<0â 0ᥠ?€Î@?>%F1‡B˛–šCã¨C ú1>yšū=ŒûCÂ;ķAdŦC€€Ž=OZ´C€=KädÅ*<DqĀ >ĩÅæ?†Á˛Áy<<žr<:ÄQG6 G6 Á:ŧxÁ:ŧxŧk07=- )ģj—=%4.ŧ˙W&SōJ=3'-Â? @ßz C€€ū= ė(C€û= D<ÅIHD*Â>XVw?АÁ-ļ<Ŗ¨<BîG6 G6 Á:ŧxÁ:ŧxģū _<Đßjē?`Z<Č_ ŧ™ā<6iéŊŒ˙%<=? =ö´R@?žC„EëķBXH÷C‰ÚîCBôé>Hø\<øĢ˛Â;Ē @ĸ˜äC€=<ÔĀ\C€€ <Ō$-ÅM]ĐCî)Ũ>q?U}Ág;˙˛ë;ũāĢGrŲĸG’"Á?ž>ÁC ģÆķœ<ŽļŌ¸ļGj<‰ĖŧY[[<ŗ Ŋ7*6<*Ȍ >Z@˙@?ŅJEEÜ0îB.[C^BÆC)‚>E˙<ģ˜RÂ9d(@yŠC€€â<¯ˇvC€á<Ž<"ÅHtŠCĒ7Ŋ=Ųb%?ŊöÁë;ÜÉĮ;ÛeĢGrŲĸG’"Á?ž>ÁC ģíæ?€“<†ÄÂ6ã€@7ͧC€<‰āC€€ <‰EúÅ=—C\?ž=•"ô?”;xÁƒ;°č ;°­G’"G’"ÁCÁC ŧķí<Eš€ō<=Ŗģøŧ];ąŦ%ŧŦˇû;¯ņņ =”¯@?ũ=ĩEš×AŠ„BגB˜nr>7&<<-Ī×Â49?÷RC€,šö<§œÂ/ũv@<9ÜC€€<šUC€n<›-LÅĨC¨=€Ŋ?›w¤Á ˙;Ē#;ŠTjG’"GēëËÁCÁG<´ ŧyUÖ;įyēō2–;âĮ/ģ$éĶ;•˜<…Ëe;—­œ @čh¨@@5úE˜—A:YģBv„ĀB.P >4-<2Â5Ū‰?ŧ†ŋC€~Õ<0—(C€€ē<0:’ÄŅäžBĻåT=KŽÁ?Ÿ7[ÁŒ ;)ņ;)7GâëÕHgpÁJ›ÁM ēÅrX;“%ēFā'; cģ ¤Ŋ; DŽ븛@@(ˆ-EŒŠ‹ACgđB…É B=3x>=ÂV<R€Â-‚Õ?ČéC€Ā<[îC€€û<][ŠÄŦyKByņ=8Ü?Ŗ7Á";AP@;@ĪYGâëÕHgpÁJ›ÁM ģaûÎ;Ĩ-:Áĸ;ŖÚŽģ-qĨ;ĸŊÉ痁=;ĄŠN ?P1m@@9b˜EĘėAąXBa€aBt!>D—;ËéÂ4?ˆ´ÔC€€{<(iC€b<(4ÄŽ3˙Bô&<āÅá?ĻüÆÁŧk;%×Č;%oJH ‘‡HģÁNÁNáēʕ ;oāÄ:šß[;eEĒģiûH;DËģŨ˛ũ;:˙™ =đĪÄ@@KėtEiöy@Ŧ™4BrįAˇ|>B,>;„ÅÂ3‘ã?-.C€€ˆ;ëųŸC€;ė9ĉ3–B kz=Ŗ?Ģ›Áî@:Í%l:ĖôØH ‘‡H9gÁNÁSĄ%:=ÅK;9ˇ;}Ē;2ī9át—;á};q;ŧ~ >[Ėņ@@`PæET;^A•ŸBf0rB"Ä´>DŊß;ü_ŦÂ:¤Ģ?ĨxnC€äît;>fēH,ĘîH9gÁQéģÁSĄ!%;VîB;ĖŨ;›;‹Ô›9"y;‹2<;ZYž;‰6 >t2@@vŋdEA†-@ēžûBŧėAÚÕ!>FÄü;ĸ.zÂ7ŸĨ?SC€€w<1^ŌC€‚‚<0‰ąÄ?Ō AĒQ<ãMģ?ŗ\pÁ ĸ[;!,;æ.HEŅVHP=^ÁTC:ÁU')-;˛ž;&*;w\-;#[ë;ģÖr; ëYēĐĀĖ; LH ={iÄ@@‡ļE.šō@Ē­íBĐ AŅ é>D"č;˜×ëÂ3( ?G@lC€€˛<6-íC€‚‰<6cÄ(Ũ…A…Ãb<ĘÉ?ˇ¯§Á Ûđ;Ũâ;ĨHEŅVHnéÁTC:ÁW{Í)9;̀z;m&;%yj;];ú¨:×{Á9ēēÁ:Õ  <1”ü@@•HFEŧ„@‘ÁBC Aŧuô>C%X;•øfÂ6ė?HtąC€€(JHc;ŠîŽÂ5ö?XTC€}ßO’i;–@Â4å?;ü}C€{<PZĸ;PpũÂ5Ĩ?QEC€|´<8uC€~V<7ĒÜĶz@õúĀ<ČÄ%?ĘÁŠĒ:Či…:ČĐHŸĮH´B×Á\…”Á^°u_yēŪ?:;|f;:ėŸ[ēû:âÎģ-G:ā!# <§*`@@ڐuDÍ-ĩ@WV´AíēF’;œÜÂ3ČÔ?KĻC€€É<–ĩC€‚)<–Ā;Ãw@@Õåc<Ũw'?ÎíSÁ›ß;û;ēkHŗe`HÆpÁ^›Á`[ąw‘ģ=&ũ;&Û˛<!6;$Úģ{ú`;ũ]ģw‰;Ĩ ' =ĩ@@đk´Dļ‹n@6n\AĶy{A•ˆû>:‡a;“ŸŖÂ4/ė?J=:C€-<›B–C€ƒŠ<›9lÃKpŸ@ȕj<ügá?ĶęĩĀũ(; ’; „HŃvHÛŽĶÁ`FîÁb ą9 F;Ø<$ä;åĘģ˜; jŗ;iđ1; !Ū+ =Ä0@A;=D¤ #@j‚[BkAČöÚ>>Ú!;ʰËÂ9}V?‡ŽĨC€}<ë  C€~ˆ<éõiÃ+ÆZ@Đw˜=W|?ŲåĀųqo;FöŲ;FbōHŨŧŦHõ-ÁbIgÁd8ŗŨ;‹,;n^;Đę;lxŋ:z6ģ;låŽ:ëˇK;km„/ >’<´@At]D‰ånA<&B¸ÄÅB‚Ϟ=ÚŸ;Ō};Âeų'?ęBC€€= C€€<ųL&à Č'AOâ0=Áô?ŪB ĀķiQ<î8<įHû´ĘIQŲÁd},Áe}|į쯠ō;bŽã;Ō";_Âģ/dË;^&:öęĶ;] 62B~O@A Dtzv@øßŊB¨÷jBnôe=ÚŸ;éãfÂeų'@,ĀC€€=‡ĨC€€=ĨHÂŨēāA%åˆ=ŋ‰ƒ?㞊Āī9Z< /< ę7I ËIk×Áf7ãÁgZ=ģ*°y;{BÉ;§ˆr;~'í;Ƌĸ;qD;Ցë;s˛;2B{—@A0De†@No‰BŦTAË/=>>ļE<B[Â.‘Ú?°ÃC€‰Û=KØÄC€vŲ=M\ĨÂ̈æ@“ĐÁ=\™á?éČĀí;z)û;yNŠI_IôÁföģÁh‹5y;ž Õ;œ&<;œÔÅŧû;‡€Ã<~4;‰´ > ?*$ú@AA™šDRŲm@IhˇBˇĻAÎT>Z ;ķČÂ7ŧx?ˆC€‚h=RbC€pÎ=Q& ‰@aô =4(Ü?îēÉĀę$;…;„€&I{I/NÃÁh)cÁj?eŅģÕM!;”KŠ;Āpĸ;”œü8ŗ';’•Ō;Ą‰Ķ;“3FC >’ĶĐ@ATõÃD8á’@7{jB ‚~AÅKė>O9;ōQ]Â5]Ú?”āGC€=cĢ@C€zũ=c2ˆÂ‡H@L>=A?ô|}Āå„&;Š/é;‰¨.I+ĸI=ÕwÁiäcÁkĄš);ž×ŋ;”(ē{';”Ņ’O9<+ĪzÂAÔF?Ô¯rC€Ā=ŗžæC€‰=°*âÂFīQ@=Ô=tGō?úaēĀá û;ĩ™~;´ĢĄII?;<YÂ3ą2?´Ÿ~C€‰=ĄØXC€‚ =ĄęøÂ͔@åx=i”ä@5ŽĀÜÂą;”įø;”GSINxGI`x]ÁmŗÁnŠn•%;įU8;ŽÚģ@ëZ;Ž`×ŧ:¯_;ŠX7욌c;Š iZ >O͜@Aš’D?ō@87ÆB!0ÍAãõ>>UPĖXG<8ÉÂCš ?ڗ'C€Ž˛>õtC€y,=ûˇÁĐá¤?éW_=Žũ@wĢĀÔĶ;Ä.Š;Ã8IqCNI…ĢģÁoËEÁq“ē­ą<4÷;ã܍<&ã˜;ä÷V<`3;â(ģa;âžl ?2ū@AĢ|ŗCŗ‚@Û§BMˇAÎį>.ō~<†.āÂCš @@= C€u%>HxTC€yĩ>D ‹Á~ ø@,ˇx>. l@ ĩĀĀĪžq;Ķl;Ō/‡I…Õ˙IæöÁq™8Árú…­Š<ļŪ<(q;WôE<_ˆ;˜Xw< Kŧ?4M<U¸z ?ÜfE@AŧĸÅCą“@ 2ņBl’AÉk>2Ķ)eąC€oO>)8ēÁŠ ã@á=ōũM@ ŲĀËī;;Û*Ž;ŲÖPI‘,Iœ˙äÁsÍÁt_ĨÕ<ÕÆ;ú‹:;ø:Ē:c$;ö.ūŧ|Ę;ų~™† ? šø@AĪŲCĸ •@ 7,BfAÍ|>Zf<]*ĮÂ%pŨ@ĨÆC€_ā>IlC€z>N2ÁZ’ˇ?øZ—>pĢ@nrĀČÚ;ė%Æ;ę™ I™ø6IЌÕÁtrÁuĩ y-;ú´,< %=<‘ū< yšģģ”p< ZÕ;”s< ȏ2@Œz@Aä?ÕCšn@AWB%́Aęƒ~>Zf<zÂ%pŨ@|‡C€_ā>‚}C€z>…6Á0š*?Ę;ė>“ę@ę ĀįQ<ę<ŧáI§5YI¸†8ÁuwGÁw-€ß ŧ 9Ŧ<$¸ģītÔ<$!gŧ#PN<#ö;‚<"Ęcž2AG@AûCā[?÷ÚúBAËĀu>ZįC€kˇ>ƒģÃC€Íx>†ÛŨÁØ?šÕ_>Ļí@{ ĀÁ*1<H<¸IĩIČ-Áv×ņÁx•j  ŧPâs<īî:eĨ <”ZģŧšA<(<_n<JÔ­2@Rp@B BCin“@ ‡8B+Š“AōÄn>Zį<ŋkHÂ%pŨ@aˇC€Vē>čGC€ĩĄ>íčŅĀŧ́?¨S>cˆ@";ĀŊsė<(}<&‹ÕIĈŖIŲw„ÁxEéÁz ƒĄ:qĘB`)<EéÂD}r@;ŽC€ĸĩ>ÁŠ%C€i<>Ŋ7¸ĀģėĮ?Ũ>Bîû@ßãĀš×/<@–<>IØ Iëį}Áyė-Á{qíUŧļŗL<;¨u69Ė=DÂTÆõ@ĐåC€Õ1?_NqC€„ŧ?UķŊĀ*t3?jâÆ>°b_@"´ŖĀļąu<"ßĘ@ŒS=CY'ÂD~AąC€ßZ?žiûChÄ?šĐŋå*H?Eč…>Ũ?@&Ą žŧ<0°Ë<.ōēI˙­/J ?2Á|×ŪÁ~sŲ Õ=!ûČ= Ú3<Õ\(<˙üŧ˜G<öM3ģĢĐš<īaĮ? kw@BJ-ČC6r?š˛B ūšAÅû›>r¸=ׁÂ.b@Ôf’C€Ŧå?aâĄC€R1?cG“Ā #ä>Ķr>CėŦ@*Ĩ¨Ā°(<"ÖE!€Ú<ŅčÂ'@Ú@ĄœC€˙ų?F"C€Ÿ[?I ¸Ā ‘?â>}uō@.ÃĀŽZŸ<*z<(Ũ3JhŧJ(ĢkÁ€1Á€ÔBí%ųģˇLėG­Ö=R1ŨÂ'@ÚAō0CfÖ?ßģ”CŦ-?ãÔ0ŋ’•‘>˛J>›|&@2ųįĀŦ9’G­Ö=EĶÂRæ@û”dC€Ũ?îB0C~ĩô?äU”ŋvŗ>ŽĐ>”šÄ@7JžĀĒč<7’<5) J7`ĸJM‘ÁŽ)Á‚Œ+,ĩ7Š=b‚ß= Töŧ9›7<õ‹n=ab=q =ąĻ=HVy2?ßą@B”JBūņ,? ˛B AΐW>(‘=<9‚ÂWTA ËSC~?ô29C€~?ęHAŋ`W9>k˙˙>†Ļ˙@;ļ9nÔ<0ē<.VåJNämJcõ$Á‚šqÁƒr 8-C)<Ō<æĀņŧrŸ<âįĩ=–0ž=üĩŊ7@,<ō ȧ ?^Ư@BĸÎ8Bô’ō?ž}PBh8AĶK&>IĀT=؀ÕÂWTAˆoVCŠŧ@žŊRC‚Fí@–áČž§×u>\¨­?(Gš@@<ũĀĻũŒ<5<36ÖJ`dËJ~8ÁƒOÁ„fíAOQi=ģÔj=ļ0Jŧ2w˙=‡IU>Cĸ{>ĢęŊz4 =œv@Į?Ž[T@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/data/synth_table.fits0000644000175100001770000007020014637570305024410 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/data/synth_table_mean.fits0000644000175100001770000023540014637570305025415 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / There may be standard extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_table.fits' / name of file NEXTEND = 3 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCČũë<,ß|=Ø[=˜Ėą>M˙j8’’Â4 ī<6KMC€ø:]XÆC€€:]VOÁ¤Ēˇ?$Ę/=v@ ĩĀĀĐV17ņ97ív I„5{Iœ^ÁqbĶÁrņ’ЏÅG81˜a7‰y81ûû7]4Ė8$ÔZ¸ā* 8&š2 :7Nå@AŧĸÅC˛áu<.=ģŸz=„Ģb>Nˇ8~yōÂ4Ė<ŋC€đ:TZC€˙:TQÁuŲ>Ōtķ<Đ4@ ŲĀĖI˜7čŊˇ7égąIIœĩ%ÁrÂ~ÁtVÖ{Õ5Ū!—8`˙5’ë8ŊW8^ËÛ8ĸʸxļ;84“2 9Ǐ@AĪŲCŸŸ >$ÔT@Ov?‘°Ŗ>MĢ–:—@HÂ4Ė><īC€đ<Š˜wC€˙<Š—ÉÁG‚Ė>‘‘@<ēČs@nrĀČTi:‹Ë:~ëI›ßIŠ*tÁt'ÁuĢ -5Äad:6uø:m‰Ö:7išĀ?9:'+ã:ö:(:ž22>|be@LÍÚAä?ÕCŽ‹x>jŨ@Žņƒ?Ī—”>JĨ‡:ôūÂ4Ė>šfčC€<õīžC€€î<õî‡Á!ú >UjĒ<¨Ĩē@ę ĀÄf:e•:dŪ/I¨HūI¸[ÎÁu“ŌÁw)‚ ē^Râ:”Mįš†Úr:•D!9ޏY:}j:ũ_“:ļ'2 >9\ä@ōÂzAûC€Õ> Ū @; Î?yI>JĨ‡:ĒÖdÂ4Ė>WU§C€€Â<ŧĨBC€-<ŧ¤RĀú÷…>ē–<’œ€@{ ĀĀŽh:Ũˇ:؅IļXÆIĮãdÁvøļÁx‘- ¯ :qŪú:Må9áö:Nk§9Oa:LTšĀRf:Mņ22>K‘ AnŪB BCg€Š>b—@_%?‹i&>Lgc:Öw8Â3}>†¤^C€Ė=C€€#=Ž`ĀÂnÄ=Ã×+<€íC@";ĀŊ*:4¤E:4|IIÅúYIŲ"ôÁxfxÁzB ŊĄšsÖ]:ƒ ã8§ã7:„bšOųa:rÉģ:Uƒ:sTy7 =°ĄUA#íĶBæbCQÜ]=Æ,T@!ąÆ?Ay>Lœ›:ĨxÂ3×#>QTņC€Ė<ŪągC€€#<ŪēĀ•Î=Šv,Ĩ:>BI֛Ië‘ÁyÍÁ{k'šoņÖ:M;­šEo:L9\@:L(.ē $:KH= =[ļA2Ī9B'C?ތ=]A”?Ƨî>âaę>MIm:TgŠÂ4Ô>âjC€G<CéC€ž<AdĀlU='‡<5Š4@"´ŖĀļ›B9 tO9 ^™IéÎJqÁ{ļ­_>MIm:A9ŨÂ4Ô=ņISC€€ĩ<œĸ×C€ <œ #Ā4ęŲ<ÜĐé<:@&Ą ĀŗĒ 9‡š9†æŠIũ™âJ .Á|ŗŸÁ~n$ĢÕˇąė„9î1&š"ã09īO|9 eP9ëv"ē “79ėāĢI =Ērs>LÛg:I%pÂ3ö4=ũŽ™C€ž<´ûC€õ<´­Ā …™<˛<ˆ@*Ĩ¨Ā°íÚ9T”9YˆJ aŠJz˜Á~8IÁ€f ]ˇÕl–9úkŊˇX199ųb¸‹‡9úAl7fÕ9ųązQ :âÜæAo†ƒB^eCũ=´5?ĄTC>žŲl>Lôœ:UÆėÂ3ö4>ĻC€ž<Ō[vC€õ<Ō]tŋÔÜY<_ }<y[@.ÃĀŽuē9w=9všJpuJ(w•ÁÉNÁ€Ņ–]%ų8SM:B7Ŧ{:ÉW8xŧ:4úēÉĩ: hY <ŗ“ŌA„BtĸėC<2<ˆî?5(:>)q9>Lôœ:õÂ3ö4=ĸž,C€ž<‹Ė¤C€õ<‹Íôŋ Æ$< Qž;Ú§@2ųįĀŦ=•9×g9ĄīJ& ĀJ9€GÁ€˛Á§Ä$Å-ņ8ŸÁŸ9 ūķ¸ģØF9 o”8Ÿ T9 Éo¸Š09 lUb2=Vį˜A’OĨB†ŒĪCnÄ>/>Mcë: ĐËÂ3ö4=­‡C€ž<ĨzâC€õ<Ĩ|ĸŋsÎŲ;ļmī;ŋV@7JžĀĒGČ8ũˇ8üJ6qKJM8{Á‚ĖÁ‚ˆg,S7Š8´x9ŦŽM´ęō9­ī˜7P-z9Ģ™H9ÅD9ŦˇŨj <¯Ū9AŖ€UB”JB˙ǎ÷Ĩ>KđÁ: ģķÂ3ö4=ŽL’C€€0<ˇC€c<ˇSŋ8 ;n-Ī;Ĩ—S@;ļ9ˆ8ÜßB8ÜČrJIĖyJc†ÜÁ‚cÁƒmÖ5ĮC)¸8Û˙9­)¤ˇ=÷E9ŽķÜ6%íģ9­Ä9%9­úōq ;Î4A¸Ę˜BĸÎ8BôËÚ<…? ķ=Μ`>L˜:Ģ=Â3ö4=žäaC€€j<¸*SC€Ą<¸-ŋ DT;#€;˜´N@@<ũ§Ą8Ŧ @8Ģũ™J_æßJ~ĩÁƒJ Á„cAQiļ ÷9Ÿ'z8—Ų9žųI8(šÂ9˜0:€×d9—4*w ;ļAÔI%Bŗ BëōŽ;žûļ>¸a=„aĩ>LÆŖ9ÄÔ9Â3ö4=qLļC€€j<š|C€Ą<š~ÉžĖÁ:ĘFÂ;|æ˙@D߲ĀĨž/8aĢ08`T„Jy‰-JŽetÁ„;'Á…aNŋbi8™~y9rŖŲļĒ6…9qœa¸ŦIŠ9ož*9ĢNq9pTĀ{ ;dįĀAøLĪBÄūĻBäĢ{;}Ŧ:>†ė™=1÷„>LŽ]9“øÂ3ö4=8‘ÅC€[<ŧC€€Ž< ž—",:wÃ;X]?@IŸ¤§8(8J‹ĶVJ Ŧ_Á…8‚Á†m‰_Yw=8‡-k97à 8>› 97EH8øÛ97Coš€96Åč~ ;¨aDB$ÄBØąˇBŪžÚ;#}ë>?NŲ<åf">LŽ]9‘fÂ3ö4=5\úC€€Y<‹p)C€ą<‹rŋž^Y:;60@N{ĻĀŖž 7Íče7Ę!ėJcĄJļ !Á†?Á‡ƒ'sG)¸ ęc94§1¸"Øē94+‘ˇūÚĢ94–h¸.žC94Ą~ ;š B2 ŲBî]BŲķ/:úEr>! :<¯”œ>M%9‚ŠãÂ4:D="ŋ4C€€Y<‰č9C€ą<‰Øūž#ŒÎ9Ņ:Ž;#ŋņ@SvJĀĸüt7 ēm7žßJ˛…JĪdEÁ‡R™ÁˆĨ‹[ŽMļs{9"uĸˇ¨=9"v7;ģ9"K…¸ +Ã9!ÜN~ <÷€BWX)C™BÖō:ø÷5>079<ŽĒ>M%9uuÂ3ä™=ŨC€€Y<ŽpŋC€ą<Žx&Ŋîo69„ā&;Ē#@XĒĀĸ\Û7ĸ7Ą;ŖJĘÚÆJíÅzÁˆsäÁ‰Õ ¨ąŌũˇāÖ39wŽ8ƒÄW9:8đB9‹ā9 -p9$~ ;FĸõB‚H›C5ÂBŌ÷ō:–ŋĨ=ę LŨĨ9ŽĪÂ4Č<ĮsC€€YLÚ,9fÂ3û­<ÄQŽC€S<].éC€€ˇ<]0¸Ŋwņ8Õ -:ÛųĘ@c!ĀĄrA2–Ŋ˛–ŊKļlK¨NÁŠÚaÁŒVŧ÷ 4Ņ8ŋ8ðČ5cįI8Ã*ÛĩŊmÛ8ĀÛbš7ƒ8ĀWq~ :†ŠBžČvC.~}B΍<:îb0>`–Ž<§=”>Lį§9 é[Â3û­<ȄČC€€ -_LÁĀ8ëäÂ3û­<’Ü„C€€ ¸ģ<(EV>LËp8ã˛"Â3û­<đEC€€ U÷)Láæ8ŌØÂ3ūÉ<‚čC€ä@yØ-Ā 87hpe7lYKvÂėK•”hÁ,ÖÁ‘Ųƒ–)7 8‚”j5+oü8‚; ļÉt`8‚>8N Ž8äņ~ 8ZÚŪCLyqCzBĘ):ŽŊ>pŨĪLŨĨ8š¨Â3ūÉLŋâ8Ĩ1DÂ4<–ĖC€õ<}XTC€Û<}UŅģđŲĶ@ƒĀ GYŗņũL3ņũLKĢęĪKÆ …Á“|Á”I ]ŒKļė7l8‹ė5û*8OšĶˇnDl8m]Ö8)k8lzqK3 7ķRQC•ļCš@BÉ1';/ ‡>¨°ƒ<Ė­>Lŋâ8€ ‹Â4<¯[C€õ<‹ōC€Û<‹īũģĨ`…8ĩ2Ē<Œ>Ä@†-ĖĀ 4Ú7đ?¯7ķG{KÁįKĶ›(Á”íÁ”Ü[rĨŅcļf 8Hōę7ŧV8H= 1.0: aux = saux return abs(a**2 * (1.0 - eps) / 2.0 * math.acos(aux)) def test_angles(phi_min=0.05, phi_max=0.2): a = 40.0 astep = 1.1 eps = 0.1 # r = a a1 = a * (1.0 - ((1.0 - 1.0 / astep) / 2.0)) a2 = a * (1.0 + (astep - 1.0) / 2.0) r3 = a2 r4 = a1 aux = min((a2 - a1), 3.0) sarea = (a2 - a1) * aux dphi = max(min((aux / a), phi_max), phi_min) phi = dphi / 2.0 phi2 = phi - dphi / 2.0 aux = 1.0 - eps r3 = a2 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) r4 = a1 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) ncount = 0 while phi < np.pi * 2: phi1 = phi2 r1 = r4 r2 = r3 phi2 = phi + dphi / 2.0 aux = 1.0 - eps r3 = a2 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) r4 = a1 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) sa1 = sector_area(a1, eps, phi1, r1) sa2 = sector_area(a2, eps, phi1, r2) sa3 = sector_area(a2, eps, phi2, r3) sa4 = sector_area(a1, eps, phi2, r4) area = abs((sa3 - sa2) - (sa4 - sa1)) # Compute step to next sector and its angular span dphi = max(min((sarea / (r3 - r4) / r4), phi_max), phi_min) phistep = dphi / 2.0 + phi2 - phi ncount += 1 assert 11.0 < area < 12.4 phi = phi + min(phistep, 0.5) # r = (a * (1.0 - eps) / np.sqrt(((1.0 - eps) * np.cos(phi))**2 + # (np.sin(phi))**2)) assert ncount == 72 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_ellipse.py0000644000175100001770000001361114637570305023345 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse module. """ import math from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from photutils.datasets import get_path, make_noise_image from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import Isophote, IsophoteList from photutils.isophote.tests.make_test_data import make_test_image from photutils.tests.helper import PYTEST_LT_80 from photutils.utils._optional_deps import HAS_SCIPY # define an off-center position and a tilted sma POS = 384 PA = 10.0 / 180.0 * np.pi # build off-center test data. It's fine to have a single np array to use # in all tests that need it, but do not use a single instance of # EllipseGeometry. The code may eventually modify it's contents. The safe # bet is to build it wherever it's needed. The cost is negligible. OFFSET_GALAXY = make_test_image(x0=POS, y0=POS, pa=PA, noise=1.0e-12, seed=0) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestEllipse: def setup_class(self): # centered, tilted galaxy self.data = make_test_image(pa=PA, seed=0) @pytest.mark.remote_data def test_find_center(self): path = get_path('isophote/M51.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) data = hdu[0].data hdu.close() geometry = EllipseGeometry(252, 253, 10.0, 0.2, np.pi / 2) geometry.find_center(data) assert geometry.x0 == 257.0 assert geometry.y0 == 258.0 def test_basic(self): ellipse = Ellipse(self.data) isophote_list = ellipse.fit_image() assert isinstance(isophote_list, IsophoteList) assert len(isophote_list) > 1 assert isinstance(isophote_list[0], Isophote) # verify that the list is properly sorted in sem-major axis length assert isophote_list[-1] > isophote_list[0] # the fit should stop where gradient loses reliability. assert len(isophote_list) == 69 assert isophote_list[-1].stop_code == 1 def test_linear(self): ellipse = Ellipse(self.data) isophote_list = ellipse.fit_image(linear=True, step=2.0) # verify that the list is properly sorted in sem-major axis length assert isophote_list[-1] > isophote_list[0] # difference in sma between successive isohpotes must be constant. step = isophote_list[-1].sma - isophote_list[-2].sma assert math.isclose((isophote_list[-2].sma - isophote_list[-3].sma), step, rel_tol=0.01) assert math.isclose((isophote_list[-3].sma - isophote_list[-4].sma), step, rel_tol=0.01) assert math.isclose((isophote_list[2].sma - isophote_list[1].sma), step, rel_tol=0.01) def test_fit_one_ellipse(self): ellipse = Ellipse(self.data) isophote = ellipse.fit_isophote(40.0) assert isinstance(isophote, Isophote) assert isophote.valid def test_offcenter_fail(self): # A first guess ellipse that is centered in the image frame. # This should result in failure since the real galaxy # image is off-center by a large offset. ellipse = Ellipse(OFFSET_GALAXY) match1 = 'Degrees of freedom' ctx1 = pytest.warns(RuntimeWarning, match=match1) if PYTEST_LT_80: ctx2 = nullcontext() ctx3 = nullcontext() ctx4 = nullcontext() else: match2 = 'Mean of empty slice' match3 = 'invalid value encountered' match4 = 'No meaningful fit was possible' ctx2 = pytest.warns(RuntimeWarning, match=match2) ctx3 = pytest.warns(RuntimeWarning, match=match3) ctx4 = pytest.warns(AstropyUserWarning, match=match4) with ctx1, ctx2, ctx3, ctx4: isophote_list = ellipse.fit_image() assert len(isophote_list) == 0 def test_offcenter_fit(self): # A first guess ellipse that is roughly centered on the # offset galaxy image. g = EllipseGeometry(POS + 5, POS + 5, 10.0, eps=0.2, pa=PA, astep=0.1) ellipse = Ellipse(OFFSET_GALAXY, geometry=g) isophote_list = ellipse.fit_image() # the fit should stop when too many potential sample # points fall outside the image frame. assert len(isophote_list) == 63 assert isophote_list[-1].stop_code == 1 def test_offcenter_go_beyond_frame(self): # Same as before, but now force the fit to goo # beyond the image frame limits. g = EllipseGeometry(POS + 5, POS + 5, 10.0, eps=0.2, pa=PA, astep=0.1) ellipse = Ellipse(OFFSET_GALAXY, geometry=g) isophote_list = ellipse.fit_image(maxsma=400.0) # the fit should go to maxsma, but with fixed geometry assert len(isophote_list) == 71 assert isophote_list[-1].stop_code == 4 # check that no zero-valued intensities were left behind # in the sample arrays when sampling outside the image. for iso in isophote_list: assert not np.any(iso.sample.values[2] == 0) def test_ellipse_shape(self): """Regression test for #670/673.""" ny = 500 nx = 150 g = Gaussian2D(100.0, nx / 2.0, ny / 2.0, 20, 12, theta=40.0 * np.pi / 180.0) y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=0) data = g(x, y) + noise ellipse = Ellipse(data) # estimates initial center isolist = ellipse.fit_image() assert len(isolist) == 54 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_fitter.py0000644000175100001770000001714614637570305023214 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the fitter module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.fitter import CentralEllipseFitter, EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.harmonics import fit_first_and_second_harmonics from photutils.isophote.integrator import MEAN from photutils.isophote.isophote import Isophote from photutils.isophote.sample import CentralEllipseSample, EllipseSample from photutils.isophote.tests.make_test_data import make_test_image from photutils.utils._optional_deps import HAS_SCIPY DATA = make_test_image(seed=0) DEFAULT_POS = 256 DEFAULT_FIX = np.array([False, False, False, False]) def test_gradient(): sample = EllipseSample(DATA, 40.0) sample.update(DEFAULT_FIX) assert_allclose(sample.mean, 200.02, atol=0.01) assert_allclose(sample.gradient, -4.222, atol=0.001) assert_allclose(sample.gradient_error, 0.0003, atol=0.0001) assert_allclose(sample.gradient_relative_error, 7.45e-05, atol=1.0e-5) assert_allclose(sample.sector_area, 2.00, atol=0.01) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_raw(): """ This test performs a raw (no EllipseFitter), 1-step correction in one single ellipse coefficient. """ # pick first guess ellipse that is off in just # one of the parameters (eps). sample = EllipseSample(DATA, 40.0, eps=2 * 0.2) sample.update(DEFAULT_FIX) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) _, a1, b1, a2, b2 = harmonics[0] # when eps is off, b2 is the largest (in absolute value). assert abs(b2) > abs(a1) assert abs(b2) > abs(b1) assert abs(b2) > abs(a2) correction = (b2 * 2.0 * (1.0 - sample.geometry.eps) / sample.geometry.sma / sample.gradient) new_eps = sample.geometry.eps - correction # got closer to test data (eps=0.2) assert_allclose(new_eps, 0.21, atol=0.01) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_small_radii(): sample = EllipseSample(DATA, 2.0) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isinstance(isophote, Isophote) assert isophote.ndata == 13 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_eps(): # initial guess is off in the eps parameter sample = EllipseSample(DATA, 40.0, eps=2 * 0.2) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isinstance(isophote, Isophote) g = isophote.sample.geometry assert g.eps >= 0.19 assert g.eps <= 0.21 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_pa(): data = make_test_image(pa=np.pi / 4, noise=0.01, seed=0) # initial guess is off in the pa parameter sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) isophote = fitter.fit() g = isophote.sample.geometry assert g.pa >= (np.pi / 4 - 0.05) assert g.pa <= (np.pi / 4 + 0.05) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_xy(): pos = DEFAULT_POS - 5 data = make_test_image(x0=pos, y0=pos, seed=0) # initial guess is off in the x0 and y0 parameters sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) isophote = fitter.fit() g = isophote.sample.geometry assert g.x0 >= (pos - 1) assert g.x0 <= (pos + 1) assert g.y0 >= (pos - 1) assert g.y0 <= (pos + 1) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitting_all(): # build test image that is off from the defaults # assumed by the EllipseSample constructor. pos = DEFAULT_POS - 5 angle = np.pi / 4 eps = 2 * 0.2 data = make_test_image(x0=pos, y0=pos, eps=eps, pa=angle, seed=0) sma = 60.0 # initial guess is off in all parameters. We find that the initial # guesses, especially for position angle, must be kinda close to the # actual value. 20% off max seems to work in this case of high SNR. sample = EllipseSample(data, sma, position_angle=(1.2 * angle)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.stop_code == 0 g = isophote.sample.geometry assert g.x0 >= (pos - 1.5) # position within 1.5 pixel assert g.x0 <= (pos + 1.5) assert g.y0 >= (pos - 1.5) assert g.y0 <= (pos + 1.5) assert g.eps >= (eps - 0.01) # eps within 0.01 assert g.eps <= (eps + 0.01) assert g.pa >= (angle - 0.05) # pa within 5 deg assert g.pa <= (angle + 0.05) sample_m = EllipseSample(data, sma, position_angle=(1.2 * angle), integrmode=MEAN) fitter_m = EllipseFitter(sample_m) isophote_m = fitter_m.fit() assert isophote_m.stop_code == 0 @pytest.mark.remote_data @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestM51: def setup_class(self): path = get_path('isophote/M51.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) self.data = hdu[0].data hdu.close() def test_m51(self): # here we evaluate the detailed convergence behavior # for a particular ellipse where we can see the eps # parameter jumping back and forth. # sample = EllipseSample(self.data, 13.31000001, eps=0.16, # position_angle=((-37.5+90)/180.*np.pi)) # sample.update() # fitter = EllipseFitter(sample) # isophote = fitter.fit() # we start the fit with initial values taken from # previous isophote, as determined by the old code. # sample taken in high SNR region sample = EllipseSample(self.data, 21.44, eps=0.18, position_angle=(36.0 / 180.0 * np.pi)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.ndata == 119 assert_allclose(isophote.intens, 685.4, atol=0.1) # last sample taken by the original code, before turning inwards. sample = EllipseSample(self.data, 61.16, eps=0.219, position_angle=((77.5 + 90) / 180 * np.pi)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.ndata == 382 assert_allclose(isophote.intens, 155.0, atol=0.1) def test_m51_outer(self): # sample taken at the outskirts of the image, so many # data points lay outside the image frame. This checks # for the presence of gaps in the sample arrays. sample = EllipseSample(self.data, 330.0, eps=0.2, position_angle=((90) / 180 * np.pi), integrmode='median') fitter = EllipseFitter(sample) isophote = fitter.fit() assert not np.any(isophote.sample.values[2] == 0) def test_m51_central(self): # this code finds central x and y offset by about 0.1 pixel wrt the # spp code. In here we use as input the position computed by this # code, thus this test is checking just the extraction algorithm. g = EllipseGeometry(257.02, 258.1, 0.0, 0.0, 0.0, 0.1, False) sample = CentralEllipseSample(self.data, 0.0, geometry=g) fitter = CentralEllipseFitter(sample) isophote = fitter.fit() # the central pixel intensity is about 3% larger than # found by the spp code. assert isophote.ndata == 1 assert isophote.intens <= 7560.0 assert isophote.intens >= 7550.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_geometry.py0000644000175100001770000001165714637570305023553 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the geometry module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.isophote.geometry import EllipseGeometry @pytest.mark.parametrize('astep, linear_growth', [(0.2, False), (20.0, True)]) def test_geometry(astep, linear_growth): geometry = EllipseGeometry(255.0, 255.0, 100.0, 0.4, np.pi / 2, astep, linear_growth) sma1, sma2 = geometry.bounding_ellipses() assert_allclose((sma1, sma2), (90.0, 110.0), atol=0.01) # using an arbitrary angle of 0.5 rad. This is to avoid a polar # vector that sits on top of one of the ellipse's axis. vertex_x, vertex_y = geometry.initialize_sector_geometry(0.6) assert_allclose(geometry.sector_angular_width, 0.0571, atol=0.01) assert_allclose(geometry.sector_area, 63.83, atol=0.01) assert_allclose(vertex_x, [215.4, 206.6, 213.5, 204.3], atol=0.1) assert_allclose(vertex_y, [316.1, 329.7, 312.5, 325.3], atol=0.1) def test_to_polar(): # trivial case of a circle centered in (0.0, 0.0) geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, False) r, p = geometry.to_polar(100.0, 0.0) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, 0.0, atol=0.0001) r, p = geometry.to_polar(0.0, 100.0) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, np.pi / 2.0, atol=0.0001) # vector with length 100.0 at 45 deg angle r, p = geometry.to_polar(70.71, 70.71) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, np.pi / 4.0, atol=0.0001) # position angle tilted 45 deg from X axis geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, np.pi / 4.0, 0.2, False) r, p = geometry.to_polar(100.0, 0.0) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, np.pi * 7.0 / 4.0, atol=0.0001) r, p = geometry.to_polar(0.0, 100.0) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, np.pi / 4.0, atol=0.0001) # vector with length 100.0 at 45 deg angle r, p = geometry.to_polar(70.71, 70.71) assert_allclose(r, 100.0, atol=0.1) assert_allclose(p, np.pi * 2.0, atol=0.0001) def test_area(): # circle with center at origin geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 45.0 / 180.0 * np.pi) assert_allclose(vertex_x, [65.21, 79.70, 62.03, 75.81], atol=0.01) assert_allclose(vertex_y, [62.03, 75.81, 65.21, 79.70], atol=0.01) # sector at 0 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry(0) assert_allclose(vertex_x, [89.97, 109.97, 89.97, 109.96], atol=0.01) assert_allclose(vertex_y, [-2.25, -2.75, 2.25, 2.75], atol=0.01) def test_area2(): # circle with center at 100.0, 100.0 geometry = EllipseGeometry(100.0, 100.0, 100.0, 0.0, 0.0, 0.2, False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 45.0 / 180.0 * np.pi) assert_allclose(vertex_x, [165.21, 179.70, 162.03, 175.81], atol=0.01) assert_allclose(vertex_y, [162.03, 175.81, 165.21, 179.70], atol=0.01) # sector at 225 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 225.0 / 180.0 * np.pi) assert_allclose(vertex_x, [34.79, 20.30, 37.97, 24.19], atol=0.01) assert_allclose(vertex_y, [37.97, 24.19, 34.79, 20.30], atol=0.01) def test_reset_sma(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, False) sma, step = geometry.reset_sma(0.2) assert_allclose(sma, 83.33, atol=0.01) assert_allclose(step, -0.1666, atol=0.001) geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 20.0, True) sma, step = geometry.reset_sma(20.0) assert_allclose(sma, 80.0, atol=0.01) assert_allclose(step, -20.0, atol=0.01) def test_update_sma(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, False) sma = geometry.update_sma(0.2) assert_allclose(sma, 120.0, atol=0.01) geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 20.0, True) sma = geometry.update_sma(20.0) assert_allclose(sma, 120.0, atol=0.01) def test_polar_angle_sector_limits(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.3, np.pi / 4, 0.2, False) geometry.initialize_sector_geometry(np.pi / 3) phi1, phi2 = geometry.polar_angle_sector_limits() assert_allclose(phi1, 1.022198, atol=0.0001) assert_allclose(phi2, 1.072198, atol=0.0001) def test_bounding_ellipses(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.3, np.pi / 4, 0.2, False) sma1, sma2 = geometry.bounding_ellipses() assert_allclose((sma1, sma2), (90.0, 110.0), atol=0.01) def test_radius(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.3, np.pi / 4, 0.2, False) r = geometry.radius(0.0) assert_allclose(r, 100.0, atol=0.01) r = geometry.radius(np.pi / 2) assert_allclose(r, 70.0, atol=0.01) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_harmonics.py0000644000175100001770000001732714637570305023703 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the harmonics module. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from numpy.testing import assert_allclose from photutils.isophote.ellipse import Ellipse from photutils.isophote.fitter import EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics, fit_upper_harmonic) from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_harmonics_1(): from scipy.optimize import leastsq # this is an almost as-is example taken from stackoverflow N = 100 # number of data points t = np.linspace(0, 4 * np.pi, N) # create artificial data with noise: # mean = 0.5, amplitude = 3.0, phase = 0.1, noise-std = 0.01 rng = np.random.default_rng(0) data = 3.0 * np.sin(t + 0.1) + 0.5 + 0.01 * rng.standard_normal(N) # first guesses for harmonic parameters guess_mean = np.mean(data) guess_std = 3 * np.std(data) / 2**0.5 guess_phase = 0 # Minimize the difference between the actual data and our "guessed" # parameters # optimize_func = lambda x: x[0] * np.sin(t + x[1]) + x[2] - data def optimize_func(x): return x[0] * np.sin(t + x[1]) + x[2] - data est_std, est_phase, est_mean = leastsq( optimize_func, [guess_std, guess_phase, guess_mean])[0] # recreate the fitted curve using the optimized parameters data_fit = est_std * np.sin(t + est_phase) + est_mean residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.001) assert_allclose(np.std(residual), 0.01, atol=0.01) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_harmonics_2(): # this uses the actual functional form used for fitting ellipses N = 100 E = np.linspace(0, 4 * np.pi, N) y0_0 = 100.0 a1_0 = 10.0 b1_0 = 5.0 a2_0 = 8.0 b2_0 = 2.0 rng = np.random.default_rng(0) data = (y0_0 + a1_0 * np.sin(E) + b1_0 * np.cos(E) + a2_0 * np.sin(2 * E) + b2_0 * np.cos(2 * E) + 0.01 * rng.standard_normal(N)) harmonics = fit_first_and_second_harmonics(E, data) y0, a1, b1, a2, b2 = harmonics[0] data_fit = (y0 + a1 * np.sin(E) + b1 * np.cos(E) + a2 * np.sin(2 * E) + b2 * np.cos(2 * E) + 0.01 * rng.standard_normal(N)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.01) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_harmonics_3(): """Tests an upper harmonic fit.""" N = 100 E = np.linspace(0, 4 * np.pi, N) y0_0 = 100.0 a1_0 = 10.0 b1_0 = 5.0 order = 3 rng = np.random.default_rng(0) data = (y0_0 + a1_0 * np.sin(order * E) + b1_0 * np.cos(order * E) + 0.01 * rng.standard_normal(N)) harmonic = fit_upper_harmonic(E, data, order) y0, a1, b1 = harmonic[0] rng = np.random.default_rng(0) data_fit = (y0 + a1 * np.sin(order * E) + b1 * np.cos(order * E) + 0.01 * rng.standard_normal(N)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.014) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestFitEllipseSamples: def setup_class(self): # major axis parallel to X image axis self.data1 = make_test_image(seed=0) # major axis tilted 45 deg wrt X image axis self.data2 = make_test_image(pa=np.pi / 4, seed=0) def test_fit_ellipsesample_1(self): sample = EllipseSample(self.data1, 40.0) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 200.019, atol=0.001) assert_allclose(np.mean(a1), -0.000138, atol=0.001) assert_allclose(np.mean(b1), 0.000254, atol=0.001) assert_allclose(np.mean(a2), -5.658e-05, atol=0.001) assert_allclose(np.mean(b2), -0.00911, atol=0.001) # check that harmonics subtract nicely model = first_and_second_harmonic_function( s[0], np.array([y0, a1, b1, a2, b2])) residual = s[2] - model assert_allclose(np.mean(residual), 0.0, atol=0.001) assert_allclose(np.std(residual), 0.015, atol=0.01) def test_fit_ellipsesample_2(self): # initial guess is rounder than actual image sample = EllipseSample(self.data1, 40.0, eps=0.1) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 188.686, atol=0.001) assert_allclose(np.mean(a1), 0.000283, atol=0.001) assert_allclose(np.mean(b1), 0.00692, atol=0.001) assert_allclose(np.mean(a2), -0.000215, atol=0.001) assert_allclose(np.mean(b2), 10.153, atol=0.001) def test_fit_ellipsesample_3(self): # initial guess for center is offset sample = EllipseSample(self.data1, x0=220.0, y0=210.0, sma=40.0) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 152.660, atol=0.001) assert_allclose(np.mean(a1), 55.338, atol=0.001) assert_allclose(np.mean(b1), 33.091, atol=0.001) assert_allclose(np.mean(a2), 33.036, atol=0.001) assert_allclose(np.mean(b2), -14.306, atol=0.001) def test_fit_ellipsesample_4(self): sample = EllipseSample(self.data2, 40.0, eps=0.4) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 245.102, atol=0.001) assert_allclose(np.mean(a1), -0.003108, atol=0.001) assert_allclose(np.mean(b1), -0.0578, atol=0.001) assert_allclose(np.mean(a2), 28.781, atol=0.001) assert_allclose(np.mean(b2), -63.184, atol=0.001) def test_fit_upper_harmonics(self): data = make_test_image(noise=1.0e-10, seed=0) sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) iso = fitter.fit(maxit=400) assert_allclose(iso.a3, 6.825e-7, atol=1.0e-8) assert_allclose(iso.b3, -1.68e-6, atol=1.0e-8) assert_allclose(iso.a4, 4.36e-6, atol=1.0e-8) assert_allclose(iso.b4, -4.73e-5, atol=1.0e-7) assert_allclose(iso.a3_err, 8.152e-6, atol=1.0e-7) assert_allclose(iso.b3_err, 8.115e-6, atol=1.0e-7) assert_allclose(iso.a4_err, 7.501e-6, atol=1.0e-7) assert_allclose(iso.b4_err, 7.473e-6, atol=1.0e-7) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_upper_harmonics_sign(): """ Regression test for #1486/#1501. """ angle = 40.0 * np.pi / 180.0 g1 = Gaussian2D(100.0, 75, 75, 15, 3, theta=angle) g2 = Gaussian2D(100.0, 75, 75, 10, 8, theta=angle) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] data = g1(x, y) + g2(x, y) geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.9, pa=angle) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() # test image is "disky: disky isophotes have b4 > 0 # (boxy isophotes have b4 < 0) assert np.all(isolist.b4[30:] > 0) assert isolist.a3[-1] < 0 assert isolist.a4[-1] < 0 assert isolist.b3[-1] > 0 assert isolist.b4[-1] > 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_integrator.py0000644000175100001770000001215314637570305024066 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the integrator module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.integrator import (BILINEAR, MEAN, MEDIAN, NEAREST_NEIGHBOR) from photutils.isophote.sample import EllipseSample @pytest.mark.remote_data class TestData: def setup_class(self): path = get_path('isophote/synth_highsnr.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) self.data = hdu[0].data hdu.close() def make_sample(self, masked=False, sma=40.0, integrmode=BILINEAR): if masked: data = np.ma.masked_values(self.data, 200.0, atol=10.0, rtol=0.0) else: data = self.data sample = EllipseSample(data, sma, integrmode=integrmode) s = sample.extract() assert len(s) == 3 assert len(s[0]) == len(s[1]) assert len(s[0]) == len(s[2]) return s, sample @pytest.mark.remote_data class TestUnmasked(TestData): def test_bilinear(self): s, sample = self.make_sample() assert len(s[0]) == 225 # intensities assert_allclose(np.mean(s[2]), 200.76, atol=0.01) assert_allclose(np.std(s[2]), 21.55, atol=0.01) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 225 def test_bilinear_small(self): # small radius forces sub-pixel sampling s, sample = self.make_sample(sma=10.0) # intensities assert_allclose(np.mean(s[2]), 1045.4, atol=0.1) assert_allclose(np.std(s[2]), 143.0, atol=0.1) # radii assert_allclose(np.max(s[1]), 10.0, atol=0.1) assert_allclose(np.min(s[1]), 8.0, atol=0.1) assert sample.total_points == 57 assert sample.actual_points == 57 def test_nearest_neighbor(self): s, sample = self.make_sample(integrmode=NEAREST_NEIGHBOR) assert len(s[0]) == 225 # intensities assert_allclose(np.mean(s[2]), 201.1, atol=0.1) assert_allclose(np.std(s[2]), 21.8, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 225 def test_mean(self): s, sample = self.make_sample(integrmode=MEAN) assert len(s[0]) == 64 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 21.3, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 64 def test_mean_small(self): s, sample = self.make_sample(sma=5.0, integrmode=MEAN) assert len(s[0]) == 29 # intensities assert_allclose(np.mean(s[2]), 2339.0, atol=0.1) assert_allclose(np.std(s[2]), 284.7, atol=0.1) # radii assert_allclose(np.max(s[1]), 5.0, atol=0.01) assert_allclose(np.min(s[1]), 4.0, atol=0.01) assert_allclose(sample.sector_area, 2.0, atol=0.1) assert sample.total_points == 29 assert sample.actual_points == 29 def test_median(self): s, sample = self.make_sample(integrmode=MEDIAN) assert len(s[0]) == 64 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 21.3, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.01, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 64 @pytest.mark.remote_data class TestMasked(TestData): def test_bilinear(self): s, sample = self.make_sample(masked=True, integrmode=BILINEAR) assert len(s[0]) == 157 # intensities assert_allclose(np.mean(s[2]), 201.52, atol=0.01) assert_allclose(np.std(s[2]), 25.21, atol=0.01) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 157 def test_mean(self): s, sample = self.make_sample(masked=True, integrmode=MEAN) assert len(s[0]) == 51 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 24.12, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 51 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_isophote.py0000644000175100001770000002537014637570305023547 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the isophote module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.fitter import EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import Isophote, IsophoteList from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image from photutils.utils._optional_deps import HAS_SCIPY DEFAULT_FIX = np.array([False, False, False, False]) @pytest.mark.remote_data @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestIsophote: def setup_class(self): path = get_path('isophote/M51.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) self.data = hdu[0].data hdu.close() def test_fit(self): # low noise image, fitted perfectly by sample data = make_test_image(noise=1.0e-10, seed=0) sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) iso = fitter.fit(maxit=400) assert iso.valid assert iso.stop_code in (0, 2) # fitted values assert iso.intens <= 201.0 assert iso.intens >= 199.0 assert iso.int_err <= 0.0010 assert iso.int_err >= 0.0009 assert iso.pix_stddev <= 0.03 assert iso.pix_stddev >= 0.02 assert abs(iso.grad) <= 4.25 assert abs(iso.grad) >= 4.20 # integrals assert iso.tflux_e <= 1.85e6 assert iso.tflux_e >= 1.82e6 assert iso.tflux_c <= 2.025e6 assert iso.tflux_c >= 2.022e6 # deviations from perfect ellipticity. Note # that sometimes a None covariance can be # generated by scipy.optimize.leastsq assert iso.a3 is None or abs(iso.a3) <= 0.01 assert iso.b3 is None or abs(iso.b3) <= 0.01 assert iso.a4 is None or abs(iso.a4) <= 0.01 assert iso.b4 is None or abs(iso.b4) <= 0.01 def test_m51(self): sample = EllipseSample(self.data, 21.44) fitter = EllipseFitter(sample) iso = fitter.fit() assert iso.valid assert iso.stop_code in (0, 2) # geometry g = iso.sample.geometry assert g.x0 >= (257 - 1.5) # position within 1.5 pixel assert g.x0 <= (257 + 1.5) assert g.y0 >= (259 - 1.5) assert g.y0 <= (259 + 2.0) assert g.eps >= (0.19 - 0.05) # eps within 0.05 assert g.eps <= (0.19 + 0.05) assert g.pa >= (0.62 - 0.05) # pa within 5 deg assert g.pa <= (0.62 + 0.05) # fitted values assert_allclose(iso.intens, 682.9, atol=0.1) assert_allclose(iso.rms, 83.27, atol=0.01) assert_allclose(iso.int_err, 7.63, atol=0.01) assert_allclose(iso.pix_stddev, 117.8, atol=0.1) assert_allclose(iso.grad, -36.08, atol=0.1) # integrals assert iso.tflux_e <= 1.20e6 assert iso.tflux_e >= 1.19e6 assert iso.tflux_c <= 1.38e6 assert iso.tflux_c >= 1.36e6 # deviations from perfect ellipticity. Note # that sometimes a None covariance can be # generated by scipy.optimize.leastsq assert iso.a3 is None or abs(iso.a3) <= 0.05 assert iso.b3 is None or abs(iso.b3) <= 0.05 assert iso.a4 is None or abs(iso.a4) <= 0.05 assert iso.b4 is None or abs(iso.b4) <= 0.05 def test_m51_niter(self): # compares with old STSDAS task. In this task, the # default for the starting value of SMA is 10; it # fits with 20 iterations. sample = EllipseSample(self.data, 10) fitter = EllipseFitter(sample) iso = fitter.fit() assert iso.valid assert iso.niter == 50 def test_isophote_comparisons(): data = make_test_image(seed=0) sma1 = 40.0 sma2 = 100.0 k = 5 sample0 = EllipseSample(data, sma1 + k) sample1 = EllipseSample(data, sma1 + k) sample2 = EllipseSample(data, sma2 + k) sample0.update(DEFAULT_FIX) sample1.update(DEFAULT_FIX) sample2.update(DEFAULT_FIX) iso0 = Isophote(sample0, k, True, 0) iso1 = Isophote(sample1, k, True, 0) iso2 = Isophote(sample2, k, True, 0) assert iso1 < iso2 assert iso2 > iso1 assert iso1 <= iso2 assert iso2 >= iso1 assert iso1 != iso2 assert iso0 == iso1 with pytest.raises(AttributeError): assert iso1 < sample1 with pytest.raises(AttributeError): assert iso1 > sample1 with pytest.raises(AttributeError): assert iso1 <= sample1 with pytest.raises(AttributeError): assert iso1 >= sample1 with pytest.raises(AttributeError): assert iso1 == sample1 with pytest.raises(AttributeError): assert iso1 != sample1 class TestIsophoteList: def setup_class(self): data = make_test_image(seed=0) self.slen = 5 self.isolist_sma10 = self.build_list(data, sma0=10.0, slen=self.slen) self.isolist_sma100 = self.build_list(data, sma0=100.0, slen=self.slen) self.isolist_sma200 = self.build_list(data, sma0=200.0, slen=self.slen) @staticmethod def build_list(data, sma0, slen=5): iso_list = [] for k in range(slen): sample = EllipseSample(data, float(k + sma0)) sample.update(DEFAULT_FIX) iso_list.append(Isophote(sample, k, True, 0)) result = IsophoteList(iso_list) return result def test_basic_list(self): # make sure it can be indexed as a list. result = self.isolist_sma10[:] assert isinstance(result[0], Isophote) # make sure the important arrays contain floats. # especially the sma array, which is derived # from a property in the Isophote class. assert isinstance(result.sma, np.ndarray) assert isinstance(result.sma[0], float) assert isinstance(result.intens, np.ndarray) assert isinstance(result.intens[0], float) assert isinstance(result.rms, np.ndarray) assert isinstance(result.int_err, np.ndarray) assert isinstance(result.pix_stddev, np.ndarray) assert isinstance(result.grad, np.ndarray) assert isinstance(result.grad_error, np.ndarray) assert isinstance(result.grad_r_error, np.ndarray) assert isinstance(result.sarea, np.ndarray) assert isinstance(result.niter, np.ndarray) assert isinstance(result.ndata, np.ndarray) assert isinstance(result.nflag, np.ndarray) assert isinstance(result.valid, np.ndarray) assert isinstance(result.stop_code, np.ndarray) assert isinstance(result.tflux_c, np.ndarray) assert isinstance(result.tflux_e, np.ndarray) assert isinstance(result.npix_c, np.ndarray) assert isinstance(result.npix_e, np.ndarray) assert isinstance(result.a3, np.ndarray) assert isinstance(result.a4, np.ndarray) assert isinstance(result.b3, np.ndarray) assert isinstance(result.b4, np.ndarray) samples = result.sample assert isinstance(samples, list) assert isinstance(samples[0], EllipseSample) iso = result.get_closest(13.6) assert isinstance(iso, Isophote) assert_allclose(iso.sma, 14.0, atol=1e-6) def test_extend(self): # the extend method shouldn't return anything, # and should modify the first list in place. inner_list = self.isolist_sma10[:] outer_list = self.isolist_sma100[:] assert len(inner_list) == self.slen assert len(outer_list) == self.slen inner_list.extend(outer_list) assert len(inner_list) == 2 * self.slen # the __iadd__ operator should behave like the # extend method. inner_list = self.isolist_sma10[:] outer_list = self.isolist_sma100[:] inner_list += outer_list assert len(inner_list) == 2 * self.slen # the __add__ operator should create a new IsophoteList # instance with the result, and should not modify # the operands. inner_list = self.isolist_sma10[:] outer_list = self.isolist_sma100[:] result = inner_list + outer_list assert isinstance(result, IsophoteList) assert len(inner_list) == self.slen assert len(outer_list) == self.slen assert len(result) == 2 * self.slen def test_slicing(self): iso_list = self.isolist_sma10[:] assert len(iso_list) == self.slen assert len(iso_list[1:-1]) == self.slen - 2 assert len(iso_list[2:-2]) == self.slen - 4 def test_combined(self): # combine extend with slicing. inner_list = self.isolist_sma10[:] outer_list = self.isolist_sma100[:] sublist = inner_list[2:-2] sublist.extend(outer_list) assert len(sublist) == 2 * self.slen - 4 # try one more slice. even_outer_list = self.isolist_sma200 sublist.extend(even_outer_list[1:-1]) assert len(sublist) == 2 * self.slen - 4 + 3 # combine __add__ with slicing. sublist = inner_list[2:-2] result = sublist + outer_list assert isinstance(result, IsophoteList) assert len(sublist) == self.slen - 4 assert len(result) == 2 * self.slen - 4 result = inner_list[2:-2] + outer_list assert isinstance(result, IsophoteList) assert len(result) == 2 * self.slen - 4 def test_sort(self): inner_list = self.isolist_sma10[:] outer_list = self.isolist_sma100[:] result = outer_list[2:-2] + inner_list assert result[-1].sma < result[0].sma result.sort() assert result[-1].sma > result[0].sma @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_to_table(self): test_img = make_test_image(nx=55, ny=55, x0=27, y0=27, background=100.0, noise=1.0e-6, i0=100.0, sma=10.0, eps=0.2, pa=0.0, seed=1) g = EllipseGeometry(27, 27, 5, 0.2, 0) ellipse = Ellipse(test_img, geometry=g, threshold=0.1) isolist = ellipse.fit_image(maxsma=27) assert len(isolist.get_names()) >= 30 # test for get_names tbl = isolist.to_table() assert len(tbl.colnames) == 18 tbl = isolist.to_table(columns='all') assert len(tbl.colnames) >= 30 tbl = isolist.to_table(columns='main') assert len(tbl.colnames) == 18 tbl = isolist.to_table(columns=['sma']) assert len(tbl.colnames) == 1 tbl = isolist.to_table(columns=['tflux_e', 'tflux_c', 'npix_e', 'npix_c']) assert len(tbl.colnames) == 4 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_model.py0000644000175100001770000000730614637570305023014 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model module. """ import warnings from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits from astropy.utils.data import get_pkg_data_filename from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.model import build_ellipse_model from photutils.isophote.tests.make_test_data import make_test_image from photutils.tests.helper import PYTEST_LT_80 from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.remote_data @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_model(): path = get_path('isophote/M105-S001-RGB.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) data = hdu[0].data[0] hdu.close() g = EllipseGeometry(530.0, 511, 10.0, 0.1, 10.0 / 180.0 * np.pi) ellipse = Ellipse(data, geometry=g, threshold=1.0e5) # NOTE: this sometimes emits warnings (e.g., py38, ubuntu), but # sometimes not. Here we simply ignore any RuntimeWarning, whether # there is one or not. with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) isophote_list = ellipse.fit_image() model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[10:100, 10:100])) assert data.shape == model.shape residual = data - model assert np.mean(residual) <= 5.0 assert np.mean(residual) >= -5.0 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_model_simulated_data(): data = make_test_image(nx=200, ny=200, i0=10.0, sma=5.0, eps=0.5, pa=np.pi / 3.0, noise=0.05, seed=0) g = EllipseGeometry(100.0, 100.0, 5.0, 0.5, np.pi / 3.0) ellipse = Ellipse(data, geometry=g, threshold=1.0e5) isophote_list = ellipse.fit_image() model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[0:50, 0:50])) assert data.shape == model.shape residual = data - model assert np.mean(residual) <= 5.0 assert np.mean(residual) >= -5.0 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_model_minimum_radius(): # This test requires a "defective" image that drives the # model building algorithm into a corner, where it fails. # With the algorithm fixed, it bypasses the failure and # succeeds in building the model image. filepath = get_pkg_data_filename('data/minimum_radius_test.fits') with fits.open(filepath) as hdu: data = hdu[0].data g = EllipseGeometry(50.0, 45, 530.0, 0.1, 10.0 / 180.0 * np.pi) g.find_center(data) ellipse = Ellipse(data, geometry=g) match1 = 'Degrees of freedom' ctx1 = pytest.warns(RuntimeWarning, match=match1) if PYTEST_LT_80: ctx2 = nullcontext() ctx3 = nullcontext() else: match2 = 'Mean of empty slice' match3 = 'invalid value encountered' ctx2 = pytest.warns(RuntimeWarning, match=match2) ctx3 = pytest.warns(RuntimeWarning, match=match3) with ctx1, ctx2, ctx3: isophote_list = ellipse.fit_image(sma0=40, minsma=0, maxsma=350.0, step=0.4, nclip=3) model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[0:50, 0:50])) # It's enough that the algorithm reached this point. The # actual accuracy of the modelling is being tested elsewhere. assert data.shape == model.shape ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_regression.py0000644000175100001770000001761414637570305024077 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Despite being cast as a unit test, this code implements regression testing of the Ellipse algorithm, against results obtained by the stsdas$analysis/isophote task 'ellipse'. The stsdas task was run on test images and results were stored in tables. The code here runs the Ellipse algorithm on the same images, producing a list of Isophote instances. The contents of this list then get compared with the contents of the corresponding table. Some quantities are compared in assert statements. These were designed to be executed only when the synth_highsnr.fits image is used as input. That way, we are mainly checking numerical differences that originate in the algorithms themselves, and not caused by noise. The quantities compared this way are: - mean intensity: less than 1% diff. for sma > 3 pixels, 5% otherwise - ellipticity: less than 1% diff. for sma > 3 pixels, 20% otherwise - position angle: less than 1 deg. diff. for sma > 3 pixels, 20 deg. otherwise - X and Y position: less than 0.2 pixel diff. For the M51 image we have mostly good agreement with the SPP code in most of the parameters (mean isophotal intensity agrees within a fraction of 1% mostly), but every now and then the ellipticity and position angle of the semi-major axis may differ by a large amount from what the SPP code measures. The code also stops prematurely wrt the larger sma values measured by the SPP code. This is caused by a difference in the way the gradient relative error is measured in each case, and suggests that the SPP code may have a bug. The not-so-good behavior observed in the case of the M51 image is to be expected though. This image is exactly the type of galaxy image for which the algorithm *wasn't* designed for. It has an almost negligible smooth ellipsoidal component, and a lot of lumpy spiral structure that causes the radial gradient computation to go berserk. On top of that, the ellipticity is small (roundish isophotes) throughout the image, causing large relative errors and instability in the fitting algorithm. For now, we can only check the bilinear integration mode. The mean and median modes cannot be checked since the original 'ellipse' task has a bug that causes the creation of erroneous output tables. A partial comparison could be made if we write new code that reads the standard output of 'ellipse' instead, captured from screen, and use it as reference for the regression. """ import math import os.path as op import numpy as np import pytest from astropy.io import fits from astropy.table import Table from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.integrator import BILINEAR from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') # @pytest.mark.parametrize('name', ['M51', 'synth', 'synth_lowsnr', # 'synth_highsnr']) @pytest.mark.parametrize('name', ['synth_highsnr']) @pytest.mark.remote_data def test_regression(name, integrmode=BILINEAR, verbose=False): """ NOTE: The original code in SPP won't create the right table for the MEAN integration moder, so use the screen output at synth_table_mean.txt to compare results visually with synth_table_mean.fits. """ filename = f'{name}_table.fits' path = op.join(op.dirname(op.abspath(__file__)), 'data', filename) table = Table.read(path) nrows = len(table['SMA']) path = get_path(f'isophote/{name}.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) data = hdu[0].data hdu.close() ellipse = Ellipse(data) isophote_list = ellipse.fit_image() # isophote_list = ellipse.fit_image(sclip=2.0, nclip=3) fmt = ('%5.2f %6.1f %8.3f %8.3f %8.3f %9.5f %6.2f ' '%6.2f %6.2f %5.2f %4d %3d %3d %2d') for row in range(nrows): try: iso = isophote_list[row] except IndexError: # skip non-existent rows in isophote list, if that's the case. break # data from Isophote sma_i = iso.sample.geometry.sma intens_i = iso.intens int_err_i = iso.int_err if iso.int_err else 0.0 pix_stddev_i = iso.pix_stddev if iso.pix_stddev else 0.0 rms_i = iso.rms if iso.rms else 0.0 ellip_i = iso.sample.geometry.eps if iso.sample.geometry.eps else 0.0 pa_i = iso.sample.geometry.pa if iso.sample.geometry.pa else 0.0 x0_i = iso.sample.geometry.x0 y0_i = iso.sample.geometry.y0 rerr_i = (iso.sample.gradient_relative_error if iso.sample.gradient_relative_error else 0.0) ndata_i = iso.ndata nflag_i = iso.nflag niter_i = iso.niter stop_i = iso.stop_code # convert to old code reference system pa_i = (pa_i - np.pi / 2) / np.pi * 180.0 x0_i += 1 y0_i += 1 # ref data from table sma_t = table['SMA'][row] intens_t = table['INTENS'][row] int_err_t = table['INT_ERR'][row] pix_stddev_t = table['PIX_VAR'][row] rms_t = table['RMS'][row] ellip_t = table['ELLIP'][row] pa_t = table['PA'][row] x0_t = table['X0'][row] y0_t = table['Y0'][row] rerr_t = table['GRAD_R_ERR'][row] ndata_t = table['NDATA'][row] nflag_t = table['NFLAG'][row] niter_t = table['NITER'][row] if table['NITER'][row] else 0 stop_t = table['STOP'][row] if table['STOP'][row] else -1 # relative differences sma_d = (sma_i - sma_t) / sma_t * 100.0 if sma_t > 0.0 else 0.0 intens_d = (intens_i - intens_t) / intens_t * 100.0 int_err_d = ((int_err_i - int_err_t) / int_err_t * 100.0 if int_err_t > 0.0 else 0.0) pix_stddev_d = ((pix_stddev_i - pix_stddev_t) / pix_stddev_t * 100.0 if pix_stddev_t > 0.0 else 0.0) rms_d = (rms_i - rms_t) / rms_t * 100.0 if rms_t > 0.0 else 0.0 ellip_d = (ellip_i - ellip_t) / ellip_t * 100.0 pa_d = pa_i - pa_t # diff in angle is absolute x0_d = x0_i - x0_t # diff in position is absolute y0_d = y0_i - y0_t rerr_d = rerr_i - rerr_t # diff in relative error is absolute ndata_d = (ndata_i - ndata_t) / ndata_t * 100.0 nflag_d = 0 niter_d = 0 stop_d = 0 if stop_i == stop_t else -1 if verbose: print('* data ' + fmt % (sma_i, intens_i, int_err_i, pix_stddev_i, rms_i, ellip_i, pa_i, x0_i, y0_i, rerr_i, ndata_i, nflag_i, niter_i, stop_i)) print(' ref ' + fmt % (sma_t, intens_t, int_err_t, pix_stddev_t, rms_t, ellip_t, pa_t, x0_t, y0_t, rerr_t, ndata_t, nflag_t, niter_t, stop_t)) print(' diff ' + fmt % (sma_d, intens_d, int_err_d, pix_stddev_d, rms_d, ellip_d, pa_d, x0_d, y0_d, rerr_d, ndata_d, nflag_d, niter_d, stop_d)) print() if name == 'synth_highsnr' and integrmode == BILINEAR: assert abs(x0_d) <= 0.21 assert abs(y0_d) <= 0.21 if sma_i > 3.0: assert abs(intens_d) <= 1.0 else: assert abs(intens_d) <= 5.0 # prevent "converting a masked element to nan" warning if ellip_d is np.ma.masked: continue if not math.isnan(ellip_d): if sma_i > 3.0: assert abs(ellip_d) <= 1.0 # 1% else: assert abs(ellip_d) <= 20.0 # 20% if not math.isnan(pa_d): if sma_i > 3.0: assert abs(pa_d) <= 1.0 # 1 deg. else: assert abs(pa_d) <= 20.0 # 20 deg. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/isophote/tests/test_sample.py0000644000175100001770000000345714637570305023200 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the sample module. """ import numpy as np import pytest from photutils.isophote.integrator import (BILINEAR, MEAN, MEDIAN, NEAREST_NEIGHBOR) from photutils.isophote.isophote import Isophote from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image DEFAULT_FIX = np.array([False, False, False, False]) DATA = make_test_image(background=100.0, i0=0.0, noise=10.0, seed=0) # the median is not so good at estimating rms @pytest.mark.parametrize('integrmode, amin, amax', [(NEAREST_NEIGHBOR, 7.0, 15.0), (BILINEAR, 7.0, 15.0), (MEAN, 7.0, 15.0), (MEDIAN, 6.0, 15.0)]) def test_scatter(integrmode, amin, amax): """ Check that the pixel standard deviation can be reliably estimated from the rms scatter and the sector area. The test data is just a flat image with noise, no galaxy. We define the noise rms and then compare how close the pixel std dev estimated at extraction matches this input noise. """ sample = EllipseSample(DATA, 50.0, astep=0.2, integrmode=integrmode) sample.update(DEFAULT_FIX) iso = Isophote(sample, 0, True, 0) assert iso.pix_stddev < amax assert iso.pix_stddev > amin def test_coordinates(): sample = EllipseSample(DATA, 50.0) sample.update(DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) def test_sclip(): sample = EllipseSample(DATA, 50.0, nclip=3) sample.update(DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.030885 photutils-1.13.0/photutils/morphology/0000755000175100001770000000000014637570322017477 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/__init__.py0000644000175100001770000000041614637570305021612 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for measuring morphological properties of objects in an astronomical image. """ from .core import * # noqa: F401, F403 from .non_parametric import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/core.py0000644000175100001770000000432714637570305021010 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for measuring morphological properties of sources. """ import numpy as np __all__ = ['data_properties'] def data_properties(data, mask=None, background=None, wcs=None): """ Calculate the morphological properties (and centroid) of a 2D array (e.g., an image cutout of an object) using image moments. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array of the image. mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. background : float, array_like, or `~astropy.units.Quantity`, optional The background level that was previously present in the input ``data``. ``background`` may either be a scalar value or a 2D image with the same shape as the input ``data``. Inputting the ``background`` merely allows for its properties to be measured within each source segment. The input ``background`` does *not* get subtracted from the input ``data``, which should already be background-subtracted. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then all sky-based properties will be set to `None`. Returns ------- result : `~photutils.segmentation.SourceCatalog` instance A `~photutils.segmentation.SourceCatalog` object. """ # prevent circular import from photutils.segmentation import SegmentationImage, SourceCatalog segment_image = SegmentationImage(np.ones(data.shape, dtype=int)) if background is not None: background = np.atleast_1d(background) if background.shape == (1,): background = np.zeros(data.shape) + background return SourceCatalog(data, segment_image, mask=mask, background=background, wcs=wcs)[0] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/non_parametric.py0000644000175100001770000000352414637570305023057 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides functions for measuring non-parametric morphologies of sources. """ import numpy as np __all__ = ['gini'] def gini(data): r""" Calculate the `Gini coefficient `_ of a 2D array. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over all pixel values :math:`x_i`. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. Usually Gini's measurement needs some sort of preprocessing for defining the galaxy region in the image based on the quality of the input data. As there is not a general standard for doing this, this is left for the user. Parameters ---------- data : array_like The 2D data array or object that can be converted to an array. Returns ------- gini : `float` The Gini coefficient of the input 2D array. """ flattened = np.sort(np.ravel(data)) npix = np.size(flattened) normalization = np.abs(np.mean(flattened)) * npix * (npix - 1) kernel = (2.0 * np.arange(1, npix + 1) - npix - 1) * np.abs(flattened) return np.sum(kernel) / normalization ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.034885 photutils-1.13.0/photutils/morphology/tests/0000755000175100001770000000000014637570322020641 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/tests/__init__.py0000644000175100001770000000000014637570305022741 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/tests/test_core.py0000644000175100001770000000313514637570305023205 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.datasets import make_wcs from photutils.morphology import data_properties from photutils.utils._optional_deps import HAS_SKIMAGE XCS = [25.7] YCS = [26.2] XSTDDEVS = [3.2, 4.0] YSTDDEVS = [5.7, 4.1] THETAS = np.array([30.0, 45.0]) * np.pi / 180.0 DATA = np.zeros((3, 3)) DATA[0:2, 1] = 1.0 DATA[1, 0:2] = 1.0 DATA[1, 1] = 2.0 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_data_properties(): data = np.ones((2, 2)).astype(float) mask = np.array([[False, False], [True, True]]) props = data_properties(data, mask=None) props2 = data_properties(data, mask=mask) properties = ['xcentroid', 'ycentroid'] result = [getattr(props, i) for i in properties] result2 = [getattr(props2, i) for i in properties] assert_allclose([0.5, 0.5], result, rtol=0, atol=1.0e-6) assert_allclose([0.5, 0.0], result2, rtol=0, atol=1.0e-6) assert props.area.value == 4.0 assert props2.area.value == 2.0 wcs = make_wcs(data.shape) props = data_properties(data, mask=None, wcs=wcs) assert props.sky_centroid is not None @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_data_properties_bkg(): data = np.ones((3, 3)).astype(float) props = data_properties(data, background=1.0) assert props.area.value == 9.0 assert props.background_sum == 9.0 with pytest.raises(ValueError): data_properties(data, background=[1.0, 2.0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/morphology/tests/test_non_parametric.py0000644000175100001770000000073014637570305025254 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the non_parametric module. """ import numpy as np from photutils.morphology.non_parametric import gini def test_gini(): """ Test Gini coefficient calculation. """ data_evenly_distributed = np.ones((100, 100)) data_point_like = np.zeros((100, 100)) data_point_like[50, 50] = 1 assert gini(data_evenly_distributed) == 0.0 assert gini(data_point_like) == 1.0 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.034885 photutils-1.13.0/photutils/profiles/0000755000175100001770000000000014637570322017123 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/__init__.py0000644000175100001770000000042514637570305021236 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for generating radial profiles. """ from .core import * # noqa: F401, F403 from .curve_of_growth import * # noqa: F401, F403 from .radial_profile import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/core.py0000644000175100001770000002631414637570305020434 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a base class for profiles. """ import abc import warnings import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._quantity_helpers import process_quantities __all__ = ['ProfileBase'] class ProfileBase(metaclass=abc.ABCMeta): """ Abstract base class for profile classes. Parameters ---------- data : 2D `numpy.ndarray` The 2D data array. The data should be background-subtracted. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `numpy.ndarray` An array of radii defining the radial bins. ``radii`` must be strictly increasing with a minimum value greater than or equal to zero, and contain at least 2 values. The radial spacing does not need to be constant. For `~photutils.profiles.RadialProfile`, the input radii are the *edges* of the radial annulus bins, and the output `radius` represents the bin centers. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. mask : 2D bool `numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. """ def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): (data, error), unit = process_quantities((data, error), ('data', 'error')) if error is not None and error.shape != data.shape: raise ValueError('error must have the same shape as data') self.data = data self.unit = unit self.xycen = xycen self.radii = self._validate_radii(radii) self.error = error self.mask = self._compute_mask(data, error, mask) self.method = method self.subpixels = subpixels self.normalization_value = 1.0 def _validate_radii(self, radii): radii = np.array(radii) if radii.ndim != 1 or radii.size < 2: raise ValueError('radii must be a 1D array and have at ' 'least two values') if radii.min() < 0: raise ValueError('minimum radii must be >= 0') if not np.all(radii[1:] > radii[:-1]): raise ValueError('radii must be strictly increasing') return radii def _compute_mask(self, data, error, mask): """ Compute the mask array, automatically masking non-finite data or error values. """ badmask = ~np.isfinite(data) if error is not None: badmask |= ~np.isfinite(error) if mask is not None: if mask.shape != data.shape: raise ValueError('mask must have the same shape as data') badmask &= ~mask # non-finite values not in input mask mask |= badmask # all masked pixels else: mask = badmask if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN ' 'or inf) that were automatically masked.', AstropyUserWarning) return mask @lazyproperty def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @property @abc.abstractmethod def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @property @abc.abstractmethod def profile_error(self): """ The radial profile errors as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def _circular_apertures(self): """ A list of `~photutils.aperture.CircularAperture` objects. The first element may be `None`. """ from photutils.aperture import CircularAperture apertures = [] for radius in self.radii: if radius <= 0.0: apertures.append(None) else: apertures.append(CircularAperture(self.xycen, radius)) return apertures @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of radius. """ fluxes = [] fluxerrs = [] areas = [] for aperture in self._circular_apertures: if aperture is None: flux, fluxerr = [0.0], [0.0] area = 0.0 else: flux, fluxerr = aperture.do_photometry( self.data, error=self.error, mask=self.mask, method=self.method, subpixels=self.subpixels) area = aperture.area_overlap(self.data, mask=self.mask, method=self.method, subpixels=self.subpixels) fluxes.append(flux[0]) if self.error is not None: fluxerrs.append(fluxerr[0]) areas.append(area) fluxes = np.array(fluxes) fluxerrs = np.array(fluxerrs) areas = np.array(areas) if self.unit is not None: fluxes <<= self.unit fluxerrs <<= self.unit return fluxes, fluxerrs, areas def normalize(self, method='max'): """ Normalize the profile. Parameters ---------- method : {'max', 'sum'}, optional The method used to normalize the profile: * 'max' (default): The profile is normalized such that its maximum value is 1. * 'sum': The profile is normalized such that its sum (integral) is 1. """ if method == 'max': normalization = np.nanmax(self.profile) elif method == 'sum': normalization = np.nansum(self.profile) else: raise ValueError('invalid method, must be "max" or "sum"') if normalization == 0: warnings.warn('The profile cannot be normalized because the ' 'max or sum is zero.', AstropyUserWarning) else: # normalization_values accumulate if normalize is run # multiple times (e.g., different methods) self.normalization_value *= normalization # need to use __dict__ as these are lazy properties self.__dict__['profile'] = self.profile / normalization self.__dict__['profile_error'] = self.profile_error / normalization def unnormalize(self): """ Unnormalize the profile back to the original state before any calls to `normalize`. """ self.__dict__['profile'] = self.profile * self.normalization_value self.__dict__['profile_error'] = self.profile_error * self.normalization_value self.normalization_value = 1.0 def plot(self, ax=None, **kwargs): """ Plot the profile. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.pyplot.plot`. Returns ------- lines : list of `~matplotlib.lines.Line2D` A list of lines representing the plotted data. """ import matplotlib.pyplot as plt if ax is None: ax = plt.gca() lines = ax.plot(self.radius, self.profile, **kwargs) ax.set_xlabel('Radius (pixels)') ylabel = 'Profile' if self.unit is not None: ylabel = f'{ylabel} ({self.unit})' ax.set_ylabel(ylabel) return lines def plot_error(self, ax=None, **kwargs): """ Plot the profile errors. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.pyplot.fill_between`. Returns ------- lines : `matplotlib.collections.PolyCollection` A `~matplotlib.collections.PolyCollection` containing the plotted polygons. """ if self.profile_error.shape == (0,): warnings.warn('Errors were not input', AstropyUserWarning) return None import matplotlib.pyplot as plt if ax is None: ax = plt.gca() # set default fill_between facecolor # facecolor must be first key, otherwise it will override color kwarg # (i.e., cannot use setdefault here) if 'facecolor' not in kwargs: kws = {'facecolor': (0.5, 0.5, 0.5, 0.3)} kws.update(kwargs) else: kws = kwargs profile = self.profile profile_error = self.profile_error if self.unit is not None: profile = profile.value profile_error = profile_error.value ymin = profile - profile_error ymax = profile + profile_error polycoll = ax.fill_between(self.radius, ymin, ymax, **kws) return polycoll ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/curve_of_growth.py0000644000175100001770000003062314637570305022704 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating curves of growth. """ import numpy as np from astropy.utils import lazyproperty from photutils.profiles.core import ProfileBase __all__ = ['CurveOfGrowth'] class CurveOfGrowth(ProfileBase): """ Class to create a curve of growth using concentric circular apertures. The curve of growth profile represents the circular aperture flux as a function of circular radius. Parameters ---------- data : 2D `numpy.ndarray` The 2D data array. The data should be background-subtracted. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `numpy.ndarray` An array of the circular radii. ``radii`` must be strictly increasing with a minimum value greater than zero, and contain at least 2 values. The radial spacing does not need to be constant. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. mask : 2D bool `numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from astropy.visualization import simple_norm >>> from photutils.centroids import centroid_quadratic >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import CurveOfGrowth Create an artificial single source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) >>> data += error Create the curve of growth. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> radii = np.arange(1, 26) >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) >>> print(cog.radius) # doctest: +FLOAT_CMP [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25] >>> print(cog.profile) # doctest: +FLOAT_CMP [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 5.32777186 9.37111012 13.41750992 16.62928904 21.7350922 25.39862532 30.3867526 34.11478867 39.28263973 43.96047829 48.11931395 52.00967328 55.7471834 60.48824739 64.81392778 68.71042311 72.71899201 76.54959872 81.33806741 85.98568713 91.34841248 95.5173253 99.22190499 102.51980185 106.83601366] Plot the curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth cog.plot() cog.plot_error() Normalize the profile and plot the normalized curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth cog.normalize() cog.plot() cog.plot_error() Plot a couple of the apertures on the data. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) plt.imshow(data, norm=norm) cog.apertures[5].plot(color='C0', lw=2) cog.apertures[10].plot(color='C1', lw=2) cog.apertures[15].plot(color='C3', lw=2) """ def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): radii = np.array(radii) if radii.min() <= 0: raise ValueError('radii must be > 0') super().__init__(data, xycen, radii, error=error, mask=mask, method=method, subpixels=subpixels) @lazyproperty def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. This is the same as the input ``radii``. Note that these are the radii of the circular apertures used to measure the profile. Thus, they are the radial values that enclose the given flux. They can be used directly to measure the encircled energy/flux at a given radius. """ return self.radii @lazyproperty def apertures(self): """ A list of `~photutils.aperture.CircularAperture` objects used to measure the profile. If ``radius_min`` is zero, then the first item will be `None`. """ return self._circular_apertures @lazyproperty def profile(self): """ The curve-of-growth profile as a 1D `~numpy.ndarray`. """ return self._photometry[0] @lazyproperty def profile_error(self): """ The curve-of-growth profile errors as a 1D `~numpy.ndarray`. """ return self._photometry[1] @lazyproperty def area(self): """ The unmasked area in each circular aperture as a function of radius as a 1D `~numpy.ndarray`. """ return self._photometry[2] def calc_ee_at_radius(self, radius): """ Calculate the encircled energy at a given radius using a cubic interpolator based on the profile data. Note that this function assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large radius. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``radii``. Parameters ---------- radius : float or 1D `~numpy.ndarray` The circular radius/radii. Returns ------- ee : `~numpy.ndarray` The encircled energy at each radius/radii. Returns NaN for radii outside the range of the profile data. """ from scipy.interpolate import PchipInterpolator return PchipInterpolator(self.radius, self.profile, extrapolate=False)(radius) def calc_radius_at_ee(self, ee): """ Calculate the radius at a given encircled energy using a cubic interpolator based on the profile data. Note that this function assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large radius. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``radii``. This interpolator returns values only for regions where the curve-of-growth profile is monotonically increasing. Parameters ---------- ee : float or 1D `~numpy.ndarray` The encircled energy. Returns ------- radius : `~numpy.ndarray` The radius at each encircled energy. Returns NaN for encircled energies outside the range of the profile data. """ from scipy.interpolate import PchipInterpolator # restrict the profile to the monotonically increasing region; # this is necessary for the interpolator radius = self.radius profile = self.profile diff = np.diff(profile) <= 0 if np.any(diff): idx = np.argmax(diff) # first non-monotonic point radius = radius[0:idx] profile = profile[0:idx] if len(radius) < 2: raise ValueError('The curve-of-growth profile is not ' 'monotonically increasing even at the ' 'smallest radii -- cannot interpolate. Try ' 'using different input radii (especially the ' 'starting radii) and/or using the "exact" ' 'aperture overlap method.') return PchipInterpolator(profile, radius, extrapolate=False)(ee) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/radial_profile.py0000644000175100001770000003407614637570305022464 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating radial profiles. """ import warnings import numpy as np from astropy.modeling.fitting import LevMarLSQFitter from astropy.modeling.models import Gaussian1D from astropy.stats import gaussian_sigma_to_fwhm from astropy.utils import lazyproperty from photutils.profiles.core import ProfileBase __all__ = ['RadialProfile'] __doctest_requires__ = {'RadialProfile': ['scipy']} class RadialProfile(ProfileBase): """ Class to create a radial profile using concentric circular annulus apertures. The radial profile represents the azimuthally-averaged flux in circular annuli apertures as a function of radius. For this class, the input radii represent the edges of the radial bins. This differs from the `RadialProfile` class, where the inputs represent the centers of the radial bins. The output `radius` attribute represents the bin centers. Parameters ---------- data : 2D `numpy.ndarray` The 2D data array. The data should be background-subtracted. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `numpy.ndarray` An array of radii defining the *edges* of the radial bins. ``radii`` must be strictly increasing with a minimum value greater than or equal to zero, and contain at least 2 values. The radial spacing does not need to be constant. The output `radius` attribute will be defined at the bin centers. error : 2D `numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. mask : 2D bool `numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. See Also -------- RadialProfile Notes ----- If the minimum of ``radii`` is zero, then a circular aperture with radius equal to ``radii[1]`` will be used for the innermost aperture. Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from astropy.visualization import simple_norm >>> from photutils.centroids import centroid_quadratic >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import RadialProfile Create an artificial single source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) >>> data += error Create the radial profile. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> edge_radii = np.arange(26) >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) >>> print(rp.radius) # doctest: +FLOAT_CMP [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5 24.5] >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02 7.84616804e-04] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.69588246 0.81797694 0.61132694 0.44670831 0.49499835 0.38025361 0.40844702 0.32906672 0.36466713 0.33059274 0.29661894 0.27314739 0.25551933 0.27675376 0.25553986 0.23421017 0.22966813 0.21747036 0.23654884 0.22760386 0.23941711 0.20661313 0.18999134 0.17469024 0.19527558] Plot the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.plot() rp.plot_error() Normalize the profile and plot the normalized radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() rp.plot() rp.plot_error() Plot two of the annulus apertures on the data. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') plt.figure(figsize=(5, 5)) plt.imshow(data, norm=norm) rp.apertures[5].plot(color='C0', lw=2) rp.apertures[10].plot(color='C1', lw=2) rp.apertures[15].plot(color='C3', lw=2) Fit a 1D Gaussian to the radial profile and return the Gaussian model. >>> rp.gaussian_fit # doctest: +FLOAT_CMP >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.09260130738712 Plot the fitted 1D Gaussian on the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() rp.plot(label='Radial Profile') rp.plot_error() plt.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') plt.legend() """ @lazyproperty def radius(self): """ The profile radius (bin centers) in pixels as a 1D `~numpy.ndarray`. The returned radius values are defined as the arithmetic means of the input radial-bins edges (``radii``). For logarithmically-spaced input ``radii``, one could instead use a radius array defined using the geometric mean of the bin edges, i.e. ``np.sqrt(radii[:-1] * radii[1:])``. """ # define the radial bin centers from the radial bin edges return (self.radii[:-1] + self.radii[1:]) / 2 @lazyproperty def apertures(self): """ A list of the circular annulus apertures used to measure the radial profile. If the ``min_radius`` is less than or equal to half the ``radius_step``, then a circular aperture with radius equal to ``min_radius + 0.5 * radius_step`` will be used for the innermost aperture. """ from photutils.aperture import CircularAnnulus, CircularAperture apertures = [] for i in range(len(self.radii) - 1): try: aperture = CircularAnnulus(self.xycen, self.radii[i], self.radii[i + 1]) except ValueError: # zero radius aperture = CircularAperture(self.xycen, self.radii[i + 1]) apertures.append(aperture) return apertures @lazyproperty def _flux(self): """ The flux in a circular annulus. """ return np.diff(self._photometry[0]) @lazyproperty def _fluxerr(self): """ The flux error in a circular annulus. """ return np.sqrt(np.diff(self._photometry[1] ** 2)) @lazyproperty def area(self): """ The unmasked area in each circular annulus (or aperture) as a function of radius as a 1D `~numpy.ndarray`. """ return np.diff(self._photometry[2]) @lazyproperty def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux / self.area @lazyproperty def profile_error(self): """ The radial profile errors as a 1D `~numpy.ndarray`. """ if self.error is None: return self._fluxerr # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._fluxerr / self.area @lazyproperty def _profile_nanmask(self): return np.isfinite(self.profile) @lazyproperty def gaussian_fit(self): """ The fitted 1D Gaussian to the radial profile as a `~astropy.modeling.functional_models.Gaussian1D` model. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] amplitude = np.max(profile) std = np.sqrt(abs(np.sum(profile * radius**2) / np.sum(profile))) g_init = Gaussian1D(amplitude=amplitude, mean=0.0, stddev=std) g_init.mean.fixed = True fitter = LevMarLSQFitter() g_fit = fitter(g_init, radius, profile) return g_fit @lazyproperty def gaussian_profile(self): """ The fitted 1D Gaussian profile to the radial profile as a 1D `~numpy.ndarray`. """ return self.gaussian_fit(self.radius) @lazyproperty def gaussian_fwhm(self): """ The full-width at half-maximum (FWHM) in pixels of the 1D Gaussian fitted to the radial profile. """ return self.gaussian_fit.stddev.value * gaussian_sigma_to_fwhm ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.034885 photutils-1.13.0/photutils/profiles/tests/0000755000175100001770000000000014637570322020265 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/tests/__init__.py0000644000175100001770000000000014637570305022365 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/tests/test_curve_of_growth.py0000644000175100001770000001506614637570305025111 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture import CircularAperture from photutils.profiles import CurveOfGrowth from photutils.utils._optional_deps import HAS_MATPLOTLIB, HAS_SCIPY @pytest.fixture(name='profile_data') def fixture_profile_data(): xsize = 101 ysize = 80 xcen = (xsize - 1) / 2 ycen = (ysize - 1) / 2 xycen = (xcen, ycen) sig = 10.0 model = Gaussian2D(21., xcen, ycen, sig, sig) y, x = np.mgrid[0:ysize, 0:xsize] data = model(x, y) error = 10.0 * np.sqrt(data) mask = np.zeros(data.shape, dtype=bool) mask[:int(ycen), :int(xcen)] = True return xycen, data, error, mask def test_curve_of_growth(profile_data): xycen, data, _, _ = profile_data radii = np.arange(1, 37) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) assert_equal(cg1.radius, radii) assert cg1.area.shape == (36,) assert cg1.profile.shape == (36,) assert cg1.profile_error.shape == (0,) assert_allclose(cg1.area[0], np.pi) assert len(cg1.apertures) == 36 assert isinstance(cg1.apertures[0], CircularAperture) radii = np.arange(1, 36) cg2 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) assert cg2.area[0] > 0.0 assert isinstance(cg2.apertures[0], CircularAperture) def test_curve_of_growth_units(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) unit = u.Jy cg1 = CurveOfGrowth(data << unit, xycen, radii, error=error << unit, mask=None) assert cg1.profile.unit == unit assert cg1.profile_error.unit == unit with pytest.raises(ValueError): CurveOfGrowth(data << unit, xycen, radii, error=error, mask=None) def test_curve_of_growth_error(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) assert cg1.profile.shape == (35,) assert cg1.profile_error.shape == (35,) def test_curve_of_growth_mask(profile_data): xycen, data, error, mask = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) cg2 = CurveOfGrowth(data, xycen, radii, error=error, mask=mask) assert cg1.profile.sum() > cg2.profile.sum() assert np.sum(cg1.profile_error**2) > np.sum(cg2.profile_error**2) def test_curve_of_growth_normalize(profile_data): xycen, data, _, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg2 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) profile1 = cg1.profile cg1.normalize() profile2 = cg1.profile assert np.mean(profile2) < np.mean(profile1) cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='sum') cg1.normalize(method='max') cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='max') cg1.normalize(method='sum') cg1.normalize(method='max') cg1.normalize(method='max') cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='sum') profile3 = cg1.profile assert np.mean(profile3) < np.mean(profile1) cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) with pytest.raises(ValueError): cg1.normalize(method='invalid') cg1.__dict__['profile'] -= np.max(cg1.__dict__['profile']) msg = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=msg): cg1.normalize(method='max') @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_curve_of_growth_interp(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.normalize() ee_radii = np.array([0, 5, 10, 20, 25, 50], dtype=float) ee_vals = cg1.calc_ee_at_radius(ee_radii) ee_expected = np.array([np.nan, 0.1176754, 0.39409357, 0.86635049, 0.95805792, np.nan]) assert_allclose(ee_vals, ee_expected, rtol=1e-6) rr = cg1.calc_radius_at_ee(ee_vals) ee_radii[[0, -1]] = np.nan assert_allclose(rr, ee_radii, rtol=1e-6) radii = np.linspace(0.1, 36, 200) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None, method='center') ee_vals = cg1.calc_ee_at_radius(ee_radii) match = 'The curve-of-growth profile is not monotonically increasing' with pytest.raises(ValueError, match=match): cg1.calc_radius_at_ee(ee_vals) def test_curve_of_growth_inputs(profile_data): xycen, data, error, _ = profile_data msg = 'radii must be > 0' with pytest.raises(ValueError, match=msg): radii = np.arange(10) CurveOfGrowth(data, xycen, radii, error=None, mask=None) msg = 'radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=msg): CurveOfGrowth(data, xycen, [1], error=None, mask=None) with pytest.raises(ValueError, match=msg): CurveOfGrowth(data, xycen, np.arange(1, 7).reshape(2, 3), error=None, mask=None) msg = 'radii must be strictly increasing' with pytest.raises(ValueError, match=msg): radii = np.arange(1, 10)[::-1] CurveOfGrowth(data, xycen, radii, error=None, mask=None) with pytest.raises(ValueError): unit1 = u.Jy unit2 = u.km radii = np.arange(1, 36) CurveOfGrowth(data << unit1, xycen, radii, error=error << unit2) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_curve_of_growth_plot(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.plot() with pytest.warns(AstropyUserWarning, match='Errors were not input'): cg1.plot_error() cg2 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) cg2.plot() pc1 = cg2.plot_error() assert_allclose(pc1.get_facecolor(), [[0.5, 0.5, 0.5, 0.3]]) pc2 = cg2.plot_error(facecolor='blue') assert_allclose(pc2.get_facecolor(), [[0, 0, 1, 1]]) unit = u.Jy cg3 = CurveOfGrowth(data << unit, xycen, radii, error=error << unit, mask=None) cg3.plot() cg3.plot_error() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/profiles/tests/test_radial_profile.py0000644000175100001770000001322314637570305024654 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture import CircularAnnulus, CircularAperture from photutils.profiles import RadialProfile from photutils.utils._optional_deps import HAS_SCIPY @pytest.fixture(name='profile_data') def fixture_profile_data(): xsize = 101 ysize = 80 xcen = (xsize - 1) / 2 ycen = (ysize - 1) / 2 xycen = (xcen, ycen) sig = 10.0 model = Gaussian2D(21., xcen, ycen, sig, sig) y, x = np.mgrid[0:ysize, 0:xsize] data = model(x, y) error = 10.0 * np.sqrt(data) mask = np.zeros(data.shape, dtype=bool) mask[:int(ycen), :int(xcen)] = True return xycen, data, error, mask def test_radial_profile(profile_data): xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert_equal(rp1.radius, np.arange(35) + 0.5) assert rp1.area.shape == (35,) assert rp1.profile.shape == (35,) assert rp1.profile_error.shape == (0,) assert rp1.area[0] > 0.0 assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) edge_radii = np.arange(36) + 0.1 rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.apertures[0], CircularAnnulus) def test_radial_profile_inputs(profile_data): xycen, data, _, _ = profile_data msg = 'minimum radii must be >= 0' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(-1, 10) RadialProfile(data, xycen, edge_radii, error=None, mask=None) msg = 'radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=msg): edge_radii = [1] RadialProfile(data, xycen, edge_radii, error=None, mask=None) with pytest.raises(ValueError, match=msg): edge_radii = np.arange(6).reshape(2, 3) RadialProfile(data, xycen, edge_radii, error=None, mask=None) msg = 'radii must be strictly increasing' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(10)[::-1] RadialProfile(data, xycen, edge_radii, error=None, mask=None) msg = 'error must have the same shape as data' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(10) RadialProfile(data, xycen, edge_radii, error=np.ones(3), mask=None) msg = 'mask must have the same shape as data' with pytest.raises(ValueError, match=msg): edge_radii = np.arange(10) mask = np.ones(3, dtype=bool) RadialProfile(data, xycen, edge_radii, error=None, mask=mask) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_radial_profile_gaussian(profile_data): xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp1.gaussian_fit, Gaussian1D) assert rp1.gaussian_profile.shape == (35,) assert rp1.gaussian_fwhm < 23.6 edge_radii = np.arange(201) rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.gaussian_fit, Gaussian1D) assert rp2.gaussian_profile.shape == (200,) assert rp2.gaussian_fwhm < 23.6 def test_radial_profile_unit(profile_data): xycen, data, error, _ = profile_data edge_radii = np.arange(36) unit = u.Jy rp1 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit, mask=None) assert rp1.profile.unit == unit assert rp1.profile_error.unit == unit with pytest.raises(ValueError): RadialProfile(data << unit, xycen, edge_radii, error=error, mask=None) def test_radial_profile_error(profile_data): xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=error, mask=None) assert_equal(rp1.radius, np.arange(35) + 0.5) assert rp1.area.shape == (35,) assert rp1.profile.shape == (35,) assert rp1.profile_error.shape == (35,) assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) def test_radial_profile_normalize_nan(profile_data): """ If the profile has NaNs (e.g., aperture outside of the image), make sure the normalization ignores them. """ xycen, data, _, _ = profile_data edge_radii = np.arange(101) rp1 = RadialProfile(data, xycen, edge_radii) rp1.normalize() assert not np.isnan(rp1.profile[0]) def test_radial_profile_nonfinite(profile_data): xycen, data, error, _ = profile_data data2 = data.copy() data2[40, 40] = np.nan mask = ~np.isfinite(data2) edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=mask) rp2 = RadialProfile(data2, xycen, edge_radii, error=error, mask=mask) assert_allclose(rp1.profile, rp2.profile) msg = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=msg): rp3 = RadialProfile(data2, xycen, edge_radii, error=error, mask=None) assert_allclose(rp1.profile, rp3.profile) error2 = error.copy() error2[40, 40] = np.inf with pytest.warns(AstropyUserWarning, match=msg): rp4 = RadialProfile(data, xycen, edge_radii, error=error2, mask=None) assert_allclose(rp1.profile, rp4.profile) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.034885 photutils-1.13.0/photutils/psf/0000755000175100001770000000000014637570322016070 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/__init__.py0000644000175100001770000000162514637570305020206 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to perform point-spread-function (PSF) photometry. """ from . import (epsf, epsf_stars, griddedpsfmodel, groupers, models, photometry, utils) from .epsf import * # noqa: F401, F403 from .epsf_stars import * # noqa: F401, F403 from .griddedpsfmodel import * # noqa: F401, F403 from .groupers import * # noqa: F401, F403 from .matching import * # noqa: F401, F403 from .models import * # noqa: F401, F403 from .photometry import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 # exclude matching from this list to avoid sphinx warnings __all__ = [] __all__.extend(epsf.__all__) __all__.extend(epsf_stars.__all__) __all__.extend(griddedpsfmodel.__all__) __all__.extend(groupers.__all__) __all__.extend(models.__all__) __all__.extend(photometry.__all__) __all__.extend(utils.__all__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/epsf.py0000644000175100001770000007621014637570305017406 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to build and fit an effective PSF (ePSF) based on Anderson and King (2000; PASP 112, 1360) and Anderson (2016; WFC3 ISR 2016-12). """ import copy import warnings import numpy as np from astropy.modeling.fitting import LevMarLSQFitter from astropy.nddata.utils import NoOverlapError, PartialOverlapError from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from photutils.centroids import centroid_com from photutils.psf.epsf_stars import EPSFStar, EPSFStars, LinkedEPSFStar from photutils.psf.models import EPSFModel from photutils.psf.utils import _interpolate_missing_data from photutils.utils._optional_deps import HAS_BOTTLENECK from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils._round import py2intround from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['EPSFFitter', 'EPSFBuilder'] class EPSFFitter: """ Class to fit an ePSF model to one or more stars. Parameters ---------- fitter : `astropy.modeling.fitting.Fitter`, optional A `~astropy.modeling.fitting.Fitter` object. fit_boxsize : int, tuple of int, or `None`, optional The size (in pixels) of the box centered on the star to be used for ePSF fitting. This allows using only a small number of central pixels of the star (i.e., where the star is brightest) for fitting. If ``fit_boxsize`` is a scalar then a square box of size ``fit_boxsize`` will be used. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. ``fit_boxsize`` must have odd values and be greater than or equal to 3 for both axes. If `None`, the fitter will use the entire star image. **fitter_kwargs : dict-like, optional Any additional keyword arguments (except ``x``, ``y``, ``z``, or ``weights``) to be passed directly to the ``__call__()`` method of the input ``fitter``. """ def __init__(self, *, fitter=LevMarLSQFitter(), fit_boxsize=5, **fitter_kwargs): self.fitter = fitter self.fitter_has_fit_info = hasattr(self.fitter, 'fit_info') self.fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(3, 0), check_odd=True) # remove any fitter keyword arguments that we need to set remove_kwargs = ['x', 'y', 'z', 'weights'] fitter_kwargs = copy.deepcopy(fitter_kwargs) for kwarg in remove_kwargs: if kwarg in fitter_kwargs: del fitter_kwargs[kwarg] self.fitter_kwargs = fitter_kwargs def __call__(self, epsf, stars): """ Fit an ePSF model to stars. Parameters ---------- epsf : `EPSFModel` An ePSF model to be fitted to the stars. stars : `EPSFStars` object The stars to be fit. The center coordinates for each star should be as close as possible to actual centers. For stars than contain weights, a weighted fit of the ePSF to the star will be performed. Returns ------- fitted_stars : `EPSFStars` object The fitted stars. The ePSF-fitted center position and flux are stored in the ``center`` (and ``cutout_center``) and ``flux`` attributes. """ if len(stars) == 0: return stars if not isinstance(epsf, EPSFModel): raise TypeError('The input epsf must be an EPSFModel.') # make a copy of the input ePSF epsf = epsf.copy() # perform the fit fitted_stars = [] for star in stars: if isinstance(star, EPSFStar): fitted_star = self._fit_star(epsf, star, self.fitter, self.fitter_kwargs, self.fitter_has_fit_info, self.fit_boxsize) elif isinstance(star, LinkedEPSFStar): fitted_star = [] for linked_star in star: fitted_star.append( self._fit_star(epsf, linked_star, self.fitter, self.fitter_kwargs, self.fitter_has_fit_info, self.fit_boxsize)) fitted_star = LinkedEPSFStar(fitted_star) fitted_star.constrain_centers() else: raise TypeError('stars must contain only EPSFStar and/or ' 'LinkedEPSFStar objects.') fitted_stars.append(fitted_star) return EPSFStars(fitted_stars) def _fit_star(self, epsf, star, fitter, fitter_kwargs, fitter_has_fit_info, fit_boxsize): """ Fit an ePSF model to a single star. The input ``epsf`` will usually be modified by the fitting routine in this function. Make a copy before calling this function if the original is needed. """ if fit_boxsize is not None: try: xcenter, ycenter = star.cutout_center large_slc, _ = overlap_slices(star.shape, fit_boxsize, (ycenter, xcenter), mode='strict') except (PartialOverlapError, NoOverlapError): warnings.warn(f'The star at ({star.center[0]}, ' f'{star.center[1]}) cannot be fit because ' 'its fitting region extends beyond the star ' 'cutout image.', AstropyUserWarning) star = copy.deepcopy(star) star._fit_error_status = 1 return star data = star.data[large_slc] weights = star.weights[large_slc] # define the origin of the fitting region x0 = large_slc[1].start y0 = large_slc[0].start else: # use the entire cutout image data = star.data weights = star.weights # define the origin of the fitting region x0 = 0 y0 = 0 # Define positions in the undersampled grid. The fitter will # evaluate on the defined interpolation grid, currently in the # range [0, len(undersampled grid)]. yy, xx = np.indices(data.shape, dtype=float) xx = xx + x0 - star.cutout_center[0] yy = yy + y0 - star.cutout_center[1] # define the initial guesses for fitted flux and shifts epsf.flux = star.flux epsf.x_0 = 0.0 epsf.y_0 = 0.0 try: fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, weights=weights, **fitter_kwargs) except TypeError: # fitter doesn't support weights fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, **fitter_kwargs) fit_error_status = 0 if fitter_has_fit_info: fit_info = copy.copy(fitter.fit_info) if 'ierr' in fit_info and fit_info['ierr'] not in [1, 2, 3, 4]: fit_error_status = 2 # fit solution was not found else: fit_info = None # compute the star's fitted position x_center = star.cutout_center[0] + fitted_epsf.x_0.value y_center = star.cutout_center[1] + fitted_epsf.y_0.value star = copy.deepcopy(star) star.cutout_center = (x_center, y_center) # set the star's flux to the ePSF-fitted flux star.flux = fitted_epsf.flux.value star._fit_info = fit_info star._fit_error_status = fit_error_status return star class EPSFBuilder: """ Class to build an effective PSF (ePSF). See `Anderson and King (2000; PASP 112, 1360) `_ and `Anderson (2016; WFC3 ISR 2016-12) `_ for details. Parameters ---------- oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. shape : float, tuple of two floats, or `None`, optional The shape of the output ePSF. If the ``shape`` is not `None`, it will be derived from the sizes of the input ``stars`` and the ePSF oversampling factor. If the size is even along any axis, it will be made odd by adding one. The output ePSF will always have odd sizes along both axes to ensure a well-defined central pixel. smoothing_kernel : {'quartic', 'quadratic'}, 2D `~numpy.ndarray`, or `None` The smoothing kernel to apply to the ePSF. The predefined ``'quartic'`` and ``'quadratic'`` kernels are derived from fourth and second degree polynomials, respectively. Alternatively, a custom 2D array can be input. If `None` then no smoothing will be performed. recentering_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The callable must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally ``error`` and ``oversampling`` keywords. The callable object must return a tuple of two 1D `~numpy.ndarray` variables, representing the x and y centroids. recentering_maxiters : int, optional The maximum number of recentering iterations to perform during each ePSF build iteration. fitter : `EPSFFitter` object, optional A `EPSFFitter` object use to fit the ePSF to stars. To set custom fitter options, input a new `EPSFFitter` object. See the `EPSFFitter` documentation for options. maxiters : int, optional The maximum number of iterations to perform. progress_bar : bool, option Whether to print the progress bar during the build iterations. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. norm_radius : float, optional The pixel radius over which the ePSF is normalized. recentering_boxsize : float or tuple of two floats, optional The size (in pixels) of the box used to calculate the centroid of the ePSF during each build iteration. If a single integer number is provided, then a square box will be used. If two values are provided, then they must be in ``(ny, nx)`` order. ``recentering_boxsize`` must have odd must have odd values and be greater than or equal to 3 for both axes. center_accuracy : float, optional The desired accuracy for the centers of stars. The building iterations will stop if the centers of all the stars change by less than ``center_accuracy`` pixels between iterations. All stars must meet this condition for the loop to exit. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters used to determine which pixels are ignored when stacking the ePSF residuals in each iteration step. If `None` then no sigma clipping will be performed. Notes ----- If your image contains NaN values, you may see better performance if you have the `bottleneck`_ package installed. .. _bottleneck: https://github.com/pydata/bottleneck """ def __init__(self, *, oversampling=4, shape=None, smoothing_kernel='quartic', recentering_func=centroid_com, recentering_maxiters=20, fitter=EPSFFitter(), maxiters=10, progress_bar=True, norm_radius=5.5, recentering_boxsize=(5, 5), center_accuracy=1.0e-3, sigma_clip=SigmaClip(sigma=3, cenfunc='median', maxiters=10)): if oversampling is None: raise ValueError("'oversampling' must be specified.") self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) self._norm_radius = norm_radius if shape is not None: self.shape = as_pair('shape', shape, lower_bound=(0, 1)) else: self.shape = shape self.recentering_func = recentering_func self.recentering_maxiters = recentering_maxiters self.recentering_boxsize = as_pair('recentering_boxsize', recentering_boxsize, lower_bound=(3, 0), check_odd=True) self.smoothing_kernel = smoothing_kernel if not isinstance(fitter, EPSFFitter): raise TypeError('fitter must be an EPSFFitter instance.') self.fitter = fitter if center_accuracy <= 0.0: raise ValueError('center_accuracy must be a positive number.') self.center_accuracy_sq = center_accuracy**2 maxiters = int(maxiters) if maxiters <= 0: raise ValueError("'maxiters' must be a positive number.") self.maxiters = maxiters self.progress_bar = progress_bar if not isinstance(sigma_clip, SigmaClip): raise ValueError('sigma_clip must be an ' 'astropy.stats.SigmaClip instance.') self._sigma_clip = sigma_clip # store each ePSF build iteration self._epsf = [] def __call__(self, stars): return self.build_epsf(stars) def _create_initial_epsf(self, stars): """ Create an initial `EPSFModel` object. The initial ePSF data are all zeros. If ``shape`` is not specified, the shape of the ePSF data array is determined from the shape of the input ``stars`` and the oversampling factor. If the size is even along any axis, it will be made odd by adding one. The output ePSF will always have odd sizes along both axes to ensure a central pixel. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. Returns ------- epsf : `EPSFModel` The initial ePSF model. """ norm_radius = self._norm_radius oversampling = self.oversampling shape = self.shape # define the ePSF shape if shape is not None: shape = as_pair('shape', shape, lower_bound=(0, 1), check_odd=True) else: # Stars class should have odd-sized dimensions, and thus we # get the oversampled shape as oversampling * len + 1; if # len=25, then newlen=101, for example. x_shape = (np.ceil(stars._max_shape[0]) * oversampling[1] + 1).astype(int) y_shape = (np.ceil(stars._max_shape[1]) * oversampling[0] + 1).astype(int) shape = np.array((y_shape, x_shape)) # verify odd sizes of shape shape = [(i + 1) if i % 2 == 0 else i for i in shape] data = np.zeros(shape, dtype=float) # ePSF origin should be in the undersampled pixel units, not the # oversampled grid units. The middle, fractional (as we wish for # the center of the pixel, so the center should be at (v.5, w.5) # detector pixels) value is simply the average of the two values # at the extremes. xcenter = stars._max_shape[0] / 2.0 ycenter = stars._max_shape[1] / 2.0 epsf = EPSFModel(data=data, origin=(xcenter, ycenter), oversampling=oversampling, norm_radius=norm_radius) return epsf def _resample_residual(self, star, epsf): """ Compute a normalized residual image in the oversampled ePSF grid. A normalized residual image is calculated by subtracting the normalized ePSF model from the normalized star at the location of the star in the undersampled grid. The normalized residual image is then resampled from the undersampled star grid to the oversampled ePSF grid. Parameters ---------- star : `EPSFStar` object A single star object. epsf : `EPSFModel` object The ePSF model. Returns ------- image : 2D `~numpy.ndarray` A 2D image containing the resampled residual image. The image contains NaNs where there is no data. """ # Compute the normalized residual by subtracting the ePSF model # from the normalized star at the location of the star in the # undersampled grid. x = star._xidx_centered y = star._yidx_centered stardata = (star._data_values_normalized - epsf.evaluate(x=x, y=y, flux=1.0, x_0=0.0, y_0=0.0)) x = epsf.oversampling[1] * star._xidx_centered y = epsf.oversampling[0] * star._yidx_centered epsf_xcenter, epsf_ycenter = (int((epsf.data.shape[1] - 1) / 2), int((epsf.data.shape[0] - 1) / 2)) xidx = py2intround(x + epsf_xcenter) yidx = py2intround(y + epsf_ycenter) resampled_img = np.full(epsf.shape, np.nan) mask = np.logical_and(np.logical_and(xidx >= 0, xidx < epsf.shape[1]), np.logical_and(yidx >= 0, yidx < epsf.shape[0])) xidx_ = xidx[mask] yidx_ = yidx[mask] resampled_img[yidx_, xidx_] = stardata[mask] return resampled_img def _resample_residuals(self, stars, epsf): """ Compute normalized residual images for all the input stars. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `EPSFModel` object The ePSF model. Returns ------- epsf_resid : 3D `~numpy.ndarray` A 3D cube containing the resampled residual images. """ shape = (stars.n_good_stars, epsf.shape[0], epsf.shape[1]) epsf_resid = np.zeros(shape) for i, star in enumerate(stars.all_good_stars): epsf_resid[i, :, :] = self._resample_residual(star, epsf) return epsf_resid def _smooth_epsf(self, epsf_data): """ Smooth the ePSF array by convolving it with a kernel. Parameters ---------- epsf_data : 2D `~numpy.ndarray` A 2D array containing the ePSF image. Returns ------- result : 2D `~numpy.ndarray` The smoothed (convolved) ePSF data. """ from scipy.ndimage import convolve if self.smoothing_kernel is None: return epsf_data # do this check first as comparing a ndarray to string causes a warning elif isinstance(self.smoothing_kernel, np.ndarray): kernel = self.smoothing_kernel elif self.smoothing_kernel == 'quartic': # from Polynomial2D fit with degree=4 to 5x5 array of # zeros with 1.0 at the center # Polynomial2D(4, c0_0=0.04163265, c1_0=-0.76326531, # c2_0=0.99081633, c3_0=-0.4, c4_0=0.05, # c0_1=-0.76326531, c0_2=0.99081633, c0_3=-0.4, # c0_4=0.05, c1_1=0.32653061, c1_2=-0.08163265, # c1_3=0.0, c2_1=-0.08163265, c2_2=0.02040816, # c3_1=-0.0)> kernel = np.array( [[+0.041632, -0.080816, 0.078368, -0.080816, +0.041632], [-0.080816, -0.019592, 0.200816, -0.019592, -0.080816], [+0.078368, +0.200816, 0.441632, +0.200816, +0.078368], [-0.080816, -0.019592, 0.200816, -0.019592, -0.080816], [+0.041632, -0.080816, 0.078368, -0.080816, +0.041632]]) elif self.smoothing_kernel == 'quadratic': # from Polynomial2D fit with degree=2 to 5x5 array of # zeros with 1.0 at the center # Polynomial2D(2, c0_0=-0.07428571, c1_0=0.11428571, # c2_0=-0.02857143, c0_1=0.11428571, # c0_2=-0.02857143, c1_1=-0.0) kernel = np.array( [[-0.07428311, 0.01142786, 0.03999952, 0.01142786, -0.07428311], [+0.01142786, 0.09714283, 0.12571449, 0.09714283, +0.01142786], [+0.03999952, 0.12571449, 0.15428215, 0.12571449, +0.03999952], [+0.01142786, 0.09714283, 0.12571449, 0.09714283, +0.01142786], [-0.07428311, 0.01142786, 0.03999952, 0.01142786, -0.07428311]]) else: raise TypeError('Unsupported kernel.') return convolve(epsf_data, kernel) def _recenter_epsf(self, epsf, centroid_func=centroid_com, box_size=(5, 5), maxiters=20, center_accuracy=1.0e-4): """ Calculate the center of the ePSF data and shift the data so the ePSF center is at the center of the ePSF data array. Parameters ---------- epsf : `EPSFModel` object The ePSF model. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The callable must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray` variables, representing the x and y centroids. box_size : float or tuple of two floats, optional The size (in pixels) of the box used to calculate the centroid of the ePSF during each build iteration. If a single integer number is provided, then a square box will be used. If two values are provided, then they must be in ``(ny, nx)`` order. ``box_size`` must have odd values and be greater than or equal to 3 for both axes. maxiters : int, optional The maximum number of recentering iterations to perform. center_accuracy : float, optional The desired accuracy for the centers of stars. The building iterations will stop if the center of the ePSF changes by less than ``center_accuracy`` pixels between iterations. Returns ------- result : 2D `~numpy.ndarray` The recentered ePSF data. """ epsf_data = epsf._data epsf = EPSFModel(data=epsf._data, origin=epsf.origin, oversampling=epsf.oversampling, norm_radius=epsf._norm_radius, normalize=False) xcenter, ycenter = epsf.origin y, x = np.indices(epsf._data.shape, dtype=float) x /= epsf.oversampling[1] y /= epsf.oversampling[0] dx_total, dy_total = 0, 0 iter_num = 0 center_accuracy_sq = center_accuracy**2 center_dist_sq = center_accuracy_sq + 1.0e6 center_dist_sq_prev = center_dist_sq + 1 while (iter_num < maxiters and center_dist_sq >= center_accuracy_sq): iter_num += 1 # Anderson & King (2000) recentering function depends # on specific pixels, and thus does not need a cutout slices_large, _ = overlap_slices(epsf_data.shape, box_size, (ycenter * self.oversampling[0], xcenter * self.oversampling[1])) epsf_cutout = epsf_data[slices_large] mask = ~np.isfinite(epsf_cutout) # find a new center position xcenter_new, ycenter_new = centroid_func(epsf_cutout, mask=mask) xcenter_new /= self.oversampling[1] ycenter_new /= self.oversampling[0] xcenter_new += slices_large[1].start / self.oversampling[1] ycenter_new += slices_large[0].start / self.oversampling[0] # Calculate the shift; dx = i - x_star so if dx was positively # incremented then x_star was negatively incremented for a given i. # We will therefore actually subsequently subtract dx from xcenter # (or x_star). dx = xcenter_new - xcenter dy = ycenter_new - ycenter center_dist_sq = dx**2 + dy**2 if center_dist_sq >= center_dist_sq_prev: # don't shift break center_dist_sq_prev = center_dist_sq dx_total += dx dy_total += dy epsf_data = epsf.evaluate(x=x, y=y, flux=1.0, x_0=xcenter - dx_total, y_0=ycenter - dy_total) return epsf_data def _build_epsf_step(self, stars, epsf=None): """ A single iteration of improving an ePSF. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `EPSFModel` object, optional The initial ePSF model. If not input, then the ePSF will be built from scratch. Returns ------- epsf : `EPSFModel` object The updated ePSF. """ if len(stars) < 1: raise ValueError('stars must contain at least one EPSFStar or ' 'LinkedEPSFStar object.') if epsf is None: # create an initial ePSF (array of zeros) epsf = self._create_initial_epsf(stars) else: # improve the input ePSF epsf = copy.deepcopy(epsf) # compute a 3D stack of 2D residual images residuals = self._resample_residuals(stars, epsf) # compute the sigma-clipped average along the 3D stack with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) warnings.simplefilter('ignore', category=AstropyUserWarning) residuals = self._sigma_clip(residuals, axis=0, masked=False, return_bounds=False) if HAS_BOTTLENECK: import bottleneck residuals = bottleneck.nanmedian(residuals, axis=0) else: residuals = np.nanmedian(residuals, axis=0) # interpolate any missing data (np.nan) mask = ~np.isfinite(residuals) if np.any(mask): residuals = _interpolate_missing_data(residuals, mask, method='cubic') # fill any remaining nans (outer points) with zeros residuals[~np.isfinite(residuals)] = 0.0 # add the residuals to the previous ePSF image new_epsf = epsf._data + residuals # smooth and recenter the ePSF new_epsf = self._smooth_epsf(new_epsf) epsf = EPSFModel(data=new_epsf, origin=epsf.origin, oversampling=epsf.oversampling, norm_radius=epsf._norm_radius, normalize=False) epsf._data = self._recenter_epsf( epsf, centroid_func=self.recentering_func, box_size=self.recentering_boxsize, maxiters=self.recentering_maxiters) # Return the new ePSF object, but with undersampled grid pixel # coordinates. xcenter = (epsf._data.shape[1] - 1) / 2.0 / epsf.oversampling[1] ycenter = (epsf._data.shape[0] - 1) / 2.0 / epsf.oversampling[0] return EPSFModel(data=epsf._data, origin=(xcenter, ycenter), oversampling=epsf.oversampling, norm_radius=epsf._norm_radius) def build_epsf(self, stars, *, init_epsf=None): """ Build iteratively an ePSF from star cutouts. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. init_epsf : `EPSFModel` object, optional The initial ePSF model. If not input, then the ePSF will be built from scratch. Returns ------- epsf : `EPSFModel` object The constructed ePSF. fitted_stars : `EPSFStars` object The input stars with updated centers and fluxes derived from fitting the output ``epsf``. """ iter_num = 0 n_stars = stars.n_stars fit_failed = np.zeros(n_stars, dtype=bool) epsf = init_epsf center_dist_sq = self.center_accuracy_sq + 1.0 centers = stars.cutout_center_flat pbar = None if self.progress_bar: desc = f'EPSFBuilder ({self.maxiters} maxiters)' pbar = add_progress_bar(total=self.maxiters, desc=desc) # pragma: no cover while (iter_num < self.maxiters and not np.all(fit_failed) and np.max(center_dist_sq) >= self.center_accuracy_sq): iter_num += 1 # build/improve the ePSF epsf = self._build_epsf_step(stars, epsf=epsf) # fit the new ePSF to the stars to find improved centers # we catch fit warnings here -- stars with unsuccessful fits # are excluded from the ePSF build process with warnings.catch_warnings(): message = '.*The fit may be unsuccessful;.*' warnings.filterwarnings('ignore', message=message, category=AstropyUserWarning) stars = self.fitter(epsf, stars) # find all stars where the fit failed fit_failed = np.array([star._fit_error_status > 0 for star in stars.all_stars]) if np.all(fit_failed): raise ValueError('The ePSF fitting failed for all stars.') # permanently exclude fitting any star where the fit fails # after 3 iterations if iter_num > 3 and np.any(fit_failed): idx = fit_failed.nonzero()[0] for i in idx: # pylint: disable=not-an-iterable stars.all_stars[i]._excluded_from_fit = True # if no star centers have moved by more than pixel accuracy, # stop the iteration loop early dx_dy = stars.cutout_center_flat - centers dx_dy = dx_dy[np.logical_not(fit_failed)] center_dist_sq = np.sum(dx_dy * dx_dy, axis=1, dtype=np.float64) centers = stars.cutout_center_flat self._epsf.append(epsf) if pbar is not None: pbar.update() if pbar is not None: if iter_num < self.maxiters: pbar.write(f'EPSFBuilder converged after {iter_num} ' f'iterations (of {self.maxiters} maximum ' 'iterations)') pbar.close() return epsf, stars ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/epsf_stars.py0000644000175100001770000006457414637570305020634 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to extract cutouts of stars and data structures to hold the cutouts for fitting and building ePSFs. """ import warnings import numpy as np from astropy.nddata import NDData from astropy.nddata.utils import NoOverlapError, PartialOverlapError from astropy.table import Table from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import BoundingBox from photutils.psf.utils import _interpolate_missing_data from photutils.utils._parameters import as_pair from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['EPSFStar', 'EPSFStars', 'LinkedEPSFStar', 'extract_stars'] class EPSFStar: """ A class to hold a 2D cutout image and associated metadata of a star used to build an ePSF. Parameters ---------- data : `~numpy.ndarray` A 2D cutout image of a single star. weights : `~numpy.ndarray` or `None`, optional A 2D array of the weights associated with the input ``data``. cutout_center : tuple of two floats or `None`, optional The ``(x, y)`` position of the star's center with respect to the input cutout ``data`` array. If `None`, then the center of the input cutout ``data`` array will be used. origin : tuple of two int, optional The ``(x, y)`` index of the origin (bottom-left corner) pixel of the input cutout array with respect to the original array from which the cutout was extracted. This can be used to convert positions within the cutout image to positions in the original image. ``origin`` and ``wcs_large`` must both be input for a linked star (a single star extracted from different images). wcs_large : `None` or WCS object, optional A WCS object associated with the large image from which the cutout array was extracted. It should not be the WCS object of the input cutout ``data`` array. The WCS object must support the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). ``origin`` and ``wcs_large`` must both be input for a linked star (a single star extracted from different images). id_label : int, str, or `None`, optional An optional identification number or label for the star. """ def __init__(self, data, *, weights=None, cutout_center=None, origin=(0, 0), wcs_large=None, id_label=None): self._data = np.asanyarray(data) self.shape = self._data.shape if weights is not None: if weights.shape != data.shape: raise ValueError('weights must have the same shape as the ' 'input data array.') self.weights = np.asanyarray(weights, dtype=float).copy() else: self.weights = np.ones_like(self._data, dtype=float) self.mask = (self.weights <= 0.0) # mask out invalid image data invalid_data = np.logical_not(np.isfinite(self._data)) if np.any(invalid_data): self.weights[invalid_data] = 0.0 self.mask[invalid_data] = True self._cutout_center = cutout_center self.origin = np.asarray(origin) self.wcs_large = wcs_large self.id_label = id_label self.flux = self.estimate_flux() self._excluded_from_fit = False self._fitinfo = None def __array__(self): """ Array representation of the mask data array (e.g., for matplotlib). """ return self._data @property def data(self): """The 2D cutout image.""" return self._data @property def cutout_center(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of the star's center with respect to the input cutout ``data`` array. """ return self._cutout_center @cutout_center.setter def cutout_center(self, value): if value is None: value = ((self.shape[1] - 1) / 2.0, (self.shape[0] - 1) / 2.0) else: if len(value) != 2: raise ValueError('The "cutout_center" attribute must have ' 'two elements in (x, y) form.') self._cutout_center = np.asarray(value) @property def center(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of the star's center in the original (large) image (not the cutout image). """ return self.cutout_center + self.origin @lazyproperty def slices(self): """ A tuple of two slices representing the cutout region with respect to the original (large) image. """ return (slice(self.origin[1], self.origin[1] + self.shape[1]), slice(self.origin[0], self.origin[0] + self.shape[0])) @lazyproperty def bbox(self): """ The minimal `~photutils.aperture.BoundingBox` for the cutout region with respect to the original (large) image. """ return BoundingBox(self.slices[1].start, self.slices[1].stop, self.slices[0].start, self.slices[0].stop) def estimate_flux(self): """ Estimate the star's flux by summing values in the input cutout array. Missing data is filled in by interpolation to better estimate the total flux. """ if np.any(self.mask): data_interp = _interpolate_missing_data(self.data, method='cubic', mask=self.mask) data_interp = _interpolate_missing_data(data_interp, method='nearest', mask=self.mask) flux = np.sum(data_interp, dtype=float) else: flux = np.sum(self.data, dtype=float) return flux def register_epsf(self, epsf): """ Register and scale (in flux) the input ``epsf`` to the star. Parameters ---------- epsf : `EPSFModel` The ePSF to register. Returns ------- data : `~numpy.ndarray` A 2D array of the registered/scaled ePSF. """ yy, xx = np.indices(self.shape, dtype=float) xx = xx - self.cutout_center[0] yy = yy - self.cutout_center[1] return self.flux * epsf.evaluate(xx, yy, flux=1.0, x_0=0.0, y_0=0.0) def compute_residual_image(self, epsf): """ Compute the residual image of the star data minus the registered/scaled ePSF. Parameters ---------- epsf : `EPSFModel` The ePSF to subtract. Returns ------- data : `~numpy.ndarray` A 2D array of the residual image. """ return self.data - self.register_epsf(epsf) @lazyproperty def _xy_idx(self): """ 1D arrays of x and y indices of unmasked pixels in the cutout reference frame. """ yidx, xidx = np.indices(self._data.shape) return xidx[~self.mask].ravel(), yidx[~self.mask].ravel() @lazyproperty def _xidx(self): """ 1D arrays of x indices of unmasked pixels in the cutout reference frame. """ return self._xy_idx[0] @lazyproperty def _yidx(self): """ 1D arrays of y indices of unmasked pixels in the cutout reference frame. """ return self._xy_idx[1] @property def _xidx_centered(self): """ 1D array of x indices of unmasked pixels, with respect to the star center, in the cutout reference frame. """ return self._xy_idx[0] - self.cutout_center[0] @property def _yidx_centered(self): """ 1D array of y indices of unmasked pixels, with respect to the star center, in the cutout reference frame. """ return self._xy_idx[1] - self.cutout_center[1] @lazyproperty def _data_values(self): """1D array of unmasked cutout data values.""" return self.data[~self.mask].ravel() @lazyproperty def _data_values_normalized(self): """ 1D array of unmasked cutout data values, normalized by the star's total flux. """ return self._data_values / self.flux @lazyproperty def _weight_values(self): """ 1D array of unmasked weight values. """ return self.weights[~self.mask].ravel() class EPSFStars: """ Class to hold a list of `EPSFStar` and/or `LinkedEPSFStar` objects. Parameters ---------- stars_list : list of `EPSFStar` or `LinkedEPSFStar` objects A list of `EPSFStar` and/or `LinkedEPSFStar` objects. """ def __init__(self, stars_list): if isinstance(stars_list, (EPSFStar, LinkedEPSFStar)): self._data = [stars_list] elif isinstance(stars_list, list): self._data = stars_list else: raise ValueError('stars_list must be a list of EPSFStar and/or ' 'LinkedEPSFStar objects.') def __len__(self): return len(self._data) def __getitem__(self, index): return self.__class__(self._data[index]) def __delitem__(self, index): del self._data[index] def __iter__(self): yield from self._data # explicit set/getstate to avoid infinite recursion # from pickler using __getattr__ def __getstate__(self): return self.__dict__ def __setstate__(self, d): self.__dict__ = d def __getattr__(self, attr): if attr in ['cutout_center', 'center', 'flux', '_excluded_from_fit']: result = np.array([getattr(star, attr) for star in self._data]) else: result = [getattr(star, attr) for star in self._data] if len(self._data) == 1: result = result[0] return result def _getattr_flat(self, attr): values = [] for item in self._data: if isinstance(item, LinkedEPSFStar): values.extend(getattr(item, attr)) else: values.append(getattr(item, attr)) return np.array(values) @property def cutout_center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers (including linked stars) with respect to the input cutout ``data`` array, as a 2D array (``n_all_stars`` x 2). Note that when `EPSFStars` contains any `LinkedEPSFStar`, the ``cutout_center`` attribute will be a nested 3D array. """ return self._getattr_flat('cutout_center') @property def center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers (including linked stars) with respect to the original (large) image (not the cutout image) as a 2D array (``n_all_stars`` x 2). Note that when `EPSFStars` contains any `LinkedEPSFStar`, the ``center`` attribute will be a nested 3D array. """ return self._getattr_flat('center') @lazyproperty def all_stars(self): """ A list of all `EPSFStar` objects stored in this object, including those that comprise linked stars (i.e., `LinkedEPSFStar`), as a flat list. """ stars = [] for item in self._data: if isinstance(item, LinkedEPSFStar): stars.extend(item.all_stars) else: stars.append(item) return stars @property def all_good_stars(self): """ A list of all `EPSFStar` objects stored in this object that have not been excluded from fitting, including those that comprise linked stars (i.e., `LinkedEPSFStar`), as a flat list. """ stars = [] for star in self.all_stars: if star._excluded_from_fit: continue stars.append(star) return stars @lazyproperty def n_stars(self): """ The total number of stars. A linked star is counted only once. """ return len(self._data) @lazyproperty def n_all_stars(self): """ The total number of `EPSFStar` objects, including all the linked stars within `LinkedEPSFStar`. Each linked star is included in the count. """ return len(self.all_stars) @property def n_good_stars(self): """ The total number of `EPSFStar` objects, including all the linked stars within `LinkedEPSFStar`, that have not been excluded from fitting. Each non-excluded linked star is included in the count. """ return len(self.all_good_stars) @lazyproperty def _max_shape(self): """ The maximum x and y shapes of all the `EPSFStar` objects (including linked stars). """ return np.max([star.shape for star in self.all_stars], axis=0) class LinkedEPSFStar(EPSFStars): """ A class to hold a list of `EPSFStar` objects for linked stars. Linked stars are `EPSFStar` cutouts from different images that represent the same physical star. When building the ePSF, linked stars are constrained to have the same sky coordinates. Parameters ---------- stars_list : list of `EPSFStar` objects A list of `EPSFStar` objects for the same physical star. Each `EPSFStar` object must have a valid ``wcs_large`` attribute to convert between pixel and sky coordinates. """ def __init__(self, stars_list): for star in stars_list: if not isinstance(star, EPSFStar): raise ValueError('stars_list must contain only EPSFStar ' 'objects.') if star.wcs_large is None: raise ValueError('Each EPSFStar object must have a valid ' 'wcs_large attribute.') super().__init__(stars_list) def constrain_centers(self): """ Constrain the centers of linked `EPSFStar` objects (i.e., the same physical star) to have the same sky coordinate. Only `EPSFStar` objects that have not been excluded during the ePSF build process will be used to constrain the centers. The single sky coordinate is calculated as the mean of sky coordinates of the linked stars. """ if len(self._data) < 2: # no linked stars return idx = np.logical_not(self._excluded_from_fit).nonzero()[0] if idx.shape == (0,): # pylint: disable=no-member warnings.warn('Cannot constrain centers of linked stars because ' 'all the stars have been excluded during the ePSF ' 'build process.', AstropyUserWarning) return good_stars = [self._data[i] for i in idx] # pylint: disable=not-an-iterable coords = [] for star in good_stars: wcs = star.wcs_large xposition = star.center[0] yposition = star.center[1] coords.append(wcs.pixel_to_world_values(xposition, yposition)) # compute mean cartesian coordinates lon, lat = np.transpose(coords) lon *= np.pi / 180.0 lat *= np.pi / 180.0 x_mean = np.mean(np.cos(lat) * np.cos(lon)) y_mean = np.mean(np.cos(lat) * np.sin(lon)) z_mean = np.mean(np.sin(lat)) # convert mean cartesian coordinates back to spherical hypot = np.hypot(x_mean, y_mean) mean_lon = np.arctan2(y_mean, x_mean) mean_lat = np.arctan2(z_mean, hypot) mean_lon *= 180.0 / np.pi mean_lat *= 180.0 / np.pi # convert mean sky coordinates back to center pixel coordinates # for each star for star in good_stars: center = star.wcs_large.world_to_pixel_values(mean_lon, mean_lat) star.cutout_center = np.array(center) - star.origin def extract_stars(data, catalogs, *, size=(11, 11)): """ Extract cutout images centered on stars defined in the input catalog(s). Stars where the cutout array bounds partially or completely lie outside of the input ``data`` image will not be extracted. Parameters ---------- data : `~astropy.nddata.NDData` or list of `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object or a list of `~astropy.nddata.NDData` objects containing the 2D image(s) from which to extract the stars. If the input ``catalogs`` contain only the sky coordinates (i.e., not the pixel coordinates) of the stars then each of the `~astropy.nddata.NDData` objects must have a valid ``wcs`` attribute. catalogs : `~astropy.table.Table`, list of `~astropy.table.Table` A catalog or list of catalogs of sources to be extracted from the input ``data``. To link stars in multiple images as a single source, you must use a single source catalog where the positions defined in sky coordinates. If a list of catalogs is input (or a single catalog with a single `~astropy.nddata.NDData` object), they are assumed to correspond to the list of `~astropy.nddata.NDData` objects input in ``data`` (i.e., a separate source catalog for each 2D image). For this case, the center of each source can be defined either in pixel coordinates (in ``x`` and ``y`` columns) or sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). If both are specified, then the pixel coordinates will be used. If a single source catalog is input with multiple `~astropy.nddata.NDData` objects, then these sources will be extracted from every 2D image in the input ``data``. In this case, the sky coordinates for each source must be specified as a `~astropy.coordinates.SkyCoord` object contained in a column called ``skycoord``. Each `~astropy.nddata.NDData` object in the input ``data`` must also have a valid ``wcs`` attribute. Pixel coordinates (in ``x`` and ``y`` columns) will be ignored. Optionally, each catalog may also contain an ``id`` column representing the ID/name of stars. If this column is not present then the extracted stars will be given an ``id`` number corresponding the table row number (starting at 1). Any other columns present in the input ``catalogs`` will be ignored. size : int or array_like (int), optional The extraction box size along each axis. If ``size`` is a scalar then a square box of size ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` must have odd values and be greater than or equal to 3 for both axes. Returns ------- stars : `EPSFStars` instance A `EPSFStars` instance containing the extracted stars. """ if isinstance(data, NDData): data = [data] if isinstance(catalogs, Table): catalogs = [catalogs] for img in data: if not isinstance(img, NDData): raise ValueError('data must be a single or list of NDData ' 'objects.') for cat in catalogs: if not isinstance(cat, Table): raise ValueError('catalogs must be a single or list of Table ' 'objects.') if len(catalogs) == 1 and len(data) > 1: if 'skycoord' not in catalogs[0].colnames: raise ValueError('When inputting a single catalog with multiple ' 'NDData objects, the catalog must have a ' '"skycoord" column.') if any(img.wcs is None for img in data): raise ValueError('When inputting a single catalog with multiple ' 'NDData objects, each NDData object must have ' 'a wcs attribute.') else: for cat in catalogs: if 'x' not in cat.colnames or 'y' not in cat.colnames: if 'skycoord' not in cat.colnames: raise ValueError('When inputting multiple catalogs, ' 'each one must have a "x" and "y" ' 'column or a "skycoord" column.') if any(img.wcs is None for img in data): raise ValueError('When inputting catalog(s) with only ' 'skycoord positions, each NDData object ' 'must have a wcs attribute.') if len(data) != len(catalogs): raise ValueError('When inputting multiple catalogs, the number ' 'of catalogs must match the number of input ' 'images.') size = as_pair('size', size, lower_bound=(3, 0), check_odd=True) if len(catalogs) == 1: # may included linked stars use_xy = True if len(data) > 1: use_xy = False # linked stars require skycoord positions stars = [] # stars is a list of lists, one list of stars in each image for img in data: stars.append(_extract_stars(img, catalogs[0], size=size, use_xy=use_xy)) # transpose the list of lists, to associate linked stars stars = list(map(list, zip(*stars))) # remove 'None' stars (i.e., no or partial overlap in one or # more images) and handle the case of only one "linked" star stars_out = [] n_input = len(catalogs[0]) * len(data) n_extracted = 0 for star in stars: good_stars = [i for i in star if i is not None] n_extracted += len(good_stars) if not good_stars: continue # no overlap in any image if len(good_stars) == 1: good_stars = good_stars[0] # only one star, cannot be linked else: good_stars = LinkedEPSFStar(good_stars) stars_out.append(good_stars) else: # no linked stars stars_out = [] for img, cat in zip(data, catalogs): stars_out.extend(_extract_stars(img, cat, size=size, use_xy=True)) n_input = len(stars_out) stars_out = [star for star in stars_out if star is not None] n_extracted = len(stars_out) n_excluded = n_input - n_extracted if n_excluded > 0: warnings.warn(f'{n_excluded} star(s) were not extracted because ' 'their cutout region extended beyond the input image.', AstropyUserWarning) return EPSFStars(stars_out) def _extract_stars(data, catalog, *, size=(11, 11), use_xy=True): """ Extract cutout images from a single image centered on stars defined in the single input catalog. Parameters ---------- data : `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object containing the 2D image from which to extract the stars. If the input ``catalog`` contains only the sky coordinates (i.e., not the pixel coordinates) of the stars then the `~astropy.nddata.NDData` object must have a valid ``wcs`` attribute. catalog : `~astropy.table.Table` A single catalog of sources to be extracted from the input ``data``. The center of each source can be defined either in pixel coordinates (in ``x`` and ``y`` columns) or sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). If both are specified, then the value of the ``use_xy`` keyword determines which coordinates will be used. size : int or array_like (int), optional The extraction box size along each axis. If ``size`` is a scalar then a square box of size ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` must have odd values and be greater than or equal to 3 for both axes. use_xy : bool, optional Whether to use the ``x`` and ``y`` pixel positions when both pixel and sky coordinates are present in the input catalog table. If `False` then sky coordinates are used instead of pixel coordinates (e.g., for linked stars). The default is `True`. Returns ------- stars : list of `EPSFStar` objects A list of `EPSFStar` instances containing the extracted stars. """ size = as_pair('size', size, lower_bound=(3, 0), check_odd=True) colnames = catalog.colnames if ('x' not in colnames or 'y' not in colnames) or not use_xy: xcenters, ycenters = data.wcs.world_to_pixel(catalog['skycoord']) else: xcenters = catalog['x'].data.astype(float) ycenters = catalog['y'].data.astype(float) if 'id' in colnames: ids = catalog['id'] else: ids = np.arange(len(catalog), dtype=int) + 1 if data.uncertainty is None: weights = np.ones_like(data.data) else: if data.uncertainty.uncertainty_type == 'weights': weights = np.asanyarray(data.uncertainty.array, dtype=float) else: warnings.warn('The data uncertainty attribute has an unsupported ' 'type. Only uncertainty_type="weights" can be ' 'used to set weights. Weights will be set to 1.', AstropyUserWarning) weights = np.ones_like(data.data) if data.mask is not None: weights[data.mask] = 0.0 stars = [] for xcenter, ycenter, obj_id in zip(xcenters, ycenters, ids): try: large_slc, _ = overlap_slices(data.data.shape, size, (ycenter, xcenter), mode='strict') data_cutout = data.data[large_slc] weights_cutout = weights[large_slc] except (PartialOverlapError, NoOverlapError): stars.append(None) continue origin = (large_slc[1].start, large_slc[0].start) cutout_center = (xcenter - origin[0], ycenter - origin[1]) star = EPSFStar(data_cutout, weights=weights_cutout, cutout_center=cutout_center, origin=origin, wcs_large=data.wcs, id_label=obj_id) stars.append(star) return stars ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/griddedpsfmodel.py0000644000175100001770000012226114637570305021603 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines the GriddedPSFModel and related tools. """ import copy import io import itertools import os import warnings from functools import lru_cache import astropy import numpy as np from astropy.io import fits, registry from astropy.io.fits.verify import VerifyWarning from astropy.modeling import Fittable2DModel, Parameter from astropy.nddata import NDData, reshape_as_blocks from astropy.utils import minversion from astropy.visualization import simple_norm from photutils.utils._parameters import as_pair __all__ = ['GriddedPSFModel', 'ModelGridPlotMixin', 'stdpsf_reader', 'webbpsf_reader', 'STDPSFGrid'] __doctest_skip__ = ['GriddedPSFModelRead', 'STDPSFGrid'] class ModelGridPlotMixin: """ Mixin class to plot a grid of ePSF models. """ def _reshape_grid(self, data): """ Reshape the 3D ePSF grid as a 2D array of horizontally and vertically stacked ePSFs. """ nypsfs = self._ygrid.shape[0] nxpsfs = self._xgrid.shape[0] ny, nx = self.data.shape[1:] data.shape = (nypsfs, nxpsfs, ny, nx) return data.transpose([0, 2, 1, 3]).reshape(nypsfs * ny, nxpsfs * nx) def plot_grid(self, *, ax=None, vmax_scale=None, peak_norm=False, deltas=False, cmap=None, dividers=True, divider_color='darkgray', divider_ls='-', figsize=None): """ Plot the grid of ePSF models. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. vmax_scale : float, optional Scale factor to apply to the image stretch limits. This value is multiplied by the peak ePSF value to determine the plotting ``vmax``. The defaults are 1.0 for plotting the ePSF data and 0.03 for plotting the ePSF difference data (``deltas=True``). If ``deltas=True``, the ``vmin`` is set to ``-vmax``. If ``deltas=False`` the ``vmin`` is set to ``vmax`` / 1e4. peak_norm : bool, optional Whether to normalize the ePSF data by the peak value. The default shows the ePSF flux per pixel. deltas : bool, optional Set to `True` to show the differences between each ePSF and the average ePSF. cmap : str or `matplotlib.colors.Colormap`, optional The colormap to use. The default is `None`, which uses the 'viridis' colormap for plotting ePSF data and the 'gray_r' colormap for plotting the ePSF difference data (``deltas=True``). dividers : bool, optional Whether to show divider lines between the ePSFs. divider_color, divider_ls : str, optional Matplotlib color and linestyle options for the divider lines between ePSFs. These keywords have no effect unless ``show_dividers=True``. figsize : (float, float), optional The figure (width, height) in inches. Returns ------- fig : `matplotlib.figure.Figure` The matplotlib figure object. This will be the current figure if ``ax=None``. Use ``fig.savefig()`` to save the figure to a file. Note that when calling this method in a notebook, if you do not store the return value of this function, the figure will be displayed twice due to the REPL automatically displaying the return value of the last function call. Alternatively, you can append a semicolon to the end of the function call to suppress the display of the return value. """ import matplotlib.pyplot as plt from matplotlib import cm data = self.data.copy() if deltas: # Compute mean ignoring any blank (all zeros) ePSFs. # This is the case for MIRI with its non-square FOV. mask = np.zeros(data.shape[0], dtype=bool) for i, arr in enumerate(data): if np.count_nonzero(arr) == 0: mask[i] = True data -= np.mean(data[~mask], axis=0) data[mask] = 0.0 data = self._reshape_grid(data) if ax is None: if figsize is None and self.meta.get('detector', '') == 'NRCSW': figsize = (20, 8) fig, ax = plt.subplots(figsize=figsize) else: fig = plt.gcf() if peak_norm: # normalize relative to peak if data.max() != 0: data /= data.max() if deltas: if cmap is None: cmap = cm.gray_r.copy() if vmax_scale is None: vmax_scale = 0.03 vmax = data.max() * vmax_scale vmin = -vmax if minversion(astropy, '6.1.dev'): norm = simple_norm(data, 'linear', vmin=vmin, vmax=vmax) else: norm = simple_norm(data, 'linear', min_cut=vmin, max_cut=vmax) else: if cmap is None: cmap = cm.viridis.copy() if vmax_scale is None: vmax_scale = 1.0 vmax = data.max() * vmax_scale vmin = vmax / 1.0e4 if minversion(astropy, '6.1.dev'): norm = simple_norm(data, 'log', vmin=vmin, vmax=vmax, log_a=1.0e4) else: norm = simple_norm(data, 'log', min_cut=vmin, max_cut=vmax, log_a=1.0e4) # Set up the coordinate axes to later set tick labels based on # detector ePSF coordinates. This sets up axes to have, behind the # scenes, the ePSFs centered at integer coords 0, 1, 2, 3 etc. # extent = (left, right, bottom, top) nypsfs = self._ygrid.shape[0] nxpsfs = self._xgrid.shape[0] extent = [-0.5, nxpsfs - 0.5, -0.5, nypsfs - 0.5] ax.imshow(data, extent=extent, norm=norm, cmap=cmap, origin='lower') # Use the axes set up above to set appropriate tick labels xticklabels = self._xgrid.astype(int) yticklabels = self._ygrid.astype(int) if self.meta.get('detector', '') == 'NRCSW': xticklabels = list(xticklabels[0:5]) * 4 yticklabels = list(yticklabels[0:5]) * 2 ax.set_xticks(np.arange(nxpsfs)) ax.set_xticklabels(xticklabels) ax.set_xlabel('ePSF location in detector X pixels') ax.set_yticks(np.arange(nypsfs)) ax.set_yticklabels(yticklabels) ax.set_ylabel('ePSF location in detector Y pixels') if dividers: for ix in range(nxpsfs): ax.axvline(ix + 0.5, color=divider_color, ls=divider_ls) for iy in range(nypsfs): ax.axhline(iy + 0.5, color=divider_color, ls=divider_ls) instrument = self.meta.get('instrument', '') if not instrument: # WebbPSF output instrument = self.meta.get('instrume', '') detector = self.meta.get('detector', '') filtername = self.meta.get('filter', '') # WebbPSF outputs a tuple with the comment in the second element if isinstance(instrument, (tuple, list, np.ndarray)): instrument = instrument[0] if isinstance(detector, (tuple, list, np.ndarray)): detector = detector[0] if isinstance(filtername, (tuple, list, np.ndarray)): filtername = filtername[0] title = f'{instrument} {detector} {filtername}' if title != '': # add extra space at end title += ' ' if deltas: ax.set_title(f'{title}(ePSFs − )') if peak_norm: label = 'Difference relative to average ePSF peak' else: label = 'Difference relative to average ePSF values' else: ax.set_title(f'{title}ePSFs') if peak_norm: label = 'Scale relative to ePSF peak pixel' else: label = 'ePSF flux per pixel' cbar = plt.colorbar(label=label, mappable=ax.images[0]) if not deltas: cbar.ax.set_yscale('log') if self.meta.get('detector', '') == 'NRCSW': # NIRCam NRCSW STDPSF files contain all detectors. # The plot gets extra divider lines and SCA name labels. nxpsfs = len(self._xgrid) nypsfs = len(self._ygrid) plt.axhline(nypsfs / 2 - 0.5, color='orange') for i in range(1, 4): ax.axvline(nxpsfs / 4 * i - 0.5, color='orange') det_labels = [['A1', 'A3', 'B4', 'B2'], ['A2', 'A4', 'B3', 'B1']] for i in range(2): for j in range(4): ax.text(j * nxpsfs / 4 - 0.45, (i + 1) * nypsfs / 2 - 0.55, det_labels[i][j], color='orange', verticalalignment='top', fontsize=12) fig.tight_layout() return fig class GriddedPSFModelRead(registry.UnifiedReadWrite): """ Read and parse a FITS file into a `GriddedPSFModel` instance. This class enables the astropy unified I/O layer for `GriddedPSFModel`. This allows easily reading a file in different supported data formats using syntax such as:: >>> from photutils.psf import GriddedPSFModel >>> psf_model = GriddedPSFModel.read('filename.fits', format=format) Get help on the available readers for `GriddedPSFModel` using the ``help()`` method:: >>> # Get help reading Table and list supported formats >>> GriddedPSFModel.read.help() >>> # Get detailed help on the STSPSF FITS reader >>> GriddedPSFModel.read.help('stdpsf') >>> # Get detailed help on the WebbPSF FITS reader >>> GriddedPSFModel.read.help('webbpsf') >>> # Print list of available formats >>> GriddedPSFModel.read.list_formats() Parameters ---------- *args : tuple, optional Positional arguments passed through to data reader. If supplied the first argument is typically the input filename. format : str File format specifier. **kwargs : dict, optional Keyword arguments passed through to data reader. Returns ------- out : `~photutils.psf.GriddedPSFModel` A gridded ePSF model corresponding to FITS file contents. """ def __init__(self, instance, cls): super().__init__(instance, cls, 'read', registry=None) # uses default global registry def __call__(self, *args, **kwargs): return self.registry.read(self._cls, *args, **kwargs) class GriddedPSFModel(ModelGridPlotMixin, Fittable2DModel): """ A fittable 2D model containing a grid ePSF models. The ePSF models are defined at fiducial detector locations and are bilinearly interpolated to calculate an ePSF model at an arbitrary (x, y) detector position. When evaluating this model, it cannot be called with x and y arrays that have greater than 2 dimensions. Parameters ---------- nddata : `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object containing the grid of reference ePSF arrays. The data attribute must contain a 3D `~numpy.ndarray` containing a stack of the 2D ePSFs with a shape of ``(N_psf, ePSF_ny, ePSF_nx)``. The meta attribute must be `dict` containing the following: * ``'grid_xypos'``: A list of the (x, y) grid positions of each reference ePSF. The order of positions should match the first axis of the 3D `~numpy.ndarray` of ePSFs. In other words, ``grid_xypos[i]`` should be the (x, y) position of the reference ePSF defined in ``data[i]``. * ``'oversampling'``: The integer oversampling factor(s) of the ePSF. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. The meta attribute may contain other properties such as the telescope, instrument, detector, and filter of the ePSF. Methods ------- read(\\*args, \\**kwargs) Class method to create a `GriddedPSFModel` instance from a STDPSF FITS file. This method uses :func:`stdpsf_reader` with the provided parameters. Notes ----- Internally, the grid of ePSFs will be arranged and stored such that it is sorted first by y and then by x. """ flux = Parameter(description='Intensity scaling factor for the ePSF ' 'model.', default=1.0) x_0 = Parameter(description='x position in the output coordinate grid ' 'where the model is evaluated.', default=0.0) y_0 = Parameter(description='y position in the output coordinate grid ' 'where the model is evaluated.', default=0.0) read = registry.UnifiedReadWriteMethod(GriddedPSFModelRead) def __init__(self, nddata, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fill_value=0.0): self._validate_data(nddata) self.data, self.grid_xypos = self._define_grid(nddata) # use _meta to avoid the meta descriptor self._meta = nddata.meta.copy() self.oversampling = as_pair('oversampling', nddata.meta['oversampling'], lower_bound=(0, 1)) self.fill_value = fill_value self._grid_xpos, self._grid_ypos = np.transpose(self.grid_xypos) self._xgrid = np.unique(self._grid_xpos) # also sorts values self._ygrid = np.unique(self._grid_ypos) # also sorts values self.meta['grid_shape'] = (len(self._ygrid), len(self._xgrid)) if (len(list(itertools.product(self._xgrid, self._ygrid))) != len(self.grid_xypos)): raise ValueError('"grid_xypos" must form a regular grid.') self._xidx = np.arange(self.data.shape[2], dtype=float) self._yidx = np.arange(self.data.shape[1], dtype=float) # Here we avoid decorating the instance method with @lru_cache # to prevent memory leaks; we set maxsize=128 to prevent the # cache from growing too large. self._calc_interpolator = lru_cache(maxsize=128)( self._calc_interpolator_uncached) super().__init__(flux, x_0, y_0) @staticmethod def _validate_data(data): if not isinstance(data, NDData): raise TypeError('data must be an NDData instance.') if data.data.ndim != 3: raise ValueError('The NDData data attribute must be a 3D numpy ' 'ndarray') if 'grid_xypos' not in data.meta: raise ValueError('"grid_xypos" must be in the nddata meta ' 'dictionary.') if len(data.meta['grid_xypos']) != data.data.shape[0]: raise ValueError('The length of grid_xypos must match the number ' 'of input ePSFs.') if 'oversampling' not in data.meta: raise ValueError('"oversampling" must be in the nddata meta ' 'dictionary.') def _define_grid(self, nddata): """ Sort the input ePSF data into a regular grid where the ePSFs are sorted first by y and then by x. Parameters ---------- nddata : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Returns ------- data : 3D `~numpy.ndarray` The 3D array of ePSFs. grid_xypos : array of (x, y) pairs The (x, y) positions of the ePSFs, sorted first by y and then by x. """ grid_xypos = np.array(nddata.meta['grid_xypos']) # sort by y and then by x idx = np.lexsort((grid_xypos[:, 0], grid_xypos[:, 1])) grid_xypos = grid_xypos[idx] data = nddata.data[idx] return data, grid_xypos def _cls_info(self): cls_info = [] keys = ('STDPSF', 'instrument', 'detector', 'filter', 'grid_shape') for key in keys: if key in self.meta: name = key.capitalize() if key != 'STDPSF' else key cls_info.append((name, self.meta[key])) cls_info.extend([('Number of ePSFs', len(self.grid_xypos)), ('ePSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', tuple(self.oversampling))]) return cls_info def __str__(self): return self._format_str(keywords=self._cls_info()) def copy(self): """ Return a copy of this model where only the model parameters are copied. All other copied model attributes are references to the original model. This prevents copying the ePSF grid data, which may contain a large array. This method is useful if one is interested in only changing the model parameters in a model copy. It is used in the PSF photometry classes during model fitting. Use the `deepcopy` method if you want to copy all of the model attributes, including the ePSF grid data. """ newcls = object.__new__(self.__class__) for key, val in self.__dict__.items(): if key in self.param_names: # copy only the parameter values newcls.__dict__[key] = copy.copy(val) else: newcls.__dict__[key] = val return newcls def deepcopy(self): """ Return a deep copy of this model. """ return copy.deepcopy(self) def clear_cache(self): """ Clear the internal cache. """ self._calc_interpolator.cache_clear() def _cache_info(self): """ Return information about the internal cache. """ return self._calc_interpolator.cache_info() @staticmethod def _find_start_idx(data, x): """ Find the index of the lower bound where ``x`` should be inserted into ``a`` to maintain order. The index of the upper bound is the index of the lower bound plus 2. Both bound indices must be within the array. Parameters ---------- data : 1D `~numpy.ndarray` The 1D array to search. x : float The value to insert. Returns ------- index : int The index of the lower bound. """ idx = np.searchsorted(data, x) if idx == 0: idx0 = 0 elif idx == len(data): # pragma: no cover idx0 = idx - 2 else: idx0 = idx - 1 return idx0 def _find_bounding_points(self, x, y): """ Find the indices of the grid points that bound the input ``(x, y)`` position. Parameters ---------- x, y : float The ``(x, y)`` position where the ePSF is to be evaluated. The position must be inside the region defined by the grid of ePSF positions. Returns ------- indices : list of int A list of indices of the bounding grid points. """ x0 = self._find_start_idx(self._xgrid, x) y0 = self._find_start_idx(self._ygrid, y) xypoints = list(itertools.product(self._xgrid[x0:x0 + 2], self._ygrid[y0:y0 + 2])) # find the grid_xypos indices of the reference xypoints indices = [] for xx, yy in xypoints: indices.append(np.argsort(np.hypot(self._grid_xpos - xx, self._grid_ypos - yy))[0]) return indices @staticmethod def _bilinear_interp(xyref, zref, xi, yi): """ Perform bilinear interpolation of four 2D arrays located at points on a regular grid. Parameters ---------- xyref : list of 4 (x, y) pairs A list of 4 ``(x, y)`` pairs that form a rectangle. zref : 3D `~numpy.ndarray` A 3D `~numpy.ndarray` of shape ``(4, nx, ny)``. The first axis corresponds to ``xyref``, i.e., ``refdata[0, :, :]`` is the 2D array located at ``xyref[0]``. xi, yi : float The ``(xi, yi)`` point at which to perform the interpolation. The ``(xi, yi)`` point must lie within the rectangle defined by ``xyref``. Returns ------- result : 2D `~numpy.ndarray` The 2D interpolated array. """ xyref = [tuple(i) for i in xyref] idx = sorted(range(len(xyref)), key=xyref.__getitem__) xyref = sorted(xyref) # sort by x, then y (x0, y0), (_x0, y1), (x1, _y0), (_x1, _y1) = xyref if x0 != _x0 or x1 != _x1 or y0 != _y0 or y1 != _y1: raise ValueError('The refxy points do not form a rectangle.') if not np.isscalar(xi): xi = xi[0] if not np.isscalar(yi): yi = yi[0] if not x0 <= xi <= x1 or not y0 <= yi <= y1: raise ValueError('The (x, y) input is not within the rectangle ' 'defined by xyref.') data = np.asarray(zref)[idx] weights = np.array([(x1 - xi) * (y1 - yi), (x1 - xi) * (yi - y0), (xi - x0) * (y1 - yi), (xi - x0) * (yi - y0)]) norm = (x1 - x0) * (y1 - y0) return np.sum(data * weights[:, None, None], axis=0) / norm def _calc_interpolator_uncached(self, x_0, y_0): """ Return the local interpolation function for the ePSF model at (x_0, y_0). Note that the interpolator will be cached by _calc_interpolator. It can be cleared by calling the clear_cache method. """ from scipy.interpolate import RectBivariateSpline if (x_0 < self._xgrid[0] or x_0 > self._xgrid[-1] or y_0 < self._ygrid[0] or y_0 > self._ygrid[-1]): # position is outside of the grid, so simply use the # closest reference ePSF ref_index = np.argsort(np.hypot(self._grid_xpos - x_0, self._grid_ypos - y_0))[0] psf_image = self.data[ref_index, :, :] else: # find the four bounding reference ePSFs and interpolate ref_indices = self._find_bounding_points(x_0, y_0) xyref = self.grid_xypos[ref_indices] psfs = self.data[ref_indices, :, :] psf_image = self._bilinear_interp(xyref, psfs, x_0, y_0) interpolator = RectBivariateSpline(self._xidx, self._yidx, psf_image.T, kx=3, ky=3, s=0) return interpolator def evaluate(self, x, y, flux, x_0, y_0): """ Evaluate the `GriddedPSFModel` for the input parameters. """ if x.ndim > 2: raise ValueError('x and y must be 1D or 2D.') # NOTE: the astropy base Model.__call__() method converts scalar # inputs to size-1 arrays before calling evaluate(). if not np.isscalar(flux): flux = flux[0] if not np.isscalar(x_0): x_0 = x_0[0] if not np.isscalar(y_0): y_0 = y_0[0] # Calculate the local interpolation function for the ePSF at # (x_0, y_0). Only the integer part of the position is input in # order to have effective caching. interpolator = self._calc_interpolator(int(x_0), int(y_0)) # now evaluate the ePSF at the (x_0, y_0) subpixel position on # the input (x, y) values xi = self.oversampling[1] * (np.asarray(x, dtype=float) - x_0) yi = self.oversampling[0] * (np.asarray(y, dtype=float) - y_0) # define origin at the ePSF image center ny, nx = self.data.shape[1:] xi += (nx - 1) / 2 yi += (ny - 1) / 2 evaluated_model = flux * interpolator.ev(xi, yi) if self.fill_value is not None: # find indices of pixels that are outside the input pixel # grid and set these pixels to the fill_value invalid = (((xi < 0) | (xi > nx - 1)) | ((yi < 0) | (yi > ny - 1))) evaluated_model[invalid] = self.fill_value return evaluated_model def _read_stdpsf(filename): with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) with fits.open(filename, ignore_missing_end=True) as hdulist: header = hdulist[0].header data = hdulist[0].data try: npsfs = header['NAXIS3'] nxpsfs = header['NXPSFS'] nypsfs = header['NYPSFS'] except KeyError as exc: raise ValueError('Invalid STDPDF FITS file.') from exc if 'IPSFX01' in header: xgrid = [header[f'IPSFX{i:02d}'] for i in range(1, nxpsfs + 1)] ygrid = [header[f'JPSFY{i:02d}'] for i in range(1, nypsfs + 1)] elif 'IPSFXA5' in header: xgrid = [] ygrid = [] xkeys = ('IPSFXA5', 'IPSFXB5', 'IPSFXC5', 'IPSFXD5') for xkey in xkeys: xgrid.extend([int(n) for n in header[xkey].split()]) ykeys = ('JPSFYA5', 'JPSFYB5') for ykey in ykeys: ygrid.extend([int(n) for n in header[ykey].split()]) else: raise ValueError('Unknown STDPSF FITS file.') # STDPDF FITS positions are 1-indexed xgrid = np.array(xgrid) - 1 ygrid = np.array(ygrid) - 1 # (nypsfs, nxpsfs) # (6, 6) # WFPC2, 4 det # (1, 1) # ACS/HRC # (10, 9) # ACS/WFC, 2 det # (3, 3) # WFC3/IR # (8, 7) # WFC3/UVIS, 2 det # (5, 5) # NIRISS # (5, 5) # NIRCam SW # (10, 20) # NIRCam SW (NRCSW), 8 det # (5, 5) # NIRCam LW # (3, 3) # MIRI grid_data = {'data': data, 'npsfs': npsfs, 'nxpsfs': nxpsfs, 'nypsfs': nypsfs, 'xgrid': xgrid, 'ygrid': ygrid} return grid_data def _split_detectors(grid_data, detector_data, detector_id): """ Split an ePSF array into individual detectors. In particular:: * HST WFPC2 STDPSF file contains 4 detectors * HST ACS/WFC STDPSF file contains 2 detectors * HST WFC3/UVIS STDPSF file contains 2 detectors * JWST NIRCam "NRCSW" STDPSF file contains 8 detectors """ data = grid_data['data'] npsfs = grid_data['npsfs'] nxpsfs = grid_data['nxpsfs'] nypsfs = grid_data['nypsfs'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] nxdet = detector_data['nxdet'] nydet = detector_data['nydet'] det_map = detector_data['det_map'] det_size = detector_data['det_size'] ii = np.arange(npsfs).reshape((nypsfs, nxpsfs)) nxpsfs //= nxdet nypsfs //= nydet ndet = nxdet * nydet ii = reshape_as_blocks(ii, (nypsfs, nxpsfs)) ii = ii.reshape(ndet, npsfs // ndet) # detector_id -> index det_idx = det_map[detector_id] idx = ii[det_idx] data = data[idx] xp = det_idx % nxdet i0 = xp * nxpsfs i1 = i0 + nxpsfs xgrid = xgrid[i0:i1] - xp * det_size if det_idx < nxdet: ygrid = ygrid[:nypsfs] else: ygrid = ygrid[nypsfs:] - det_size return data, xgrid, ygrid def _split_wfc_uvis(grid_data, detector_id): if detector_id is None: raise ValueError('detector_id must be specified for ACS/WFC and ' 'WFC3/UVIS ePSFs.') if detector_id not in (1, 2): raise ValueError('detector_id must be 1 or 2.') # ACS/WFC1 and WFC3/UVIS1 chip1 (sci, 2) are above chip2 (sci, 1) # in y-pixel coordinates xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] ygrid = ygrid.reshape((2, ygrid.shape[0] // 2))[detector_id - 1] if detector_id == 2: ygrid -= 2048 npsfs = grid_data['npsfs'] data = grid_data['data'] data_ny, data_nx = data.shape[1:] data = data.reshape((2, npsfs // 2, data_ny, data_nx))[detector_id - 1] return data, xgrid, ygrid def _split_wfpc2(grid_data, detector_id): if detector_id is None: raise ValueError('detector_id must be specified for WFPC2 ePSFs') if detector_id not in range(1, 5): raise ValueError('detector_id must be between 1 and 4, inclusive') nxdet = 2 nydet = 2 det_size = 800 # det (exten:idx) # WF2 (2:2) PC (1:3) # WF3 (3:0) WF4 (4:1) det_map = {1: 3, 2: 2, 3: 0, 4: 1} detector_data = {'nxdet': nxdet, 'nydet': nydet, 'det_size': det_size, 'det_map': det_map} return _split_detectors(grid_data, detector_data, detector_id) def _split_nrcsw(grid_data, detector_id): if detector_id is None: raise ValueError('detector_id must be specified for NRCSW ePSFs') if detector_id not in range(1, 9): raise ValueError('detector_id must be between 1 and 8, inclusive') nxdet = 4 nydet = 2 det_size = 2048 # det (ext:idx) # A2 (2:4) A4 (4:5) B3 (7:6) B1 (5:7) # A1 (1:0) A3 (3:1) B4 (8:2) B2 (6:3) det_map = {1: 0, 3: 1, 8: 2, 6: 3, 2: 4, 4: 5, 7: 6, 5: 7} detector_data = {'nxdet': nxdet, 'nydet': nydet, 'det_size': det_size, 'det_map': det_map} return _split_detectors(grid_data, detector_data, detector_id) def _get_metadata(filename, detector_id): """ Get metadata from the filename and ``detector_id``. """ if isinstance(filename, io.FileIO): filename = filename.name parts = os.path.basename(filename).strip('.fits').split('_') if len(parts) not in (3, 4): return None # filename from astropy download_file detector, filter_name = parts[1:3] meta = {'STDPSF': filename, 'detector': detector, 'filter': filter_name} if detector_id is not None: detector_map = {'WFPC2': ['HST/WFPC2', 'WFPC2'], 'ACSHRC': ['HST/ACS', 'HRC'], 'ACSWFC': ['HST/ACS', 'WFC'], 'WFC3UV': ['HST/WFC3', 'UVIS'], 'WFC3IR': ['HST/WFC3', 'IR'], 'NRCSW': ['JWST/NIRCam', 'NRCSW'], 'NRCA1': ['JWST/NIRCam', 'A1'], 'NRCA2': ['JWST/NIRCam', 'A2'], 'NRCA3': ['JWST/NIRCam', 'A3'], 'NRCA4': ['JWST/NIRCam', 'A4'], 'NRCB1': ['JWST/NIRCam', 'B1'], 'NRCB2': ['JWST/NIRCam', 'B2'], 'NRCB3': ['JWST/NIRCam', 'B3'], 'NRCB4': ['JWST/NIRCam', 'B4'], 'NRCAL': ['JWST/NIRCam', 'A5'], 'NRCBL': ['JWST/NIRCam', 'B5'], 'NIRISS': ['JWST/NIRISS', 'NIRISS'], 'MIRI': ['JWST/MIRI', 'MIRIM']} try: inst_det = detector_map[detector] except KeyError as exc: raise ValueError(f'Unknown detector {detector}.') from exc if inst_det[1] == 'WFPC2': wfpc2_map = {1: 'PC', 2: 'WF2', 3: 'WF3', 4: 'WF4'} inst_det[1] = wfpc2_map[detector_id] if inst_det[1] in ('WFC', 'UVIS'): chip = 2 if detector_id == 1 else 1 inst_det[1] = f'{inst_det[1]}{chip}' if inst_det[1] == 'NRCSW': sw_map = {1: 'A1', 2: 'A2', 3: 'A3', 4: 'A4', 5: 'B1', 6: 'B2', 7: 'B3', 8: 'B4'} inst_det[1] = sw_map[detector_id] meta['instrument'] = inst_det[0] meta['detector'] = inst_det[1] return meta def stdpsf_reader(filename, detector_id=None): """ Generate a `~photutils.psf.GriddedPSFModel` from a STScI standard-format ePSF (STDPSF) FITS file. .. note:: Instead of being used directly, this function is intended to be used via the `GriddedPSFModel` ``read`` method, e.g., ``model = GriddedPSFModel.read(filename, format='stdpsf')``. STDPSF files are FITS files that contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. The oversampling factor for STDPSF FITS files is assumed to be 4. Parameters ---------- filename : str The name of the STDPDF FITS file. A URL can also be used. detector_id : `None` or int, optional For STDPSF files that contain ePSF grids for multiple detectors, one will need to identify the detector for which to extract the ePSF grid. This keyword is ignored for STDPSF files that do not contain ePSF grids for multiple detectors. For WFPC2, the detector value (int) should be: - 1: PC, 2: WF2, 3: WF3, 4: WF4 For ACS/WFC and WFC3/UVIS, the detector value should be: - 1: WFC2, UVIS2 (sci, 1) - 2: WFC1, UVIS1 (sci, 2) Note that for these two instruments, detector 1 is above detector 2 in the y direction. However, in the FLT FITS files, the (sci, 1) extension corresponds to detector 2 (WFC2, UVIS2) and the (sci, 2) extension corresponds to detector 1 (WFC1, UVIS1). For NIRCam NRCSW files that contain ePSF grids for all 8 SW detectors, the detector value should be: * 1: A1, 2: A2, 3: A3, 4: A4 * 5: B1, 6: B2, 7: B3, 8: B4 Returns ------- model : `~photutils.psf.GriddedPSFModel` The gridded ePSF model. """ grid_data = _read_stdpsf(filename) npsfs = grid_data['npsfs'] if npsfs in (90, 56, 36, 200): if npsfs in (90, 56): # ACS/WFC or WFC3/UVIS data (2 chips) data, xgrid, ygrid = _split_wfc_uvis(grid_data, detector_id) elif npsfs == 36: # WFPC2 data (4 chips) data, xgrid, ygrid = _split_wfpc2(grid_data, detector_id) elif npsfs == 200: # NIRCam SW data (8 chips) data, xgrid, ygrid = _split_nrcsw(grid_data, detector_id) else: raise ValueError('Unknown detector or STDPSF format') else: data = grid_data['data'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] # itertools.product iterates over the last input first xy_grid = [yx[::-1] for yx in itertools.product(ygrid, xgrid)] oversampling = 4 # assumption for STDPSF files nxpsfs = xgrid.shape[0] nypsfs = ygrid.shape[0] meta = {'grid_xypos': xy_grid, 'oversampling': oversampling, 'nxpsfs': nxpsfs, 'nypsfs': nypsfs} # try to get additional metadata from the filename because this # information is not currently available in the FITS headers file_meta = _get_metadata(filename, detector_id) if file_meta is not None: meta.update(file_meta) return GriddedPSFModel(NDData(data, meta=meta)) def webbpsf_reader(filename): """ Generate a `~photutils.psf.GriddedPSFModel` from a WebbPSF FITS file containing a PSF grid. .. note:: Instead of being used directly, this function is intended to be used via the `GriddedPSFModel` ``read`` method, e.g., ``model = GriddedPSFModel.read(filename, format='webbpsf')``. The WebbPSF FITS file contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. Parameters ---------- filename : str The name of the WebbPSF FITS file. A URL can also be used. Returns ------- model : `~photutils.psf.GriddedPSFModel` The gridded ePSF model. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) with fits.open(filename, ignore_missing_end=True) as hdulist: header = hdulist[0].header data = hdulist[0].data # handle the case of only one 2D PSF data = np.atleast_3d(data) if not any('DET_YX' in key for key in header.keys()): raise ValueError('Invalid WebbPSF FITS file; missing "DET_YX{}" ' 'header keys.') if 'OVERSAMP' not in header.keys(): raise ValueError('Invalid WebbPSF FITS file; missing "OVERSAMP" ' 'header key.') # convert header to meta dict header = header.copy(strip=True) header.pop('HISTORY', None) header.pop('COMMENT', None) header.pop('', None) meta = dict(header) meta = {key.lower(): meta[key] for key in meta} # user lower-case keys # define grid_xypos from DET_YX{} FITS header keywords xypos = [] for key in meta.keys(): if 'det_yx' in key: vals = header[key].lstrip('(').rstrip(')').split(',') xypos.append((float(vals[0]), float(vals[1]))) meta['grid_xypos'] = xypos if 'oversampling' not in meta: meta['oversampling'] = meta['oversamp'] ndd = NDData(data, meta=meta) return GriddedPSFModel(ndd) def is_stdpsf(origin, filepath, fileobj, *args, **kwargs): """ Determine whether `origin` is a STDPSF FITS file. Parameters ---------- origin : str or readable file-like Path or file object containing a potential FITS file. Returns ------- is_stdpsf : bool Returns `True` if the given file is a STDPSF FITS file. """ if filepath is not None: extens = ('.fits', '.fits.gz', '.fit', '.fit.gz', '.fts', '.fts.gz') isfits = filepath.lower().endswith(extens) if isfits: with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(filepath) keys = ('NAXIS3', 'NXPSFS', 'NYPSFS') for key in keys: if key not in header: return False return True return False def is_webbpsf(origin, filepath, fileobj, *args, **kwargs): """ Determine whether `origin` is a WebbPSF FITS file. Parameters ---------- origin : str or readable file-like Path or file object containing a potential FITS file. Returns ------- is_webbpsf : bool Returns `True` if the given file is a WebbPSF FITS file. """ if filepath is not None: extens = ('.fits', '.fits.gz', '.fit', '.fit.gz', '.fts', '.fts.gz') isfits = filepath.lower().endswith(extens) if isfits: with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(filepath) keys = ('NAXIS3', 'OVERSAMP', 'DET_YX0') for key in keys: if key not in header: return False return True return False class STDPSFGrid(ModelGridPlotMixin): """ Class to read and plot "STDPSF" format ePSF model grids. STDPSF files are FITS files that contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. The oversampling factor for STDPSF FITS files is assumed to be 4. Parameters ---------- filename : str The name of the STDPDF FITS file. A URL can also be used. Examples -------- >>> psfgrid = STDPSFGrid.read('STDPSF_ACSWFC_F814W.fits') >>> psfgrid.plot_grid() """ def __init__(self, filename): grid_data = _read_stdpsf(filename) self.data = grid_data['data'] self._xgrid = grid_data['xgrid'] self._ygrid = grid_data['ygrid'] xy_grid = [yx[::-1] for yx in itertools.product(self._ygrid, self._xgrid)] oversampling = 4 # assumption for STDPSF files self.grid_xypos = xy_grid self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) meta = {'grid_shape': (len(self._ygrid), len(self._xgrid)), 'grid_xypos': xy_grid, 'oversampling': oversampling} # try to get additional metadata from the filename because this # information is not currently available in the FITS headers file_meta = _get_metadata(filename, None) if file_meta is not None: meta.update(file_meta) self.meta = meta def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' cls_info = [] keys = ('STDPSF', 'detector', 'filter', 'grid_shape') for key in keys: if key in self.meta: name = key.capitalize() if key != 'STDPSF' else key cls_info.append((name, self.meta[key])) cls_info.extend([('Number of ePSFs', len(self.grid_xypos)), ('ePSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', self.oversampling)]) with np.printoptions(threshold=25, edgeitems=5): fmt = [f'{key}: {val}' for key, val in cls_info] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() with registry.delay_doc_updates(GriddedPSFModel): registry.register_reader('stdpsf', GriddedPSFModel, stdpsf_reader) registry.register_identifier('stdpsf', GriddedPSFModel, is_stdpsf) registry.register_reader('webbpsf', GriddedPSFModel, webbpsf_reader) registry.register_identifier('webbpsf', GriddedPSFModel, is_webbpsf) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/groupers.py0000644000175100001770000000515514637570305020317 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to perform grouping of stars. """ from collections import defaultdict import numpy as np __all__ = ['SourceGrouper'] class SourceGrouper: """ Class to group sources into clusters based on a minimum separation distance. The groups are formed using hierarchical agglomerative clustering with a distance criterion, calling the `scipy.cluster.hierarchy.fclusterdata` function. Parameters ---------- min_separation : float The minimum distance (in pixels) such that any two sources separated by less than this distance will be placed in the same group if the ``min_size`` criteria is also met. """ def __init__(self, min_separation): self.min_separation = min_separation def __call__(self, x, y): """ Group sources into clusters based on a minimum distance criteria. Parameters ---------- x, y : 1D float `~numpy.ndarray` The 1D arrays of the x and y centroid coordinates of the sources. Returns ------- result : 1D int `~numpy.ndarray` A 1D array of the groups, in the same order as the input x and y coordinates. """ return self._group_sources(x, y) def _group_sources(self, x, y): """ Group sources into clusters based on a minimum distance criteria. Parameters ---------- x, y : 1D float `~numpy.ndarray` The 1D arrays of the x and y centroid coordinates of the sources. Returns ------- result : 1D int `~numpy.ndarray` A 1D array of the groups, in the same order as the input x and y coordinates. """ from scipy.cluster.hierarchy import fclusterdata x = np.atleast_1d(x) y = np.atleast_1d(y) if x.shape != y.shape: raise ValueError('x and y must have the same shape') if x.shape == (0,): # no sources raise ValueError('x and y must not be empty') if x.shape == (1,): # single source -> single group return np.array([1]) xypos = np.transpose((x, y)) group_id = fclusterdata(xypos, t=self.min_separation, criterion='distance') # reorder the group_ids so that unique group_ids start from 1 # and increase (this matches the output of DBSCAN) mapping = defaultdict(lambda: len(mapping) + 1) return np.array([mapping[group] for group in group_id]) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.034885 photutils-1.13.0/photutils/psf/matching/0000755000175100001770000000000014637570322017662 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/__init__.py0000644000175100001770000000036714637570305022002 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to generate kernels for matching point spread functions. """ from .fourier import * # noqa: F401, F403 from .windows import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/fourier.py0000644000175100001770000000652214637570305021715 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for matching PSFs using Fourier methods. """ import numpy as np from numpy.fft import fft2, fftshift, ifft2, ifftshift __all__ = ['resize_psf', 'create_matching_kernel'] def resize_psf(psf, input_pixel_scale, output_pixel_scale, *, order=3): """ Resize a PSF using spline interpolation of the requested order. Parameters ---------- psf : 2D `~numpy.ndarray` The 2D data array of the PSF. input_pixel_scale : float The pixel scale of the input ``psf``. The units must match ``output_pixel_scale``. output_pixel_scale : float The pixel scale of the output ``psf``. The units must match ``input_pixel_scale``. order : float, optional The order of the spline interpolation (0-5). The default is 3. Returns ------- result : 2D `~numpy.ndarray` The resampled/interpolated 2D data array. """ from scipy.ndimage import zoom ratio = input_pixel_scale / output_pixel_scale return zoom(psf, ratio, order=order) / ratio**2 def create_matching_kernel(source_psf, target_psf, *, window=None): """ Create a kernel to match 2D point spread functions (PSF) using the ratio of Fourier transforms. Parameters ---------- source_psf : 2D `~numpy.ndarray` The source PSF. The source PSF should have higher resolution (i.e., narrower) than the target PSF. ``source_psf`` and ``target_psf`` must have the same shape and pixel scale. target_psf : 2D `~numpy.ndarray` The target PSF. The target PSF should have lower resolution (i.e., broader) than the source PSF. ``source_psf`` and ``target_psf`` must have the same shape and pixel scale. window : callable, optional The window (or taper) function or callable class instance used to remove high frequency noise from the PSF matching kernel. Some examples include: * `~photutils.psf.matching.HanningWindow` * `~photutils.psf.matching.TukeyWindow` * `~photutils.psf.matching.CosineBellWindow` * `~photutils.psf.matching.SplitCosineBellWindow` * `~photutils.psf.matching.TopHatWindow` For more information on window functions and example usage, see :ref:`psf_matching`. Returns ------- kernel : 2D `~numpy.ndarray` The matching kernel to go from ``source_psf`` to ``target_psf``. The output matching kernel is normalized such that it sums to 1. """ # inputs are copied so that they are not changed when normalizing source_psf = np.copy(np.asanyarray(source_psf)) target_psf = np.copy(np.asanyarray(target_psf)) if source_psf.shape != target_psf.shape: raise ValueError('source_psf and target_psf must have the same shape ' '(i.e., registered with the same pixel scale).') # ensure input PSFs are normalized source_psf /= source_psf.sum() target_psf /= target_psf.sum() source_otf = fftshift(fft2(source_psf)) target_otf = fftshift(fft2(target_psf)) ratio = target_otf / source_otf # apply a window function in frequency space if window is not None: ratio *= window(target_psf.shape) kernel = np.real(fftshift(ifft2(ifftshift(ratio)))) return kernel / kernel.sum() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.038885 photutils-1.13.0/photutils/psf/matching/tests/0000755000175100001770000000000014637570322021024 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/tests/__init__.py0000644000175100001770000000000014637570305023124 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/tests/test_fourier.py0000644000175100001770000000307114637570305024112 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the fourier module. """ import numpy as np import pytest from astropy.modeling.fitting import LevMarLSQFitter from astropy.modeling.models import Gaussian2D from numpy.testing import assert_allclose from photutils.psf.matching.fourier import create_matching_kernel, resize_psf from photutils.psf.matching.windows import SplitCosineBellWindow from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_resize_psf(): psf1 = np.ones((5, 5)) psf2 = resize_psf(psf1, 0.1, 0.05) assert psf2.shape == (10, 10) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_create_matching_kernel(): """Test with noiseless 2D Gaussians.""" size = 25 cen = (size - 1) / 2.0 y, x = np.mgrid[0:size, 0:size] std1 = 3.0 std2 = 5.0 gm1 = Gaussian2D(1.0, cen, cen, std1, std1) gm2 = Gaussian2D(1.0, cen, cen, std2, std2) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() window = SplitCosineBellWindow(0.0, 0.2) k = create_matching_kernel(g1, g2, window=window) fitter = LevMarLSQFitter() gfit = fitter(gm1, x, y, k) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(std2**2 - std1**2), 0.06) def test_create_matching_kernel_shapes(): """Test with wrong PSF shapes.""" with pytest.raises(ValueError): psf1 = np.ones((5, 5)) psf2 = np.ones((3, 3)) create_matching_kernel(psf1, psf2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/tests/test_windows.py0000644000175100001770000000404314637570305024131 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the windows module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.psf.matching.windows import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) from photutils.utils._optional_deps import HAS_SCIPY def test_hanning(): win = HanningWindow() data = win((5, 5)) ref = [0.0, 0.19715007, 0.5, 0.19715007, 0.0] assert_allclose(data[1, :], ref) def test_hanning_numpy(): """Test Hanning window against 1D numpy version.""" size = 101 cen = (size - 1) // 2 shape = (size, size) win = HanningWindow() data = win(shape) ref1d = np.hanning(shape[0]) assert_allclose(data[cen, :], ref1d) def test_tukey(): win = TukeyWindow(0.5) data = win((5, 5)) ref = [0.0, 0.63312767, 1.0, 0.63312767, 0.0] assert_allclose(data[1, :], ref) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_tukey_scipy(): """Test Tukey window against 1D scipy version.""" from scipy.signal.windows import tukey size = 101 cen = (size - 1) // 2 shape = (size, size) alpha = 0.4 win = TukeyWindow(alpha=alpha) data = win(shape) ref1d = tukey(shape[0], alpha=alpha) assert_allclose(data[cen, :], ref1d) def test_cosine_bell(): win = CosineBellWindow(alpha=0.8) data = win((7, 7)) ref = [0.0, 0.0, 0.19715007, 0.5, 0.19715007, 0.0, 0.0] assert_allclose(data[2, :], ref) def test_split_cosine_bell(): win = SplitCosineBellWindow(alpha=0.8, beta=0.2) data = win((5, 5)) ref = [0.0, 0.3454915, 1.0, 0.3454915, 0.0] assert_allclose(data[2, :], ref) def test_tophat(): win = TopHatWindow(beta=0.5) data = win((5, 5)) ref = [0.0, 1.0, 1.0, 1.0, 0.0] assert_allclose(data[2, :], ref) def test_invalid_shape(): with pytest.raises(ValueError): win = HanningWindow() win((5,)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/matching/windows.py0000644000175100001770000001527014637570305021734 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides window (or tapering) functions for matching PSFs using Fourier methods. """ import numpy as np __all__ = ['SplitCosineBellWindow', 'HanningWindow', 'TukeyWindow', 'CosineBellWindow', 'TopHatWindow'] def _radial_distance(shape): """ Return an array where each value is the Euclidean distance from the array center. Parameters ---------- shape : tuple of int The size of the output array along each axis. Returns ------- result : 2D `~numpy.ndarray` An array containing the Euclidean radial distances from the array center. """ if len(shape) != 2: raise ValueError('shape must have only 2 elements') position = (np.asarray(shape) - 1) / 2.0 x = np.arange(shape[1]) - position[1] y = np.arange(shape[0]) - position[0] xx, yy = np.meshgrid(x, y) return np.sqrt(xx**2 + yy**2) class SplitCosineBellWindow: """ Class to define a 2D split cosine bell taper function. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. beta : float, optional The inner diameter as a fraction of the array size beyond which the taper begins. ``beta`` must be less or equal to 1.0. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import SplitCosineBellWindow taper = SplitCosineBellWindow(alpha=0.4, beta=0.3) data = taper((101, 101)) plt.imshow(data, cmap='viridis', origin='lower') plt.colorbar() A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import SplitCosineBellWindow taper = SplitCosineBellWindow(alpha=0.4, beta=0.3) data = taper((101, 101)) plt.plot(data[50, :]) """ def __init__(self, alpha, beta): self.alpha = alpha self.beta = beta def __call__(self, shape): """ Call self as a function to return a 2D window function of the given shape. Parameters ---------- shape : tuple of int The size of the output array along each axis. Returns ------- result : 2D `~numpy.ndarray` The window function as a 2D array. """ radial_dist = _radial_distance(shape) npts = (np.array(shape).min() - 1.0) / 2.0 r_inner = self.beta * npts r = radial_dist - r_inner r_taper = int(np.floor(self.alpha * npts)) if r_taper != 0: f = 0.5 * (1.0 + np.cos(np.pi * r / r_taper)) else: f = np.ones(shape) f[radial_dist < r_inner] = 1.0 r_cut = r_inner + r_taper f[radial_dist > r_cut] = 0.0 return f class HanningWindow(SplitCosineBellWindow): """ Class to define a 2D `Hanning (or Hann) window `_ function. The Hann window is a taper formed by using a raised cosine with ends that touch zero. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import HanningWindow taper = HanningWindow() data = taper((101, 101)) plt.imshow(data, cmap='viridis', origin='lower') plt.colorbar() A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import HanningWindow taper = HanningWindow() data = taper((101, 101)) plt.plot(data[50, :]) """ def __init__(self): super().__init__(alpha=1.0, beta=0.0) class TukeyWindow(SplitCosineBellWindow): """ Class to define a 2D `Tukey window `_ function. The Tukey window is a taper formed by using a split cosine bell function with ends that touch zero. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import TukeyWindow taper = TukeyWindow(alpha=0.4) data = taper((101, 101)) plt.imshow(data, cmap='viridis', origin='lower') plt.colorbar() A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import TukeyWindow taper = TukeyWindow(alpha=0.4) data = taper((101, 101)) plt.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=1.0 - alpha) class CosineBellWindow(SplitCosineBellWindow): """ Class to define a 2D cosine bell window function. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CosineBellWindow taper = CosineBellWindow(alpha=0.3) data = taper((101, 101)) plt.imshow(data, cmap='viridis', origin='lower') plt.colorbar() A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CosineBellWindow taper = CosineBellWindow(alpha=0.3) data = taper((101, 101)) plt.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=0.0) class TopHatWindow(SplitCosineBellWindow): """ Class to define a 2D top hat window function. Parameters ---------- beta : float, optional The inner diameter as a fraction of the array size beyond which the taper begins. ``beta`` must be less or equal to 1.0. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import TopHatWindow taper = TopHatWindow(beta=0.4) data = taper((101, 101)) plt.imshow(data, cmap='viridis', origin='lower', interpolation='nearest') plt.colorbar() A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import TopHatWindow taper = TopHatWindow(beta=0.4) data = taper((101, 101)) plt.plot(data[50, :]) """ def __init__(self, beta): super().__init__(alpha=0.0, beta=beta) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/models.py0000644000175100001770000010305214637570305017727 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides models for doing PSF/PRF-fitting photometry. """ import copy import warnings import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import CircularAperture from photutils.utils._parameters import as_pair __all__ = ['FittableImageModel', 'EPSFModel', 'IntegratedGaussianPRF', 'PRFAdapter'] class FittableImageModel(Fittable2DModel): r""" A fittable 2D model of an image allowing for image intensity scaling and image translations. This class takes 2D image data and computes the values of the model at arbitrary locations (including at intra-pixel, fractional positions) within this image using spline interpolation provided by :py:class:`~scipy.interpolate.RectBivariateSpline`. The fittable model provided by this class has three model parameters: an image intensity scaling factor (``flux``) which is applied to (normalized) image, and two positional parameters (``x_0`` and ``y_0``) indicating the location of a feature in the coordinate grid on which the model is to be evaluated. If this class is initialized with ``flux`` (intensity scaling factor) set to `None`, then ``flux`` is going to be estimated as ``sum(data)``. Parameters ---------- data : `~numpy.ndarray` Array containing 2D image. origin : tuple, None, optional A reference point in the input image ``data`` array. When origin is `None`, origin will be set at the middle of the image array. If ``origin`` represents the location of a feature (e.g., the position of an intensity peak) in the input ``data``, then model parameters ``x_0`` and ``y_0`` show the location of this peak in an another target image to which this model was fitted. Fundamentally, it is the coordinate in the model's image data that should map to coordinate (``x_0``, ``y_0``) of the output coordinate system on which the model is evaluated. Alternatively, when ``origin`` is set to ``(0, 0)``, then model parameters ``x_0`` and ``y_0`` are shifts by which model's image should be translated in order to match a target image. normalize : bool, optional Indicates whether or not the model should be build on normalized input image data. If true, then the normalization constant (*N*) is computed so that .. math:: N \cdot C \cdot \sum\limits_{i,j} D_{i,j} = 1, where *N* is the normalization constant, *C* is correction factor given by the parameter ``normalization_correction``, and :math:`D_{i,j}` are the elements of the input image ``data`` array. normalization_correction : float, optional A strictly positive number that represents correction that needs to be applied to model's data normalization (see *C* in the equation in the comments to ``normalize`` for more details). A possible application for this parameter is to account for aperture correction. Assuming model's data represent a PSF to be fitted to some target star, we set ``normalization_correction`` to the aperture correction that needs to be applied to the model. That is, ``normalization_correction`` in this case should be set to the ratio between the total flux of the PSF (including flux outside model's data) to the flux of model's data. Then, best fitted value of the ``flux`` model parameter will represent an aperture-corrected flux of the target star. In the case of aperture correction, ``normalization_correction`` should be a value larger than one, as the total flux, including regions outside of the aperture, should be larger than the flux inside the aperture, and thus the correction is applied as an inversely multiplied factor. fill_value : float, optional The value to be returned by the `evaluate` or ``astropy.modeling.Model.__call__`` methods when evaluation is performed outside the definition domain of the model. kwargs : dict, optional Additional optional keyword arguments to be passed directly to the `compute_interpolator` method. See `compute_interpolator` for more details. oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. """ flux = Parameter(description='Intensity scaling factor for image data.', default=1.0) x_0 = Parameter(description='X-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) y_0 = Parameter(description='Y-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, normalize=False, normalization_correction=1.0, origin=None, oversampling=1, fill_value=0.0, **kwargs): self._fill_value = fill_value self._img_norm = None self._normalization_status = 0 if normalize else 2 self._store_interpolator_kwargs(**kwargs) self._oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) if normalization_correction <= 0: raise ValueError("'normalization_correction' must be strictly " 'positive.') self._normalization_correction = normalization_correction self._normalization_constant = 1.0 / self._normalization_correction self._data = np.array(data, copy=True, dtype=float) if not np.all(np.isfinite(self._data)): raise ValueError("All elements of input 'data' must be finite.") # set input image related parameters: self._ny, self._nx = self._data.shape self._shape = self._data.shape if self._data.size < 1: raise ValueError('Image data array cannot be zero-sized.') # set the origin of the coordinate system in image's pixel grid: self.origin = origin flux = self._initial_norm(flux, normalize) super().__init__(flux, x_0, y_0) # initialize interpolator: self.compute_interpolator(**kwargs) def _initial_norm(self, flux, normalize): if flux is None: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() flux = self._img_norm self._compute_normalization(normalize) return flux def _compute_raw_image_norm(self): """ Helper function that computes the uncorrected inverse normalization factor of input image data. This quantity is computed as the *sum of all pixel values*. .. note:: This function is intended to be overridden in a subclass if one desires to change the way the normalization factor is computed. """ return np.sum(self._data, dtype=float) def _compute_normalization(self, normalize=True): r""" Helper function that computes (corrected) normalization factor of the original image data. This quantity is computed as the inverse "raw image norm" (or total "flux" of model's image) corrected by the ``normalization_correction``: .. math:: N = 1/(\Phi * C), where :math:`\Phi` is the "total flux" of model's image as computed by `_compute_raw_image_norm` and *C* is the normalization correction factor. :math:`\Phi` is computed only once if it has not been previously computed. Otherwise, the existing (stored) value of :math:`\Phi` is not modified as :py:class:`FittableImageModel` does not allow image data to be modified after the object is created. .. note:: Normally, this function should not be called by the end-user. It is intended to be overridden in a subclass if one desires to change the way the normalization factor is computed. """ self._normalization_constant = 1.0 / self._normalization_correction if normalize: # compute normalization constant so that # N*C*sum(data) = 1: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() if self._img_norm != 0.0 and np.isfinite(self._img_norm): self._normalization_constant /= self._img_norm self._normalization_status = 0 else: self._normalization_constant = 1.0 self._normalization_status = 1 warnings.warn('Overflow encountered while computing ' 'normalization constant. Normalization ' 'constant will be set to 1.', AstropyUserWarning) else: self._normalization_status = 2 @property def oversampling(self): """ The factor by which the stored image is oversampled. An input to this model is multiplied by this factor to yield the index into the stored image. """ return self._oversampling @property def data(self): """Get original image data.""" return self._data @property def normalized_data(self): """Get normalized and/or intensity-corrected image data.""" return self._normalization_constant * self._data @property def normalization_constant(self): """Get normalization constant.""" return self._normalization_constant @property def normalization_status(self): """ Get normalization status. Possible status values are: - 0: **Performed**. Model has been successfully normalized at user's request. - 1: **Failed**. Attempt to normalize has failed. - 2: **NotRequested**. User did not request model to be normalized. """ return self._normalization_status @property def normalization_correction(self): """ Set/Get flux correction factor. .. note:: When setting correction factor, model's flux will be adjusted accordingly such that if this model was a good fit to some target image before, then it will remain a good fit after correction factor change. """ return self._normalization_correction @normalization_correction.setter def normalization_correction(self, normalization_correction): old_cf = self._normalization_correction self._normalization_correction = normalization_correction self._compute_normalization(normalize=self._normalization_status != 2) # adjust model's flux so that if this model was a good fit to # some target image, then it will remain a good fit after # correction factor change: self.flux *= normalization_correction / old_cf @property def shape(self): """ A tuple of dimensions of the data array in numpy style (ny, nx). """ return self._shape @property def nx(self): """Number of columns in the data array.""" return self._nx @property def ny(self): """Number of rows in the data array.""" return self._ny @property def origin(self): """ A tuple of ``x`` and ``y`` coordinates of the origin of the coordinate system in terms of pixels of model's image. When setting the coordinate system origin, a tuple of two `int` or `float` may be used. If origin is set to `None`, the origin of the coordinate system will be set to the middle of the data array (``(npix-1)/2.0``). .. warning:: Modifying ``origin`` will not adjust (modify) model's parameters ``x_0`` and ``y_0``. """ return (self._x_origin, self._y_origin) @origin.setter def origin(self, origin): if origin is None: self._x_origin = (self._nx - 1) / 2.0 self._y_origin = (self._ny - 1) / 2.0 elif hasattr(origin, '__iter__') and len(origin) == 2: self._x_origin, self._y_origin = origin else: raise TypeError('Parameter "origin" must be either None or an ' 'iterable with two elements.') @property def x_origin(self): """X-coordinate of the origin of the coordinate system.""" return self._x_origin @property def y_origin(self): """Y-coordinate of the origin of the coordinate system.""" return self._y_origin @property def fill_value(self): """ Fill value to be returned for coordinates outside of the domain of definition of the interpolator. If ``fill_value`` is `None`, then values outside of the domain of definition are the ones returned by the interpolator. """ return self._fill_value @fill_value.setter def fill_value(self, fill_value): self._fill_value = fill_value def _store_interpolator_kwargs(self, **kwargs): """ Store interpolator keyword arguments. This function should be called in a subclass whenever model's interpolator is (re-)computed. """ self._interpolator_kwargs = copy.deepcopy(kwargs) @property def interpolator_kwargs(self): """ Get current interpolator's arguments used when interpolator was created. """ return self._interpolator_kwargs def compute_interpolator(self, **kwargs): """ Compute/define the interpolating spline. This function can be overridden in a subclass to define custom interpolators. Parameters ---------- **kwargs : dict, optional Additional optional keyword arguments: - **degree** : int, tuple, optional Degree of the interpolating spline. A tuple can be used to provide different degrees for the X- and Y-axes. Default value is degree=3. - **s** : float, optional Non-negative smoothing factor. Default value s=0 corresponds to interpolation. See :py:class:`~scipy.interpolate.RectBivariateSpline` for more details. Notes ----- * When subclassing :py:class:`FittableImageModel` for the purpose of overriding :py:func:`compute_interpolator`, the :py:func:`evaluate` may need to overridden as well depending on the behavior of the new interpolator. In addition, for improved future compatibility, make sure that the overriding method stores keyword arguments ``kwargs`` by calling ``_store_interpolator_kwargs`` method. * Use caution when modifying interpolator's degree or smoothness in a computationally intensive part of the code as it may decrease code performance due to the need to recompute interpolator. """ from scipy.interpolate import RectBivariateSpline if 'degree' in kwargs: degree = kwargs['degree'] if hasattr(degree, '__iter__') and len(degree) == 2: degx = int(degree[0]) degy = int(degree[1]) else: degx = int(degree) degy = int(degree) if degx < 0 or degy < 0: raise ValueError('Interpolator degree must be a non-negative ' 'integer') else: degx = 3 degy = 3 smoothness = kwargs.get('s', 0) x = np.arange(self._nx, dtype=float) y = np.arange(self._ny, dtype=float) self.interpolator = RectBivariateSpline( x, y, self._data.T, kx=degx, ky=degy, s=smoothness ) self._store_interpolator_kwargs(**kwargs) def evaluate(self, x, y, flux, x_0, y_0, *, use_oversampling=True): """ Evaluate the model on some input variables and provided model parameters. Parameters ---------- use_oversampling : bool, optional Whether to use the oversampling factor to calculate the model pixel indices. The default is `True`, which means the input indices will be multiplied by this factor. """ if use_oversampling: xi = self._oversampling[1] * (np.asarray(x) - x_0) yi = self._oversampling[0] * (np.asarray(y) - y_0) else: xi = np.asarray(x) - x_0 yi = np.asarray(y) - y_0 xi = xi.astype(float) yi = yi.astype(float) xi += self._x_origin yi += self._y_origin f = flux * self._normalization_constant evaluated_model = f * self.interpolator.ev(xi, yi) if self._fill_value is not None: # find indices of pixels that are outside the input pixel grid and # set these pixels to the 'fill_value': invalid = (((xi < 0) | (xi > self._nx - 1)) | ((yi < 0) | (yi > self._ny - 1))) evaluated_model[invalid] = self._fill_value return evaluated_model class EPSFModel(FittableImageModel): """ A class that models an effective PSF (ePSF). The EPSFModel is normalized such that the sum of the PSF over the (undersampled) pixels within the input ``norm_radius`` is 1.0. This means that when the EPSF is fit to stars, the resulting flux corresponds to aperture photometry within a circular aperture of radius ``norm_radius``. While this class is a subclass of `FittableImageModel`, it is very similar. The primary differences/motivation are a few additional parameters necessary specifically for ePSFs. Parameters ---------- oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. norm_radius : float, optional The radius inside which the ePSF is normalized by the sum over undersampled integer pixel values inside a circular aperture. """ def __init__(self, data, *, flux=1.0, x_0=0.0, y_0=0.0, normalize=True, normalization_correction=1.0, origin=None, oversampling=1, fill_value=0.0, norm_radius=5.5, **kwargs): self._norm_radius = norm_radius super().__init__(data=data, flux=flux, x_0=x_0, y_0=y_0, normalize=normalize, normalization_correction=normalization_correction, origin=origin, oversampling=oversampling, fill_value=fill_value, **kwargs) def _initial_norm(self, flux, normalize): if flux is None: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() flux = self._img_norm if normalize: self._compute_normalization() else: self._img_norm = self._compute_raw_image_norm() return flux def _compute_raw_image_norm(self): """ Compute the normalization of input image data as the flux within a given radius. """ xypos = (self._nx / 2.0, self._ny / 2.0) # TODO: generalize "radius" (ellipse?) is oversampling is # different along x/y axes radius = self._norm_radius * self.oversampling[0] aper = CircularAperture(xypos, r=radius) flux, _ = aper.do_photometry(self._data, method='exact') return flux[0] / np.prod(self.oversampling) def _compute_normalization(self, normalize=True): """ Helper function that computes (corrected) normalization factor of the original image data. For the ePSF this is defined as the sum over the inner N (default=5.5) pixels of the non-oversampled image. Will re-normalize the data to the value calculated. """ if normalize: if self._img_norm is None: if np.sum(self._data) == 0: self._img_norm = 1 else: self._img_norm = self._compute_raw_image_norm() if self._img_norm != 0.0 and np.isfinite(self._img_norm): self._data /= (self._img_norm * self._normalization_correction) self._normalization_status = 0 else: self._normalization_status = 1 self._img_norm = 1 warnings.warn('Overflow encountered while computing ' 'normalization constant. Normalization ' 'constant will be set to 1.', AstropyUserWarning) else: self._normalization_status = 2 @property def normalized_data(self): """ Overloaded dummy function that also returns self._data, as the normalization occurs within _compute_normalization in EPSFModel, and as such self._data will sum, accounting for under/oversampled pixels, to 1/self._normalization_correction. """ return self._data @FittableImageModel.origin.setter def origin(self, origin): if origin is None: self._x_origin = (self._nx - 1) / 2.0 / self.oversampling[1] self._y_origin = (self._ny - 1) / 2.0 / self.oversampling[0] elif (hasattr(origin, '__iter__') and len(origin) == 2): self._x_origin, self._y_origin = origin else: raise TypeError('Parameter "origin" must be either None or an ' 'iterable with two elements.') def compute_interpolator(self, **kwargs): """ Compute/define the interpolating spline. This function can be overridden in a subclass to define custom interpolators. Parameters ---------- **kwargs : dict, optional Additional optional keyword arguments: - **degree** : int, tuple, optional Degree of the interpolating spline. A tuple can be used to provide different degrees for the X- and Y-axes. Default value is degree=3. - **s** : float, optional Non-negative smoothing factor. Default value s=0 corresponds to interpolation. See :py:class:`~scipy.interpolate.RectBivariateSpline` for more details. Notes ----- * When subclassing :py:class:`FittableImageModel` for the purpose of overriding :py:func:`compute_interpolator`, the :py:func:`evaluate` may need to overridden as well depending on the behavior of the new interpolator. In addition, for improved future compatibility, make sure that the overriding method stores keyword arguments ``kwargs`` by calling ``_store_interpolator_kwargs`` method. * Use caution when modifying interpolator's degree or smoothness in a computationally intensive part of the code as it may decrease code performance due to the need to recompute interpolator. """ from scipy.interpolate import RectBivariateSpline if 'degree' in kwargs: degree = kwargs['degree'] if hasattr(degree, '__iter__') and len(degree) == 2: degx = int(degree[0]) degy = int(degree[1]) else: degx = int(degree) degy = int(degree) if degx < 0 or degy < 0: raise ValueError('Interpolator degree must be a non-negative ' 'integer') else: degx = 3 degy = 3 smoothness = kwargs.get('s', 0) # Interpolator must be set to interpolate on the undersampled # pixel grid, going from 0 to len(undersampled_grid) x = np.arange(self._nx, dtype=float) / self.oversampling[1] y = np.arange(self._ny, dtype=float) / self.oversampling[0] self.interpolator = RectBivariateSpline( x, y, self._data.T, kx=degx, ky=degy, s=smoothness) self._store_interpolator_kwargs(**kwargs) def evaluate(self, x, y, flux, x_0, y_0): """ Evaluate the model on some input variables and provided model parameters. """ xi = np.asarray(x) - x_0 + self._x_origin yi = np.asarray(y) - y_0 + self._y_origin evaluated_model = flux * self.interpolator.ev(xi, yi) if self._fill_value is not None: # find indices of pixels that are outside the input pixel # grid and set these pixels to the 'fill_value': invalid = (((xi < 0) | (xi > (self._nx - 1) / self.oversampling[1])) | ((yi < 0) | (yi > (self._ny - 1) / self.oversampling[0]))) evaluated_model[invalid] = self._fill_value return evaluated_model class IntegratedGaussianPRF(Fittable2DModel): r""" Circular Gaussian model integrated over pixels. Because it is integrated, this model is considered a PRF, *not* a PSF (see :ref:`psf-terminology` for more about the terminology used here.) This model is a Gaussian *integrated* over an area of ``1`` (in units of the model input coordinates, e.g., 1 pixel). This is in contrast to the apparently similar `astropy.modeling.functional_models.Gaussian2D`, which is the value of a 2D Gaussian *at* the input coordinates, with no integration. So this model is equivalent to assuming the PSF is Gaussian at a *sub-pixel* level. Parameters ---------- sigma : float Width of the Gaussian PSF. flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak in x direction. y_0 : float, optional Position of the peak in y direction. Notes ----- This model is evaluated according to the following formula: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left(\frac{x - x_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{x - x_0 - 0.5} {\sqrt{2} \sigma} \right) \right] \left[ {\rm erf} \left(\frac{y - y_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{y - y_0 - 0.5} {\sqrt{2} \sigma} \right) \right] where ``erf`` denotes the error function and ``F`` the total integrated flux. """ flux = Parameter(default=1) x_0 = Parameter(default=0) y_0 = Parameter(default=0) sigma = Parameter(default=1, fixed=True) _erf = None def bounding_box(self, factor=5.5): """ Tuple defining the default ``bounding_box`` limits, ``(x_low, x_high)``. Parameters ---------- factor : float The multiple of `sigma` used to define the limits. The default is 5.5, corresponding to a relative flux error less than 5e-9. Examples -------- >>> from photutils.psf import IntegratedGaussianPRF >>> model = IntegratedGaussianPRF(x_0=0, y_0=0, sigma=2) >>> model.bounding_box ModelBoundingBox( intervals={ x: Interval(lower=-11.0, upper=11.0) y: Interval(lower=-11.0, upper=11.0) } model=IntegratedGaussianPRF(inputs=('x', 'y')) order='C' ) This range can be set directly (see: `Model.bounding_box `) or by using a different factor, like: >>> model.bounding_box = model.bounding_box(factor=2) >>> model.bounding_box ModelBoundingBox( intervals={ x: Interval(lower=-4.0, upper=4.0) y: Interval(lower=-4.0, upper=4.0) } model=IntegratedGaussianPRF(inputs=('x', 'y')) order='C' ) """ delta = factor * self.sigma return ( (self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta), ) def __init__(self, sigma=sigma.default, x_0=x_0.default, y_0=y_0.default, flux=flux.default, **kwargs): if self._erf is None: from scipy.special import erf self.__class__._erf = erf super().__init__(n_models=1, sigma=sigma, x_0=x_0, y_0=y_0, flux=flux, **kwargs) def evaluate(self, x, y, flux, x_0, y_0, sigma): """Model function Gaussian PSF model.""" return (flux / 4 * ((self._erf((x - x_0 + 0.5) / (np.sqrt(2) * sigma)) - self._erf((x - x_0 - 0.5) / (np.sqrt(2) * sigma))) * (self._erf((y - y_0 + 0.5) / (np.sqrt(2) * sigma)) - self._erf((y - y_0 - 0.5) / (np.sqrt(2) * sigma))))) class PRFAdapter(Fittable2DModel): """ A model that adapts a supplied PSF model to act as a PRF. It integrates the PSF model over pixel "boxes". A critical built-in assumption is that the PSF model scale and location parameters are in *pixel* units. Parameters ---------- psfmodel : a 2D model The model to assume as representative of the PSF. renormalize_psf : bool If True, the model will be integrated from -inf to inf and re-scaled so that the total integrates to 1. Note that this renormalization only occurs *once*, so if the total flux of ``psfmodel`` depends on position, this will *not* be correct. xname : str or None The name of the ``psfmodel`` parameter that corresponds to the x-axis center of the PSF. If None, the model will be assumed to be centered at x=0. yname : str or None The name of the ``psfmodel`` parameter that corresponds to the y-axis center of the PSF. If None, the model will be assumed to be centered at y=0. fluxname : str or None The name of the ``psfmodel`` parameter that corresponds to the total flux of the star. If None, a scaling factor will be applied by the ``PRFAdapter`` instead of modifying the ``psfmodel``. Notes ----- This current implementation of this class (using numerical integration for each pixel) is extremely slow, and only suited for experimentation over relatively few small regions. """ flux = Parameter(default=1) x_0 = Parameter(default=0) y_0 = Parameter(default=0) def __init__(self, psfmodel, *, renormalize_psf=True, flux=flux.default, x_0=x_0.default, y_0=y_0.default, xname=None, yname=None, fluxname=None, **kwargs): self.psfmodel = psfmodel.copy() if renormalize_psf: from scipy.integrate import dblquad self._psf_scale_factor = 1.0 / dblquad(self.psfmodel, -np.inf, np.inf, lambda x: -np.inf, lambda x: np.inf)[0] else: self._psf_scale_factor = 1 self.xname = xname self.yname = yname self.fluxname = fluxname # these can be used to adjust the integration behavior. Might be # used in the future to expose how the integration happens self._dblquadkwargs = {} super().__init__(n_models=1, x_0=x_0, y_0=y_0, flux=flux, **kwargs) def evaluate(self, x, y, flux, x_0, y_0): """The evaluation function for PRFAdapter.""" if not np.isscalar(flux): flux = flux[0] if not np.isscalar(x_0): x_0 = x_0[0] if not np.isscalar(y_0): y_0 = y_0[0] if self.xname is None: dx = x - x_0 else: dx = x setattr(self.psfmodel, self.xname, x_0) if self.xname is None: dy = y - y_0 else: dy = y setattr(self.psfmodel, self.yname, y_0) if self.fluxname is None: return (flux * self._psf_scale_factor * self._integrated_psfmodel(dx, dy)) else: setattr(self.psfmodel, self.yname, flux * self._psf_scale_factor) return self._integrated_psfmodel(dx, dy) def _integrated_psfmodel(self, dx, dy): from scipy.integrate import dblquad # infer type/shape from the PSF model. Seems wasteful, but the # integration step is a *lot* more expensive so its just peanuts out = np.empty_like(self.psfmodel(dx, dy)) outravel = out.ravel() for i, (xi, yi) in enumerate(zip(dx.ravel(), dy.ravel())): outravel[i] = dblquad(self.psfmodel, xi - 0.5, xi + 0.5, lambda x: yi - 0.5, lambda x: yi + 0.5, **self._dblquadkwargs)[0] return out ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/photometry.py0000644000175100001770000024040114637570305020656 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to perform PSF-fitting photometry. """ import inspect import warnings from collections import defaultdict from copy import deepcopy from itertools import chain import astropy.units as u import numpy as np from astropy.modeling.fitting import LevMarLSQFitter from astropy.nddata import NDData, NoOverlapError, StdDevUncertainty from astropy.table import QTable, Table, hstack, join, vstack from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import CircularAperture from photutils.background import LocalBackground from photutils.datasets import make_model_image as _make_model_image from photutils.psf.groupers import SourceGrouper from photutils.psf.utils import _get_psf_model_params, _validate_psf_model from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import _overlap_slices as overlap_slices from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['ModelImageMixin', 'PSFPhotometry', 'IterativePSFPhotometry'] class ModelImageMixin: """ Mixin class to provide methods to calculate model images and residuals. """ def make_model_image(self, shape, psf_shape, *, include_localbkg=False): """ Create a 2D image from the fit PSF models and optional local background. Parameters ---------- shape : 2 tuple of int The shape of the output array. psf_shape : 2 tuple of int The shape of region around the center of the fit model to render in the output image. include_localbkg : bool, optional Whether to include the local background in the rendered output image. Note that the local background level is included around each source over the region defined by ``psf_shape``. Thus, regions where the ``psf_shape`` of sources overlap will have the local background added multiple times. Returns ------- array : 2D `~numpy.ndarray` The rendered image from the fit PSF models. This image will not have any units. """ if isinstance(self, PSFPhotometry): progress_bar = self.progress_bar psf_model = self.psf_model fit_params = self._fit_model_params local_bkgs = self.init_params['local_bkg'] else: psf_model = self._psfphot.psf_model progress_bar = self._psfphot.progress_bar if self.mode == 'new': # collect the fit params and local backgrounds from each # iteration local_bkgs = [] for i, psfphot in enumerate(self.fit_results): if i == 0: fit_params = psfphot._fit_model_params else: fit_params = vstack((fit_params, psfphot._fit_model_params)) local_bkgs.append(psfphot.init_params['local_bkg']) local_bkgs = _flatten(local_bkgs) else: # use the fit params and local backgrounds only from the # final iteration, which includes all sources fit_params = self.fit_results[-1]._fit_model_params local_bkgs = self.fit_results[-1].init_params['local_bkg'] model_params = fit_params if include_localbkg: # add local_bkg model_params = model_params.copy() model_params['local_bkg'] = local_bkgs try: x_name = psf_model.x_name y_name = psf_model.y_name except AttributeError: x_name = 'x_0' y_name = 'y_0' return _make_model_image(shape, psf_model, model_params, model_shape=psf_shape, x_name=x_name, y_name=y_name, progress_bar=progress_bar) def make_residual_image(self, data, psf_shape, *, include_localbkg=False): """ Create a 2D residual image from the fit PSF models and local background. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which photometry was performed. This should be the same array input when calling the PSF-photometry class. psf_shape : 2 tuple of int The shape of region around the center of the fit model to subtract. include_localbkg : bool, optional Whether to include the local background in the subtracted model. Note that the local background level is subtracted around each source over the region defined by ``psf_shape``. Thus, regions where the ``psf_shape`` of sources overlap will have the local background subtracted multiple times. Returns ------- array : 2D `~numpy.ndarray` The residual image of the ``data`` minus the fit PSF models minus the optional``local_bkg``. """ if isinstance(data, NDData): residual = deepcopy(data) data_arr = data.data if data.unit is not None: data_arr <<= data.unit residual.data[:] = self.make_residual_image( data_arr, psf_shape, include_localbkg=include_localbkg) else: residual = self.make_model_image(data.shape, psf_shape, include_localbkg=include_localbkg) np.subtract(data, residual, out=residual) return residual class PSFPhotometry(ModelImageMixin): """ Class to perform PSF photometry. This class implements a flexible PSF photometry algorithm that can find sources in an image, group overlapping sources, fit the PSF model to the sources, and subtract the fit PSF models from the image. Parameters ---------- psf_model : 2D `astropy.modeling.Model` The PSF model to fit to the data. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. fit_shape : int or length-2 array_like The rectangular shape around the center of a star that will be used to define the PSF-fitting data. If ``fit_shape`` is a scalar then a square shape of size ``fit_shape`` will be used. If ``fit_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``fit_shape`` must be an odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` or `None`, optional A callable used to identify stars in an image. The ``finder`` must accept a 2D image as input and return a `~astropy.table.Table` containing the x and y centroid positions. These positions are used as the starting points for the PSF fitting. The allowed ``x`` column names are (same suffix for ``y``): ``'x_init'``, ``'xinit'``, ``'x'``, ``'x_0'``, ``'x0'``, ``'xcentroid'``, ``'x_centroid'``, ``'x_peak'``, ``'xcen'``, ``'x_cen'``, ``'xpos'``, ``'x_pos'``, ``'x_fit'``, and ``'xfit'``. If `None`, then the initial (x, y) model positions must be input using the ``init_params`` keyword when calling the class. The (x, y) values in ``init_params`` override this keyword. If this class is run on an image that has units (i.e., a `~astropy.units.Quantity` array), then certain ``finder`` keywords (e.g., ``threshold``) must have the same units. Please see the documentation for the specific ``finder`` class for more information. grouper : `~photutils.psf.SourceGrouper` or callable or `None`, optional A callable used to group stars. Typically, grouped stars are those that overlap with their neighbors. Stars that are grouped are fit simultaneously. The ``grouper`` must accept the x and y coordinates of the sources and return an integer array of the group id numbers (starting from 1) indicating the group in which a given source belongs. If `None`, then no grouping is performed, i.e. each source is fit independently. The ``group_id`` values in ``init_params`` override this keyword. A warning is raised if any group size is larger than 25 sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. This keyword can be increased if the fit is not converging for sources (e.g., the output ``flags`` value contains 8). localbkg_estimator : `~photutils.background.LocalBackground` or `None`, optional The object used to estimate the local background around each source. If `None`, then no local background is subtracted. The ``local_bkg`` values in ``init_params`` override this keyword. This option should be used with care, especially in crowded fields where the ``fit_shape`` of sources overlap (see Notes below). aperture_radius : float, optional The radius of the circular aperture used to estimate the initial flux of each source. If initial flux values are present in the ``init_params`` table, they will override this keyword. progress_bar : bool, optional Whether to display a progress bar when fitting the sources (or groups). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- The data that will be fit for each source is defined by the ``fit_shape`` parameter. A cutout will be made around the initial center of each source with a shape defined by ``fit_shape``. The PSF model will be fit to the data in this region. The cutout region that is fit does not shift if the source center shifts during the fit iterations. Therefore, the initial source positions should be close to the true source positions. One way to ensure this is to use a ``finder`` to identify sources in the data. If the fitted positions are significantly different from the initial positions, one can re-run the `PSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After calling `PSFPhotometry` on the data, it will have a ``fit_params`` attribute containing the fitted model parameters. This table can be used as the ``init_params`` input in a subsequent call to `PSFPhotometry`. If the returned model parameter errors are NaN, then either the fit did not converge, the model parameter was fixed, or the input ``fitter`` did not return parameter errors. For the later case, one can try a different fitter that may return parameter errors (e.g., `astropy.modeling.fitting.LMLSQFitter` or `astropy.modeling.fitting.TRFLSQFitter`). Note that these fitters are typically slower than the default `astropy.modeling.fitting.LevMarLSQFitter`. The local background value around each source is optionally estimated using the ``localbkg_estimator`` or obtained from the ``local_bkg`` column in the input ``init_params`` table. This local background is then subtracted from the data over the ``fit_shape`` region for each source before fitting the PSF model. For sources where their ``fit_shape`` regions overlap, the local background will effectively be subtracted twice in the overlapping ``fit_shape`` regions, even if the source ``grouper`` is input. This is not an issue if the sources are well-separated. However, for crowded fields, please use the ``localbkg_estimator`` (or ``local_bkg`` column in ``init_params``) with care. """ def __init__(self, psf_model, fit_shape, *, finder=None, grouper=None, fitter=LevMarLSQFitter(), fitter_maxiters=100, localbkg_estimator=None, aperture_radius=None, progress_bar=False): self._param_maps = self._define_param_maps(psf_model) self.psf_model = _validate_psf_model(psf_model) self.fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(0, 1), check_odd=True) self.grouper = self._validate_grouper(grouper, 'grouper') self.finder = self._validate_callable(finder, 'finder') self.fitter = self._validate_callable(fitter, 'fitter') self.localbkg_estimator = self._validate_localbkg( localbkg_estimator, 'localbkg_estimator') self.fitter_maxiters = self._validate_maxiters(fitter_maxiters) self.aperture_radius = self._validate_radius(aperture_radius) self.progress_bar = progress_bar # be sure to reset these attributes for each __call__ # (see _reset_results) self.data_unit = None self.finder_results = None self.init_params = None self.fit_params = None self._fit_model_params = None self.fit_results = defaultdict(list) self._group_results = defaultdict(list) def _reset_results(self): """ Reset these attributes for each __call__. """ self.data_unit = None self.finder_results = None self.init_params = None self.fit_params = None self._fit_model_params = None self.fit_results = defaultdict(list) self._group_results = defaultdict(list) def _validate_grouper(self, grouper, name): if grouper is not None and not isinstance(grouper, SourceGrouper): raise ValueError('grouper must be a SourceGrouper instance.') return grouper @staticmethod def _define_model_params_map(psf_model): # The main parameter names are checked together as a unit in the # following order: # * ('x_0', 'y_0', 'flux') parameters # * ('x_name', 'y_name', 'flux_name') attributes main_params = _get_psf_model_params(psf_model) main_aliases = ('x', 'y', 'flux') params_map = dict(zip(main_aliases, main_params)) # define the fitted model parameters fitted_params = [] for key, val in psf_model.fixed.items(): if not val: fitted_params.append(key) # define the "extra" fitted model parameters that do not # correspond to x, y, or flux extra_params = [] for key in fitted_params: if key not in main_params: extra_params.append(key) other_params = {key: key for key in extra_params} params_map.update(other_params) return params_map def _define_param_maps(self, psf_model): """ Map x, y, and flux column names to the PSF model parameter names. Also include any extra PSF model parameters that are fit, but do not correspond to x, y, or flux. The column names include the ``_init``, ``_fit``, and ``_err`` suffixes for each parameter. """ params_map = self._define_model_params_map(psf_model) param_maps = {} param_maps['model'] = params_map # Keep track of only the fitted parameters in the same order as # they are stored in the psf_model. This is used to extract the # fitted parameter errors from the fitter output. fit_params = {} inv_pmap = {val: key for key, val in params_map.items()} for name in psf_model.param_names: if not psf_model.fixed[name]: fit_params[inv_pmap[name]] = name param_maps['fit_params'] = fit_params suffixes = ('init', 'fit', 'err') for suffix in suffixes: pmap = {} for key, val in params_map.items(): pmap[val] = f'{key}_{suffix}' param_maps[suffix] = pmap init_cols = {} for key in param_maps['model'].keys(): init_cols[key] = f'{key}_init' param_maps['init_cols'] = init_cols return param_maps @staticmethod def _validate_callable(obj, name): if obj is not None and not callable(obj): raise TypeError(f'{name!r} must be a callable object') return obj def _validate_localbkg(self, value, name): if value is not None and not isinstance(value, LocalBackground): raise ValueError('localbkg_estimator must be a ' 'LocalBackground instance.') return self._validate_callable(value, name) def _validate_maxiters(self, maxiters): spec = inspect.signature(self.fitter.__call__) if 'maxiter' not in spec.parameters: warnings.warn('"maxiters" will be ignored because it is not ' 'accepted by the input fitter __call__ method', AstropyUserWarning) maxiters = None return maxiters @staticmethod def _validate_radius(radius): if radius is not None and (not np.isscalar(radius) or radius <= 0 or ~np.isfinite(radius)): raise ValueError('aperture_radius must be a strictly-positive ' 'scalar') return radius def _validate_array(self, array, name, data_shape=None): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if data_shape is not None and array.shape != data_shape: raise ValueError(f'data and {name} must have the same shape.') return array @lazyproperty def _valid_colnames(self): """ A dictionary of valid column names for the input ``init_params`` table. These lists are searched in order. """ xy_suffixes = ('_init', 'init', '', '_0', '0', 'centroid', '_centroid', '_peak', 'cen', '_cen', 'pos', '_pos', '_fit', 'fit') x_valid = ['x' + i for i in xy_suffixes] y_valid = ['y' + i for i in xy_suffixes] valid_colnames = {} valid_colnames['x'] = x_valid valid_colnames['y'] = y_valid valid_colnames['flux'] = ('flux_init', 'fluxinit', 'flux', 'flux_0', 'flux0', 'flux_fit', 'fluxfit', 'source_sum', 'segment_flux', 'kron_flux') return valid_colnames def _find_column_name(self, key, colnames): """ Find the first valid matching column name for x, y, or flux (defined by `_valid_colnames` in the input ``init_params`` table). """ name = '' try: valid_names = self._valid_colnames[key] except KeyError: # parameters other than (x, y, flux) must have "_init", "", # or "_fit" suffixes valid_names = [f'{key}_init', key, f'{key}_fit'] for valid_name in valid_names: if valid_name in colnames: name = valid_name break # return the first match return name def _check_init_units(self, init_params, colname): values = init_params[colname] if isinstance(values, u.Quantity): if self.data_unit is None: raise ValueError(f'init_params {colname} column has ' 'units, but the input data does not ' 'have units.') try: init_params[colname] = values.to(self.data_unit) except u.UnitConversionError as exc: raise ValueError(f'init_params {colname} column has ' 'units that are incompatible with ' 'the input data units.') from exc else: if self.data_unit is not None: raise ValueError('The input data has units, but the ' f'init_params {colname} column does not ' 'have units.') return init_params @staticmethod def _rename_init_columns(init_params, param_maps, find_column_name): """ This is a static method to allow the method to be called from IterativePSFPhotometry. """ for param in param_maps['model'].keys(): colname = find_column_name(param, init_params.colnames) if colname: init_name = param_maps['init_cols'][param] if colname != init_name: init_params.rename_column(colname, init_name) return init_params def _validate_init_params(self, init_params): """ Validate the input ``init_params`` table. Also rename the columns to the expected names with the "_init" suffix if necessary. """ if init_params is None: return init_params if not isinstance(init_params, Table): raise TypeError('init_params must be an astropy Table') # copy is used to preserve the input init_params init_params = self._rename_init_columns(init_params.copy(), self._param_maps, self._find_column_name) # x and y columns are always required xcolname = self._param_maps['init_cols']['x'] ycolname = self._param_maps['init_cols']['y'] if (xcolname not in init_params.colnames or ycolname not in init_params.colnames): raise ValueError('init_param must contain valid column names ' 'for the x and y source positions') fluxcolname = self._param_maps['init_cols']['flux'] if fluxcolname in init_params.colnames: init_params = self._check_init_units(init_params, fluxcolname) if 'local_bkg' in init_params.colnames: if not np.all(np.isfinite(init_params['local_bkg'])): raise ValueError('init_params local_bkg column contains ' 'non-finite values.') init_params = self._check_init_units(init_params, 'local_bkg') return init_params @staticmethod def _make_mask(image, mask): def warn_nonfinite(): warnings.warn('Input data contains unmasked non-finite values ' '(NaN or inf), which were automatically ignored.', AstropyUserWarning) # if NaNs are in the data, no actual fitting takes place # https://github.com/astropy/astropy/pull/12811 finite_mask = ~np.isfinite(image) if mask is not None: finite_mask |= mask if np.any(finite_mask & ~mask): warn_nonfinite() else: mask = finite_mask if np.any(finite_mask): warn_nonfinite() else: mask = None return mask def _get_aper_fluxes(self, data, mask, init_params): xpos = init_params[self._param_maps['init_cols']['x']] ypos = init_params[self._param_maps['init_cols']['y']] apertures = CircularAperture(zip(xpos, ypos), r=self.aperture_radius) flux, _ = apertures.do_photometry(data, mask=mask) return flux def _prepare_init_params(self, data, mask, init_params): xcolname = self._param_maps['init_cols']['x'] ycolname = self._param_maps['init_cols']['y'] fluxcolname = self._param_maps['init_cols']['flux'] if init_params is None: if self.finder is None: raise ValueError('finder must be defined if init_params ' 'is not input') # restore units to the input data (stripped earlier) if self.data_unit is not None: sources = self.finder(data << self.data_unit, mask=mask) else: sources = self.finder(data, mask=mask) if sources is None: return None self.finder_results = sources init_params = QTable() init_params['id'] = np.arange(len(sources)) + 1 init_params[xcolname] = sources['xcentroid'] init_params[ycolname] = sources['ycentroid'] else: colnames = init_params.colnames if 'id' not in colnames: init_params['id'] = np.arange(len(init_params)) + 1 if 'local_bkg' not in init_params.colnames: if self.localbkg_estimator is None: local_bkg = np.zeros(len(init_params)) else: local_bkg = self.localbkg_estimator( data, init_params[xcolname], init_params[ycolname], mask=mask) if self.data_unit is not None: local_bkg <<= self.data_unit init_params['local_bkg'] = local_bkg if fluxcolname not in init_params.colnames: flux = self._get_aper_fluxes(data, mask, init_params) if self.data_unit is not None: flux <<= self.data_unit flux -= init_params['local_bkg'] init_params[fluxcolname] = flux if 'group_id' in init_params.colnames: # grouper is ignored if group_id is input in init_params self.grouper = None if self.grouper is not None: group_id = self.grouper(init_params[xcolname], init_params[ycolname]) else: group_id = init_params['id'].copy() init_params['group_id'] = group_id # add columns for any additional parameters that are fit for param_name, colname in self._param_maps['init'].items(): if colname not in init_params.colnames: init_params[colname] = getattr(self.psf_model, param_name) extra_param_cols = [] for colname in self._param_maps['init_cols'].values(): if colname in (xcolname, ycolname, fluxcolname): continue extra_param_cols.append(colname) # order init_params columns colname_order = ['id', 'group_id', 'local_bkg', xcolname, ycolname, fluxcolname] colname_order.extend(extra_param_cols) init_params = init_params[colname_order] return init_params def _get_invalid_positions(self, init_params, shape): """ Get a mask of sources with no overlap with the data. This code is based on astropy.nddata.overlap_slices. """ x = init_params[self._param_maps['init_cols']['x']] y = init_params[self._param_maps['init_cols']['y']] positions = np.column_stack((y, x)) delta = self.fit_shape / 2 min_idx = np.ceil(positions - delta) max_idx = np.ceil(positions + delta) mask = np.any(max_idx <= 0, axis=1) | np.any(min_idx >= shape, axis=1) return mask def _check_init_positions(self, init_params, shape): """ Check the initial source positions to ensure they are within the data shape. """ if np.any(self._get_invalid_positions(init_params, shape)): raise ValueError('Some of the sources have no overlap with the ' 'data. Check the initial source positions or ' 'increase the fit_shape.') def _make_psf_model(self, sources): """ Make a PSF model to fit a single source or several sources within a group. """ init_param_map = self._param_maps['init'] for index, source in enumerate(sources): model = self.psf_model.copy() for model_param, init_col in init_param_map.items(): value = source[init_col] if isinstance(value, u.Quantity): value = value.value # psf model cannot be fit with units setattr(model, model_param, value) model.name = source['id'] if index == 0: psf_model = model else: psf_model += model return psf_model @staticmethod def _move_column(table, colname, colname_after): """ Move a column to a new position in a table. The table is modified in place. Parameters ---------- colname : str The column name to move. colname_after : str The column name after which to place the moved column. table : `~astropy.table.Table` The input table. Returns ------- table : `~astropy.table.Table` The input table with the column moved. """ colnames = table.colnames if colname not in colnames or colname_after not in colnames: return table if colname == colname_after: return table old_index = colnames.index(colname) new_index = colnames.index(colname_after) if old_index > new_index: new_index += 1 colnames.insert(new_index, colnames.pop(old_index)) table = table[colnames] return table def _model_params_to_table(self, models): """ Convert a list of PSF models to a table of model parameters. The inputs ``models`` are assumed to be instances of the same model class (i.e., the parameters names are the same for all models). """ param_names = list(models[0].param_names) params = defaultdict(list) for model in models: for name in param_names: value = getattr(model, name).value if (self.data_unit is not None and name == self._param_maps['model']['flux']): value <<= self.data_unit # add the flux units params[name].append(value) table = QTable(params) ids = np.arange(len(table)) + 1 table.add_column(ids, index=0, name='id') return table def _param_errors_to_table(self): param_err = self.fit_results.pop('fit_param_errs') err_param_map = self._param_maps['err'] table = QTable() for index, name in enumerate(self._param_maps['fit_params'].values()): colname = err_param_map[name] value = param_err[:, index] if (self.data_unit is not None and name == self._param_maps['model']['flux']): value <<= self.data_unit # add the flux units table[colname] = value colnames = list(err_param_map.values()) # add error columns for fixed params; errors are set to NaN nsources = len(self.init_params) for colname in colnames: if colname not in table.colnames: table[colname] = [np.nan] * nsources # sort column names return table[colnames] def _prepare_fit_results(self, fit_params): """ Prepare the output table of fit results. """ # remove parameters that are not fit out_params = fit_params.copy() for column in out_params.colnames: if column == 'id': continue if column not in self._param_maps['fit'].keys(): out_params.remove_column(column) # rename columns to have the "fit" suffix for key, val in self._param_maps['fit'].items(): out_params.rename_column(key, val) # reorder columns to have "flux" come immediately after "y" ymodelparam = self._param_maps['model']['y'] fluxmodelparam = self._param_maps['model']['flux'] y_col = self._param_maps['fit'][ymodelparam] flux_col = self._param_maps['fit'][fluxmodelparam] out_params = self._move_column(out_params, flux_col, y_col) # add parameter error columns param_errs = self._param_errors_to_table() out_params = hstack([out_params, param_errs]) return out_params def _define_fit_data(self, sources, data, mask): yi = [] xi = [] cutout = [] npixfit = [] cen_index = [] for row in sources: xcen = row[self._param_maps['init_cols']['x']] ycen = row[self._param_maps['init_cols']['y']] try: slc_lg, _ = overlap_slices(data.shape, self.fit_shape, (ycen, xcen), mode='trim') except NoOverlapError as exc: # pragma: no cover # this should never happen because the initial positions # are checked in _prepare_fit_inputs msg = (f'Initial source at ({xcen}, {ycen}) does not ' 'overlap with the input data.') raise ValueError(msg) from exc yy, xx = np.mgrid[slc_lg] if mask is not None: inv_mask = ~mask[yy, xx] if np.count_nonzero(inv_mask) == 0: msg = (f'Source at ({xcen}, {ycen}) is completely masked. ' 'Remove the source from init_params or correct ' 'the input mask.') raise ValueError(msg) yy = yy[inv_mask] xx = xx[inv_mask] else: xx = xx.ravel() yy = yy.ravel() xi.append(xx) yi.append(yy) local_bkg = row['local_bkg'] if isinstance(local_bkg, u.Quantity): local_bkg = local_bkg.value cutout.append(data[yy, xx] - local_bkg) npixfit.append(len(xx)) # this is overlap_slices center pixel index (before any trimming) xcen = np.ceil(xcen - 0.5).astype(int) ycen = np.ceil(ycen - 0.5).astype(int) idx = np.where((xx == xcen) & (yy == ycen))[0] if len(idx) == 0: idx = [np.nan] cen_index.append(idx[0]) # flatten the lists, which may contain arrays of different lengths # due to masking xi = _flatten(xi) yi = _flatten(yi) cutout = _flatten(cutout) self._group_results['npixfit'].append(npixfit) self._group_results['psfcenter_indices'].append(cen_index) return yi, xi, cutout @staticmethod def _split_compound_model(model, chunk_size): for i in range(0, model.n_submodels, chunk_size): yield model[i:i + chunk_size] @staticmethod def _split_param_errs(param_err, nparam): for i in range(0, len(param_err), nparam): yield param_err[i:i + nparam] def _order_by_id(self, iterable): """ Reorder the list from group-id to source-id order. """ return [iterable[i] for i in self._group_results['ungroup_indices']] def _ungroup(self, iterable): """ Expand a list of lists (groups) and reorder in source-id order. """ iterable = _flatten(iterable) return self._order_by_id(iterable) def _get_fit_error_indices(self): indices = [] for index, fit_info in enumerate(self.fit_results['fit_infos']): ierr = fit_info.get('ierr', None) # check if in good flags defined by scipy if ierr is not None: # scipy.optimize.leastsq if ierr not in (1, 2, 3, 4): indices.append(index) else: # scipy.optimize.least_squares status = fit_info.get('status', None) if status is not None and status in (-1, 0): indices.append(index) return np.array(indices, dtype=int) def _parse_fit_results(self, group_models, group_fit_infos): """ Parse the fit results for each source or group of sources. """ psf_nsub = self.psf_model.n_submodels fit_models = [] fit_infos = [] fit_param_errs = [] nfitparam = len(self._param_maps['fit_params'].keys()) for model, fit_info in zip(group_models, group_fit_infos): model_nsub = model.n_submodels npsf_models = model_nsub // psf_nsub # NOTE: param_cov/param_err are returned in the same order # as the model parameters param_cov = fit_info.get('param_cov', None) if param_cov is None: if nfitparam == 0: # model params are all fixed nfitparam = 3 # x_err, y_err, and flux_err are np.nan param_err = np.array([np.nan] * nfitparam * npsf_models) else: param_err = np.sqrt(np.diag(param_cov)) # model is for a single source (which may be compound) if npsf_models == 1: fit_models.append(model) fit_infos.append(fit_info) fit_param_errs.append(param_err) continue # model is a grouped model for multiple sources fit_models.extend(self._split_compound_model(model, psf_nsub)) fit_infos.extend([fit_info] * npsf_models) # views fit_param_errs.extend(self._split_param_errs(param_err, nfitparam)) if len(fit_models) != len(fit_infos): # pragma: no cover raise ValueError('fit_models and fit_infos have different lengths') # change the sorting from group_id to source id order fit_models = self._order_by_id(fit_models) fit_infos = self._order_by_id(fit_infos) fit_param_errs = np.array(self._order_by_id(fit_param_errs)) self.fit_results['fit_infos'] = fit_infos self.fit_results['fit_error_indices'] = self._get_fit_error_indices() self.fit_results['fit_param_errs'] = fit_param_errs return fit_models def _fit_sources(self, data, init_params, *, error=None, mask=None): if self.fitter_maxiters is not None: kwargs = {'maxiter': self.fitter_maxiters} else: kwargs = {} sources = init_params.group_by('group_id') ungroup_idx = np.argsort(sources['id'].value) self._group_results['ungroup_indices'] = ungroup_idx sources = sources.groups if self.progress_bar: # pragma: no cover desc = 'Fit source/group' sources = add_progress_bar(sources, desc=desc) # Save the fit_info results for these keys if they are present. # Some of these keys are returned only by some fitters. These # keys contain the fit residuals (fvec or fun), the parameter # covariance matrix (param_cov), and the fit status (ierr, # message) or (status). fit_info_keys = ('fvec', 'fun', 'param_cov', 'ierr', 'message', 'status') fit_models = [] fit_infos = [] nmodels = [] for sources_ in sources: # fit in group_id order nsources = len(sources_) nmodels.append([nsources] * nsources) psf_model = self._make_psf_model(sources_) yi, xi, cutout = self._define_fit_data(sources_, data, mask) if error is not None: weights = 1.0 / error[yi, xi] else: weights = None with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) try: fit_model = self.fitter(psf_model, xi, yi, cutout, weights=weights, **kwargs) try: fit_model.clear_cache() except AttributeError: pass except TypeError as exc: msg = ('For one or more sources, the number of data ' 'points available to fit is less than the ' 'number of fit parameters. This could be due to ' 'a source(s) near the edge of the detector or ' 'if it has few unmasked pixels. Please check the ' 'input mask or source positions.') raise ValueError(msg) from exc fit_info = {} for key in fit_info_keys: value = self.fitter.fit_info.get(key, None) if value is not None: fit_info[key] = value fit_models.append(fit_model) fit_infos.append(fit_info) self._group_results['fit_infos'] = fit_infos self._group_results['nmodels'] = nmodels # split the groups and return objects in source-id order fit_models = self._parse_fit_results(fit_models, fit_infos) _fit_model_params = self._model_params_to_table(fit_models) fit_params = self._prepare_fit_results(_fit_model_params) self._fit_model_params = _fit_model_params # ungrouped self.fit_params = fit_params return fit_params def _calc_fit_metrics(self, data, results_tbl): # Keep cen_idx as a list because it can have NaNs with the ints. # If NaNs are present, turning it into an array will convert the # ints to floats, which cannot be used as slices. cen_idx = self._ungroup(self._group_results['psfcenter_indices']) split_index = [] for npixfit in self._group_results['npixfit']: split_index.append(np.cumsum(npixfit)[:-1]) # find the key with the fit residual (fitter dependent) finfo_keys = self._group_results['fit_infos'][0].keys() keys = ('fvec', 'fun') key = None for key_ in keys: if key_ in finfo_keys: key = key_ # SimplexLSQFitter if key is None: qfit = cfit = np.array([[np.nan]] * len(results_tbl)) return qfit, cfit fit_residuals = [] for idx, fit_info in zip(split_index, self._group_results['fit_infos']): fit_residuals.extend(np.split(fit_info[key], idx)) fit_residuals = self._order_by_id(fit_residuals) with warnings.catch_warnings(): # ignore divide-by-zero if flux = 0 warnings.simplefilter('ignore', RuntimeWarning) flux_name = self._param_maps['model']['flux'] fluxcolname = self._param_maps['fit'][flux_name] qfit = [] cfit = [] for index, (residual, cen_idx_) in enumerate( zip(fit_residuals, cen_idx)): flux_fit = results_tbl[fluxcolname][index] if isinstance(flux_fit, u.Quantity): flux_fit = flux_fit.value qfit.append(np.sum(np.abs(residual)) / flux_fit) if np.isnan(cen_idx_): # masked central pixel cen_residual = np.nan else: # find residual at center pixel; # astropy fitters compute residuals as # (model - data), thus need to negate the residual cen_residual = -residual[cen_idx_] cfit.append(cen_residual / flux_fit) return qfit, cfit def _define_flags(self, results_tbl, shape): flags = np.zeros(len(results_tbl), dtype=int) model_names = self._param_maps['model'] param_map = self._param_maps['fit'] xcolname = param_map[model_names['x']] ycolname = param_map[model_names['y']] fluxcolname = param_map[model_names['flux']] for index, row in enumerate(results_tbl): if row['npixfit'] < np.prod(self.fit_shape): flags[index] += 1 if (row[xcolname] < 0 or row[ycolname] < 0 or row[xcolname] > shape[1] or row[ycolname] > shape[0]): flags[index] += 2 if row[fluxcolname] <= 0: flags[index] += 4 flags[self.fit_results['fit_error_indices']] += 8 try: for index, fit_info in enumerate(self.fit_results['fit_infos']): if fit_info['param_cov'] is None: flags[index] += 16 except KeyError: pass return flags def _prepare_fit_inputs(self, data, *, mask=None, error=None, init_params=None): """ Prepare inputs for PSF fitting. Tasks: * Checks array input shapes and units. * Calculates a total mask * Validates inputs for init_params and aperture_radius * Prepares initial parameters table - Runs source finder if needed - Runs aperture photometry if needed - Runs local background estimation if needed - Groups sources if needed """ (data, error), unit = process_quantities((data, error), ('data', 'error')) self.data_unit = unit data = self._validate_array(data, 'data') error = self._validate_array(error, 'error', data_shape=data.shape) mask = self._validate_array(mask, 'mask', data_shape=data.shape) mask = self._make_mask(data, mask) init_params = self._validate_init_params(init_params) # copies fluxcol = self._param_maps['init_cols']['flux'] if (self.aperture_radius is None and (init_params is None or fluxcol not in init_params.colnames)): raise ValueError('aperture_radius must be defined if init_params ' 'is not input or if a flux column is not in ' 'init_params') init_params = self._prepare_init_params(data, mask, init_params) if init_params is None: # no sources detected by finder return None self._check_init_positions(init_params, data.shape) self.init_params = init_params _, counts = np.unique(init_params['group_id'], return_counts=True) if max(counts) > 25: warnings.warn('Some groups have more than 25 sources. Fitting ' 'such groups may take a long time and be ' 'error-prone. You may want to consider using ' 'different `SourceGrouper` parameters or ' 'changing the "group_id" column in "init_params".', AstropyUserWarning) return data, mask, error, init_params def __call__(self, data, *, mask=None, error=None, init_params=None): """ Perform PSF photometry. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. Invalid data values (i.e., NaN or inf) are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D `~numpy.ndarray`, optional The pixel-wise 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must also be a `~astropy.units.Quantity` array with the same units. init_params : `~astropy.table.Table` or `None`, optional A table containing the initial guesses of the model parameters (e.g., x, y, flux) for each source. If the x and y values are not input, then the ``finder`` keyword must be defined. If the flux values are not input, then the initial fluxes will be measured using the ``aperture_radius`` keyword, which must be defined. Note that the initial flux values refer to the model flux parameters and are not corrected for local background values (computed using ``localbkg_estimator`` or input in a ``local_bkg`` column) The allowed column names are: * ``x_init``, ``xinit``, ``x``, ``x_0``, ``x0``, ``xcentroid``, ``x_centroid``, ``x_peak``, ``xcen``, ``x_cen``, ``xpos``, ``x_pos``, ``x_fit``, and ``xfit``. * ``y_init``, ``yinit``, ``y``, ``y_0``, ``y0``, ``ycentroid``, ``y_centroid``, ``y_peak``, ``ycen``, ``y_cen``, ``ypos``, ``y_pos``, ``y_fit``, and ``yfit``. * ``flux_init``, ``fluxinit``, ``flux``, ``flux_0``, ``flux0``, ``flux_fit``, ``fluxfit``, ``source_sum``, ``segment_flux``, and ``kron_flux``. * If the PSF model has additional free parameters that are fit, they can be included in the table. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. The suffix search order is "_init", "" (no suffix), and "_fit". For example, if the PSF model has an additional parameter named "sigma", then the allowed column names are: "sigma_init", "sigma", and "sigma_fit". If the column name is not found in the table, then the default value from the PSF model will be used. The parameter names are searched in the input table in the above order, stopping at the first match. If ``data`` is a `~astropy.units.Quantity` array, then the initial flux values in this table must also must also have compatible units. The table can also have ``group_id`` and ``local_bkg`` columns. If ``group_id`` is input, the values will be used and ``grouper`` keyword will be ignored. If ``local_bkg`` is input, those values will be used and the ``localbkg_estimator`` will be ignored. If ``data`` has units, then the ``local_bkg`` values must have the same units. Returns ------- table : `~astropy.table.QTable` An astropy table with the PSF-fitting results. The table will contain the following columns: * ``id`` : unique identification number for the source * ``group_id`` : unique identification number for the source group * ``group_size`` : the total number of sources that were simultaneously fit along with the given source * ``x_init``, ``x_fit``, ``x_err`` : the initial, fit, and error of the source x center * ``y_init``, ``y_fit``, ``y_err`` : the initial, fit, and error of the source y center * ``flux_init``, ``flux_fit``, ``flux_err`` : the initial, fit, and error of the source flux * ``npixfit`` : the number of unmasked pixels used to fit the source * ``qfit`` : a quality-of-fit metric defined as the absolute value of the sum of the fit residuals divided by the fit flux * ``cfit`` : a quality-of-fit metric defined as the fit residual in the initial central pixel value divided by the fit flux. NaN values indicate that the central pixel was masked. * ``flags`` : bitwise flag values: * 1 : one or more pixels in the ``fit_shape`` region were masked * 2 : the fit x and/or y position lies outside of the input data * 4 : the fit flux is less than or equal to zero * 8 : the fitter may not have converged. In this case, you can try increasing the maximum number of fit iterations using the ``fitter_maxiters`` keyword. * 16 : the fitter parameter covariance matrix was not returned """ if isinstance(data, NDData): data_ = data.data if data.unit is not None: data_ <<= data.unit mask = data.mask unc = data.uncertainty if unc is not None: error = unc.represent_as(StdDevUncertainty).quantity if error.unit is u.dimensionless_unscaled: error = error.value else: error = error.to(data.unit) return self.__call__(data_, mask=mask, error=error, init_params=init_params) # reset results from previous runs self._reset_results() # Prepare fit inputs, including defining the initial source # parameters. This also runs the source finder, aperture # photometry, local background estimator, and source grouper, if # needed. fit_inputs = self._prepare_fit_inputs(data, mask=mask, error=error, init_params=init_params) if fit_inputs is None: return None # fit the sources data, mask, error, init_params = fit_inputs fit_params = self._fit_sources(data, init_params, error=error, mask=mask) # stack initial and fit params to create output table results_tbl = join(init_params, fit_params) npixfit = np.array(self._ungroup(self._group_results['npixfit'])) results_tbl['npixfit'] = npixfit nmodels = np.array(self._ungroup(self._group_results['nmodels'])) index = results_tbl.index_column('group_id') + 1 results_tbl.add_column(nmodels, name='group_size', index=index) qfit, cfit = self._calc_fit_metrics(data, results_tbl) results_tbl['qfit'] = qfit results_tbl['cfit'] = cfit results_tbl['flags'] = self._define_flags(results_tbl, data.shape) meta = _get_meta() attrs = ('fit_shape', 'fitter_maxiters', 'aperture_radius', 'progress_bar') for attr in attrs: meta[attr] = getattr(self, attr) results_tbl.meta = meta if len(self.fit_results['fit_error_indices']) > 0: warnings.warn('One or more fit(s) may not have converged. Please ' 'check the "flags" column in the output table.', AstropyUserWarning) # convert results from defaultdict to dict self.fit_results = dict(self.fit_results) return results_tbl def make_model_image(self, shape, psf_shape, *, include_localbkg=False): return ModelImageMixin.make_model_image( self, shape, psf_shape, include_localbkg=include_localbkg) def make_residual_image(self, data, psf_shape, *, include_localbkg=False): return ModelImageMixin.make_residual_image( self, data, psf_shape, include_localbkg=include_localbkg) class IterativePSFPhotometry(ModelImageMixin): """ Class to iteratively perform PSF photometry. This is a convenience class that iteratively calls the `PSFPhotometry` class to perform PSF photometry on an input image. It can be useful for crowded fields where faint stars are very close to bright stars and are not detected in the first pass of PSF photometry. For complex cases, one may need to manually run `PSFPhotometry` in an iterative manner and inspect the residual image after each iteration. Parameters ---------- psf_model : 2D `astropy.modeling.Model` The PSF model to fit to the data. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. fit_shape : int or length-2 array_like The rectangular shape around the center of a star that will be used to define the PSF-fitting data. If ``fit_shape`` is a scalar then a square shape of size ``fit_shape`` will be used. If ``fit_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``fit_shape`` must be an odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` A callable used to identify stars in an image. The ``finder`` must accept a 2D image as input and return a `~astropy.table.Table` containing the x and y centroid positions. These positions are used as the starting points for the PSF fitting. The allowed ``x`` column names are (same suffix for ``y``): ``'x_init'``, ``'xinit'``, ``'x'``, ``'x_0'``, ``'x0'``, ``'xcentroid'``, ``'x_centroid'``, ``'x_peak'``, ``'xcen'``, ``'x_cen'``, ``'xpos'``, ``'x_pos'``, ``'x_fit'``, and ``'xfit'``. If `None`, then the initial (x, y) model positions must be input using the ``init_params`` keyword when calling the class. The (x, y) values in ``init_params`` override this keyword *only for the first iteration*. If this class is run on an image that has units (i.e., a `~astropy.units.Quantity` array), then certain ``finder`` keywords (e.g., ``threshold``) must have the same units. Please see the documentation for the specific ``finder`` class for more information. grouper : `~photutils.psf.SourceGrouper` or callable or `None`, optional A callable used to group stars. Typically, grouped stars are those that overlap with their neighbors. Stars that are grouped are fit simultaneously. The ``grouper`` must accept the x and y coordinates of the sources and return an integer array of the group id numbers (starting from 1) indicating the group in which a given source belongs. If `None`, then no grouping is performed, i.e. each source is fit independently. The ``group_id`` values in ``init_params`` override this keyword *only for the first iteration*. A warning is raised if any group size is larger than 25 sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. maxiters : int, optional The maximum number of PSF-fitting/subtraction iterations to perform. mode : {'new', 'all'}, optional For the 'new' mode, `PSFPhotometry` is run in each iteration only on the new sources detected in the residual image. For the 'all' mode, `PSFPhotometry` is run in each iteration on all the detected sources (from all previous iterations) on the original, unsubtracted, data. For the 'all' mode, a source ``grouper`` must be input. See the Notes section for more details. localbkg_estimator : `~photutils.background.LocalBackground` or `None`, optional The object used to estimate the local background around each source. If `None`, then no local background is subtracted. The ``local_bkg`` values in ``init_params`` override this keyword. This option should be used with care, especially in crowded fields where the ``fit_shape`` of sources overlap (see Notes below). aperture_radius : float, optional The radius of the circular aperture used to estimate the initial flux of each source. If initial flux values are present in the ``init_params`` table, they will override this keyword *only for the first iteration*. sub_shape : `None`, int, or length-2 array_like The rectangular shape around the center of a star that will be used when subtracting the fitted PSF models. If ``sub_shape`` is a scalar then a square shape of size ``sub_shape`` will be used. If ``sub_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``sub_shape`` must be an odd number. If `None`, then ``sub_shape`` is set to ``fit_shape``. progress_bar : bool, optional Whether to display a progress bar when fitting the sources (or groups). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- The data that will be fit for each source is defined by the ``fit_shape`` parameter. A cutout will be made around the initial center of each source with a shape defined by ``fit_shape``. The PSF model will be fit to the data in this region. The cutout region that is fit does not shift if the source center shifts during the fit iterations. Therefore, the initial source positions should be close to the true source positions. One way to ensure this is to use a ``finder`` to identify sources in the data. If the fitted positions are significantly different from the initial positions, one can re-run the `PSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After calling `PSFPhotometry` on the data, it will have a ``fit_params`` attribute containing the fitted model parameters. This table can be used as the ``init_params`` input in a subsequent call to `PSFPhotometry`. If the returned model parameter errors are NaN, then either the fit did not converge, the model parameter was fixed, or the input ``fitter`` did not return parameter errors. For the later case, one can try a different fitter that may return parameter errors (e.g., `astropy.modeling.fitting.LMLSQFitter` or `astropy.modeling.fitting.TRFLSQFitter`). Note that these fitters are typically slower than the default `astropy.modeling.fitting.LevMarLSQFitter`. The local background value around each source is optionally estimated using the ``localbkg_estimator`` or obtained from the ``local_bkg`` column in the input ``init_params`` table. This local background is then subtracted from the data over the ``fit_shape`` region for each source before fitting the PSF model. For sources where their ``fit_shape`` regions overlap, the local background will effectively be subtracted twice in the overlapping ``fit_shape`` regions, even if the source ``grouper`` is input. This is not an issue if the sources are well-separated. However, for crowded fields, please use the ``localbkg_estimator`` (or ``local_bkg`` column in ``init_params``) with care. This class has two modes of operation: 'new' and 'all'. For both modes, `PSFPhotometry` is first run on the data, a residual image is created, and the source finder is run on the residual image to detect any new sources. In the 'new' mode, `PSFPhotometry` is then run on the residual image to fit the PSF model to the new sources. The process is repeated until no new sources are detected or a maximum number of iterations is reached. In the 'all' mode, a new source list combining the sources from first `PSFPhotometry` run and the new sources detected in the residual image is created. `PSFPhotometry` is then run on the original, unsubtracted, data with this combined source list. This allows the source ``grouper`` (which is required for the 'all' mode) to combine close sources to be fit simultaneously, improving the fit. Again, the process is repeated until no new sources are detected or a maximum number of iterations is reached. """ def __init__(self, psf_model, fit_shape, finder, *, grouper=None, fitter=LevMarLSQFitter(), fitter_maxiters=100, maxiters=3, mode='new', localbkg_estimator=None, aperture_radius=None, sub_shape=None, progress_bar=False): if finder is None: raise ValueError('finder cannot be None for ' 'IterativePSFPhotometry.') if aperture_radius is None: raise ValueError('aperture_radius cannot be None for ' 'IterativePSFPhotometry.') self._psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, fitter=fitter, fitter_maxiters=fitter_maxiters, localbkg_estimator=localbkg_estimator, aperture_radius=aperture_radius, progress_bar=progress_bar) self.maxiters = self._validate_maxiters(maxiters) if mode not in ['new', 'all']: raise ValueError('mode must be "new" or "all".') if mode == 'all' and grouper is None: raise ValueError('grouper must be input for the "all" mode.') self.mode = mode if sub_shape is None: sub_shape = fit_shape self.sub_shape = as_pair('sub_shape', sub_shape, lower_bound=(0, 1), check_odd=True) self.fit_results = [] def _reset_results(self): """ Reset these attributes for each __call__. """ self.fit_results = [] @staticmethod def _validate_maxiters(maxiters): if (not np.isscalar(maxiters) or maxiters <= 0 or ~np.isfinite(maxiters)): raise ValueError('maxiters must be a strictly-positive scalar') if maxiters != int(maxiters): raise ValueError('maxiters must be an integer') return maxiters @staticmethod def _emit_warnings(recorded_warnings): """ Emit unique warnings from a list of recorded warnings. Parameters ---------- recorded_warnings : list A list of recorded warnings. """ msgs = [] emit_warnings = [] for warning in recorded_warnings: if str(warning.message) not in msgs: msgs.append(str(warning.message)) emit_warnings.append(warning) for warning in emit_warnings: warnings.warn_explicit(warning.message, warning.category, warning.filename, warning.lineno) def _convert_finder_to_init(self, sources): """ Convert the output of the finder to a table with initial (x, y) position column names """ xcol = self._psfphot._param_maps['init_cols']['x'] ycol = self._psfphot._param_maps['init_cols']['y'] sources = sources[('xcentroid', 'ycentroid')] sources.rename_column('xcentroid', xcol) sources.rename_column('ycentroid', ycol) return sources def _measure_init_fluxes(self, data, mask, sources): """ Measure initial fluxes for the new sources from the residual data. The fluxes are added in place to the input ``sources`` table. The fluxes are measured using aperture photometry with a circular aperture of radius ``aperture_radius``. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. mask : 2D bool `~numpy.ndarray` A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. sources : `~astropy.table.Table` A table containing the initial (x, y) positions of the sources. Returns ------- sources : `~astropy.table.Table` The input ``sources`` table with the new flux column added. """ flux = self._psfphot._get_aper_fluxes(data, mask, sources) unit = getattr(data, 'unit', None) if unit is not None: flux <<= unit fluxcol = self._psfphot._param_maps['init_cols']['flux'] sources[fluxcol] = flux return sources def _create_init_params(self, data, mask, new_sources, orig_sources): """ Create the initial parameters table by combining the original and new sources. """ # rename the columns from the fit results init_params = self._psfphot._rename_init_columns( orig_sources, self._psfphot._param_maps, self._psfphot._find_column_name) for colname in init_params.colnames: if '_init' not in colname: init_params.remove_column(colname) # add initial fluxes for the new sources from the residual data new_sources = self._measure_init_fluxes(data, mask, new_sources) # add columns for any additional parameters that are fit for param_name, colname in self._psfphot._param_maps['init'].items(): if colname not in new_sources.colnames: new_sources[colname] = getattr(self._psfphot.psf_model, param_name) # combine original and new source tables new_sources.meta.pop('date', None) # prevent merge conflicts return vstack([orig_sources, new_sources]) def __call__(self, data, *, mask=None, error=None, init_params=None): """ Perform PSF photometry. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. Invalid data values (i.e., NaN or inf) are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D `~numpy.ndarray`, optional The pixel-wise 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must also be a `~astropy.units.Quantity` array with the same units. init_params : `~astropy.table.Table` or `None`, optional A table containing the initial guesses of the model parameters (e.g., x, y, flux) for each source *only for the first iteration*. If the x and y values are not input, then the ``finder`` will be used for all iterations. If the flux values are not input, then the initial fluxes will be measured using the ``aperture_radius`` keyword. Note that the initial flux values refer to the model flux parameters and are not corrected for local background values (computed using ``localbkg_estimator`` or input in a ``local_bkg`` column) The allowed column names are: * ``x_init``, ``xinit``, ``x``, ``x_0``, ``x0``, ``xcentroid``, ``x_centroid``, ``x_peak``, ``xcen``, ``x_cen``, ``xpos``, ``x_pos``, ``x_fit``, and ``xfit``. * ``y_init``, ``yinit``, ``y``, ``y_0``, ``y0``, ``ycentroid``, ``y_centroid``, ``y_peak``, ``ycen``, ``y_cen``, ``ypos``, ``y_pos``, ``y_fit``, and ``yfit``. * ``flux_init``, ``fluxinit``, ``flux``, ``flux_0``, ``flux0``, ``flux_fit``, ``fluxfit``, ``source_sum``, ``segment_flux``, and ``kron_flux``. * If the PSF model has additional free parameters that are fit, they can be included in the table. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. The suffix search order is "_init", "" (no suffix), and "_fit". For example, if the PSF model has an additional parameter named "sigma", then the allowed column names are: "sigma_init", "sigma", and "sigma_fit". If the column name is not found in the table, then the default value from the PSF model will be used. The default values from the PSF model will also be used for all iterations after the first. The parameter names are searched in the input table in the above order, stopping at the first match. If ``data`` is a `~astropy.units.Quantity` array, then the initial flux values in this table must also must also have compatible units. Returns ------- table : `~astropy.table.QTable` An astropy table with the PSF-fitting results. The table will contain the following columns: * ``id`` : unique identification number for the source * ``group_id`` : unique identification number for the source group * ``group_size`` : the total number of sources that were simultaneously fit along with the given source * ``iter_detected`` : the iteration number in which the source was detected * ``x_init``, ``x_fit``, ``x_err`` : the initial, fit, and error of the source x center * ``y_init``, ``y_fit``, ``y_err`` : the initial, fit, and error of the source y center * ``flux_init``, ``flux_fit``, ``flux_err`` : the initial, fit, and error of the source flux * ``npixfit`` : the number of unmasked pixels used to fit the source * ``qfit`` : a quality-of-fit metric defined as the absolute value of the sum of the fit residuals divided by the fit flux * ``cfit`` : a quality-of-fit metric defined as the fit residual in the initial central pixel value divided by the fit flux. NaN values indicate that the central pixel was masked. * ``flags`` : bitwise flag values: * 1 : one or more pixels in the ``fit_shape`` region were masked * 2 : the fit x and/or y position lies outside of the input data * 4 : the fit flux is less than or equal to zero * 8 : the fitter may not have converged * 16 : the fitter parameter covariance matrix was not returned """ if isinstance(data, NDData): data_ = data.data if data.unit is not None: data_ <<= data.unit mask = data.mask unc = data.uncertainty if unc is not None: error = unc.represent_as(StdDevUncertainty).quantity if error.unit is u.dimensionless_unscaled: error = error.value else: error = error.to(data.unit) return self.__call__(data_, mask=mask, error=error, init_params=init_params) # reset results from previous runs self._reset_results() with warnings.catch_warnings(record=True) as rwarn0: phot_tbl = self._psfphot(data, mask=mask, error=error, init_params=init_params) self.fit_results.append(deepcopy(self._psfphot)) # this needs to be run outside of the context manager to be able # to re-emit any warnings if phot_tbl is None: self._emit_warnings(rwarn0) return None residual_data = data with warnings.catch_warnings(record=True) as rwarn1: phot_tbl['iter_detected'] = 1 if self.mode == 'all': iter_detected = np.ones(len(phot_tbl), dtype=int) iter_num = 2 while iter_num <= self.maxiters and phot_tbl is not None: residual_data = self._psfphot.make_residual_image( residual_data, self.sub_shape) # do not warn if no sources are found beyond the first # iteration with warnings.catch_warnings(): warnings.simplefilter('ignore', NoDetectionsWarning) new_sources = self._psfphot.finder(residual_data, mask=mask) if new_sources is None: # no new sources detected break finder_results = new_sources.copy() new_sources = self._convert_finder_to_init(new_sources) if self.mode == 'all': init_params = self._create_init_params( residual_data, mask, new_sources, self._psfphot.fit_params) residual_data = data # keep track of the iteration number in which the source # was detected current_iter = (np.ones(len(new_sources), dtype=int) * iter_num) iter_detected = np.concatenate((iter_detected, current_iter)) elif self.mode == 'new': # fit new sources on the residual data init_params = new_sources # remove any sources that do not overlap the data imask = self._psfphot._get_invalid_positions(init_params, data.shape) init_params = init_params[~imask] if self.mode == 'all': iter_detected = iter_detected[~imask] new_tbl = self._psfphot(residual_data, mask=mask, error=error, init_params=init_params) self._psfphot.finder_results = finder_results self.fit_results.append(deepcopy(self._psfphot)) if self.mode == 'all': new_tbl['iter_detected'] = iter_detected phot_tbl = new_tbl elif self.mode == 'new': # combine tables new_tbl['id'] += np.max(phot_tbl['id']) new_tbl['group_id'] += np.max(phot_tbl['group_id']) new_tbl['iter_detected'] = iter_num new_tbl.meta = {} # prevent merge conflicts on date phot_tbl = vstack([phot_tbl, new_tbl]) iter_num += 1 # move 'iter_detected' column phot_tbl = self._psfphot._move_column(phot_tbl, 'iter_detected', 'group_size') # emit unique warnings recorded_warnings = rwarn0 + rwarn1 self._emit_warnings(recorded_warnings) return phot_tbl def make_model_image(self, shape, psf_shape, *, include_localbkg=False): return ModelImageMixin.make_model_image( self, shape, psf_shape, include_localbkg=include_localbkg) def make_residual_image(self, data, psf_shape, *, include_localbkg=False): return ModelImageMixin.make_residual_image( self, data, psf_shape, include_localbkg=include_localbkg) def _flatten(iterable): """ Flatten a list of lists. """ return list(chain.from_iterable(iterable)) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.038885 photutils-1.13.0/photutils/psf/tests/0000755000175100001770000000000014637570322017232 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/__init__.py0000644000175100001770000000000014637570305021332 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.038885 photutils-1.13.0/photutils/psf/tests/data/0000755000175100001770000000000014637570322020143 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits0000644000175100001770000002070014637570305024725 0ustar00runnerdockerSIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 90 DATE = '2013-03-13' TIME = '11:57:58' BSCALE = 1.0000 BZERO = 0.0000 COMMENT NXPSFS = 9 NYPSFS = 10 IPSFX01 = 0 IPSFX02 = 512 IPSFX03 = 1024 IPSFX04 = 1536 IPSFX05 = 2168 IPSFX06 = 2800 IPSFX07 = 3192 IPSFX08 = 3584 IPSFX09 = 4096 IPSFX10 = 9999 JPSFY01 = 0 JPSFY02 = 512 JPSFY03 = 1024 JPSFY04 = 1536 JPSFY05 = 2048 JPSFY06 = 2048 JPSFY07 = 2560 JPSFY08 = 3072 JPSFY09 = 3584 JPSFY10 = 4096 COMMENT ../PSFEFF.F814W.fits COMMENT ACSPSF_F814W_SM3_STDPSF.fits END >%&­>1–Ā>(Z‚>0Č/><M>0„ļ>(CÆ>1=$>%h>+`7>8ļĻ>/cŅ>7ũ_>D]/>8ƒæ>.Íp>8zø>+äÜ>-ą<>: Ä>1 *>< >Gø><–>1ēe>;*Ŋ>.jK>4>A…~>6‚š>B6>N|÷>A@ã>7‡Y>BK/>4ˇ„>7Ķ<>EĘø>;ã>GOG>TįP>Ht¨>;ŧ >FŦ!>9]>;ŪĐ>JŸ*>=Đ÷>Kb>XÖ>Iíą>?ķ>J¯>;Ée>:g~>GS†>;4a>HĀ…>Uf >GC‰>=Ä >Hģ‹>:Ļ5>;qš>HĻ>=râ>J‹Š>W—>JŒ>>Á>J] >= =>;Š.>IĄ>=˛ø>J>X5#>Jdĸ>>¨M>JöŸ><ã9>*>H>7ĩ>.…ī>7’>C!L>8>ē>-œ•>7Gę>+i†>4§Š>BWR>8o“>AōB>Ođ>Bôp>8s>C/Ļ>6A3>9§û>IVL>>ä–>HŪ,>WŨ|>K >=°ˆ>IōĐ>
=ī.>L;>A å>LjŦ>Z˛Ī>MÄ>@w]>L3>>‰>> \>LÔ>AD—>LđN>[C¨>M•ļ>@ų>M@Ö>?2h>>z>MĢ>@Á›>MD.>\0+>Lũ>AFÉ>Mۊ>>h‰>?0đ>Mˇ>AKL>MÜ.>\!v>MÁD>Aē”>MŌs>?I4>>Ya>LŽ>?iF>LČi>Z>é>K6ˆ>AąH>Mh >>2‚>;ŧš>IĨī><÷x>H‡Ŗ>VV>GŌy>>N{>JC*><0e>+ŗ>8gF>/w>8ą>C°)>8wÁ>/E\>8„ŋ>,A™>8Ļ3>GB><\i>FNƒ>Sô6>G,w>; u>GF>9Á.>=ÖĨ>LeC>@îi>Køë>YđI>L=ŗ>@û >LˇZ>>‡g>>Ά>N{Ŧ>BŠë>M=>\ĸ>N•>A•>N°w>@%ķ>=”>>Ltũ>@ˆ >LŅ>Zgx>LĄ>@Ū>M#k>>i]>>Š>LŒ7>@ Š>L+!>ZLb>Kâ#>@Íz>Lė->>qË>>Š>M,i>@vË>MRė>[ĸÁ>L¯7>Ašc>NHR>?;(><:X>Jä3>>i>J>WãŽ>Ih„>?ŧ>JrD>;‰‰>7V>Dŗŋ>8F>D7(>QŗP>CS>9ų<>F2>7Ké>,™7>:>>0Đ>9Ŗ >F7>:[Î>0Ŋ>:Šĸ>-áË>8Đü>GDw><Ø)>Eöū>Sŋ>G0°>;A>Fc|>9"Z>6üL>DķŖ>:kX>D€Ÿ>R4>EŠ!>9Îz>E8i>89€>2ø÷>@1y>5Ķø>?×>L9>?ķ!>5Kf>@ē>3gÁ>7æĨ>EŌÜ>:™Đ>Dōö>RY)>E1>:˜Ŧ>Eō;>8†><ž¨>K5s>>ˇË>IôŸ>X`ā>Iķx>>å>K'é><ä'>9˛m>H/đ>;üŠ>G¯Ą>U_ŗ>FĐT><‘>G¤~>8­>9æ|>H’ >FäF>U\S>G0c>;ãF>Hyņ>:Jv>5iü>Cė>7h9>A Ë>O”->@ĘÜ>7é>D0 >5)ú>/Üū>=S+>4ÃÎ><Ēí>IWņ>>s§>3†e>=ŗŌ>1ļ‰>5>CˆÛ>9áF>B §>Oä$>CęL>9=>CšÂ>6Č>/ŽN><Ûî>2Ļõ>;ĐŖ>Hq¸>2â><ĩŠ>0G‘>+­ >7ö >.NĀ>8 …>D>8ļ†>.7~>86Į>,}>21Ī>?v>4yJ>>Œ–>KK§>>¤1>4‡Ė>?e}>2¨&>EĢĀ>U”B>H^ >U 6>d™Š>U@>HÁî>V:ŧ>Fœb>7ëi>Eá™>9đ^>Dëų>R_‰>D\4>:ôŽ>FHÅ>7Îå>8[>FãĮ>:´>EîP>S˙o>Eŧ\>;‚Ų>G–ę>9,>;´˙>Jš>>Q>Hmņ>Vė>H¯:>>ǃ>KC5>*°¤>7^ú>/yÁ>62G>B>8 >,ĘM>6›>*Ûë>2p>?ôī>6q>?X>LÜ>@9>5f>?Îč>3ye>-ÉK>:Üą>0ė2>9$ļ>EĒy>9¸#>.Í%>90>>,ŗ>'Ֆ>3b>*Ū>3-p>>,L>3ĀL>*Ož>3Ŗx>(ís>/ôp>1Öõ>;’>Fņã>:¨)>1đ>;Ân>/"g>Bņâ>RÜ>Ej/>Qq›>_úS>Q9Č>Eoé>Qåģ>Búy>7tR>F á>9·>Eaâ>S„>Døæ>:‹>Fn°>7’>8o”>EŸŅ>9Žj>E€>Q‚ī>CŌ¤>:ík>E]ú>7^B>3˜>Aöĸ>6ÉD>?s…>M29>?Ę@>6hÅ>B\û>4‹Á>(…t>5Žü>-a:>4FĄ>@'5>5•>+jM>4Ī>)j>1o>? ũ>5Ž-><ōø>Ių>>XU>3rÚ>>š>1Ž%>+€>>8 >.šÔ>6é?>Bũ>7Õ)>-q >7•h>+ī >)î>4š>*ĶQ>4ņ.>?ßÖ>4<‡>+“Ĩ>4΂>(éÜ>0ĨF>=ÜD>3>@>=w>JK{>=–Ô>3›>=û>1Â>=ۇ>M­>A>LL>ZåÅ>LØ*>AAZ>M(>>÷>7ö>E4÷>9Œķ>D;>QˇŠ>CōĒ>9Ņt>E^Î>78Æ>4o2>A7(>5_C>@Ĩ•>L˙ö>?”Ú>5kä>?Ûs>2ƒ°>-CĢ>:ÅA>/ĸė>9Ĩē>G Į>9î>0Ic>;ãĖ>.Mĩ>&Ģ>32ę>+SË>1ˇE>= ‚>2ėĘ>(/š>0Ük>%Ž>4@¨>Bt,>8}>B\n>OKé>BÃ>7Ũ6>BQ>4™ž>1Ą>?áī>5øR>?GF>LiB>@å>5>?Ōķ>2ģĮ>-q>:Oq>1@Y>9>7>Egj>:cˆ>/>9\¸>-">5rc>C5ė>8Įc>BG‰>Oh‹>BũÆ>7ū>AøZ>5*>:—>I">=ĩM>GŒ‡>U‹>H”><…(>HÜ>9é>8LY>Fk§>:Iz>CŋŅ>QfÚ>C™%>9¸l>EIŽ>71Î>6]Œ>D?t>8 ė>D6É>Q9~>Cy[>9‚^>D>Å>6$ŧ>,[>8Ž:>-Ô7>70ļ>Bȝ>5úŗ>-Lķ>6Žô>) ˙>#áZ>10J>)Ÿß>/‚‰>;$Ų>0Û)>'EŒ>/ę >$Ã>/øQ>=ų†>5vŠ>=–>I›Ą>>jG>3Ã>=§Ü>1&š>8’Ŗ>G‹î>=h>Fã>SÆ?>G/8>;€^>Fa5>8Įˇ>8â>GJX><œ>Fhä>TŗY>Gx>Hã>9ķS><.ú>Jíú>?ÆK>IĀ*>W|ë>Iķj>?ī>J/I>;Ö°>:ĸ›>IuH>>/ŗ>Géŗ>Uø§>HĄœ>=ã>IbI>;’‡>7sˇ>EŨX>:ŅT>EƒD>SÁ>Eģ–>;>Ô>FdĻ>8œ/>4@k>Bļs>7¸é>@Vĸ>N 4>@Օ>6v€>Aœē>3ˆ >&|§>4D>*>3“g>@bŗ>3š{>+ē>6‹>(]k>W‰>&ߜ> ¤!>&˜¸>0=û>'UÛ>ā>%f<>۟>*X>7ĐÜ>/ņõ>6ɤ>BÆ4>7ÜÃ>-Âi>6}>*ķ>/Â>>Į>5… >Iä>>…>3‘F>=üĸ>0ũ;>2sđ>@QX>7Ļj>?¨č>LGK>A3,>6<>@§>3ÎE>3c!>@ņ‰>7Nc>@vú>M\>@Ön>6JÕ>@q >3cÄ>3‡5>AE8>6ä~>>Õ7>KÄ >?{j>5>?~ķ>2Ŋ>2Ks>?I=>4ŒĢ>=†>IÔR>= ĩ>3 >=œ>/š >/1o>=Â">3Õ">Iđ>=Ė€>4ÍC>?\Í>1΍>"ūn>0—N>& s>.ī>:Ã>.Iė>(¯>2ˆą>$ōÁ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits0000644000175100001770000001320014637570305024551 0ustar00runnerdockerSIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 25 DATE = '2022-07-30' TIME = '11:18:17' BSCALE = 1.0000 BZERO = 0.0000 NXPSFS = 5 NYPSFS = 5 IPSFX01 = 0 IPSFX02 = 512 IPSFX03 = 1024 IPSFX04 = 1536 IPSFX05 = 2048 JPSFY01 = 0 JPSFY02 = 512 JPSFY03 = 1024 JPSFY04 = 1536 JPSFY05 = 2048 COMMENT ------------------------------------------------------------------------COMMENT JWST PSF FROM LMC CAL FIELD COMMENT DATA: COM-1074 (PI-ROBBERTO) & CAL-1476 (PI-BOYER) COMMENT NORM: 1.00 out to r=5.5 pixels COMMENT REF: Anderson, Jay et al. 2022 COMMENT PCEN: 0.2264 COMMENT Y=2048 (N21) (N22) (N23) (N24) (N25) COMMENT FIDUCIAL Y=1536 (N16) (N17) (N18) (N19) (N20) COMMENT LOCATION OF Y=1024 (N11) (N12) (N13) (N14) (N15) COMMENT PSFs by N: Y-0512 (N06) (N07) (N08) (N09) (N10) COMMENT Y=0000 (N01) (N02) (N03) (N04) (N05) COMMENT F = 05 F150W ^ ^ ^ ^ ^ COMMENT C = 01 NRCA1 X=0000 X=0512 X=1024 X=1536 X=2048 END >T%>_Qū>T.Õ>_ũË>laČ>`Ã>Tą_>aLš>Vƒ>UyÂ>`Ս>Tôī>_ô>lė>_}ķ>SŦą>_B>Sï>T†>_‰•>Tã>^‹ä>j|Į>^V]>Rv*>^ˆ>RxŊ>S•ö>^€Ĩ>Rį >]™–>iJ‚>]C°>Q}>\˜}>Q;'>Tq9>_]b>Sß>]ʗ>ibÖ>]ķ¨>Qq>\ÍŠ>R@>Qˆc>\˜>Qc4>\‘^>h%ī>\O/>Qlé>\’>Q:Ô>Rqé>]šŽ>Qŋ->]Œ>iY >\Û>R(>]ŗį>Qņ0>PîT>\˜>QÅ/>[õH>gũË>\gI>PȤ>\UŦ>QQāÁ>]`>Qúŋ>\Ũ>hĒi>\Š>QN˙>\Ũá>Q6š>T2j>_c>Sc>_˙>jģo>^nL>Sj×>_b>S >Nå_>Yŧč>Nķī>ZX.>eŠô>Zą>OūB>Z†Ë>O~|>P:p>[¸ņ>Pœ¯>[`>go$>[ÚÚ>P‡’>\(>Q-Ũ>Q1ō>\wî>Q ˛>\S>gÖ>[šÚ>PĐŖ>\00>P¸›>P™¤>[īm>P¯Ē>[Ũö>g°:>[Ž>Pø >\už>Pę˛>Pį™>[Ŗs>P>\ŒÅ>gô>[ĄS>Rã>]R5>Q\Í>PA>[;Ŗ>O÷4>[ģr>f´ī>Zˆ¸>Q%>[F;>O<ŋ>NĢ>Y{>NÂC>X¤e>dЍ>Ypy>NM,>Y˛§>O>M!ļ>XY’>Mƒˇ>XČ>cɸ>XcŪ>MŒ>XĶl>Mķ‡>K >V3â>KŒą>VW5>bO>V´ā>LO>Wž”>LĘĶ>Ltļ>VĘ.>K>W­Ō>bžæ>V2Ú>M\n>X'Ė>LLŒ>N›ļ>X¸Ā>MĖd>Z>`>e\G>YŽ>P<>[¨>O˜Š>La>VæB>L=…>Wk>bXv>VėŠ>Mß>Xĩ>LéA>I?Í>T>I–[>SŠû>_J>Sęį>I2n>TQŒ>Ižâ>F­>Qԉ>G€ >PøĀ>\‹>Q’>F$0>QAŌ>FŊ>HDĘ>QÔ >E”|>S9P>]`š>PZ!>HŽ >RĸX>F_././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits0000644000175100001770000002640014637570305024647 0ustar00runnerdockerSIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 200 DATATYPE= 'INTEGER*4 ' COMMENT STDPSF_NRCSW_F150W.fits BSCALE = 1.000000 BZERO = 0.000000 NXPSFS = 20 NYPSFS = 10 COMMENT IPSFXA5 = ' 0001 0512 1024 1536 2048 ' IPSFXB5 = ' 2049 2560 3072 3584 4096 ' IPSFXC5 = ' 4097 4608 5120 5632 6144 ' IPSFXD5 = ' 6145 6656 7168 7680 8192 ' COMMENT JPSFYA5 = ' 0001 0512 1024 1536 2048 ' JPSFYB5 = ' 2049 2560 3072 3584 4096 ' COMMENT COMMENT ------------- ------------- ------------- ------------- COMMENT 4096 | 181 --> 185 | 186 --> 190 | | 191 --> 195 | 196 --> 200 | COMMENT 3584 | 161 --> 165 | 166 --> 170 | | 171 --> 175 | 176 --> 180 | COMMENT 3072 | 141 A2> 145 | 146 A4> 150 | | 151 B3> 155 | 156 B1> 160 | COMMENT 2560 | 121 --> 125 | 126 --> 120 | | 131 --> 135 | 136 --> 140 | COMMENT 2048 | 101 --> 105 | 106 --> 110 | | 111 --> 115 | 116 --> 120 | COMMENT ------------- ------------- ------------- ------------- COMMENT 2048 | 081 --> 085 | 086 --> 090 | | 091 --> 095 | 096 --> 100 | COMMENT 1536 | 061 --> 065 | 066 --> 070 | | 071 --> 075 | 076 --> 080 | COMMENT 1024 | 041 A1> 045 | 046 A3> 050 | | 051 B4> 055 | 056 B2> 060 | COMMENT 0512 | 021 --> 025 | 026 --> 030 | | 031 --> 035 | 036 --> 040 | COMMENT 0000 | 001 --> 005 | 006 --> 010 | | 011 --> 015 | 016 --> 020 | COMMENT Y ------------- ------------- ------------- ------------- COMMENT X 0001 2048 2049 4096 4097 6144 6145 8192 END >T%>_Qū>T.Õ>_ũË>laČ>`Ã>Tą_>aLš>Vƒ>UyÂ>`Ս>Tôī>_ô>lė>_}ķ>SŦą>_B>Sï>T†>_‰•>Tã>^‹ä>j|Į>^V]>Rv*>^ˆ>RxŊ>S•ö>^€Ĩ>Rį >]™–>iJ‚>]C°>Q}>\˜}>Q;'>Tq9>_]b>Sß>]ʗ>ibÖ>]ķ¨>Qq>\ÍŠ>R@>Sōļ>^Ēū>RiF>\Ķv>h}ė>\dz>Oës>[Ŋ>Pü)>Qh>\ >P“|>[Ąŧ>gdä>[$[>Pš>[ī>PŊ>S 7>]Ũ[>Qē>^~<>j&g>]u+>SWŋ>^ë}>S>Së>_í+>T€N>^čē>kž,>_S>S1å>_ws>Ty>U6ō>am >VyÆ>`jŠ>mu>aL>S8N>`Y>Tī0>U>`×ĩ>S§>_;>k‹R>].{>Rl’>]Ā}>Oë§>O‹t>Zud>Mü>Y„E>dČô>W›>M ×>Wå¯>KcS>QQW>[œä>NŅ‘>[q>f >X• >Nø„>Y<>L”9>R*Ä>]Œû>QÅY>\Ŧ€>hŒĀ>\ í>Q^>\oī>Pl>S@ >^Ö/>Räú>\Ø`>iČ>\šM>P+>\Fä>PhĒ>T1´>_F'>R>^ƒ)>i}ž>[…>Q§f>[Ė5>N=č>QĒŊ>\GÂ>OČ^>\ĩ>fīk>Yžc>Oߌ>Z >M^1>N€>Y.Ŋ>M}k>Xã1>cčą>WcQ>M">Wđû>KÔí>Kĩˆ>V‡s>K>V¨>`ā¯>U#Z>JnN>Tåģ>I÷ˇ>N^J>X˛r>Lú>YG>cÁ†>WkŠ>MųF>XY*>Lœ>Qˆc>\˜>Qc4>\‘^>h%ī>\O/>Qlé>\’>Q:Ô>Rqé>]šŽ>Qŋ->]Œ>iY >\Û>R(>]ŗį>Qņ0>PîT>\˜>QÅ/>[õH>gũË>\gI>PȤ>\UŦ>QQāÁ>]`>Qúŋ>\Ũ>hĒi>\Š>QN˙>\Ũá>Q6š>T2j>_c>Sc>_˙>jģo>^nL>Sj×>_b>S >Sŧ>] >Q|å>]RI>h@E>\ Ī>Q”š>\”—>Qr>Qĸ{>\€%>PŦm>\)[>gļķ>[Z>PųŸ>\;‹>P†ũ>S™¯>^­ë>R—‰>^"Z>iŨ<>]b>Raí>]ĪŅ>RŒ>TÃ%>`M¸>T…Č>_ž&>kÁq>_e>Sįd>_T>Sģ°>U#ã>`dā>T^R>` ŗ>l­>_a>S˜>_~Š>Sļ:>SĻ•>]•>O]>]`>gsP>YH¨>OĶC>YîF>Lāz>QØ3>\0>ODö>\e¤>gš>Yvį>P)ņ>Zzg>Mžū>SqG>]Čy>P˙Ã>^V>i >[pc>RpŪ>\ā_>Oųš>Sž>^ >QĨc>^Yk>iÚņ>\°ž>RŲÍ>^X>QƒĢ>SD†>^Ũ,>R­)>^P>jä>\Ÿb>RS>]T¤>Oû$>Q×Q>\8ī>O g>\ŌÄ>gz>YÚg>P›U>[Í>Må˙>R#>]6Z>Q3y>\-Ģ>gŸ>[Ō>P j>ZÜe>NÆ~>RXĻ>]ū>Pęx>\ø@>h´>[,Ņ>QQv>[÷s>OÎâ>RÄ>]î‘>QÜ]>^Žf>j4>] >S7>^”š>R_â>N^J>X˛r>Lú>YG>cÁ†>WkŠ>MųF>XY*>Lœ>Nå_>Yŧč>Nķī>ZX.>eŠô>Zą>OūB>Z†Ë>O~|>P:p>[¸ņ>Pœ¯>[`>go$>[ÚÚ>P‡’>\(>Q-Ũ>Q1ō>\wî>Q ˛>\S>gÖ>[šÚ>PĐŖ>\00>P¸›>P™¤>[īm>P¯Ē>[Ũö>g°:>[Ž>Pø >\už>Pę˛>Pį™>[Ŗs>P>\ŒÅ>gô>[ĄS>Rã>]R5>Q\Í>R r>\Å>Q™đ>]\(>h•>\gŒ>Ry>]k…>QpV>PĘl>[!5>O‰‘>[f9>fJ6>Z">Ph>[ö>O{7>Q¸>\r…>P˜`>\A¯>g‘">[I4>Pž_>[Ã2>PM†>TXD>_Ō_>TX>_Oō>kSŅ>_W>Té>_ĨÖ>T¤>UÉ>aÁ[>V)!>a¨Ī>nU=>bHƒ>Uˀ>b>V”ô>T}đ>^^ĸ>PĖ>_}Œ>i¤->[Šs>S<ä>]]>Oԙ>TĒŠ>_{ŧ>RO;>`Ŗ>k>M>]mž>TĨÍ>_Ib>RC…>TY >_Jī>Rå’>_Īã>k˜>]ņß>Tlá>_Et>RÕv>S8â>]–˛>QKž>^!č>hĶm>[ŗė>Rž­>]$b>P”X>RŧŠ>]ĐŨ>QÆĄ>^I2>idå>\%>R¸ŋ>]ū>P!ā>P}Ŧ>ZŽÚ>NœÚ>[fĪ>e¯Í>Yl>Oŋ >YžP>M„+>Nņ>Yšã>N ü>Z:Ö>e^C>YHĩ>O|ö>Z4<>N˜m>PøĘ>[>#>O">[÷–>fŖˆ>Yī >Q ~>[rG>O|õ>SŖ…>^Æ\>Rˆ‚>^1B>iĒŠ>\ōœ>RŅŧ>]˛‚>QÅ>IōK>T >Iĸ/>V +>bÉ>Wr>Lķä>Y Ú>P#¯>PA>[;Ŗ>O÷4>[ģr>f´ī>Zˆ¸>Q%>[F;>O<ŋ>NĢ>Y{>NÂC>X¤e>dЍ>Ypy>NM,>Y˛§>O>M!ļ>XY’>Mƒˇ>XČ>cɸ>XcŪ>MŒ>XĶl>Mķ‡>K >V3â>KŒą>VW5>bO>V´ā>LO>Wž”>LĘĶ>Ltļ>VĘ.>K>W­Ō>bžæ>V2Ú>M\n>X'Ė>LLŒ>K‡q>V)×>KM.>VíR>aë2>Uũx>L€>W>K>Ą>LJÕ>VsŦ>K*ô>W(W>aģÅ>UÂA>LV>VĖ4>KbŖ>M9>Vīl>KpŽ>WÚ*>bR?>V?đ>M5÷>W‚|>L ?>Pįe>[î7>P>[ßn>gh_>[`š>PĄ´>[ŪY>Pƒņ>TNE>`Ī>Tž>^­&>k!W>_’ŗ>R&ƒ>^Jo>Są™>Tœ>_øŌ>Sŗg>`SĐ>lŲ>^Ė’>U'Ë>`>Sa>Tww>_Ŋ>RĖ>`z>ká>^M>Uu>` x>SRė>SÆ >^æ’>RĄŠ>^Ŋļ>jbD>]’h>S~)>^¯å>RŠm>Qc[>\ \>Q ´>\¤Æ>hyÁ>\Kr>R7t>]Ĩč>Qų’>N<¸>Y>NOž>Ybņ>eM$>YŒĖ>O 8>ZI—>Nę{>J¯p>Tôš>I r>VLÅ>`›)>SĒL>L>V-6>Iˆå>HÍë>R >FHę>Së÷>]j=>Pã¤>Ié\>S!“>G36>KJ>UŖ$>JK>UēŲ>`¨>T˜>K$(>UÎ>JœY>OE1>Zw>O*°>YÎã>e„–>YŖå>OVŅ>ZĨË>Om>Tõ>`Õ>SĀW>]ŗ…>j/Ô>]Úä>Pև>\P>QÜn>N›ļ>X¸Ā>MĖd>Z>`>e\G>YŽ>P<>[¨>O˜Š>La>VæB>L=…>Wk>bXv>VėŠ>Mß>Xĩ>LéA>I?Í>T>I–[>SŠû>_J>Sęį>I2n>TQŒ>Ižâ>F­>Qԉ>G€ >PøĀ>\‹>Q’>F$0>QAŌ>FŊ>HDĘ>QÔ >E”|>S9P>]`š>PZ!>HŽ >RĸX>F_>GÄË>PW>Cŧį>QŖH>Zā~>Mā>FĢŽ>P ŋ>DZ>Iå>SŨ>H`É>T?É>_A.>SŖ>IäE>TI’>I<Ģ>K1œ>U]>Iú¯>VÍ>atĪ>U^æ>Lģž>Väã>KJš>O“S>YŠ…>Mųũ>ZŲą>ekG>XīŖ>P{>Zv>N>Qķ'>\îv>QW#>\Cv>gŒ¨>[}>Q Ž>\Â>PҚ>VF=>aTO>Tĩ9>ao)>lö>^î->U$>_“>R›>SķŽ>^æ(>RŽ>_wŅ>jöZ>]ús>T5p>_$z>Rļį>R‰œ>]Ôö>Rv>]ŧ¸>iņ>]PQ>RŸį>]ß<>R0m>NŸé>ZNc>O2)>Z;Æ>fÉ>[a>P”>\‰>P” >Kî>Uō>JôZ>V'L>a_č>U„Ú>KĘĖ>V)ē>JAĒ>Fâŗ>PųĨ>EŽ0>QÕT>\XÍ>Pk >Gį>Râ>F _>E\É>NÔō>C >Pŗ>Yj…>LŊh>F, >Ol>BĀŌ>JŠ´>T|Ã>Hy>Tßų>_,Ė>RŲ>I‡‡>Sĩ§>Gˆ>MüY>XČ>Lž>Xy¤>cĒ>WÔO>Mx_>XÄĒ>MĐŦ>Hb`>RĄ>GČt>TLB>`*”>UUū>PĸĻ>\k>QlØ>S˛Đ>^˙>S.G>]áW>i”t>]uĄ>R-ļ>]!Į>Q‰Ę>O3y>YĪæ>NY>YyĶ>dÂī>XÉ>N×l>YÖČ>Nxe>Iûî>U9>JÂĄ>T…đ>`@‡>U#j>JGĢ>U>JŌ‘>G÷&>S[¯>HúË>Qń>^W>Sl^>Fėw>S5›>I7>IĖå>TŽ‘>IH>T•z>`>T2\>I‰ >TēŽ>I[ä>Ib2>R">E`>R~Ú>\,&>Oƒ>Ff’>P9V>D\1>JZS>S”>GĖ$>SúZ>]ĀC>Q¤3>IK(>Rķ>Gu>MMK>W?û>K´ >Xģ>bĘÖ>WŽ>Lč'>WÆf>LŨŲ>P0Ĩ>Z|˙>NÅr>[ Y>eá™>Yyį>Oëŧ>Zē>O–>Sōļ>_FŨ>Sâ:>`8H>l'ī>_Ķ•>UDĒ>`æÖ>TÜ,>QB>Zø]>O•>[×>e›>XŧP>PՂ>Yâū>M7á>S->]Ĩ3>Qž&>^ Ŗ>hņF>\Rp>Rw>\Ú>>P˛î>R˙æ>^T>Rĸ)>]_>ir >]÷>QūĄ>]Gü>Q^ō>LL>W“ß>LĮ`>V8%>aöŧ>Vts>Ka>VJä>K*Ī>HwŲ>R y>FŠÍ>S]-=>P€P>H.ĩ>QŊ>EˆÁ>N;|>W Ü>I Ņ>WĻ`>aŌĻ>T„x>IĖ0>UŗŽ>J_>Hë->P| >Brä>Tz>\ä÷>M¯ >J ģ>S ÷>Dœq>LŽ:>W"į>KžL>U˙\>a >U$->J„>T֐>IĶ}>MĮŪ>Xđ)>N ô>X8 >cčp>XA>Mvų>Xˇ>M~ų>M‡>YŸ´>O Ę>ZuÁ>gĄ>[ĖĢ>P1ā>]í>QŲō>N•0>Y5 >NA >YKˇ>dN$>X~î>NČõ>YeŊ>MÕ>Lí>VČ~>Kņ>VĩÖ>aūá>Vyn>L$„>W°>L>HHĢ>S-á>HđS>Rב>^QL>Sy@>Hēu>SŅķ>IV@>HŠŅ>S ˛>Hūķ>RĄE>^iU>Sfæ>Gû>S‚ŧ>I#™>Ivŋ>T!ū>I'Ķ>Röõ>^,§>R¤H>G~˛>RDq>GkŪ>I>SOt>GØ[>RԈ>]j>Qlo>GŊ>QĶI>F–Ė>JAų>TŒÖ>IP>Tv’>_=Ī>S_g>IÖd>TEŪ>H˙>>Kŋ;>V$ >Jō)>V‰6>ažõ>UĪl>L&i>WÅ>KØŲ>OŪ>Z^ą>Or¤>Yv`>eYŊ>YĀø>MūÂ>Yŧ>Nk>Räš>^Fú>ROÃ>^K>jĒ>]\÷>R¯W>^†­>RŖ|>O 0>Yl>Mˆ >ZlG>dQļ>W—>OÉĖ>Xķ>L7ë>O`Ē>YN>N5 >Z 0>d—*>XPG>Nēį>XË>LŲÁ>M¤>Xü>Mš>WŌé>bĮx>W Ē>LáY>Woã>KöT>Kw<>Uûą>J´å>T÷R>` ´>TL;>IÅņ>TĨļ>I„Æ>IjG>SŦĸ>H9Ø>RŪī>]—.>Q >G7­>Qŗ>Fkķ>F€i>OSB>CŊ˙>OŦB>Y >MhŪ>DÄ.>N8Ô>C°ŗ>H >Rh{>FŠŊ>S#æ>]>PyG>Hnĩ>Rh>EđÍ>K­>U>JJ>U‡>_čž>Så>K,ņ>U4M>Ikb>KØt>Vßn>Lø>VuĢ>aņ=>Vĸ}>LD@>WC/>Kņ>MC7>Z,>PĮÛ>X>eæX>[‚k>NKv>[)[>PĀ>LéD>Wē>M,>WWƒ>b§e>WV>LĀN>WĪû>LÍ>Jä™>UX‘>KN>UŠ>`y>Uh›>K;•>Uũĸ>K6´>HĪ|>SĢ >IËÜ>S:Ŗ>^ŸŊ>T$>Ixú>TĢŪ>J€*>Jæû>UąI>JėÔ>U8C>`|>U[ā>Jƒn>U°ō>J˙Î>KL:>U5~>J$T>U‘>_ų3>T,ŋ>JÖĩ>Tūƒ>IËŌ>Mɜ>Wį >K˙b>W›ū>bˇ>U×=>Lf>VdĀ>K é>N? >XŌÕ>M8…>XRÚ>cUî>W;>MPĘ>WŨŸ>LtD>O7œ>Y?Û>MŒ>Y4‹>cĀx>WÍ>N:’>X}Đ>LqI>Q({>\=;>PRå>[Š>gAk>Zˇt>PČ\>[ú~>Oę#>S˛*>_¤b>SŨ>^JŅ>j‹Ę>]ė5>RÚ§>^“F>RŠ>NhF>XMÕ>LŠķ>Z)×>d€>WԚ>OØ.>YĶ\>MÁ>N“I>X’Û>M6Å>Z¨>dI>WĪ>OŸ>Yuž>M)Ŧ>NJ>Xœ;>MEQ>Y;>cæ•>W­X>N”^>Xč >MŦ>NyÄ>XÆW>Lžķ>Y{>d3>W‘•>NPž>Yū>MJ>MÔC>X} >MŽ>X:I>c^y>Wk>MZŒ>X9l>M[>LŨI>Vēˆ>Jí >W“Ų>ađĖ>UÍ_>Lī,>W>K§i>Kß]>VEC>Kkw>VË>a°i>V>Lå‡>Wb">L Ĩ>LY >W<ƒ>LÅP>W5>b˜{>WL >M7>>X#>LëŌ>KBú>V ĩ>KÛf>U÷z>aŒķ>V‹ņ>Lå>WQ>L^>>Km>W m>Mo>V”û>cˆë>Xˁ>LP>XūĒ>NņŖ>KВ>Ufė>Je„>VƒJ>`ž >U/>L[×>V–š>K›į>K™z>U å>JÄ>V!S>`ŗö>Tæö>Kˇ>Ví>Jāë>Jfq>TßĨ>J)ë>Tė>_Č>TP˜>JÍg>Ue<>Jpx>K9W>UĻ>JÛ>UĐ>`>TJ…>K“ >UĨd>J€>Lŗv>V°P>KI >W{P>aĖH>UÂ>M2l>WL>Kīĸ>Pn>[ā>O• >[Ø>fØS>ZŒa>Q¯<>[ķ>PŽ>Oęž>ZČ>OmË>[9€>fŽ´>Z™L>QP×>\đ>P“ų>P]>Z^ >Ną>[v>e›d>YdŲ>PĒm>Z§>NŲÚ>RÎ^>]Í^>RRâ>]ņ|>iš>]}8>R÷[>^G`>RŒí>T/×>_öģ>Tœ>_¯ę>ké‡>_ŲĀ>TÖ>_Å>T?>Mu\>V°Ä>J‰>YB>bõ>UM>NÜÕ>Wšš>Jš˜>MË&>Wkę>KŲ÷>YUÄ>c@˜>Vĸŗ>OÕ>XŊ$>LŸ>Mî•>WeĀ>KÄC>Y%">böt>V‘–>NōĐ>X´–>Lë>MŠŽ>W¸—>L9 >XĒū>cW2ģ>N˜A>XÚŖ>Muy>P37>[’ų>PMU>[p}>g$>[ d>Q2>\Œ>P­F>M Á>WÆ >LD9>Y3>cŖ6>W€Í>Oj>Y‰Ŋ>Mč~>M†>Wæ>Lšˆ>XĮ>c¤m>WĸŽ>N¯>YJÕ>Mģ>Kņ1>Vf >KĮv>VŗY>aĖŽ>VĻš>L–ō>WYK>LŽÚ>Hü+>S]?>Hũ>S˛ĩ>^ū\>THš>IĮ>U>Jäĸ>@ĀÆ>Këū>AÄē>Iĸš>V\Ē>K†ō>@|ē>M›->CzĘ>M2>Wfī>Lh÷>WÕ>bÁ)>Wƒ{>M\,>X?„>MĒS>Kûo>Vo>K‚Š>VÖ´>aÍą>V7”>LĪŅ>Wc>L ņ>K€ >UŽp>JĢt>V\ķ>al>UEĶ>LÂ>VÆB>KsV>K˝>UĀČ>JÂq>V}ī>aq>U˜Z>LxT>W>L7™>KĢ>VÜ >L“6>VJ >až->VÜj>KėU>VėĢ>L„F>OUm>Y°N>Nĩũ>Zn›>eA>Y¨U>OÃz>Z`Ú>O”Û>PŒÖ>[ß>P$8>\>g6¯>[Ÿü>RI>\õÜ>QĶŊ>RŨ*>]ˇN>R8O>^`y>ia >]!Ę>S >^m>RP;>S”ģ>^sā>RŒō>^˛‹>iî–>]^ē>SŸ->^uĄ>Rf‡>Sl>^qÚ>SG>^]|>jdũ>^ŦR>R8}>]õ}>Sl>Kģ,>Tį;>ID$>Vä>_Q‘>RÃ>JS4>SM>GŸl>LZ>UũŽ>Jâ*>WH>a)g>Tāl>L™y>VPr>J Ļ>Kû>UÕī>JEĩ>V´Î>a¤>Tę2>L@å>Vžš>Kd5>Iė¤>SîÛ>IJ:>U˛i>`û>T¸Ž>LÉķ>VĨ>Ka3>OQ>Zú6>Q >[ˇ>g¯>\@S>QĶC>\ėų>Qāņ>I{H>S€Ļ>Hˆ~>U^y>`k>T6M>LŖø>WgĨ>Kū÷>HéČ>S>Hû>T>_>SBŌ>KAr>U°+>JNË>DÆ>NÎ@>E c>O;Õ>Y˛ã>OfŽ>Fu@>PŅ>FÁ‰>EŌy>Nˇ>CS]>Pë>Y¨W>N =>FoH>Pƒ>E3E>B‹F>M\Ž>FŦM>KĐx>Vė!>PtÖ>B=(>M# >GŸ“././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits0000644000175100001770000001320014637570305024731 0ustar00runnerdockerSIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 56 DATE = '2014-01-31' TIME = '09:45:22' BSCALE = 1.0000 BZERO = 0.0000 COMMENT NXPSFS = 7 NYPSFS = 8 IPSFX01 = 0 IPSFX02 = 682 IPSFX03 = 1365 IPSFX04 = 2048 IPSFX05 = 2731 IPSFX06 = 3413 IPSFX07 = 4096 IPSFX08 = 9999 IPSFX09 = 9999 IPSFX10 = 9999 JPSFY01 = 0 JPSFY02 = 682 JPSFY03 = 1365 JPSFY04 = 2048 JPSFY05 = 2049 JPSFY06 = 2731 JPSFY07 = 3413 JPSFY08 = 4096 JPSFY09 = 9999 JPSFY10 = 9999 COMMENT ../../WFC3UV_PSFs/PSFEFF_WFC3UV_F814W_C0.fits COMMENT PSFSTD_WFC3UV_F814W.fits END >U>cŧ>9>÷8>&Έ>œÛ>6Ę>2×>ųX>Íß> Œ>yE> ēū>'ûč> Äô>q¯> ļ’>+‰>Ļ,> ë>$ŋ>!o>(3>!]˙>7ļ> øŗ>šf>"hk>* >#÷â>*ú8>2Ę>+/Ã>#âe>*Ôo>"įę>)ú>3÷Ē>,åU>2ėV><žd>4+§>+áB>4@>+É> Λ>)Ö%>"đƒ>)0>1Ë>)HĘ>"‚Ú>)ãâ>!Â>Ų5>$€>ā$>#Ęõ>+ŅÚ>#į!>.n>$5ų>õ×>/,>!=(>üų>!ļ>(Ŋš>!ƒĸ>Éū>!yP>û>Õ>!Üõ>œ>!ũY>)`>"'X>Če>"A*>ˆ„>cō> ×f>ר>!pÚ>(fo>!MÜ>k*>!F>ÃŖ>īT>%œK>b¤>&_>-¨´>&L> %6>&ĄÁ>õ×>%ŧ >.ÉÍ>'Ėđ>.¸°>7gé>/>'Ņ>/|Û>&Âé>!fW>*c3>#‰k>)ÛJ>2Kö>*->#æ>*{&>!ķ>3W>'.`> ¤>&&á>.‚´>&˜›>MQ>&°ˆ>eĶ>øZ>!Ķ>@> ĶĻ>(]ķ>!=>Đ>!f>—´>¯&>#5:>úM>"×Ä>*Ę.>#\Á>¯k>#\o>tĒ>ôM>#?I>œ>#$ĩ>*Õw>#vÁ> Æ>#vē>›O>§K>#ãę>õÅ>#Âė>+\P>$I >n>#ØĮ>[E> Ÿ>'ŨĻ>!Uļ>(ŧ>/°)>(q>!X|>'ū>>˙?> ß.>)‘í>"äĘ>)6›>1o>)›Ú>"_š>)ŗ†>!‘đ>!*5Ü>#l™>)<ø>1ɂ>)áĻ>"Ø>)Ŋ4>!Ą1>0ß>@>ŠĶ>ĮU>&9>0>-]>>,>lĢ>Æŧ>#fœ>Lå>"ĸĘ>*Ąĩ>#S7>Ąg>#F,>Z˙>ķČ>%Č->›ú>$ôŖ>-H>%™a>¤ >%r‡>j>>$Ŧ6>Œˇ>$D >,F>$ĖU>ŸŸ>$q>c$>pß>%›Á>wŧ>%Đ->-?Ą>%øĻ>¨>%hī>žū>žĪ>(Cé>!ų>'ī›>0ĸ>(Ŧ>!5>(t^> œ]>"]o>+l>$*É>*–>2qG>*Šē>"Ąt>*Ķ>"B>?6>žĀ>l> CF>'s> ā>˙M>Ā™>ŧD>>!đI>S>"5<>)uŠ>"Y>EI>"r÷>â÷>°b>"œ¯> Õ>"ņ>*M>#{D>ŪÎ>"õĀ>õ>æ>"ëü>úG>#I>*F#>#5">Ÿ>"•\> Ĩ>>$üÆ>'W>%P>,¨˜>$Æu>\M>$ģ<>˞>,>&@ũ>|Ę>&.2>-ŌE>&^>uü>&n>GÁ>2w>&ŨŊ>ía>%ÃV>.!>&MŸ>cY>&ŒN>ˆE>ŗŖ>e'>!„>ÛP>&Đŗ> B >x>>‘g>W>!!'>ĩ`>!S>(l >!´c>ZP>!M >üŌ>į>!Ūl>4>!ĀC>)ß>"Fß>””>!ģĒ>ké>Cō>"c˜>…n>"dM>)ë„>"úS>Ü>"d<>ũĘ>8>$ū>Ÿ>$IŸ>+´č>$,U>Ÿ>$w>Tŧ>ū]>$øj>_Ų>$Ū >,l†>$Õ+>|>%w>FL>ĩ>&Į>šU>%„“>-t\>&õ>ea>&H">“Â>u->7ŗ>ãÚ>ÛÚ>"ÖĶ>@„>ëĒ>S>E°>¸>åQ>aš>y>%¤1>÷Ú>™1>…—>K>ÂX>!ÆK>šĶ>!Ä>(w6>!oô>ĩę>!$> >´/>"Ļ>Œž>"o>)ĐĀ>"ĸV>™>"Y>ŋŨ>€û>#mI>‰>#>å>*Ę >#V6>đ5>#v%>¨ī>x>$Į>ęų>$”>+ģ€>$+[>ã<>$”¯>ž>%>%{š>ņk>%1@>,Ã9>%A¸>ÛÔ>%Õn>.e>n_>1>>Ģ>ģ“>žî>ļ>z>ŠÆ>ęĶ> :>&­>Vn>đœ>#:ö>Xė>õZ>&>Ÿā>Ž>"Š>¯>!Ä­>)›>!ŗØ>TŖ>!ˆŠ>Ņm>Õ{>#‘ >; >#.”>*5>"Á> „>"ž‰>×>W>#ū¸>8”>#Öä>+BT>#Iö>Ąí>$h>ˇl>yÔ>%§Ü>ūA>%{>,āS>%S>>ß0>%‚ü>‚%>Û>%ēŲ>ÄO>%$ų>,ļč>%Ę>=a>%hR>+././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits0000644000175100001770000001320014637570305024575 0ustar00runnerdockerSIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 36 DATE = '2017-07-31' TIME = '08:31:52' BSCALE = 1.0000 BZERO = 0.0000 COMMENT NXPSFS = 6 NYPSFS = 6 IPSFX01 = 50 IPSFX02 = 425 IPSFX03 = 800 IPSFX04 = 850 IPSFX05 = 1225 IPSFX06 = 1600 IPSFX07 = 9999 IPSFX08 = 9999 IPSFX09 = 9999 IPSFX10 = 9999 JPSFY01 = 50 JPSFY02 = 425 JPSFY03 = 800 JPSFY04 = 850 JPSFY05 = 1225 JPSFY06 = 1600 JPSFY07 = 9999 JPSFY08 = 9999 JPSFY09 = 9999 JPSFY10 = 9999 COMMENT PSF_WFPC2_META_F814W.fits COMMENT PSFSTD_WFCP2_F814W.fits END >(9ũ>.¯1>"Ē1>.”Á>6dļ>+jY>"-¨>+Ũ>"ÉŊ>'Cž>0]>%ž~>0Í4>:UŲ>/9k>&7>/?č>$ķ°>čÕ>Qb>5x>…â>$Ķ>Ŗ>Æ^>>į>’“>tÆN>‡W>v]x>…mį>‘Ŧš>Š†>x×>…5c>iCē>py>ƒØ8>nGė>ƒĸ> ŋ>€ø2>rãā>ƒ`ô>jŲ>lA>‚Š>o[>~Â>ĻJ>€s>jė >‚œo>k o>&ŽĨ>.ų:>$œF>1šb>:σ>0Q|>&Ę^>/¯>%–ļ>!˙‚>+ä;>Ë}>*>5[ę>){">!ē>,Ķ>"ĀN>?Ö>&0x>§đ>%ÄC>/_>%¸*>Ŗ™>%3>Fë>s=g>…cÎ>p<Į>…K#>‘”Ÿ>?>uūČ>†+?>n'7>pį<>…-`>püD>‚™‹>‘ce>în>n!Ä>„›>jĘ >s}>…ÂÂ>sč>ƒØk>‘ãá>„­Ģ>r,”>…+>qo>ˇŠ>)‹)>ž2>)\>4@>(']>ģ_>)L >!h>ôĶ>!sŪ>ŌN>!>* j>"Ũ >}F>" >†>zû>Ęŋ>‹Å>Ãą>' Ū>Â>=‰>“7>øˆ>l”Ē>ƒÎ‹>l3Ą>€Âŋ>ÃŊ>ü{>gÉ}>‚QĀ>jSĖ>qõû>ƒeģ>rūø>ƒlÛ>ë>ƒ4ī>q˙Ų>„mÂ>pŖ>`ß8>{̰>fm>uāį>ˆnr>v I>b“‘>x ĩ>^2>jlĻ>€ģ?>m Ŗ>€qļ>ŒÉŽ>}Ŋˇ>n>Öa>gÂ>l-†>…“ž>qū!>ī>’0^>„…ä>l×ķ>„ĪĐ>q(á>gĩÅ>€ã >kšÉ>~ĸ>l>áQ>kŖm>ōÖ>kD>^ß >qĘI>X—Û>x˜]>…ŗŌ>mÛ>eZ>t¸H>Xsâ>aéb>zš>auũ>s´ī>‡ŧ>s.ü>_†û>uûn>^\s>W´>lßP>W¤”>k–š>‚›’>m˙ļ>Xö>pû}>\~Ģ>c ū>{9ë>gD>wH->ˆj>w Œ>f3>}ô>d¯Ã>ZeA>vË>^Ą—>m¨‰>…īĄ>o>^•ā>wņ—>\„Ũ>W{ę>rK>_Žŧ>fúh>‚Ŧ>pû?>YAb>rĒĢ>^!o>V˜Ŋ>hÖ÷>MC>jp>An>`>V5Ų>jQ6>P‡Ô>N P>cí†>PÕ>a´î>zœæ>aō˜>QŦ˛>gĪ’>PsR>[+­>nūd>\ >kÜb>‚ŧ>q$P>ZGâ>p,p>[Žˆ>m°:>€ė_>h`>Đ~>Ž€,>ƒ>lâ|>‚žJ>nW>[„>u!÷>cË>pķā>‡3->vô>_X>xíb>aqĻ>? >[Ö>Pí>Uļ€>oÕ°>[°ĸ>KLā>\ˆ>DÔV>R>c¸C>Nšę>a`>yEî>bųc>Nŋ'>g‰ļ>T< >Iz¸>[UĮ>KĐæ>Xģ>o(Œ>\ú>Jœˇ>`ŋH>N#>Mš>d†h>VQ>>fņ‚>~ē>hô#>W. >hŖ>RMC././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits0000644000175100001770000002640014637570305031076 0ustar00runnerdockerSIMPLE = T / conforms to FITS standard BITPIX = -64 / array data type NAXIS = 3 / number of array dimensions NAXIS1 = 3 NAXIS2 = 3 NAXIS3 = 16 EXTEND = T COMMENT / PSF Library Information INSTRUME= 'NIRCam ' / Instrument name DETECTOR= 'NRCA1 ' / Detector name FILTER = 'F200W ' / Filter name PUPILOPD= 'JWST_OTE_OPD_cycle1_example_2022-07-30.fits' / Pupil OPD source name OPD_FILE= 'JWST_OTE_OPD_cycle1_example_2022-07-30.fits' / Pupil OPD file name OPDSLICE= 0 / Pupil OPD slice number FOVPIXEL= 101 / Field of view in pixels (full array) FOV = 3.153900235 / Field of view in arcsec (full array) OVERSAMP= 4 / Oversampling factor for FFTs in computation DET_SAMP= 4 / Oversampling factor for MFT to detector plane NWAVES = 21 / Number of wavelengths used in calculation DET_YX0 = '(0.0, 0.0)' / The #0 PSF's (y,x) detector pixel position DET_YX1 = '(682.0, 0.0)' / The #1 PSF's (y,x) detector pixel position DET_YX2 = '(1365.0, 0.0)' / The #2 PSF's (y,x) detector pixel position DET_YX3 = '(2047.0, 0.0)' / The #3 PSF's (y,x) detector pixel position DET_YX4 = '(0.0, 682.0)' / The #4 PSF's (y,x) detector pixel position DET_YX5 = '(682.0, 682.0)' / The #5 PSF's (y,x) detector pixel position DET_YX6 = '(1365.0, 682.0)' / The #6 PSF's (y,x) detector pixel position DET_YX7 = '(2047.0, 682.0)' / The #7 PSF's (y,x) detector pixel position DET_YX8 = '(0.0, 1365.0)' / The #8 PSF's (y,x) detector pixel position DET_YX9 = '(682.0, 1365.0)' / The #9 PSF's (y,x) detector pixel position DET_YX10= '(1365.0, 1365.0)' / The #10 PSF's (y,x) detector pixel position DET_YX11= '(2047.0, 1365.0)' / The #11 PSF's (y,x) detector pixel position DET_YX12= '(0.0, 2047.0)' / The #12 PSF's (y,x) detector pixel position DET_YX13= '(682.0, 2047.0)' / The #13 PSF's (y,x) detector pixel position DET_YX14= '(1365.0, 2047.0)' / The #14 PSF's (y,x) detector pixel position DET_YX15= '(2047.0, 2047.0)' / The #15 PSF's (y,x) detector pixel position NUM_PSFS= 16 / The total number of fiducial PSFs DISTORT = 'True ' / SIAF distortion coefficients applied SIAF_VER= 'PRDOPSSOC-063' / SIAF PRD version used COEF_X00= 0.0 / SIAF distortion coefficient for COEF_X00 COEF_X10= 32.119891026 / SIAF distortion coefficient for COEF_X10 COEF_X11= -7.0473141211999E-19 / SIAF distortion coefficient for COEF_X11 COEF_X20= -3.0073229265E-05 / SIAF distortion coefficient for COEF_X20 COEF_X21= 0.006963948796600001 / SIAF distortion coefficient for COEF_X21 COEF_X22= 0.0011248785914 / SIAF distortion coefficient for COEF_X22 COEF_X30= -1.2465528641E-05 / SIAF distortion coefficient for COEF_X30 COEF_X31= -2.13847138E-06 / SIAF distortion coefficient for COEF_X31 COEF_X32= -1.0887000445E-05 / SIAF distortion coefficient for COEF_X32 COEF_X33= -1.0942514666E-06 / SIAF distortion coefficient for COEF_X33 COEF_X40= 3.7956892057E-08 / SIAF distortion coefficient for COEF_X40 COEF_X41= -2.4947574403E-08 / SIAF distortion coefficient for COEF_X41 COEF_X42= 3.8674829267E-08 / SIAF distortion coefficient for COEF_X42 COEF_X43= -8.3438183693E-09 / SIAF distortion coefficient for COEF_X43 COEF_X44= 3.6922855102E-09 / SIAF distortion coefficient for COEF_X44 COEF_X50= 1.7583447738E-10 / SIAF distortion coefficient for COEF_X50 COEF_X51= 4.3670521507E-10 / SIAF distortion coefficient for COEF_X51 COEF_X52= 7.7144352949E-10 / SIAF distortion coefficient for COEF_X52 COEF_X53= -1.0216813715E-09 / SIAF distortion coefficient for COEF_X53 COEF_X54= -7.1821795827E-10 / SIAF distortion coefficient for COEF_X54 COEF_X55= 1.0795099821E-09 / SIAF distortion coefficient for COEF_X55 COEF_Y00= 0.0 / SIAF distortion coefficient for COEF_Y00 COEF_Y10= -0.028099514382 / SIAF distortion coefficient for COEF_Y10 COEF_Y11= 31.928184265 / SIAF distortion coefficient for COEF_Y11 COEF_Y20= -0.0021573376644 / SIAF distortion coefficient for COEF_Y20 COEF_Y21= -0.0012170682268 / SIAF distortion coefficient for COEF_Y21 COEF_Y22= 0.004747422114100001 / SIAF distortion coefficient for COEF_Y22 COEF_Y30= -3.6232064076E-07 / SIAF distortion coefficient for COEF_Y30 COEF_Y31= -1.3194745374E-05 / SIAF distortion coefficient for COEF_Y31 COEF_Y32= -1.814009819E-06 / SIAF distortion coefficient for COEF_Y32 COEF_Y33= -1.1010270012E-05 / SIAF distortion coefficient for COEF_Y33 COEF_Y40= 1.3905012587E-08 / SIAF distortion coefficient for COEF_Y40 COEF_Y41= 1.6343982177E-08 / SIAF distortion coefficient for COEF_Y41 COEF_Y42= -7.2502745511000E-09 / SIAF distortion coefficient for COEF_Y42 COEF_Y43= -1.1406780778E-08 / SIAF distortion coefficient for COEF_Y43 COEF_Y44= 3.2914365992E-08 / SIAF distortion coefficient for COEF_Y44 COEF_Y50= 1.5779269997E-10 / SIAF distortion coefficient for COEF_Y50 COEF_Y51= 9.83495022130000E-10 / SIAF distortion coefficient for COEF_Y51 COEF_Y52= 7.07666276370000E-10 / SIAF distortion coefficient for COEF_Y52 COEF_Y53= -1.2396208817E-09 / SIAF distortion coefficient for COEF_Y53 COEF_Y54= -7.8975646049E-11 / SIAF distortion coefficient for COEF_Y54 COEF_Y55= 2.9937450062E-10 / SIAF distortion coefficient for COEF_Y55 ROTATION= -0.54644233 / PSF rotated to match detector rotation WAVELEN = 1.97136831585906E-06 / Weighted mean wavelength in meters DIFFLMT = 0.05475896734140653 / Diffraction limit lambda/D in arcsec FFTTYPE = 'numpy.fft' / Algorithm for FFTs: numpy or fftw COMMENT / WebbPSF Creation Information NORMALIZ= 'first ' / PSF normalization method TEL_WFE = 65.24223687247368 / [nm] Telescope pupil RMS wavefront error JITRTYPE= 'Gaussian convolution' / Type of jitter applied JITRSIGM= 0.0008 / Gaussian sigma for jitter, per axis [arcsec] JITRSTRL= 1.0 / Strehl reduction from jitter CHDFTYPE= 'gaussian' / Type of detector charge diffusion model CHDFSIGM= 0.0062 / [arcsec] Gaussian sigma for charge diff model IPCINST = 'NIRCam ' / Interpixel capacitance (IPC) IPCTYPA = '481 ' / NRC SCA num used for IPC and PPC model IPCFILE = 'KERNEL_IPC_CUBE.fits' / IPC model source file DATE = '2023-11-10T01:27:18' / Date of calculation AUTHOR = 'lbradley@artemis.local' / username@host for calculation VERSION = '1.2.1 ' / WebbPSF software version DATAVERS= '1.2.1 ' / WebbPSF reference data files version COMMENT / File Description COMMENT For a given instrument, filter, and detector 1 file is produced in COMMENT the form [i, y, x] where i is the PSF position on the detector grid COMMENT and (y,x) is the 2D PSF. The order of PSFs can be found under the COMMENT header DET_YX* keywords END ?žš~‘Ü:í?ŋ$M°š~?Ŋ€Pî@ũ?žąĶå´øƒ?ŋĸ‰îŅV?Ŋq˙Eņ(?ŧ´ĢJˇō?Ŋ\‰W¤!?ģA@?ŋå­šĘ?ŋu;öP?ŊÆ+w E?ŋ'(°P1k?ŋ†T>qÅ1?Ŋ×q|“VÁ?ŊCÃq&J3?ŊšĄŸūëČ?ŧęĻ’ƒÉ?ŋI"ĸŦŦ?ŋîī éŲ?ŊÅĻ_Đpĩ?ŋ~ęÂ>ŋ?ŋÄ•ojÔ?ŊûĘ3;Ũ?Ŋŧí4øĘ?Ŋũ+؋ōO?ŧLaÄģ ?ŋfAĶŽ•Į?ŋĸđ ˜a?ŊÕ¯MJu=?ŋ3[yūC?ŋĖ8 ԓĀ?ž›éõ‡?ŊÂ%&ëÁ‹?Ŋũž }į-?ŧJ!n`ûî?ŋIÂāoí.?ŋYÕ#Ii3?Ŋ[Ž ņÚ?ŋ0}ë>0"?ŋ>'XŽŌņ?ŊB’D{wÔ?Ŋ˙}JŸ?Ŋ&z‰ĩ€5?ģI´â9M†?ŋŠģžÄŸĻ?ŋ‹Ķē~Ri?ŊßԞzŲ?ŋq#9î_?ŋq›į??Ŋj¯å?o?Ŋ\֙›o?Ŋ\ؔL?ģwĻ;ĸŦ?ŋ|™#ÉĘ?ŋāŲ Ö?Ŋti •?ŋÆÉĄ†Ŗ?ŋ¸eÁtˆô?ŊĄWG‰‡?ŊōĮį0?Ŋæ%€Ȕ?ģîqžßŨĪ?ŋļ( sģč?ŋŖėÛ2´9?Ŋ‰‡7\AÖ?ŋíž*Čv˜?ŋŨi”g§ ?ŊğŗĀ‹?ž*%rΖÂ?ž|z´$h?ŧ!üƒˆ˙~?ŋV<Ē‘d’?ŋeÂQ‚X?ŊfRVi.?ŋHD5m?ŋX õ1ųö?Ŋ[„v‡Ä?Ŋ=÷WwßZ?ŊLɘ×â?ģnÚNŸ2?ŋ’,Õ M?ŋ¸žN…a?ŊĖ¯Ō˜Y?ŋsá PîČ?ŋš–N ?Ŋ´¤÷qič?ŊZĐ_ũŸ?Ŋ€'cEŧ?ģēĩ1žØ?ŋ•ĤûÖ|?ŋŧ0Ķۋ?ŊŌö‹đÄĖ?ŋŖÛfæ ?ŋĖ*=fx(?Ŋįo–&Kv?Ŋˇ ŗō“˜?ŊßßVÎ?ŧ+vxÎ?ŋ˛ąûĮė?ŋÃÂ2=W?ŊČm„GŲĮ?ŋ֟[ģī?ŋę:ģu?Ŋņü¤ë]q?žĩpQČ?ž.ûúm?ŧ ļ?ŋráDŅ?ŋ’š Ūāé?Ŋ‰ų)õĪ_?Ŋt–vüF?ŊwīĒC…?ģ’4âë?ŋ ‹ ?ŋļzŸˇb?Ŋŋ>Vn?ŋ™Á HQĮ?ŋŗk•`o?ŊÂjˆtqi?Ŋ™hrü min_dist: coords.append(newcoord) yy, xx = np.transpose(coords) zz = rng.uniform(low=0, high=200000, size=len(xx)) # define a table of model parameters self.stddev = 2.0 sources = Table() sources['amplitude'] = zz sources['x_0'] = xx sources['y_0'] = yy sources['sigma'] = np.zeros(len(xx)) + self.stddev sources['theta'] = 0.0 psf_model = IntegratedGaussianPRF(sigma=self.stddev) self.data = make_model_image(shape, psf_model, sources) self.nddata = NDData(self.data) init_stars = Table() init_stars['x'] = xx.astype(int) init_stars['y'] = yy.astype(int) self.init_stars = init_stars def test_extract_stars(self): size = 25 with pytest.warns(AstropyUserWarning, match='were not extracted'): stars = extract_stars(self.nddata, self.init_stars, size=size) assert len(stars) == 81 assert isinstance(stars, EPSFStars) assert isinstance(stars[0], EPSFStars) assert stars[0].data.shape == (size, size) def test_epsf_build(self): """ This is an end-to-end test of EPSFBuilder on a simulated image. """ size = 25 oversampling = 4 with pytest.warns(AstropyUserWarning, match='were not extracted'): stars = extract_stars(self.nddata, self.init_stars, size=size) epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=15, progress_bar=False, norm_radius=25, recentering_maxiters=15) epsf, fitted_stars = epsf_builder(stars) ref_size = (size * oversampling) + 1 assert epsf.data.shape == (ref_size, ref_size) y0 = (ref_size - 1) / 2 / oversampling y = np.arange(ref_size, dtype=float) / oversampling psf_model = IntegratedGaussianPRF(sigma=self.stddev) z = epsf.data x = psf_model.evaluate(y.reshape(-1, 1), y.reshape(1, -1), 1, y0, y0, self.stddev) assert_allclose(z, x, rtol=1e-2, atol=1e-5) resid_star = fitted_stars[0].compute_residual_image(epsf) assert_almost_equal(np.sum(resid_star) / fitted_stars[0].flux, 0, decimal=3) def test_epsf_fitting_bounds(self): size = 25 oversampling = 4 with pytest.warns(AstropyUserWarning, match='were not extracted'): stars = extract_stars(self.nddata, self.init_stars, size=size) epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=8, progress_bar=True, norm_radius=25, recentering_maxiters=5, fitter=EPSFFitter(fit_boxsize=31), smoothing_kernel='quadratic') # With a boxsize larger than the cutout we expect the fitting to # fail for all stars, due to star._fit_error_status with pytest.raises(ValueError), pytest.warns(AstropyUserWarning): epsf_builder(stars) def test_epsf_build_invalid_fitter(self): """ Test that the input fitter is an EPSFFitter instance. """ with pytest.raises(TypeError): EPSFBuilder(fitter=EPSFFitter, maxiters=3) with pytest.raises(TypeError): EPSFBuilder(fitter=LevMarLSQFitter(), maxiters=3) with pytest.raises(TypeError): EPSFBuilder(fitter=LevMarLSQFitter, maxiters=3) def test_epsfbuilder_inputs(): # invalid inputs with pytest.raises(ValueError): EPSFBuilder(oversampling=None) with pytest.raises(ValueError): EPSFBuilder(oversampling=-1) with pytest.raises(ValueError): EPSFBuilder(maxiters=-1) with pytest.raises(ValueError): EPSFBuilder(oversampling=[-1, 4]) # valid inputs EPSFBuilder(oversampling=6) EPSFBuilder(oversampling=[4, 6]) # invalid inputs for sigma_clip in [None, [], 'a']: with pytest.raises(ValueError): EPSFBuilder(sigma_clip=sigma_clip) # valid inputs EPSFBuilder(sigma_clip=SigmaClip(sigma=2.5, cenfunc='mean', maxiters=2)) def test_epsfmodel_inputs(): data = np.array([[], []]) with pytest.raises(ValueError): EPSFModel(data) data = np.ones((5, 5), dtype=float) data[2, 2] = np.inf with pytest.raises(ValueError, match='must be finite'): EPSFModel(data) data[2, 2] = np.nan with pytest.raises(ValueError, match='must be finite'): EPSFModel(data, flux=None) data[2, 2] = 1 for oversampling in [-1, [-2, 4], (1, 4, 8), ((1, 2), (3, 4)), np.ones((2, 2, 2)), 2.1, np.nan, (1, np.inf)]: with pytest.raises(ValueError): EPSFModel(data, oversampling=oversampling) origin = (1, 2, 3) with pytest.raises(TypeError): EPSFModel(data, origin=origin) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('oversamp', [3, 4]) def test_epsf_build_oversampling(oversamp): offsets = (np.arange(oversamp) * 1.0 / oversamp - 0.5 + 1.0 / (2.0 * oversamp)) xydithers = np.array(list(itertools.product(offsets, offsets))) xdithers = np.transpose(xydithers)[0] ydithers = np.transpose(xydithers)[1] nstars = oversamp**2 sigma = 3.0 sources = Table() offset = 50 size = oversamp * offset + offset y, x = np.mgrid[0:oversamp, 0:oversamp] * offset + offset sources['amplitude'] = np.full((nstars,), 100.0) sources['x_0'] = x.ravel() + xdithers sources['y_0'] = y.ravel() + ydithers sources['sigma'] = np.full((nstars,), sigma) psf_model = IntegratedGaussianPRF(sigma=sigma) shape = (size, size) data = make_model_image(shape, psf_model, sources) nddata = NDData(data=data) stars_tbl = Table() stars_tbl['x'] = sources['x_0'] stars_tbl['y'] = sources['y_0'] stars = extract_stars(nddata, stars_tbl, size=25) epsf_builder = EPSFBuilder(oversampling=oversamp, maxiters=15, progress_bar=False, recentering_maxiters=20) epsf, _ = epsf_builder(stars) # input PSF shape size = epsf.data.shape[0] cen = (size - 1) / 2 sigma2 = oversamp * sigma m = IntegratedGaussianPRF(sigma2, x_0=cen, y_0=cen, flux=1) yy, xx = np.mgrid[0:size, 0:size] psf = m(xx, yy) assert_allclose(epsf.data, psf * epsf.data.sum(), atol=2.5e-4) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/test_epsf_stars.py0000644000175100001770000000637614637570305023031 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf_stars module. """ import numpy as np import pytest from astropy.modeling.models import Moffat2D from astropy.nddata import NDData from astropy.table import Table from numpy.testing import assert_allclose from photutils.psf.epsf_stars import EPSFStars, extract_stars from photutils.psf.models import EPSFModel, IntegratedGaussianPRF from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestExtractStars: def setup_class(self): stars_tbl = Table() stars_tbl['x'] = [15, 15, 35, 35] stars_tbl['y'] = [15, 35, 40, 10] self.stars_tbl = stars_tbl yy, xx = np.mgrid[0:51, 0:55] self.data = np.zeros(xx.shape) for (xi, yi) in zip(stars_tbl['x'], stars_tbl['y']): m = Moffat2D(100, xi, yi, 3, 3) self.data += m(xx, yy) self.nddata = NDData(data=self.data) def test_extract_stars(self): size = 11 stars = extract_stars(self.nddata, self.stars_tbl, size=size) assert len(stars) == 4 assert isinstance(stars, EPSFStars) assert isinstance(stars[0], EPSFStars) assert stars[0].data.shape == (size, size) assert stars.n_stars == stars.n_all_stars assert stars.n_stars == stars.n_good_stars assert stars.center.shape == (len(stars), 2) def test_extract_stars_inputs(self): with pytest.raises(ValueError): extract_stars(np.ones(3), self.stars_tbl) with pytest.raises(ValueError): extract_stars(self.nddata, [(1, 1), (2, 2), (3, 3)]) with pytest.raises(ValueError): extract_stars(self.nddata, [self.stars_tbl, self.stars_tbl]) with pytest.raises(ValueError): extract_stars([self.nddata, self.nddata], self.stars_tbl) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_epsf_star_residual_image(): """ Test to ensure ``compute_residual_image`` gives correct residuals. """ size = 100 yy, xx, = np.mgrid[0:size + 1, 0:size + 1] / 4 gmodel = IntegratedGaussianPRF().evaluate(xx, yy, 1, 12.5, 12.5, 2.5) epsf = EPSFModel(gmodel, oversampling=4, norm_radius=100) _size = 25 data = np.zeros((_size, _size)) _yy, _xx, = np.mgrid[0:_size, 0:_size] data += epsf.evaluate(x=_xx, y=_yy, flux=16, x_0=12, y_0=12) tbl = Table() tbl['x'] = [12] tbl['y'] = [12] stars = extract_stars(NDData(data), tbl, size=23) residual = stars[0].compute_residual_image(epsf) # As current EPSFStar instances cannot accept IntegratedGaussianPRF # as input, we have to accept some loss of precision from the # conversion to ePSF, and spline fitting (twice), so assert_allclose # cannot be more precise than 0.001 currently. assert_allclose(np.sum(residual), 0.0, atol=1.0e-3, rtol=1e-3) def test_stars_pickleable(): """ Verify that EPSFStars can be successfully pickled/unpickled for use multiprocessing """ from multiprocessing.reduction import ForkingPickler # Doesn't need to actually contain anything useful stars = EPSFStars([1]) # This should not blow up ForkingPickler.loads(ForkingPickler.dumps(stars)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/test_griddedpsfmodel.py0000644000175100001770000003201014637570305023774 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gridded PSF model module. """ import os.path as op from itertools import product import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.nddata import NDData from numpy.testing import assert_allclose, assert_equal from photutils.psf import GriddedPSFModel, STDPSFGrid from photutils.segmentation import SourceCatalog, detect_sources from photutils.utils._optional_deps import HAS_MATPLOTLIB, HAS_SCIPY # the first file has a single detector, the rest have multiple detectors STDPSF_FILENAMES = ('STDPSF_NRCA1_F150W_mock.fits', 'STDPSF_ACSWFC_F814W_mock.fits', 'STDPSF_NRCSW_F150W_mock.fits', 'STDPSF_WFC3UV_F814W_mock.fits', 'STDPSF_WFPC2_F814W_mock.fits') WEBBPSF_FILENAMES = ('nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits', 'nircam_nrca1_f200w_fovp101_samp4_npsf4_mock.fits', 'nircam_nrca5_f444w_fovp101_samp4_npsf4_mock.fits', 'nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits') @pytest.fixture(name='psfmodel') def fixture_griddedpsf_data(): psfs = [] y, x = np.mgrid[0:101, 0:101] for i in range(16): theta = i * 10.0 * np.pi / 180.0 g = Gaussian2D(1, 50, 50, 10, 5, theta=theta) m = g(x, y) psfs.append(m) xgrid = [0, 40, 160, 200] ygrid = [0, 60, 140, 200] grid_xypos = list(product(xgrid, ygrid)) meta = {} meta['grid_xypos'] = grid_xypos meta['oversampling'] = 4 nddata = NDData(psfs, meta=meta) psfmodel = GriddedPSFModel(nddata) return psfmodel class TestGriddedPSFModel: """ Tests for GriddPSFModel. """ def test_gridded_psf_model(self, psfmodel): keys = ['grid_xypos', 'oversampling'] for key in keys: assert key in psfmodel.meta grid_xypos = psfmodel.grid_xypos assert len(grid_xypos) == 16 assert_equal(psfmodel.oversampling, [4, 4]) assert_equal(psfmodel.meta['oversampling'], psfmodel.oversampling) assert psfmodel.data.shape == (16, 101, 101) idx = np.lexsort((grid_xypos[:, 0], grid_xypos[:, 1])) xypos = grid_xypos[idx] assert_allclose(xypos, grid_xypos) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_gridded_psf_model_basic_eval(self, psfmodel): y, x = np.mgrid[0:100, 0:100] psf = psfmodel.evaluate(x=x, y=y, flux=100, x_0=40, y_0=60) assert psf.shape == (100, 100) z2, y2, x2 = np.mgrid[0:100, 0:100, 0:100] match = 'x and y must be 1D or 2D' with pytest.raises(ValueError, match=match): psfmodel.evaluate(x=x2, y=y2, flux=100, x_0=40, y_0=60) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_gridded_psf_model_eval_outside_grid(self, psfmodel): y, x = np.mgrid[-50:50, -50:50] psf1 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=0, y_0=0) y, x = np.mgrid[-60:40, -60:40] psf2 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=-10, y_0=-10) assert_allclose(psf1, psf2) y, x = np.mgrid[150:250, 150:250] psf3 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=200, y_0=200) y, x = np.mgrid[170:270, 170:270] psf4 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=220, y_0=220) assert_allclose(psf3, psf4) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_gridded_psf_model_interp(self, psfmodel): # test xyref length with pytest.raises(TypeError): psfmodel._bilinear_interp([1, 1], 1, 1, 1) # test if refxy points form a rectangle with pytest.raises(ValueError): xyref = [[0, 0], [0, 1], [1, 0], [2, 2]] zref = np.ones((4, 4, 4)) psfmodel._bilinear_interp(xyref, zref, 1, 1) # test if xi and yi are outside of xyref xyref = [[0, 0], [0, 1], [1, 0], [1, 1]] zref = np.ones((4, 4, 4)) with pytest.raises(ValueError): psfmodel._bilinear_interp(xyref, zref, 100, 1) with pytest.raises(ValueError): psfmodel._bilinear_interp(xyref, zref, 1, 100) # test non-scalar xi and yi idx = [0, 1, 4, 5] xyref = np.array(psfmodel.grid_xypos)[idx] psfs = psfmodel.data[idx, :, :] val1 = psfmodel._bilinear_interp(xyref, psfs, 10, 20) val2 = psfmodel._bilinear_interp(xyref, psfs, [10], [20]) assert_allclose(val1, val2) def test_gridded_psf_model_invalid_inputs(self): data = np.ones((4, 3, 3)) # check if NDData with pytest.raises(TypeError): GriddedPSFModel(data) # check PSF data dimension with pytest.raises(ValueError): GriddedPSFModel(NDData(np.ones((3, 3)))) # check that grid_xypos is in meta meta = {'oversampling': 4} nddata = NDData(data, meta=meta) with pytest.raises(ValueError): GriddedPSFModel(nddata) # check grid_xypos length meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0]], 'oversampling': 4} nddata = NDData(data, meta=meta) with pytest.raises(ValueError): GriddedPSFModel(nddata) # check if grid_xypos is a regular grid meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0], [3, 4]], 'oversampling': 4} nddata = NDData(data, meta=meta) with pytest.raises(ValueError): GriddedPSFModel(nddata) # check that oversampling is in meta meta = {'grid_xypos': [[0, 0], [0, 1], [1, 0], [1, 1]]} nddata = NDData(data, meta=meta) with pytest.raises(ValueError): GriddedPSFModel(nddata) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_gridded_psf_model_eval(self, psfmodel): """ Create a simulated image using GriddedPSFModel and test the properties of the generated sources. """ shape = (200, 200) data = np.zeros(shape) eval_xshape = (np.ceil(psfmodel.data.shape[2] / psfmodel.oversampling[1])).astype(int) eval_yshape = (np.ceil(psfmodel.data.shape[1] / psfmodel.oversampling[0])).astype(int) xx = [40, 50, 160, 160] yy = [60, 150, 50, 140] zz = [100, 100, 100, 100] for xxi, yyi, zzi in zip(xx, yy, zz): x0 = np.floor(xxi - (eval_xshape - 1) / 2.0).astype(int) y0 = np.floor(yyi - (eval_yshape - 1) / 2.0).astype(int) x1 = x0 + eval_xshape y1 = y0 + eval_yshape x0 = max(x0, 0) y0 = max(y0, 0) x1 = min(x1, shape[1]) y1 = min(y1, shape[0]) y, x = np.mgrid[y0:y1, x0:x1] data[y, x] += psfmodel.evaluate(x=x, y=y, flux=zzi, x_0=xxi, y_0=yyi) segm = detect_sources(data, 0.0, 5) cat = SourceCatalog(data, segm) orients = cat.orientation.value assert_allclose(orients[1], 50.0, rtol=1.0e-5) assert_allclose(orients[2], -80.0, rtol=1.0e-5) assert 88.3 < orients[0] < 88.4 assert 64.0 < orients[3] < 64.2 def test_copy(self, psfmodel): flux = psfmodel.flux.value new_model = psfmodel.copy() assert_equal(new_model.data, psfmodel.data) assert_equal(new_model.grid_xypos, psfmodel.grid_xypos) new_model.flux = 100 assert new_model.flux.value != flux new_model.x_0.fixed = True new_model.y_0.fixed = True new_model2 = new_model.copy() assert new_model2.x_0.fixed assert new_model.fixed == new_model2.fixed def test_deepcopy(self, psfmodel): flux = psfmodel.flux.value new_model = psfmodel.deepcopy() new_model.flux = 100 assert new_model.flux.value != flux @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_cache(self, psfmodel): for x, y in psfmodel.grid_xypos: psfmodel.x_0 = x psfmodel.y_0 = y psfmodel(0, 0) psfmodel(1, 1) assert psfmodel._cache_info().hits == 16 assert psfmodel._cache_info().misses == 16 assert psfmodel._cache_info().currsize == 16 psfmodel.clear_cache() assert psfmodel._cache_info().hits == 0 assert psfmodel._cache_info().misses == 0 assert psfmodel._cache_info().currsize == 0 def test_repr(self, psfmodel): model_repr = repr(psfmodel) assert ' 7 model_oversampled = FittableImageModel(im, oversampling=oversamp) assert_allclose(model_oversampled(0, 0), gmodel(0, 0)) assert_allclose(model_oversampled(1, 1), gmodel(1, 1)) assert_allclose(model_oversampled(-2, 1), gmodel(-2, 1)) assert_allclose(model_oversampled(0.5, 0.5), gmodel(0.5, 0.5), rtol=.001) assert_allclose(model_oversampled(-0.5, 1.75), gmodel(-0.5, 1.75), rtol=.001) # without oversampling the same tests should fail except for at # the origin model_wrongsampled = FittableImageModel(im) assert_allclose(model_wrongsampled(0, 0), gmodel(0, 0)) assert not np.allclose(model_wrongsampled(1, 1), gmodel(1, 1)) assert not np.allclose(model_wrongsampled(-2, 1), gmodel(-2, 1)) assert not np.allclose(model_wrongsampled(0.5, 0.5), gmodel(0.5, 0.5), rtol=.001) assert not np.allclose(model_wrongsampled(-0.5, 1.75), gmodel(-0.5, 1.75), rtol=.001) def test_centering_oversampled(self, gmodel): oversamp = 3 yy, xx = np.mgrid[-3:3.00001:(1 / oversamp), -3:3.00001:(1 / oversamp)] model_oversampled = FittableImageModel(gmodel(xx, yy), oversampling=oversamp) valcen = gmodel(0, 0) val36 = gmodel(0.66, 0.66) assert_allclose(valcen, model_oversampled(0, 0)) assert_allclose(val36, model_oversampled(0.66, 0.66), rtol=1.0e-6) model_oversampled.x_0 = 2.5 model_oversampled.y_0 = -3.5 assert_allclose(valcen, model_oversampled(2.5, -3.5)) assert_allclose(val36, model_oversampled(2.5 + 0.66, -3.5 + 0.66), rtol=1.0e-6) def test_oversampling_inputs(self): data = np.arange(30).reshape(5, 6) for oversampling in [4, (3, 3), (3, 4)]: fim = FittableImageModel(data, oversampling=oversampling) if not hasattr(oversampling, '__len__'): _oversamp = float(oversampling) else: _oversamp = tuple(float(o) for o in oversampling) assert np.all(fim._oversampling == _oversamp) for oversampling in [-1, [-2, 4], (1, 4, 8), ((1, 2), (3, 4)), np.ones((2, 2, 2)), 2.1, np.nan, (1, np.inf)]: with pytest.raises(ValueError): FittableImageModel(data, oversampling=oversampling) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestIntegratedGaussianPRF: """ Tests for IntegratedGaussianPRF. """ widths = [0.001, 0.01, 0.1, 1] sigmas = [0.5, 1.0, 2.0, 10.0, 12.34] @pytest.mark.parametrize('width', widths) def test_subpixel_gauss_psf(self, width): """ Test subpixel accuracy of IntegratedGaussianPRF by checking the sum of pixels. """ gauss_psf = IntegratedGaussianPRF(width) y, x = np.mgrid[-10:11, -10:11] assert_allclose(gauss_psf(x, y).sum(), 1) @pytest.mark.parametrize('sigma', sigmas) def test_gaussian_psf_integral(self, sigma): """ Test if IntegratedGaussianPRF integrates to unity on larger scales. """ psf = IntegratedGaussianPRF(sigma=sigma) y, x = np.mgrid[-100:101, -100:101] assert_allclose(psf(y, x).sum(), 1) def test_gaussian_psf_bbox(self): """ Test bounding_box. """ psf = IntegratedGaussianPRF(sigma=2.0) assert psf.bounding_box.bounding_box() == ((-11, 11), (-11, 11)) psf = IntegratedGaussianPRF(sigma=2.2) assert_allclose(psf.bounding_box, ((-12.1, 12.1), (-12.1, 12.1))) psf = IntegratedGaussianPRF(sigma=2.0) psf.bounding_box = psf.bounding_box(factor=2) assert psf.bounding_box.bounding_box() == ((-4, 4), (-4, 4)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestPRFAdapter: """ Tests for PRFAdapter. """ def normalize_moffat(self, mof): # this is the analytic value needed to get a total flux of 1 mof = mof.copy() mof.amplitude = (mof.alpha - 1) / (np.pi * mof.gamma**2) return mof @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}, {'xname': 'x_0', 'yname': 'y_0', 'fluxname': 'amplitude', 'renormalize_psf': False}]) def test_create_eval_prfadapter(self, adapterkwargs): mof = Moffat2D(gamma=1, alpha=4.8) prf = PRFAdapter(mof, **adapterkwargs) # test that these work without errors prf.x_0 = 0.5 prf.y_0 = -0.5 prf.flux = 1.2 prf(0, 0) @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': True}, {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}]) def test_prfadapter_integrates(self, adapterkwargs): from scipy.integrate import dblquad mof = Moffat2D(gamma=1.5, alpha=4.8) if not adapterkwargs['renormalize_psf']: mof = self.normalize_moffat(mof) prf1 = PRFAdapter(mof, **adapterkwargs) # first check that the PRF over a central grid ends up summing to the # integrand over the whole PSF xg, yg = np.meshgrid(*([(-1, 0, 1)] * 2)) evalmod = prf1(xg, yg) if adapterkwargs['renormalize_psf']: mof = self.normalize_moffat(mof) integrand, itol = dblquad(mof, -1.5, 1.5, lambda x: -1.5, lambda x: 1.5) assert_allclose(np.sum(evalmod), integrand, atol=itol * 10) @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}]) def test_prfadapter_sizematch(self, adapterkwargs): from scipy.integrate import dblquad mof1 = self.normalize_moffat(Moffat2D(gamma=1, alpha=4.8)) prf1 = PRFAdapter(mof1, **adapterkwargs) # now try integrating over differently-sampled PRFs # and check that they match mof2 = self.normalize_moffat(Moffat2D(gamma=2, alpha=4.8)) prf2 = PRFAdapter(mof2, **adapterkwargs) xg1, yg1 = np.meshgrid(*([(-0.5, 0.5)] * 2)) xg2, yg2 = np.meshgrid(*([(-1.5, -0.5, 0.5, 1.5)] * 2)) eval11 = prf1(xg1, yg1) eval22 = prf2(xg2, yg2) _, itol = dblquad(mof1, -2, 2, lambda x: -2, lambda x: 2) # it's a bit of a guess that the above itol is appropriate, but # it should be close assert_allclose(np.sum(eval11), np.sum(eval22), atol=itol * 100) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/test_photometry.py0000644000175100001770000013163314637570305023065 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import LMLSQFitter, SimplexLSQFitter from astropy.modeling.models import Gaussian1D, Gaussian2D, custom_model from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable, Table from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.background import LocalBackground, MMMBackground from photutils.datasets import make_model_image, make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import (IntegratedGaussianPRF, IterativePSFPhotometry, PSFPhotometry, SourceGrouper, make_psf_model, make_psf_model_image) from photutils.utils._optional_deps import HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning @pytest.fixture(name='test_data') def fixture_test_data(): psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) model_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) return data, error, true_params @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_invalid_inputs(): model = IntegratedGaussianPRF(sigma=1.0) match = 'psf_model must be an Astropy Model subclass' with pytest.raises(TypeError, match=match): _ = PSFPhotometry(1, 3) match = 'psf_model must be two-dimensional' with pytest.raises(ValueError, match=match): psf_model = Gaussian1D() _ = PSFPhotometry(psf_model, 3) @custom_model def my_model(x, y, flux=1, x_0=0, y_0=0, sigma=1): return flux, flux * 2 m = my_model() m.n_outputs = 2 psf_model = my_model() match = 'psf_model must be two-dimensional' with pytest.raises(ValueError, match=match): psf_model = Gaussian1D() _ = PSFPhotometry(psf_model, 3) match = 'Invalid PSF model - could not find PSF parameter names' with pytest.raises(ValueError, match=match): psf_model = Gaussian2D() _ = PSFPhotometry(psf_model, 3) match = 'fit_shape must have an odd value for both axes' for shape in ((0, 0), (4, 3)): with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, shape) match = 'fit_shape must be > 0' with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, (-1, 1)) match = 'fit_shape must be a finite value' for shape in ((np.nan, 3), (5, np.inf)): with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, shape) kwargs = {'finder': 1, 'fitter': 1} for key, val in kwargs.items(): match = f"'{key}' must be a callable object" with pytest.raises(TypeError, match=match): _ = PSFPhotometry(model, 1, **{key: val}) match = 'localbkg_estimator must be a LocalBackground instance' with pytest.raises(ValueError, match=match): localbkg = MMMBackground() _ = PSFPhotometry(model, 1, localbkg_estimator=localbkg) match = 'aperture_radius must be a strictly-positive scalar' for radius in (0, -1, np.nan, np.inf): with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, 1, aperture_radius=radius) match = 'grouper must be a SourceGrouper instance' with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, (5, 5), grouper=1) match = 'data must be a 2D array' psfphot = PSFPhotometry(model, (3, 3)) with pytest.raises(ValueError, match=match): _ = psfphot(np.arange(3)) match = 'data and error must have the same shape.' with pytest.raises(ValueError, match=match): data = np.ones((11, 11)) error = np.ones((3, 3)) _ = psfphot(data, error=error) match = 'data and mask must have the same shape.' with pytest.raises(ValueError, match=match): data = np.ones((11, 11)) mask = np.ones((3, 3)) _ = psfphot(data, mask=mask) match = 'init_params must be an astropy Table' with pytest.raises(TypeError, match=match): data = np.ones((11, 11)) _ = psfphot(data, init_params=1) match = ('init_param must contain valid column names for the x and y ' 'source positions') with pytest.raises(ValueError, match=match): tbl = Table() tbl['a'] = np.arange(3) data = np.ones((11, 11)) _ = psfphot(data, init_params=tbl) # test no finder or init_params match = 'finder must be defined if init_params is not input' psfphot = PSFPhotometry(model, (3, 3), aperture_radius=5) with pytest.raises(ValueError, match=match): data = np.ones((11, 11)) _ = psfphot(data) # data has unmasked non-finite value match = 'Input data contains unmasked non-finite values' psfphot2 = PSFPhotometry(model, (3, 3), aperture_radius=3) init_params = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] with pytest.warns(AstropyUserWarning, match=match): data = np.ones((11, 11)) data[5, 5] = np.nan _ = psfphot2(data, init_params=init_params) # mask is input, but data has unmasked non-finite value match = 'Input data contains unmasked non-finite values' with pytest.warns(AstropyUserWarning, match=match): data = np.ones((11, 11)) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[7, 7] = True _ = psfphot2(data, mask=mask, init_params=init_params) match = 'init_params local_bkg column contains non-finite values' with pytest.raises(ValueError, match=match): tbl = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] init_params['local_bkg'] = [0.1, np.inf] data = np.ones((11, 11)) _ = psfphot(data, init_params=init_params) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) resid_data = psfphot.make_residual_image(data, fit_shape) assert isinstance(psfphot.finder_results, QTable) assert isinstance(phot, QTable) assert len(phot) == len(sources) assert isinstance(resid_data, np.ndarray) assert resid_data.shape == data.shape assert phot.colnames[:4] == ['id', 'group_id', 'group_size', 'local_bkg'] # test that error columns are ordered correctly assert phot['x_err'].max() > 0.0062 assert phot['y_err'].max() > 0.0065 assert phot['flux_err'].max() > 2.5 keys = ('fit_infos', 'fit_error_indices') for key in keys: assert key in psfphot.fit_results # test that repeated calls reset the results phot = psfphot(data, error=error) assert len(psfphot.fit_results['fit_infos']) == len(phot) # test units unit = u.Jy finderu = DAOStarFinder(6.0 * unit, 2.0) psfphotu = PSFPhotometry(psf_model, fit_shape, finder=finderu, aperture_radius=4) photu = psfphotu(data * unit, error=error * unit) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert photu[col].unit == unit resid_datau = psfphotu.make_residual_image(data << unit, fit_shape) assert resid_datau.unit == unit colnames = ('qfit', 'cfit') for col in colnames: assert not isinstance(col, u.Quantity) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('fit_sigma', (False, True)) def test_psf_photometry_forced(test_data, fit_sigma): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_model.x_0.fixed = True psf_model.y_0.fixed = True if fit_sigma: psf_model.sigma.fixed = False fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) resid_data = psfphot.make_residual_image(data, fit_shape) assert isinstance(psfphot.finder_results, QTable) assert isinstance(phot, QTable) assert len(phot) == len(sources) assert isinstance(resid_data, np.ndarray) assert resid_data.shape == data.shape assert phot.colnames[:4] == ['id', 'group_id', 'group_size', 'local_bkg'] assert_equal(phot['x_init'], phot['x_fit']) if fit_sigma: col = 'sigma' suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes] for colname in colnames: assert colname in phot.colnames @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry_nddata(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) # test NDData input uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot1 = psfphot(data, error=error) phot2 = psfphot(nddata) resid_data1 = psfphot.make_residual_image(data, fit_shape) resid_data2 = psfphot.make_residual_image(nddata, fit_shape) assert np.all(phot1 == phot2) assert isinstance(resid_data2, NDData) assert resid_data2.data.shape == data.shape assert_allclose(resid_data1, resid_data2.data) # test NDData input with units unit = u.Jy finderu = DAOStarFinder(6.0 * unit, 2.0) psfphotu = PSFPhotometry(psf_model, fit_shape, finder=finderu, aperture_radius=4) photu = psfphotu(data * unit, error=error * unit) uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty, unit=unit) photu = psfphotu(nddata) assert photu['flux_init'].unit == unit assert photu['flux_fit'].unit == unit assert photu['flux_err'].unit == unit resid_data3 = psfphotu.make_residual_image(nddata, fit_shape) assert resid_data3.unit == unit @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_model_residual_image(test_data): data, error, _ = test_data data = data + 10 psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(16.0, 2.0) bkgstat = MMMBackground() localbkg_estimator = LocalBackground(5, 10, bkgstat) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, localbkg_estimator=localbkg_estimator) psfphot(data, error=error) psf_shape = (25, 25) model1 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=True) resid1 = psfphot.make_residual_image(data, psf_shape, include_localbkg=False) resid2 = psfphot.make_residual_image(data, psf_shape, include_localbkg=True) x, y = 0, 100 assert model1[y, x] < 0.1 assert model2[y, x] > 9 assert resid1[y, x] > 9 assert resid2[y, x] < 0 x, y = 0, 80 assert model1[y, x] < 0.1 assert model2[y, x] > 18 assert resid1[y, x] > 9 assert resid2[y, x] < -9 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('fit_stddev', (False, True)) def test_psf_photometry_compound_psfmodel(test_data, fit_stddev): """ Test compound models output from ``make_psf_model``. """ data, error, sources = test_data x_stddev = y_stddev = 1.7 psf_func = Gaussian2D(amplitude=1, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) psf_model = make_psf_model(psf_func, x_name='x_mean', y_name='y_mean') if fit_stddev: psf_model.x_stddev_2.fixed = False psf_model.y_stddev_2.fixed = False fit_shape = (5, 5) finder = DAOStarFinder(5.0, 3.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) assert isinstance(phot, QTable) assert len(phot) == len(sources) if fit_stddev: cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test model and residual images psf_shape = (9, 9) model1 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=False) resid1 = psfphot.make_residual_image(data, psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=True) resid2 = psfphot.make_residual_image(data, psf_shape, include_localbkg=True) assert model1.shape == data.shape assert model2.shape == data.shape assert resid1.shape == data.shape assert resid2.shape == data.shape assert_equal(data - model1, resid1) assert_equal(data - model2, resid2) # test with init_params init_params = psfphot.fit_params phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(sources) if fit_stddev: cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test results when fit does not converge (fitter_maxiters=10) match = r'One or more fit\(s\) may not have converged.' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, fitter_maxiters=10) phot = psfphot(data, error=error) columns1 = ['x_err', 'y_err', 'flux_err'] if fit_stddev: columns1 += ['x_stddev_2_err', 'y_stddev_2_err'] for column in columns1: assert np.all(np.isnan(phot[column])) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.parametrize('mode', ['new', 'all']) def test_iterative_psf_photometry_compound(mode): x_stddev = y_stddev = 1.7 psf_func = Gaussian2D(amplitude=1, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) psf_model = make_psf_model(psf_func, x_name='x_mean', y_name='y_mean') psf_model.x_stddev_2.fixed = False psf_model.y_stddev_2.fixed = False other_params = {psf_model.flux_name: (500, 700)} model_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, **other_params, min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) init_params = QTable() init_params['x'] = [54, 29, 80] init_params['y'] = [8, 26, 29] fit_shape = (5, 5) finder = DAOStarFinder(6.0, 3.0) grouper = SourceGrouper(min_separation=2) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, mode=mode, maxiters=2) phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(true_params) cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test model and residual images psf_shape = (9, 9) model1 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=False) resid1 = psfphot.make_residual_image(data, psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape, include_localbkg=True) resid2 = psfphot.make_residual_image(data, psf_shape, include_localbkg=True) assert model1.shape == data.shape assert model2.shape == data.shape assert resid1.shape == data.shape assert resid2.shape == data.shape assert_equal(data - model1, resid1) assert_equal(data - model2, resid2) # test with init_params init_params = psfphot.fit_results[-1].fit_params phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(true_params) cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry_mask(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) # test np.ma.nomask phot = psfphot(data, error=error, mask=None) photm = psfphot(data, error=error, mask=np.ma.nomask) assert np.all(phot == photm) # masked near source at ~(63, 49) data_orig = data.copy() data = data.copy() data[55, 60:70] = np.nan match = 'Input data contains unmasked non-finite values' with pytest.warns(AstropyUserWarning, match=match): phot1 = psfphot(data, error=error, mask=None) assert len(phot1) == len(sources) mask = ~np.isfinite(data) phot2 = psfphot(data, error=error, mask=mask) assert np.all(phot1 == phot2) # unmasked NaN with mask not None match = 'Input data contains unmasked non-finite values' with pytest.warns(AstropyUserWarning, match=match): mask = ~np.isfinite(data) mask[55, 65] = False phot = psfphot(data, error=error, mask=mask) assert len(phot) == len(sources) # mask all True; finder returns no sources with pytest.warns(NoDetectionsWarning): mask = np.ones(data.shape, dtype=bool) psfphot(data, mask=mask) # completely masked source match = ('is completely masked. Remove the source from init_params ' 'or correct the input mask') with pytest.raises(ValueError, match=match): init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.ones(data.shape, dtype=bool) _ = psfphot(data, mask=mask, init_params=init_params) # completely masked source match = ('the number of data points available to fit is less than the ' 'number of fit parameters') with pytest.raises(ValueError, match=match): init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.zeros(data.shape, dtype=bool) mask[48:50, :] = True mask[50, 63:65] = True psfphot = PSFPhotometry(psf_model, (3, 3), finder=finder, aperture_radius=4) _ = psfphot(data_orig, mask=mask, init_params=init_params) # masked central pixel init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.zeros(data.shape, dtype=bool) mask[49, 63] = True phot = psfphot(data_orig, mask=mask, init_params=init_params) assert len(phot) == 1 assert np.isnan(phot['cfit'][0]) # this should not raise a warning because the non-finite pixel was # explicitly masked psfphot = PSFPhotometry(psf_model, (3, 3), aperture_radius=3) data = np.ones((11, 11)) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[5, 5] = True init_params = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] psfphot(data, mask=mask, init_params=init_params) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry_init_params(test_data): data, error, _ = test_data data = data.copy() psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 match = 'aperture_radius must be defined if init_params is not input' with pytest.raises(ValueError, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=None) _ = psfphot(data, error=error, init_params=init_params) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) init_params['flux'] = 650 phot = psfphot(data, error=error, init_params=init_params) assert len(phot) == 1 init_params['group_id'] = 1 phot = psfphot(data, error=error, init_params=init_params) assert len(phot) == 1 colnames = ('flux', 'local_bkg') for col in colnames: init_params2 = init_params.copy() init_params2.remove_column('flux') init_params2[col] = [650 * u.Jy] match = 'column has units, but the input data does not have units' with pytest.raises(ValueError, match=match): _ = psfphot(data, error=error, init_params=init_params2) init_params2[col] = [650 * u.Jy] match = 'column has units that are incompatible with the input data' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.m, init_params=init_params2) init_params2[col] = [650] match = 'The input data has units, but the init_params' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.Jy, init_params=init_params2) init_params = QTable() init_params['x'] = [-63] init_params['y'] = [-49] init_params['flux'] = [100] match = 'Some of the sources have no overlap with the data' with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=init_params) # check that the first matching column name is used init_params = QTable() x = 63 y = 49 flux = 680 init_params['x'] = [x] init_params['y'] = [y] init_params['flux'] = [flux] init_params['x_cen'] = [x + 0.1] init_params['y_cen'] = [y + 0.1] init_params['flux0'] = [flux + 0.1] phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 assert phot['x_init'][0] == x assert phot['y_init'][0] == y assert phot['flux_init'][0] == flux @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry_init_params_units(test_data): data, error, _ = test_data data2 = data.copy() error2 = error.copy() unit = u.Jy data2 <<= unit error2 <<= unit psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4) init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] init_params['flux'] = [650 * unit] init_params['local_bkg'] = [0.001 * unit] phot = psfphot(data2, error=error2, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 for val in (True, False): im = psfphot.make_model_image(data2.shape, fit_shape, include_localbkg=val) assert isinstance(im, u.Quantity) assert im.unit == unit resid = psfphot.make_residual_image(data2, fit_shape, include_localbkg=val) assert isinstance(resid, u.Quantity) assert resid.unit == unit # test invalid units colnames = ('flux', 'local_bkg') for col in colnames: init_params2 = init_params.copy() init_params2.remove_column('flux') init_params2.remove_column('local_bkg') init_params2[col] = [650 * u.Jy] match = 'column has units, but the input data does not have units' with pytest.raises(ValueError, match=match): _ = psfphot(data, error=error, init_params=init_params2) init_params2[col] = [650 * u.Jy] match = 'column has units that are incompatible with the input data' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.m, init_params=init_params2) init_params2[col] = [650] match = 'The input data has units, but the init_params' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.Jy, init_params=init_params2) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_psf_photometry_init_params_columns(test_data): data, error, _ = test_data data = data.copy() psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder) xy_suffixes = ('_init', 'init', 'centroid', '_centroid', '_peak', '', 'cen', '_cen', 'pos', '_pos', '_0', '0') flux_cols = ['flux_init', 'flux_0', 'flux0', 'flux', 'source_sum', 'segment_flux', 'kron_flux'] flux_cols += flux_cols[0:4] # pad to have same length as xy_suffixes xcols = ['x' + i for i in xy_suffixes] ycols = ['y' + i for i in xy_suffixes] phots = [] for xcol, ycol, fluxcol in zip(xcols, ycols, flux_cols): init_params = QTable() init_params[xcol] = [42] init_params[ycol] = [36] init_params[fluxcol] = [680] phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 phots.append(phot) for phot in phots[1:]: assert_allclose(phot['x_fit'], phots[0]['x_fit']) assert_allclose(phot['y_fit'], phots[0]['y_fit']) assert_allclose(phot['flux_fit'], phots[0]['flux_fit']) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_grouper(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) grouper = SourceGrouper(min_separation=20) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4) phot = psfphot(data, error=error) assert isinstance(phot, QTable) assert len(phot) == len(sources) assert_equal(phot['group_id'], (1, 2, 3, 4, 5, 5, 5, 6, 6, 7)) assert_equal(phot['group_size'], (1, 1, 1, 1, 3, 3, 3, 2, 2, 1)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_large_group_warning(): psf_model = IntegratedGaussianPRF(flux=1, sigma=1.0) grouper = SourceGrouper(min_separation=50) model_shape = (5, 5) fit_shape = (5, 5) n_sources = 50 shape = (301, 301) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=10, seed=0) match = 'Some groups have more than' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, grouper=grouper) psfphot(data, init_params=true_params) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_local_bkg(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) grouper = SourceGrouper(min_separation=20) bkgstat = MMMBackground() localbkg_estimator = LocalBackground(5, 10, bkgstat) finder = DAOStarFinder(10.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, localbkg_estimator=localbkg_estimator) phot = psfphot(data, error=error) assert np.count_nonzero(phot['local_bkg']) == len(sources) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fixed_params(test_data): data, error, _ = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_model.x_0.fixed = True psf_model.y_0.fixed = True psf_model.flux.fixed = True fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) match = r'One or more fit\(s\) may not have converged.' with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error) assert np.all(np.isnan(phot['x_err'])) assert np.all(np.isnan(phot['y_err'])) assert np.all(np.isnan(phot['flux_err'])) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fit_warning(test_data): data, _, _ = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_model.flux.fixed = False fit_shape = (5, 5) fitter = LMLSQFitter() # uses "status" instead of "ierr" finder = DAOStarFinder(6.0, 2.0) # set fitter_maxiters = 1 so that the fit error status is set psfphot = PSFPhotometry(psf_model, fit_shape, fitter=fitter, fitter_maxiters=1, finder=finder, aperture_radius=4) match = r'One or more fit\(s\) may not have converged.' with pytest.warns(AstropyUserWarning, match=match): _ = psfphot(data) assert len(psfphot.fit_results['fit_error_indices']) > 0 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_fitter_no_maxiters_no_metrics(test_data): """ Test with a fitter that does not have a maxiters parameter and does not produce a residual array. """ data, error, _ = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) psf_model.flux.fixed = False fit_shape = (5, 5) fitter = SimplexLSQFitter() # does not produce residual array finder = DAOStarFinder(6.0, 2.0) match = '"maxiters" will be ignored because it is not accepted by' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, fitter=fitter, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) assert np.all(np.isnan(phot['qfit'])) assert np.all(np.isnan(phot['cfit'])) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_iterative_psf_photometry_mode_new(test_data): data, error, sources = test_data psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) bkgstat = MMMBackground() localbkg_estimator = LocalBackground(5, 10, bkgstat) finder = DAOStarFinder(10.0, 2.0) init_params = QTable() init_params['x'] = [54, 29, 80] init_params['y'] = [8, 26, 29] psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, mode='new', localbkg_estimator=localbkg_estimator, aperture_radius=4) phot = psfphot(data, error=error, init_params=init_params) cols = ['id', 'group_id', 'group_size', 'iter_detected', 'local_bkg'] assert phot.colnames[:5] == cols assert len(psfphot.fit_results) == 2 assert 'iter_detected' in phot.colnames assert len(phot) == len(sources) resid_data = psfphot.make_residual_image(data, fit_shape) assert isinstance(resid_data, np.ndarray) assert resid_data.shape == data.shape nddata = NDData(data) resid_nddata = psfphot.make_residual_image(nddata, fit_shape) assert isinstance(resid_nddata, NDData) assert resid_nddata.data.shape == data.shape # test that repeated calls reset the results phot = psfphot(data, error=error, init_params=init_params) assert len(psfphot.fit_results) == 2 # test NDData without units uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty) phot0 = psfphot(nddata, init_params=init_params) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert_allclose(phot0[col], phot[col]) resid_nddata = psfphot.make_residual_image(nddata, fit_shape) assert isinstance(resid_nddata, NDData) assert_equal(resid_nddata.data, resid_data) # test with units and mode='new' unit = u.Jy finder_units = DAOStarFinder(10.0 * unit, 2.0) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder_units, mode='new', localbkg_estimator=localbkg_estimator, aperture_radius=4) phot2 = psfphot(data << unit, error=error << unit, init_params=init_params) assert phot2['flux_fit'].unit == unit colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot2[col].unit == unit assert_allclose(phot2[col].value, phot[col]) # test NDData with units uncertainty = StdDevUncertainty(error << unit) nddata = NDData(data << unit, uncertainty=uncertainty) phot3 = psfphot(nddata, init_params=init_params) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot3[col].unit == unit assert_allclose(phot3[col].value, phot2[col].value) resid_nddata = psfphot.make_residual_image(nddata, fit_shape) assert isinstance(resid_nddata, NDData) assert resid_nddata.unit == unit # test return None if no stars are found on first iteration finder = DAOStarFinder(1000.0, 2.0) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, mode='new', localbkg_estimator=localbkg_estimator, aperture_radius=4) with pytest.warns(NoDetectionsWarning): phot = psfphot(data, error=error) assert phot is None @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_iterative_psf_photometry_mode_all(): sources = QTable() sources['x_0'] = [50, 45, 55, 27, 22, 77, 82] sources['y_0'] = [50, 52, 48, 27, 30, 77, 79] sources['flux'] = [1000, 100, 50, 1000, 100, 1000, 100] shape = (101, 101) psf_model = IntegratedGaussianPRF(flux=500, sigma=4.0) psf_shape = (41, 41) data = make_model_image(shape, psf_model, sources, model_shape=psf_shape) fit_shape = (5, 5) finder = DAOStarFinder(0.2, 6.0) sub_shape = psf_shape grouper = SourceGrouper(10) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, sub_shape=sub_shape, mode='all', maxiters=3) phot = psfphot(data) cols = ['id', 'group_id', 'group_size', 'iter_detected', 'local_bkg'] assert phot.colnames[:5] == cols assert len(phot) == 7 assert_equal(phot['group_id'], [1, 2, 3, 1, 2, 2, 3]) assert_equal(phot['iter_detected'], [1, 1, 1, 2, 2, 2, 2]) assert_allclose(phot['flux_fit'], [1000, 1000, 1000, 100, 50, 100, 100]) resid = psfphot.make_residual_image(data, sub_shape) assert_allclose(resid, 0, atol=1e-6) match = 'mode must be "new" or "all".' with pytest.raises(ValueError, match=match): psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, sub_shape=sub_shape, mode='invalid') match = 'grouper must be input for the "all" mode' with pytest.raises(ValueError, match=match): psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=None, aperture_radius=4, sub_shape=sub_shape, mode='all') # test with units and mode='all' unit = u.Jy finderu = DAOStarFinder(0.2 * unit, 6.0) psfphotu = IterativePSFPhotometry(psf_model, fit_shape, finder=finderu, grouper=grouper, aperture_radius=4, sub_shape=sub_shape, mode='all', maxiters=3) phot2 = psfphotu(data << unit) assert len(phot2) == 7 assert_equal(phot2['group_id'], [1, 2, 3, 1, 2, 2, 3]) assert_equal(phot2['iter_detected'], [1, 1, 1, 2, 2, 2, 2]) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot2[col].unit == unit assert_allclose(phot2[col].value, phot[col]) # test NDData with units nddata = NDData(data * unit) phot3 = psfphotu(nddata) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot3[col].unit == unit assert_allclose(phot3[col].value, phot[col]) resid_nddata = psfphotu.make_residual_image(nddata, fit_shape) assert isinstance(resid_nddata, NDData) assert resid_nddata.unit == unit @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_iterative_psf_photometry_overlap(): """ Regression test for #1769. A ValueError should not be raised for no overlap. """ sigma = 1.5 psf_model = IntegratedGaussianPRF(flux=1, sigma=sigma) data, true_params = make_psf_model_image((150, 150), psf_model, n_sources=300, model_shape=(11, 11), flux=(50, 100), min_separation=1, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=0.01, seed=0) data += noise error = np.abs(noise) slc = (slice(0, 50), slice(0, 50)) data = data[slc] error = error[slc] daofinder = DAOStarFinder(threshold=0.5, fwhm=sigma) grouper = SourceGrouper(min_separation=3.0 * sigma) psfphot = IterativePSFPhotometry(psf_model, fit_shape=(5, 5), finder=daofinder, mode='all', grouper=grouper, maxiters=3, aperture_radius=3) with pytest.warns(AstropyUserWarning): phot = psfphot(data, error=error) assert len(phot) == 38 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_iterative_psf_photometry_inputs(): psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) match = 'finder cannot be None for IterativePSFPhotometry' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4) match = 'aperture_radius cannot be None for IterativePSFPhotometry' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=None) match = 'maxiters must be a strictly-positive scalar' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=-1) with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=[1, 2]) match = 'maxiters must be an integer' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=3.14) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_negative_xy(): sources = Table() sources['id'] = np.arange(3) + 1 sources['flux'] = 1 sources['x_0'] = [-1.4, 15.2, -0.7] sources['y_0'] = [-0.3, -0.4, 18.7] sources['sigma'] = 3.1 shape = (31, 31) psf_model = IntegratedGaussianPRF(flux=1, sigma=3.1) data = make_model_image(shape, psf_model, sources) fit_shape = (11, 11) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=10) phot = psfphot(data, init_params=sources) assert_equal(phot['x_init'], sources['x_0']) assert_equal(phot['y_init'], sources['y_0']) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_out_of_bounds_centroids(): sources = Table() sources['id'] = np.arange(8) + 1 sources['flux'] = 1 sources['x_0'] = [-1.4, 34.5, 14.2, -0.7, 34.5, 14.2, 51.3, 52.0] sources['y_0'] = [13, -0.2, -1.6, 40, 51.1, 50.9, 12.2, 42.3] sources['sigma'] = 3.1 shape = (51, 51) psf_model = IntegratedGaussianPRF(flux=1, sigma=3.1) data = make_model_image(shape, psf_model, sources) fit_shape = (11, 11) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=10) phot = psfphot(data, init_params=sources) # at least one of the best-fit centroids should be # out of the bounds of the dataset, producing a # masked value in the `cfit` column: assert np.any(np.isnan(phot['cfit'])) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model(): normalize = False sigma = 3.0 amplitude = 1.0 / (2 * np.pi * sigma**2) xcen = ycen = 0.0 psf0 = Gaussian2D(amplitude, xcen, ycen, sigma, sigma) psf1 = make_psf_model(psf0, x_name='x_mean', y_name='y_mean', normalize=normalize) psf2 = make_psf_model(psf0, normalize=normalize) psf3 = make_psf_model(psf0, x_name='x_mean', normalize=normalize) psf4 = make_psf_model(psf0, y_name='y_mean', normalize=normalize) yy, xx = np.mgrid[0:101, 0:101] psf = psf1.copy() xval = 48 yval = 52 flux = 14.51 psf.x_mean_2 = xval psf.y_mean_2 = yval data = psf(xx, yy) * flux fit_shape = 7 init_params = Table([[46.1], [57.3], [7.1]], names=['x_0', 'y_0', 'flux_0']) phot1 = PSFPhotometry(psf1, fit_shape, aperture_radius=None) tbl1 = phot1(data, init_params=init_params) phot2 = PSFPhotometry(psf2, fit_shape, aperture_radius=None) tbl2 = phot2(data, init_params=init_params) phot3 = PSFPhotometry(psf3, fit_shape, aperture_radius=None) tbl3 = phot3(data, init_params=init_params) phot4 = PSFPhotometry(psf4, fit_shape, aperture_radius=None) tbl4 = phot4(data, init_params=init_params) assert_allclose((tbl1['x_fit'][0], tbl1['y_fit'][0], tbl1['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl2['x_fit'][0], tbl2['y_fit'][0], tbl2['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl3['x_fit'][0], tbl3['y_fit'][0], tbl3['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl4['x_fit'][0], tbl4['y_fit'][0], tbl4['flux_fit'][0]), (xval, yval, flux)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_move_column(): psf_model = IntegratedGaussianPRF(flux=1, sigma=2.7 / 2.35) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) tbl = QTable() tbl['a'] = [1, 2, 3] tbl['b'] = [4, 5, 6] tbl['c'] = [7, 8, 9] tbl1 = psfphot._move_column(tbl, 'a', 'c') assert tbl1.colnames == ['b', 'c', 'a'] tbl2 = psfphot._move_column(tbl, 'd', 'b') assert tbl2.colnames == ['a', 'b', 'c'] tbl3 = psfphot._move_column(tbl, 'b', 'b') assert tbl3.colnames == ['a', 'b', 'c'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/tests/test_utils.py0000644000175100001770000003644414637570305022017 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the utils module. """ import numpy as np import pytest from astropy.convolution.utils import discretize_model from astropy.modeling.fitting import LevMarLSQFitter from astropy.modeling.models import Const2D, Gaussian2D, Moffat2D from astropy.nddata import NDData from astropy.table import Table from numpy.testing import assert_allclose, assert_equal from photutils import datasets from photutils.detection import find_peaks from photutils.psf import (EPSFBuilder, IntegratedGaussianPRF, extract_stars, grid_from_epsfs, make_psf_model, make_psf_model_image) from photutils.psf.utils import (_integrate_model, _interpolate_missing_data, _InverseShift) from photutils.utils._optional_deps import HAS_SCIPY PSF_SIZE = 11 GAUSSIAN_WIDTH = 1.0 IMAGE_SIZE = 101 # Position and FLUXES of test sources SOURCES = Table([[50.0, 23, 12, 86], [50.0, 83, 80, 84], [np.pi * 10, 3.654, 20.0, 80 / np.sqrt(3)]], names=['x_0', 'y_0', 'flux_0']) # Create test psf psf_model = Gaussian2D(1.0 / (2 * np.pi * GAUSSIAN_WIDTH ** 2), PSF_SIZE // 2, PSF_SIZE // 2, GAUSSIAN_WIDTH, GAUSSIAN_WIDTH) test_psf = discretize_model(psf_model, (0, PSF_SIZE), (0, PSF_SIZE), mode='oversample') # Set up grid for test image image = np.zeros((IMAGE_SIZE, IMAGE_SIZE)) # Add sources to test image for x, y, flux in SOURCES: model = Gaussian2D(flux / (2 * np.pi * GAUSSIAN_WIDTH ** 2), x, y, GAUSSIAN_WIDTH, GAUSSIAN_WIDTH) image += discretize_model(model, (0, IMAGE_SIZE), (0, IMAGE_SIZE), mode='oversample') def test_InverseShift(): model = _InverseShift(10) assert model(1) == -9.0 assert model(-10) == -20.0 assert model.fit_deriv(10)[0] == -1.0 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_interpolate_missing_data(): data = np.arange(100).reshape(10, 10) mask = np.zeros_like(data, dtype=bool) mask[5, 5] = True data_int = _interpolate_missing_data(data, mask, method='nearest') assert 54 <= data_int[5, 5] <= 56 data_int = _interpolate_missing_data(data, mask, method='cubic') assert 54 <= data_int[5, 5] <= 56 match = "'data' must be a 2D array." with pytest.raises(ValueError, match=match): _interpolate_missing_data(np.arange(10), mask) match = "'mask' and 'data' must have the same shape." with pytest.raises(ValueError, match=match): _interpolate_missing_data(data, mask[1:, :]) match = 'Unsupported interpolation method' with pytest.raises(ValueError, match=match): _interpolate_missing_data(data, mask, method='invalid') @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_integrate_model(): model = Gaussian2D(1, 5, 5, 1, 1) * Const2D(0.0) integral = _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0') assert integral == 0.0 integral = _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', use_dblquad=True) assert integral == 0.0 match = 'dx and dy must be > 0' with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', dx=-10, dy=10) with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', dx=10, dy=-10) match = 'subsample must be >= 1' with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', subsample=-1) match = 'model x and y positions must be finite' with pytest.raises(ValueError, match=match): model = Gaussian2D(1, np.inf, 5, 1, 1) _integrate_model(model, x_name='x_mean', y_name='y_mean') @pytest.fixture(scope='module') def moffat_source(): model = Moffat2D(alpha=4.8) # this is the analytic value needed to get a total flux of 1 model.amplitude = (model.alpha - 1.0) / (np.pi * model.gamma**2) # make sure it really is normalized # assert (1.0 - integrate.dblquad(model, -10, 10, -10, 10)[0]) < 1e-6 xx, yy = np.meshgrid(*([np.linspace(-2, 2, 100)] * 2)) return model, (xx, yy, model(xx, yy)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_moffat_fitting(moffat_source): """ Test fitting with a Moffat2D model. """ model, (xx, yy, data) = moffat_source # initial Moffat2D model close to the original guess_moffat = Moffat2D(x_0=0.1, y_0=-0.05, gamma=1.05, amplitude=model.amplitude * 1.06, alpha=4.75) fitter = LevMarLSQFitter() fit = fitter(guess_moffat, xx, yy, data) assert_allclose(fit.parameters, model.parameters, rtol=0.01, atol=0.0005) # we set the tolerances in flux to be 2-3% because the guessed model # parameters are known to be wrong @pytest.mark.parametrize('kwargs, tols', [({'x_name': 'x_0', 'y_name': 'y_0', 'flux_name': None, 'normalize': True}, (1e-3, 0.02)), ({'x_name': None, 'y_name': None, 'flux_name': None, 'normalize': True}, (1e-3, 0.02)), ({'x_name': None, 'y_name': None, 'flux_name': None, 'normalize': False}, (1e-3, 0.03)), ({'x_name': 'x_0', 'y_name': 'y_0', 'flux_name': 'amplitude', 'normalize': False}, (1e-3, None))]) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model(moffat_source, kwargs, tols): model, (xx, yy, data) = moffat_source # a close-but-wrong "guessed Moffat" guess_moffat = Moffat2D(x_0=0.1, y_0=-0.05, gamma=1.01, amplitude=model.amplitude * 1.01, alpha=4.79) if kwargs['normalize']: # definitely very wrong, so this ensures the re-normalization # works guess_moffat.amplitude = 5.0 if kwargs['x_name'] is None: guess_moffat.x_0 = 0 if kwargs['y_name'] is None: guess_moffat.y_0 = 0 psf_model = make_psf_model(guess_moffat, **kwargs) fitter = LevMarLSQFitter() fit_model = fitter(psf_model, xx, yy, data) xytol, fluxtol = tols if xytol is not None: assert np.abs(getattr(fit_model, fit_model.x_name)) < xytol assert np.abs(getattr(fit_model, fit_model.y_name)) < xytol if fluxtol is not None: assert np.abs(1.0 - getattr(fit_model, fit_model.flux_name)) < fluxtol # ensure the model parameters did not change assert fit_model[2].gamma == guess_moffat.gamma assert fit_model[2].alpha == guess_moffat.alpha if kwargs['flux_name'] is None: assert fit_model[2].amplitude == guess_moffat.amplitude @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model_compound(): model = (Const2D(0.0) + Const2D(1.0) + Gaussian2D(1, 5, 5, 1, 1) * Const2D(1.0) * Const2D(1.0)) psf_model = make_psf_model(model, x_name='x_mean_2', y_name='y_mean_2', normalize=True) assert psf_model.x_name == 'x_mean_4' assert psf_model.y_name == 'y_mean_4' assert psf_model.flux_name == 'amplitude_7' def test_make_psf_model_inputs(): model = Gaussian2D(1, 5, 5, 1, 1) match = 'parameter name not found in the input model' with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean_0', y_name='y_mean') with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean', y_name='y_mean_10') @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model_integral(): model = Gaussian2D(1, 5, 5, 1, 1) * Const2D(0.0) match = 'Cannot normalize the model because the integrated flux is zero' with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean_0', y_name='y_mean_0', normalize=True) def test_make_psf_model_offset(): """ Test to ensure the offset is in the correct direction. """ moffat = Moffat2D(x_0=0, y_0=0, alpha=4.8) psfmod1 = make_psf_model(moffat.copy(), x_name='x_0', y_name='y_0', normalize=False) psfmod2 = make_psf_model(moffat.copy(), normalize=False) moffat.x_0 = 10 psfmod1.x_0_2 = 10 psfmod2.offset_0 = 10 assert moffat(10, 0) == psfmod1(10, 0) == psfmod2(10, 0) == 1.0 @pytest.mark.remote_data @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestGridFromEPSFs: """ Tests for `photutils.psf.utils.grid_from_epsfs`. """ def setup_class(self, cutout_size=25): # make a set of 4 EPSF models self.cutout_size = cutout_size # make simulated image hdu = datasets.load_simulated_hst_star_image() data = hdu.data # break up the image into four quadrants q1 = data[0:500, 0:500] q2 = data[0:500, 500:1000] q3 = data[500:1000, 0:500] q4 = data[500:1000, 500:1000] # select some starts from each quadrant to use to build the epsf quad_stars = {'q1': {'data': q1, 'fiducial': (0., 0.), 'epsf': None}, 'q2': {'data': q2, 'fiducial': (1000., 1000.), 'epsf': None}, 'q3': {'data': q3, 'fiducial': (1000., 0.), 'epsf': None}, 'q4': {'data': q4, 'fiducial': (0., 1000.), 'epsf': None}} for q in ['q1', 'q2', 'q3', 'q4']: quad_data = quad_stars[q]['data'] peaks_tbl = find_peaks(quad_data, threshold=500.) # filter out sources near edge size = cutout_size hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (quad_data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (quad_data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = peaks_tbl['x_peak'][mask] stars_tbl['y'] = peaks_tbl['y_peak'][mask] stars = extract_stars(NDData(quad_data), stars_tbl, size=cutout_size) epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, progress_bar=False) epsf, fitted_stars = epsf_builder(stars) # set x_0, y_0 to fiducial point epsf.y_0 = quad_stars[q]['fiducial'][0] epsf.x_0 = quad_stars[q]['fiducial'][1] quad_stars[q]['epsf'] = epsf self.epsfs = [quad_stars[x]['epsf'] for x in quad_stars] self.grid_xypos = [quad_stars[x]['fiducial'] for x in quad_stars] def test_basic_test_grid_from_epsfs(self): psf_grid = grid_from_epsfs(self.epsfs) assert np.all(psf_grid.oversampling == self.epsfs[0].oversampling) assert psf_grid.data.shape == (4, psf_grid.oversampling[0] * 25 + 1, psf_grid.oversampling[1] * 25 + 1) def test_grid_xypos(self): """ Test both options for setting PSF locations. """ # default option x_0 and y_0s on input EPSFs psf_grid = grid_from_epsfs(self.epsfs) assert psf_grid.meta['grid_xypos'] == [(0.0, 0.0), (1000.0, 1000.0), (0.0, 1000.0), (1000.0, 0.0)] # or pass in a list grid_xypos = [(250.0, 250.0), (750.0, 750.0), (250.0, 750.0), (750.0, 250.0)] psf_grid = grid_from_epsfs(self.epsfs, grid_xypos=grid_xypos) assert psf_grid.meta['grid_xypos'] == grid_xypos def test_meta(self): """ Test the option for setting 'meta'. """ keys = ['grid_xypos', 'oversampling', 'fill_value'] # when 'meta' isn't provided, there should be just three keys psf_grid = grid_from_epsfs(self.epsfs) for key in keys: assert key in psf_grid.meta # when meta is provided, those new keys should exist and anything # in the list above should be overwritten meta = {'grid_xypos': 0.0, 'oversampling': 0.0, 'fill_value': -999, 'extra_key': 'extra'} psf_grid = grid_from_epsfs(self.epsfs, meta=meta) for key in keys + ['extra_key']: assert key in psf_grid.meta assert psf_grid.meta['grid_xypos'].sort() == self.grid_xypos.sort() assert_equal(psf_grid.meta['oversampling'], [4, 4]) assert psf_grid.meta['fill_value'] == 0.0 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model_image(): shape = (401, 451) n_sources = 100 model = IntegratedGaussianPRF(sigma=2.7 / 2.35) data, params = make_psf_model_image(shape, model, n_sources) assert data.shape == shape assert isinstance(params, Table) assert len(params) == n_sources model_shape = (13, 13) data2, params2 = make_psf_model_image(shape, model, n_sources, model_shape=model_shape) assert_equal(data, data2) assert len(params2) == n_sources flux = (100, 200) sigma = (1, 2) alpha = (0, 1) n_sources = 10 data, params = make_psf_model_image(shape, model, n_sources, seed=0, flux=flux, sigma=sigma, alpha=alpha) assert len(params) == n_sources colnames = ('id', 'x_0', 'y_0', 'flux', 'sigma') for colname in colnames: assert colname in params.colnames assert 'alpha' not in params.colnames assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] assert np.min(params['sigma']) >= sigma[0] assert np.max(params['sigma']) <= sigma[1] @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model_image_custom(): shape = (401, 451) n_sources = 100 model = Gaussian2D() psf_model = make_psf_model(model, x_name='x_mean', y_name='y_mean') data, params = make_psf_model_image(shape, psf_model, n_sources, model_shape=(11, 11)) assert data.shape == shape assert isinstance(params, Table) assert len(params) == n_sources @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_psf_model_image_inputs(): shape = (50, 50) match = 'psf_model must be an Astropy Model subclass' with pytest.raises(TypeError, match=match): make_psf_model_image(shape, None, 2) match = 'psf_model must be two-dimensional' with pytest.raises(ValueError, match=match): model = IntegratedGaussianPRF(sigma=2.7 / 2.35) model.n_inputs = 3 make_psf_model_image(shape, model, 2) match = 'model_shape must be specified if the model does not have' with pytest.raises(ValueError, match=match): model = IntegratedGaussianPRF(sigma=2.7 / 2.35) model.bounding_box = None make_psf_model_image(shape, model, 2) match = 'Invalid PSF model - could not find PSF parameter names' with pytest.raises(ValueError, match=match): model = Gaussian2D() make_psf_model_image(shape, model, 2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/psf/utils.py0000644000175100001770000007016714637570305017616 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides utilities for PSF-fitting photometry. """ import re import numpy as np from astropy.modeling import CompoundModel, Model from astropy.modeling.models import Const2D, Identity, Shift from astropy.nddata import NDData from photutils.datasets import make_model_image, make_model_params from photutils.utils._parameters import as_pair __all__ = ['make_psf_model', 'grid_from_epsfs', 'make_psf_model_image'] __doctest_requires__ = {('make_psf_model', 'make_psf_model_image'): ['scipy']} def _interpolate_missing_data(data, mask, method='cubic'): """ Interpolate missing data as identified by the ``mask`` keyword. Parameters ---------- data : 2D `~numpy.ndarray` An array containing the 2D image. mask : 2D bool `~numpy.ndarray` A 2D boolean mask array with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. The masked data points are those that will be interpolated. method : {'cubic', 'nearest'}, optional The method of used to interpolate the missing data: * ``'cubic'``: Masked data are interpolated using 2D cubic splines. This is the default. * ``'nearest'``: Masked data are interpolated using nearest-neighbor interpolation. Returns ------- data_interp : 2D `~numpy.ndarray` The interpolated 2D image. """ from scipy import interpolate data_interp = np.array(data, copy=True) if len(data_interp.shape) != 2: raise ValueError("'data' must be a 2D array.") if mask.shape != data.shape: raise ValueError("'mask' and 'data' must have the same shape.") y, x = np.indices(data_interp.shape) xy = np.dstack((x[~mask].ravel(), y[~mask].ravel()))[0] z = data_interp[~mask].ravel() if method == 'nearest': interpol = interpolate.NearestNDInterpolator(xy, z) elif method == 'cubic': interpol = interpolate.CloughTocher2DInterpolator(xy, z) else: raise ValueError('Unsupported interpolation method.') xy_missing = np.dstack((x[mask].ravel(), y[mask].ravel()))[0] data_interp[mask] = interpol(xy_missing) return data_interp class _InverseShift(Shift): """ A model that is the inverse of the normal `astropy.modeling.functional_models.Shift` model. """ @staticmethod def evaluate(x, offset): return x - offset @staticmethod def fit_deriv(x, *params): """ One dimensional Shift model derivative with respect to parameter. """ d_offset = -np.ones_like(x) return [d_offset] def _integrate_model(model, x_name=None, y_name=None, dx=50, dy=50, subsample=100, use_dblquad=False): """ Integrate a model over a 2D grid. By default, the model is discretized on a grid of size ``dx`` x ``dy`` from the model center with a subsampling factor of ``subsample``. The model is then integrated over the grid using trapezoidal integration. If the ``use_dblquad`` keyword is set to `True`, then the model is integrated using `scipy.integrate.dblquad`. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Also, note that the ``dblquad`` integration can sometimes fail, e.g., return zero for a non-zero model. This can happen when the model function is sharply localized relative to the size of the integration interval. Parameters ---------- model : `~astropy.modeling.Fittable2DModel` The Astropy 2D model. x_name : str or `None`, optional The name of the ``model`` parameter that corresponds to the x-axis center of the PSF. This parameter is required if ``use_dblquad`` is `False` and ignored if ``use_dblquad`` is `True`. y_name : str or `None`, optional The name of the ``model`` parameter that corresponds to the y-axis center of the PSF. This parameter is required if ``use_dblquad`` is `False` and ignored if ``use_dblquad`` is `True`. dx, dy : odd int, optional The size of the integration grid in x and y. Must be odd. These keywords are ignored if ``use_dblquad`` is `True`. subsample : int, optional The subsampling factor for the integration grid along each axis. Each pixel will be sampled ``subsample`` x ``subsample`` times. This keyword is ignored if ``use_dblquad`` is `True`. use_dblquad : bool, optional If `True`, then use `scipy.integrate.dblquad` to integrate the model. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Returns ------- integral : float The integral of the model over the 2D grid. """ if use_dblquad: from scipy.integrate import dblquad return dblquad(model, -np.inf, np.inf, -np.inf, np.inf)[0] from scipy.integrate import trapezoid if dx <= 0 or dy <= 0: raise ValueError('dx and dy must be > 0') if subsample < 1: raise ValueError('subsample must be >= 1') xc = getattr(model, x_name) yc = getattr(model, y_name) if np.any(~np.isfinite((xc.value, yc.value))): raise ValueError('model x and y positions must be finite') hx = (dx - 1) / 2 hy = (dy - 1) / 2 nxpts = int(dx * subsample) nypts = int(dy * subsample) xvals = np.linspace(xc - hx, xc + hx, nxpts) yvals = np.linspace(yc - hy, yc + hy, nypts) # evaluate the model on the subsampled grid data = model(xvals.reshape(-1, 1), yvals.reshape(1, -1)) # now integrate over the subsampled grid (first over x, then over y) int_func = trapezoid return int_func([int_func(row, xvals) for row in data], yvals) def _shift_model_param(model, param_name, shift=2): if isinstance(model, CompoundModel): # for CompoundModel, add "shift" to the parameter suffix out = re.search(r'(.*)_([\d]*)$', param_name) new_name = out.groups()[0] + '_' + str(int(out.groups()[1]) + 2) else: # simply add the shift to the parameter name new_name = param_name + '_' + str(shift) return new_name def make_psf_model(model, *, x_name=None, y_name=None, flux_name=None, normalize=True, dx=50, dy=50, subsample=100, use_dblquad=False): """ Make a PSF model that can be used with the PSF photometry classes (`PSFPhotometry` or `IterativePSFPhotometry`) from an Astropy fittable 2D model. If the ``x_name``, ``y_name``, or ``flux_name`` keywords are input, this function will map those ``model`` parameter names to ``x_0``, ``y_0``, or ``flux``, respectively. If any of the ``x_name``, ``y_name``, or ``flux_name`` keywords are `None`, then a new parameter will be added to the model corresponding to the missing parameter. Any new position parameters will be set to a default value of 0, and any new flux parameter will be set to a default value of 1. The output PSF model will have ``x_name``, ``y_name``, and ``flux_name`` attributes that contain the name of the corresponding model parameter. .. note:: This function is needed only in cases where the 2D PSF model does not have ``x_0``, ``y_0``, and ``flux`` parameters. It is *not* needed for any of the PSF models provided by Photutils (e.g., `~photutils.psf.GriddedPSFModel`, `~photutils.psf.IntegratedGaussianPRF`). Parameters ---------- model : `~astropy.modeling.Fittable2DModel` An Astropy fittable 2D model to use as a PSF. x_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the x center of the PSF. If `None`, the model will be assumed to be centered at x=0, and a new model parameter called ``xpos_0`` will be added for the x position. y_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the y center of the PSF. If `None`, the model will be assumed to be centered at y=0, and a new parameter called ``ypos_1`` will be added for the y position. flux_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the total flux of a source. If `None`, a new model parameter called ``flux_3`` will be added for model flux. normalize : bool, optional If `True`, the input ``model`` will be integrated and rescaled so that its sum integrates to 1. This normalization occurs only once for the input ``model``. If the total flux of ``model`` somehow depends on (x, y) position, then one will need to correct the fitted model fluxes for this effect. dx, dy : odd int, optional The size of the integration grid in x and y for normalization. Must be odd. These keywords are ignored if ``normalize`` is `False` or ``use_dblquad`` is `True`. subsample : int, optional The subsampling factor for the integration grid along each axis for normalization. Each pixel will be sampled ``subsample`` x ``subsample`` times. This keyword is ignored if ``normalize`` is `False` or ``use_dblquad`` is `True`. use_dblquad : bool, optional If `True`, then use `scipy.integrate.dblquad` to integrate the model for normalization. This is *much* slower than the default integration of the evaluated model, but it is more accurate. This keyword is ignored if ``normalize`` is `False`. Returns ------- result : `~astropy.modeling.CompoundModel` A PSF model that can be used with the PSF photometry classes. The returned model will always be an Astropy compound model. Notes ----- To normalize the model, by default it is discretized on a grid of size ``dx`` x ``dy`` from the model center with a subsampling factor of ``subsample``. The model is then integrated over the grid using trapezoidal integration. If the ``use_dblquad`` keyword is set to `True`, then the model is integrated using `scipy.integrate.dblquad`. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Also, note that the ``dblquad`` integration can sometimes fail, e.g., return zero for a non-zero model. This can happen when the model function is sharply localized relative to the size of the integration interval. Examples -------- >>> from astropy.modeling.models import Gaussian2D >>> from photutils.psf import make_psf_model >>> model = Gaussian2D(x_stddev=2, y_stddev=2) >>> psf_model = make_psf_model(model, x_name='x_mean', y_name='y_mean') >>> print(psf_model.param_names) # doctest: +SKIP ('amplitude_2', 'x_mean_2', 'y_mean_2', 'x_stddev_2', 'y_stddev_2', 'theta_2', 'amplitude_3', 'amplitude_4') """ input_model = model.copy() if x_name is None: x_model = _InverseShift(0, name='x_position') # "offset" is the _InverseShift parameter name; # the x inverse shift model is always the first submodel x_name = 'offset_0' else: if x_name not in input_model.param_names: raise ValueError(f'{x_name!r} parameter name not found in the ' 'input model.') x_model = Identity(1) x_name = _shift_model_param(input_model, x_name, shift=2) if y_name is None: y_model = _InverseShift(0, name='y_position') # "offset" is the _InverseShift parameter name; # the y inverse shift model is always the second submodel y_name = 'offset_1' else: if y_name not in input_model.param_names: raise ValueError(f'{y_name!r} parameter name not found in the ' 'input model.') y_model = Identity(1) y_name = _shift_model_param(input_model, y_name, shift=2) x_model.fittable = True y_model.fittable = True psf_model = (x_model & y_model) | input_model if flux_name is None: psf_model *= Const2D(1.0, name='flux') # "amplitude" is the Const2D parameter name; # the flux scaling is always the last component flux_name = psf_model.param_names[-1] else: flux_name = _shift_model_param(input_model, flux_name, shift=2) if normalize: integral = _integrate_model(psf_model, x_name=x_name, y_name=y_name, dx=dx, dy=dy, subsample=subsample, use_dblquad=use_dblquad) if integral == 0: raise ValueError('Cannot normalize the model because the ' 'integrated flux is zero.') psf_model *= Const2D(1.0 / integral, name='normalization_scaling') # fix all the output model parameters that are not x, y, or flux for name in psf_model.param_names: psf_model.fixed[name] = name not in (x_name, y_name, flux_name) # final check that the x, y, and flux parameter names are in the # output model names = (x_name, y_name, flux_name) for name in names: if name not in psf_model.param_names: raise ValueError(f'{name!r} parameter name not found in the ' 'output model.') # set the parameter names for the PSF photometry classes psf_model.x_name = x_name psf_model.y_name = y_name psf_model.flux_name = flux_name # set aliases psf_model.x_0 = getattr(psf_model, x_name) psf_model.y_0 = getattr(psf_model, y_name) psf_model.flux = getattr(psf_model, flux_name) return psf_model def grid_from_epsfs(epsfs, grid_xypos=None, meta=None): """ Create a GriddedPSFModel from a list of EPSFModels. Given a list of EPSFModels, this function will return a GriddedPSFModel. The fiducial points for each input EPSFModel can either be set on each individual model by setting the 'x_0' and 'y_0' attributes, or provided as a list of tuples (``grid_xypos``). If a ``grid_xypos`` list is provided, it must match the length of input EPSFs. In either case, the fiducial points must be on a grid. Optionally, a ``meta`` dictionary may be provided for the output GriddedPSFModel. If this dictionary contains the keys 'grid_xypos', 'oversampling', or 'fill_value', they will be overridden. Note: If set on the input EPSFModel (x_0, y_0), then ``origin`` must be the same for each input EPSF. Additionally data units and dimensions must be for each input EPSF, and values for ``flux`` and ``oversampling``, and ``fill_value`` must match as well. Parameters ---------- epsfs : list of `photutils.psf.models.EPSFModel` A list of EPSFModels representing the individual PSFs. grid_xypos : list, optional A list of fiducial points (x_0, y_0) for each PSF. If not provided, the x_0 and y_0 of each input EPSF will be considered the fiducial point for that PSF. Default is None. meta : dict, optional Additional metadata for the GriddedPSFModel. Note that, if they exist in the supplied ``meta``, any values under the keys ``grid_xypos`` , ``oversampling``, or ``fill_value`` will be overridden. Default is None. Returns ------- GriddedPSFModel: `photutils.psf.GriddedPSFModel` The gridded PSF model created from the input EPSFs. """ # prevent circular imports from photutils.psf import EPSFModel, GriddedPSFModel # optional, to store fiducial from input if `grid_xypos` is None x_0s = [] y_0s = [] data_arrs = [] oversampling = None fill_value = None dat_unit = None origin = None flux = None # make sure, if provided, that ``grid_xypos`` is the same length as # ``epsfs`` if grid_xypos is not None: if len(grid_xypos) != len(epsfs): raise ValueError('``grid_xypos`` must be the same length as ' '``epsfs``.') # loop over input once for i, epsf in enumerate(epsfs): # check input type if not isinstance(epsf, EPSFModel): raise ValueError('All input `epsfs` must be of type ' '`photutils.psf.models.EPSFModel`.') # get data array from EPSF data_arrs.append(epsf.data) if i == 0: oversampling = epsf.oversampling # same for fill value and flux, grid will have a single value # so it should be the same for all input, and error if not. fill_value = epsf.fill_value # check that origins are the same if grid_xypos is None: origin = epsf.origin flux = epsf.flux # if there's a unit, those should also all be the same try: dat_unit = epsf.data.unit except AttributeError: pass # just keep as None else: if np.any(epsf.oversampling != oversampling): raise ValueError('All input EPSFModels must have the same ' 'value for ``oversampling``.') if epsf.fill_value != fill_value: raise ValueError('All input EPSFModels must have the same ' 'value for ``fill_value``.') if epsf.data.ndim != data_arrs[0].ndim: raise ValueError('All input EPSFModels must have data with ' 'the same dimensions.') try: unitt = epsf.data_unit if unitt != dat_unit: raise ValueError('All input data must have the same unit.') except AttributeError as exc: if dat_unit is not None: raise ValueError('All input data must have the same ' 'unit.') from exc if epsf.flux != flux: raise ValueError('All input EPSFModels must have the same ' 'value for ``flux``.') if grid_xypos is None: # get gridxy_pos from x_0, y_0 if not provided x_0s.append(epsf.x_0.value) y_0s.append(epsf.y_0.value) # also check that origin is the same, if using x_0s and y_0s # from input if epsf.origin != origin: raise ValueError('If using ``x_0``, ``y_0`` as fiducial point,' '``origin`` must match for each input EPSF.') # if not supplied, use from x_0, y_0 of input EPSFs as fiducuals # these are checked when GriddedPSFModel is created to make sure they # are actually on a grid. if grid_xypos is None: grid_xypos = list(zip(x_0s, y_0s)) data_cube = np.stack(data_arrs, axis=0) if meta is None: meta = {} # add required keywords to meta meta['grid_xypos'] = grid_xypos meta['oversampling'] = oversampling meta['fill_value'] = fill_value data = NDData(data_cube, meta=meta) grid = GriddedPSFModel(data, fill_value=fill_value) return grid def _validate_psf_model(psf_model): """ Validate the PSF model. The PSF model must be a subclass of `astropy .modeling.Fittable2DModel`. It must also be two-dimensional and have a single output. Parameters ---------- psf_model : `astropy.modeling.Fittable2DModel` The PSF model to validate. Returns ------- psf_model : `astropy.modeling.Model` The validated PSF model. Raises ------ TypeError If the PSF model is not an Astropy Model subclass. ValueError If the PSF model is not two-dimensional with n_inputs=2 and n_outputs=1. """ if not isinstance(psf_model, Model): raise TypeError('psf_model must be an Astropy Model subclass.') if psf_model.n_inputs != 2 or psf_model.n_outputs != 1: raise ValueError('psf_model must be two-dimensional with ' 'n_inputs=2 and n_outputs=1.') return psf_model def _get_psf_model_params(psf_model): """ Get the names of the PSF model parameters corresponding to x, y, and flux. The PSF model must have parameters called 'x_0', 'y_0', and 'flux' or it must have 'x_name', 'y_name', and 'flux_name' attributes (i.e., output from `make_psf_model`). Otherwise, a `ValueError` is raised. The PSF model must be a subclass of `astropy.modeling.Model`. It must also be two-dimensional and have a single output. Parameters ---------- psf_model : `astropy.modeling.Model` The PSF model to validate. Returns ------- model_params : tuple A tuple of the PSF model parameter names. """ psf_model = _validate_psf_model(psf_model) params1 = ('x_0', 'y_0', 'flux') params2 = ('x_name', 'y_name', 'flux_name') if all(name in psf_model.param_names for name in params1): model_params = params1 elif all(params := [getattr(psf_model, name, None) for name in params2]): model_params = tuple(params) else: msg = 'Invalid PSF model - could not find PSF parameter names.' raise ValueError(msg) return model_params def make_psf_model_image(shape, psf_model, n_sources, *, model_shape=None, min_separation=1, border_size=None, seed=0, progress_bar=False, **kwargs): """ Make an example image containing PSF model images. Source parameters are randomly generated using an optional ``seed``. Parameters ---------- shape : 2-tuple of int The shape of the output image. psf_model : 2D `astropy.modeling.Model` The PSF model. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. n_sources : int The number of sources to generate. If ``min_separation`` is too large, the number of requested sources may not fit within the given ``shape`` and therefore the number of sources generated may be less than ``n_sources``. model_shape : `None` or 2-tuple of int, optional The shape around the center (x, y) position that will used to evaluate the ``psf_model``. If `None`, then the shape will be determined from the ``psf_model`` bounding box (an error will be raised if the model does not have a bounding box). min_separation : float, optional The minimum separation between the centers of two sources. Note that if the minimum separation is too large, the number of sources generated may be less than ``n_sources``. border_size : `None`, tuple of 2 int, or int, optional The (ny, nx) size of the exclusion border around the image edges where no sources will be generated that have centers within the border region. If a single integer is provided, it will be used for both dimensions. If `None`, then a border size equal to half the (y, x) size of the evaluated PSF model (taking any oversampling into account) will be used. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. progress_bar : bool, optional Whether to display a progress bar when creating the sources. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. **kwargs Keyword arguments are accepted for additional model parameters. The values should be 2-tuples of the lower and upper bounds for the parameter range. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. If the parameter is not in the input ``psf_model`` parameter names, it will be ignored. Returns ------- data : 2D `~numpy.ndarray` The simulated image. table : `~astropy.table.Table` A table containing the (x, y, flux) parameters of the generated sources. The column names will correspond to the names of the input ``psf_model`` (x, y, flux) parameter names. The table will also contain an ``'id'`` column with unique source IDs Examples -------- >>> from photutils.psf import IntegratedGaussianPRF, make_psf_model_image >>> shape = (150, 200) >>> psf_model= IntegratedGaussianPRF(sigma=1.5) >>> n_sources = 10 >>> data, params = make_psf_model_image(shape, psf_model, n_sources, ... flux=(100, 250), ... min_separation=10, ... seed=0) >>> params['x_0'].info.format = '.4f' # optional format >>> params['y_0'].info.format = '.4f' >>> params['flux'].info.format = '.4f' >>> print(params) # doctest: +FLOAT_CMP id x_0 y_0 flux --- -------- -------- -------- 1 125.4749 72.2784 147.9522 2 57.1803 38.6027 128.1262 3 14.6211 116.0558 200.8790 4 10.0741 132.6001 129.2661 5 158.2683 43.1937 186.6532 6 176.7725 80.2951 190.3359 7 142.6864 133.6184 244.3635 8 108.1142 12.5095 110.8398 9 180.9235 106.5528 174.9959 10 158.7488 90.5548 211.6146 .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import IntegratedGaussianPRF, make_psf_model_image shape = (150, 200) psf_model= IntegratedGaussianPRF(sigma=1.5) n_sources = 10 data, params = make_psf_model_image(shape, psf_model, n_sources, flux=(100, 250), min_separation=10, seed=0) plt.imshow(data, origin='lower') .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import IntegratedGaussianPRF, make_psf_model_image shape = (150, 200) psf_model= IntegratedGaussianPRF(sigma=1.5) n_sources = 10 data, params = make_psf_model_image(shape, psf_model, n_sources, flux=(100, 250), min_separation=10, seed=0, sigma=(1, 2)) plt.imshow(data, origin='lower') """ psf_params = _get_psf_model_params(psf_model) if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 1)) else: try: bbox = psf_model.bounding_box.bounding_box() model_shape = (int(np.round(bbox[0][1] - bbox[0][0])), int(np.round(bbox[1][1] - bbox[1][0]))) except NotImplementedError: raise ValueError('model_shape must be specified if the model ' 'does not have a bounding_box attribute') if border_size is None: border_size = (np.array(model_shape) - 1) // 2 other_params = {} if kwargs: # include only kwargs that are not x, y, or flux for key, val in kwargs.items(): if key not in psf_model.param_names or key in psf_params[0:2]: continue # skip the x, y parameters other_params[key] = val x_name, y_name = psf_params[0:2] params = make_model_params(shape, n_sources, x_name=x_name, y_name=y_name, min_separation=min_separation, border_size=border_size, seed=seed, **other_params) data = make_model_image(shape, psf_model, params, model_shape=model_shape, x_name=x_name, y_name=y_name, progress_bar=progress_bar) return data, params ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.042885 photutils-1.13.0/photutils/segmentation/0000755000175100001770000000000014637570322017775 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/__init__.py0000644000175100001770000000073314637570305022112 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for detecting sources using image segmentation and measuring their centroids, photometry, and morphological properties. """ from .catalog import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .deblend import * # noqa: F401, F403 from .detect import * # noqa: F401, F403 from .finder import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/catalog.py0000644000175100001770000040604714637570305021775 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating the properties of sources defined by a segmentation image. """ import functools import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.stats import SigmaClip, gaussian_fwhm_to_sigma from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture, RectangularAnnulus) from photutils.background import SExtractorBackground from photutils.centroids import centroid_quadratic from photutils.segmentation.core import SegmentationImage from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._progress_bars import add_progress_bar from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import CutoutImage __all__ = ['SourceCatalog'] __doctest_requires__ = {('SourceCatalog', 'SourceCatalog.*'): ['scipy']} # default table columns for `to_table()` output DEFAULT_COLUMNS = ['label', 'xcentroid', 'ycentroid', 'sky_centroid', 'bbox_xmin', 'bbox_xmax', 'bbox_ymin', 'bbox_ymax', 'area', 'semimajor_sigma', 'semiminor_sigma', 'orientation', 'eccentricity', 'min_value', 'max_value', 'local_background', 'segment_flux', 'segment_fluxerr', 'kron_flux', 'kron_fluxerr'] def as_scalar(method): """ Return a scalar value from a method if the class is scalar. Note that lazyproperties that begin with '_' should not have this decorator applied. Such properties are assumed to always be iterable and when slicing (see __getitem__) from a cached multi-object catalog to create a single-object catalog, they will no longer be scalar. """ @functools.wraps(method) def _as_scalar(*args, **kwargs): result = method(*args, **kwargs) try: return (result[0] if args[0].isscalar and len(result) == 1 else result) except TypeError: # if result has no len return result return _as_scalar def use_detcat(method): """ Return the value from the detection image catalog instead of using the method to calculate it. """ @functools.wraps(method) def _use_detcat(self, *args, **kwargs): if self._detection_cat is None: return method(self, *args, **kwargs) else: return getattr(self._detection_cat, method.__name__) return _use_detcat class SourceCatalog: r""" Class to create a catalog of photometry and morphological properties for sources defined by a segmentation image. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The 2D array from which to calculate the source photometry and properties. If ``convolved_data`` is input, then a convolved version of ``data`` will be used instead of ``data`` to calculate the source centroid and morphological properties. Source photometry is always measured from ``data``. For accurate source properties and photometry, ``data`` should be background-subtracted. Non-finite ``data`` values (NaN and inf) are automatically masked. segment_img : `~photutils.segmentation.SegmentationImage` A `~photutils.segmentation.SegmentationImage` object defining the sources. convolved_data : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The 2D array used to calculate the source centroid and morphological properties. Typically, ``convolved_data`` should be the input ``data`` array convolved by the same smoothing kernel that was applied to the detection image when deriving the source segments (e.g., see :func:`~photutils.segmentation.detect_sources`). If ``convolved_data`` is `None`, then the unconvolved ``data`` will be used instead. Non-finite ``convolved_data`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The total error array corresponding to the input ``data`` array. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``error`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Non-finite ``error`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. See the Notes section below for details on the error propagation. mask : 2D `~numpy.ndarray` (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Non-finite values (NaN and inf) in the input ``data`` are automatically masked. background : float, 2D `~numpy.ndarray`, or `~astropy.units.Quantity`, optional The background level that was *previously* present in the input ``data``. ``background`` may either be a scalar value or a 2D image with the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``background`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Inputing the ``background`` merely allows for its properties to be measured within each source segment. The input ``background`` does *not* get subtracted from the input ``data``, which should already be background-subtracted. Non-finite ``background`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then all sky-based properties will be set to `None`. This keyword will be ignored if ``detection_cat`` is input. localbkg_width : int, optional The width of the rectangular annulus used to compute a local background around each source. If zero, then no local background subtraction is performed. The local background affects the ``min_value``, ``max_value``, ``segment_flux``, ``kron_flux``, and ``fluxfrac_radius`` properties. It is also used when calculating circular and Kron aperture photometry (i.e., `circular_photometry` and `kron_photometry`). It does not affect the moment-based morphological properties of the source. apermask_method : {'correct', 'mask', 'none'}, optional The method used to handle neighboring sources when performing aperture photometry (e.g., circular apertures or elliptical Kron apertures). This parameter also affects the Kron radius. The options are: * 'correct': replace pixels assigned to neighboring sources by replacing them with pixels on the opposite side of the source center (equivalent to MASK_TYPE=CORRECT in SourceExtractor). * 'mask': mask pixels assigned to neighboring sources (equivalent to MASK_TYPE=BLANK in SourceExtractor). * 'none': do not mask any pixels (equivalent to MASK_TYPE=NONE in SourceExtractor). This keyword will be ignored if ``detection_cat`` is input. kron_params : tuple of 2 or 3 floats, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. This keyword will be ignored if ``detection_cat`` is input. detection_cat : `SourceCatalog`, optional A `SourceCatalog` object for the detection image. The segmentation image used to create the detection catalog must be the same one input to ``segment_img``. If input, then the detection catalog source centroids and morphological/shape properties will be returned instead of calculating them from the input ``data``. The detection catalog centroids and shape properties will also be used to perform aperture photometry (i.e., circular and Kron). If ``detection_cat`` is input, then the input ``wcs``, ``apermask_method``, and ``kron_params`` keywords will be ignored. This keyword affects `circular_photometry` (including returned apertures), all Kron parameters (Kron radius, flux, flux errors, apertures, and custom `kron_photometry`), and `fluxfrac_radius` (which is based on the Kron flux). progress_bar : bool, optional Whether to display a progress bar when calculating some properties (e.g., ``kron_radius``, ``kron_flux``, ``fluxfrac_radius``, ``circular_photometry``, ``centroid_win``, ``centroid_quad``). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- ``data`` should be background-subtracted for accurate source photometry and properties. The previously-subtracted background can be passed into this class to calculate properties of the background for each source. Note that this class does not convert input data in surface-brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this class. function returns the sum of the (weighted) input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. `SourceExtractor`_'s centroid and morphological parameters are always calculated from a convolved, or filtered, "detection" image (``convolved_data``), i.e., the image used to define the segmentation image. The usual downside of the filtering is the sources will be made more circular than they actually are. If you wish to reproduce `SourceExtractor`_ centroid and morphology results, then input the ``convolved_data`` If ``convolved_data`` and ``kernel`` are both `None`, then the unfiltered ``data`` will be used for the source centroid and morphological parameters. Negative data values within the source segment are set to zero when calculating morphological properties based on image moments. Negative values could occur, for example, if the segmentation image was defined from a different image (e.g., different bandpass) or if the background was oversubtracted. However, `~photutils.segmentation.SourceCatalog.segment_flux` always includes the contribution of negative ``data`` values. The input ``error`` array is assumed to include *all* sources of error, including the Poisson error of the sources. `~photutils.segmentation.SourceCatalog.segment_fluxerr` is simply the quadrature sum of the pixel-wise total errors over the unmasked pixels within the source segment: .. math:: \Delta F = \sqrt{\sum_{i \in S} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is `~photutils.segmentation.SourceCatalog.segment_fluxerr`, :math:`S` are the unmasked pixels in the source segment, and :math:`\sigma_{\mathrm{tot}, i}` is the input ``error`` array. Custom errors for source segments can be calculated using the `~photutils.segmentation.SourceCatalog.error_ma` and `~photutils.segmentation.SourceCatalog.background_ma` properties, which are 2D `~numpy.ma.MaskedArray` cutout versions of the input ``error`` and ``background`` arrays. The mask is `True` for pixels outside of the source segment, masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ def __init__(self, data, segment_img, *, convolved_data=None, error=None, mask=None, background=None, wcs=None, localbkg_width=0, apermask_method='correct', kron_params=(2.5, 1.4, 0.0), detection_cat=None, progress_bar=False): inputs = (data, convolved_data, error, background) names = ('data', 'convolved_data', 'error', 'background') inputs, unit = process_quantities(inputs, names) (data, convolved_data, error, background) = inputs self._data_unit = unit self._data = self._validate_array(data, 'data', shape=False) self._convolved_data = self._validate_array(convolved_data, 'convolved_data') self._segment_img = self._validate_segment_img(segment_img) self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._background = self._validate_array(background, 'background') self.wcs = wcs self.localbkg_width = self._validate_localbkg_width(localbkg_width) self.apermask_method = self._validate_apermask_method(apermask_method) self.kron_params = self._validate_kron_params(kron_params) self.progress_bar = progress_bar # needed for ordering and isscalar # NOTE: calculate slices before labels for performance. # _labels is initially always a non-scalar array, but # it can become a numpy scalar after indexing/slicing. self._slices = self._segment_img.slices self._labels = self._segment_img.labels if self._labels.shape == (0,): raise ValueError('segment_img must have at least one non-zero ' 'label.') self._detection_cat = self._validate_detection_cat(detection_cat) attrs = ('wcs', 'apermask_method', 'kron_params') if self._detection_cat is not None: for attr in attrs: setattr(self, attr, getattr(self._detection_cat, attr)) if convolved_data is None: self._convolved_data = self._data self._apermask_kwargs = { 'circ': {'method': 'exact'}, 'kron': {'method': 'exact'}, 'fluxfrac': {'method': 'exact'}, 'cen_win': {'method': 'center'} } self.default_columns = DEFAULT_COLUMNS self._extra_properties = [] self.meta = _get_meta() self._update_meta() def _validate_segment_img(self, segment_img): if not isinstance(segment_img, SegmentationImage): raise TypeError('segment_img must be a SegmentationImage') if segment_img.shape != self._data.shape: raise ValueError('segment_img and data must have the same shape.') return segment_img def _validate_array(self, array, name, shape=True): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: # UFuncTypeError is raised when subtracting float # local_background from int data; convert to float array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if shape and array.shape != self._data.shape: raise ValueError(f'data and {name} must have the same shape.') return array @staticmethod def _validate_localbkg_width(localbkg_width): if localbkg_width < 0: raise ValueError('localbkg_width must be >= 0') localbkg_width_int = int(localbkg_width) if localbkg_width_int != localbkg_width: raise ValueError('localbkg_width must be an integer') return localbkg_width_int @staticmethod def _validate_apermask_method(apermask_method): if apermask_method not in ('none', 'mask', 'correct'): raise ValueError('Invalid apermask_method value') return apermask_method @staticmethod def _validate_kron_params(kron_params): if np.ndim(kron_params) != 1: raise ValueError('kron_params must by 1D') nparams = len(kron_params) if nparams not in (2, 3): raise ValueError('kron_params must have 2 or 3 elements') if kron_params[0] <= 0: raise ValueError('kron_params[0] must be > 0') if kron_params[1] <= 0: raise ValueError('kron_params[1] must be > 0') if nparams == 3 and kron_params[2] < 0: raise ValueError('kron_params[2] must be >= 0') return tuple(kron_params) def _validate_detection_cat(self, detection_cat): if detection_cat is None: return None if not isinstance(detection_cat, SourceCatalog): raise TypeError('detection_cat must be a SourceCatalog ' 'instance') if not np.array_equal(detection_cat._segment_img, self._segment_img): raise ValueError('detection_cat must have same segment_img as ' 'the input segment_img') return detection_cat def _update_meta(self): attrs = ('localbkg_width', 'apermask_method', 'kron_params') for attr in attrs: self.meta[attr] = getattr(self, attr) def _set_semode(self): # SE emulation self._apermask_kwargs = { 'circ': {'method': 'subpixel', 'subpixels': 5}, 'kron': {'method': 'center'}, 'fluxfrac': {'method': 'subpixel', 'subpixels': 5}, 'cen_win': {'method': 'subpixel', 'subpixels': 11} } @property def _properties(self): """ A list of all class properties, include lazyproperties (even in superclasses). """ def isproperty(obj): return isinstance(obj, property) return [i[0] for i in inspect.getmembers(self.__class__, predicate=isproperty)] @property def properties(self): """ A list of built-in source properties. """ lazyproperties = [name for name in self._lazyproperties if not name.startswith('_')] lazyproperties.remove('isscalar') lazyproperties.remove('nlabels') lazyproperties.extend(['label', 'labels', 'slices']) lazyproperties.sort() return lazyproperties @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') newcls = object.__new__(self.__class__) # attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_segment_img', '_error', '_mask', '_background', 'wcs', '_data_unit', '_convolved_data', 'localbkg_width', 'apermask_method', 'kron_params', 'default_columns', '_extra_properties', 'meta', '_apermask_kwargs', 'progress_bar') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # _labels determines ordering and isscalar attr = '_labels' setattr(newcls, attr, getattr(self, attr)[index]) # need to slice detection_cat, if input attr = '_detection_cat' if getattr(self, attr) is None: setattr(newcls, attr, None) else: setattr(newcls, attr, getattr(self, attr)[index]) attr = '_slices' # Use a numpy object array to allow for fancy and bool indices. # NOTE: None is appended to the list (and then removed) to keep # the array only on the outer level (i.e., prevents recursion). # Otherwise, the tuple of (y, x) slices are not preserved. value = np.array(getattr(self, attr) + [None], dtype=object)[:-1][index] if not newcls.isscalar: value = value.tolist() setattr(newcls, attr, value) # evaluated lazyproperty objects and extra properties keys = (set(self.__dict__.keys()) & (set(self._lazyproperties) | set(self._extra_properties))) for key in keys: value = self.__dict__[key] # do not insert attributes that are always scalar (e.g., # isscalar, nlabels), i.e., not an array/list for each # source if np.isscalar(value): continue try: # keep _ lazyproperties as length-1 iterables; # _ lazyproperties should not have @as_scalar applied if newcls.isscalar and key.startswith('_'): if isinstance(value, np.ndarray): val = value[:, np.newaxis][index] else: val = [value[index]] else: val = value[index] except TypeError: # apply fancy indices (e.g., array/list or bool # mask) to lists val = (np.array(value + [None], dtype=object)[:-1][index]).tolist() newcls.__dict__[key] = val return newcls def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' with np.printoptions(threshold=25, edgeitems=5): fmt = [f'Length: {self.nlabels}', f'labels: {self.labels}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: raise TypeError(f'Scalar {self.__class__.__name__!r} object has ' 'no len()') return self.nlabels def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self._labels.shape == () @staticmethod def _has_len(value): if isinstance(value, str): return False try: # NOTE: cannot just check for __len__ attribute, because # it could exist, but raise an Exception for scalar objects len(value) except TypeError: return False return True def copy(self): """ Return a deep copy of this SourceCatalog. """ return deepcopy(self) @property def extra_properties(self): """ A list of the user-defined source properties. """ return self._extra_properties def add_extra_property(self, name, value, overwrite=False): """ Add a user-defined extra property as a new attribute. For example, the property ``name`` can then be included in the `to_table` ``columns`` keyword list to output the results in the table. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- name : str The name of property. The name must not conflict with any of the built-in property names or attributes. value : array_like or float The value to assign. overwrite : bool, option If `True`, will overwrite the existing property ``name``. """ internal_attributes = ((set(self.__dict__.keys()) | set(self._properties)) - set(self.extra_properties)) if name in internal_attributes: raise ValueError(f'{name} cannot be set because it is a ' 'built-in attribute') if not overwrite: if hasattr(self, name): raise ValueError(f'{name} already exists as an attribute. ' 'Set overwrite=True to overwrite an existing ' 'attribute.') if name in self._extra_properties: raise ValueError(f'{name} already exists in the ' '"extra_properties" attribute list.') property_error = False if self.isscalar: # this allows fluxfrac_radius to add len-1 array values for # scalar self if self._has_len(value) and len(value) == 1: value = value[0] if hasattr(value, 'isscalar'): # e.g., Quantity, SkyCoord, Time if not value.isscalar: property_error = True else: if not np.isscalar(value): property_error = True else: if not self._has_len(value) or len(value) != self.nlabels: property_error = True if property_error: raise ValueError('value must have the same number of elements as ' 'the catalog in order to add it as an extra ' 'property.') setattr(self, name, value) if not overwrite: self._extra_properties.append(name) def remove_extra_property(self, name): """ Remove a user-defined extra property. The property must have been defined using `add_extra_property`. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- name : str The name of the property to remove. """ self.remove_extra_properties(name) def remove_extra_properties(self, names): """ Remove user-defined extra properties. The properties must have been defined using `add_extra_property`. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- names : list of str The names of the properties to remove. """ names = np.atleast_1d(names) for name in names: if name in self._extra_properties: delattr(self, name) self._extra_properties.remove(name) else: raise ValueError(f'{name} is not a defined extra property.') def rename_extra_property(self, name, new_name): """ Rename a user-defined extra property. The renamed property will remain at the same index in the `extra_properties` list. Parameters ---------- name : str The old attribute name. new_name : str The new attribute name. """ self.add_extra_property(new_name, getattr(self, name)) idx = self.extra_properties.index(name) self.remove_extra_property(name) # preserve the order of self.extra_properties self.extra_properties.remove(new_name) self.extra_properties.insert(idx, new_name) @lazyproperty def _null_objects(self): """ Return `None` values. For example, this is used for SkyCoord properties if ``wcs`` is `None`. """ return np.array([None] * self.nlabels) @lazyproperty def _null_values(self): """ Return np.nan values. For example, this is used for background properties if ``background`` is `None`. """ values = np.empty(self.nlabels) values.fill(np.nan) return values @lazyproperty def _data_cutouts(self): """ A list of data cutouts using the segmentation image slices. """ return [self._data[slc] for slc in self._slices_iter] @lazyproperty def _segment_img_cutouts(self): """ A list of segmentation image cutouts using the segmentation image slices. """ return [self._segment_img.data[slc] for slc in self._slices_iter] @lazyproperty def _mask_cutouts(self): """ A list of mask cutouts using the segmentation image slices. If the input ``mask`` is None then a list of None is returned. """ if self._mask is None: return self._null_objects return [self._mask[slc] for slc in self._slices_iter] @lazyproperty def _error_cutouts(self): """ A list of error cutouts using the segmentation image slices. If the input ``mask`` is None then a list of None is returned. """ if self._error is None: return self._null_objects return [self._error[slc] for slc in self._slices_iter] @lazyproperty def _convdata_cutouts(self): """ A list of convolved data cutouts using the segmentation image slices. """ return [self._convolved_data[slc] for slc in self._slices_iter] @lazyproperty def _background_cutouts(self): """ A list of background cutouts using the segmentation image slices. """ if self._background is None: return self._null_objects return [self._background[slc] for slc in self._slices_iter] @staticmethod def _make_cutout_data_mask(data_cutout, mask_cutout): """ Make a cutout data mask, combining both the input ``mask`` and non-finite ``data`` values. """ data_mask = ~np.isfinite(data_cutout) if mask_cutout is not None: data_mask |= mask_cutout return data_mask def _make_cutout_data_masks(self, data_cutouts, mask_cutouts): """ Make a list of cutout data masks, combining both the input ``mask`` and non-finite ``data`` values for each source. """ data_masks = [] for (data_cutout, mask_cutout) in zip(data_cutouts, mask_cutouts): data_masks.append(self._make_cutout_data_mask(data_cutout, mask_cutout)) return data_masks @lazyproperty def _cutout_segment_masks(self): """ Cutout boolean mask for source segment. The mask is `True` for all pixels (background and from other source segments) outside of the source segment. """ return [segm != label for label, segm in zip(self.labels, self._segment_img_cutouts)] @lazyproperty def _cutout_data_masks(self): """ Cutout boolean mask of non-finite ``data`` values combined with the input ``mask`` array. The mask is `True` for non-finite ``data`` values and where the input ``mask`` is `True`. """ return self._make_cutout_data_masks(self._data_cutouts, self._mask_cutouts) @lazyproperty def _cutout_total_masks(self): """ Boolean mask representing the combination of ``_cutout_segment_masks`` and ``_cutout_data_masks``. This mask is applied to ``data``, ``error``, and ``background`` inputs when calculating properties. """ masks = [] for mask1, mask2 in zip(self._cutout_segment_masks, self._cutout_data_masks): masks.append(mask1 | mask2) return masks @lazyproperty def _moment_data_cutouts(self): """ A list of 2D `~numpy.ndarray` cutouts from the (convolved) data. The following pixels are set to zero in these arrays: * pixels outside of the source segment * any masked pixels from the input ``mask`` * invalid convolved data values (NaN and inf) * negative convolved data values; negative pixels (especially at large radii) can give image moments that have negative variances. These arrays are used to derive moment-based properties. """ cutouts = [] for convdata_cutout, mask_cutout, segmmask_cutout in zip( self._convdata_cutouts, self._mask_cutouts, self._cutout_segment_masks): convdata_mask = (~np.isfinite(convdata_cutout) | (convdata_cutout < 0) | segmmask_cutout) if self._mask is not None: convdata_mask |= mask_cutout cutout = convdata_cutout.copy() cutout[convdata_mask] = 0.0 cutouts.append(cutout) return cutouts def _prepare_cutouts(self, arrays, units=True, masked=False, dtype=None): """ Prepare cutouts by applying optional units, masks, or dtype. """ if units and masked: raise ValueError('Both units and masked cannot be True') if dtype is not None: cutouts = [] for cutout in arrays: cutouts.append(cutout.astype(dtype, copy=True)) else: cutouts = arrays if units and self._data_unit is not None: cutouts = [(cutout << self._data_unit) for cutout in cutouts] if masked: return [np.ma.masked_array(cutout, mask=mask) for cutout, mask in zip(cutouts, self._cutout_total_masks)] return cutouts def get_label(self, label): """ Return a new `SourceCatalog` object for the input ``label`` only. Parameters ---------- label : int The source label. Returns ------- cat : `SourceCatalog` A new `SourceCatalog` object containing only the source with the input ``label``. """ return self.get_labels(label) def get_labels(self, labels): """ Return a new `SourceCatalog` object for the input ``labels`` only. Parameters ---------- labels : list, tuple, or `~numpy.ndarray` of int The source label(s). Returns ------- cat : `SourceCatalog` A new `SourceCatalog` object containing only the sources with the input ``labels``. """ self._segment_img.check_labels(labels) sorter = np.argsort(self.labels) indices = sorter[np.searchsorted(self.labels, labels, sorter=sorter)] return self[indices] def to_table(self, columns=None): """ Create a `~astropy.table.QTable` of source properties. Parameters ---------- columns : str, list of str, `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are any of the `SourceCatalog` properties or custom properties added using `add_extra_property`. If ``columns`` is `None`, then a default list of scalar-valued properties (as defined by the ``default_columns`` attribute) will be used. Returns ------- table : `~astropy.table.QTable` A table of sources properties with one row per source. """ if columns is None: table_columns = self.default_columns else: table_columns = np.atleast_1d(columns) tbl = QTable() tbl.meta.update(self.meta) # keep tbl.meta type for column in table_columns: values = getattr(self, column) # column assignment requires an object with a length if self.isscalar: values = (values,) tbl[column] = values return tbl @lazyproperty def nlabels(self): """ The number of source labels. """ return len(self.labels) @property @as_scalar def label(self): """ The source label number(s). This label number corresponds to the assigned pixel value in the `~photutils.segmentation.SegmentationImage`. """ return self._labels @property def labels(self): """ The source label number(s), always as an iterable `~numpy.ndarray`. This label number corresponds to the assigned pixel value in the `~photutils.segmentation.SegmentationImage`. """ labels = self.label if self.isscalar: labels = np.array((labels,)) return labels @property @as_scalar def slices(self): """ A tuple of slice objects defining the minimal bounding box of the source. """ return self._slices @lazyproperty def _slices_iter(self): """ A tuple of slice objects defining the minimal bounding box of the source, always as an iterable. """ _slices = self.slices if self.isscalar: _slices = (_slices,) return _slices @lazyproperty @as_scalar def segment(self): """ A 2D `~numpy.ndarray` cutout of the segmentation image using the minimal bounding box of the source. """ return self._prepare_cutouts(self._segment_img_cutouts, units=False, masked=False) @lazyproperty @as_scalar def segment_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout of the segmentation image using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._segment_img_cutouts, units=False, masked=True) @lazyproperty @as_scalar def data(self): """ A 2D `~numpy.ndarray` cutout from the data using the minimal bounding box of the source. """ return self._prepare_cutouts(self._data_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def data_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the data using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._data_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def convdata(self): """ A 2D `~numpy.ndarray` cutout from the convolved data using the minimal bounding box of the source. """ return self._prepare_cutouts(self._convdata_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def convdata_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the convolved data using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._convdata_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def error(self): """ A 2D `~numpy.ndarray` cutout from the error array using the minimal bounding box of the source. """ if self._error is None: return self._null_objects return self._prepare_cutouts(self._error_cutouts, units=True, masked=False) @lazyproperty @as_scalar def error_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the error array using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ if self._error is None: return self._null_objects return self._prepare_cutouts(self._error_cutouts, units=False, masked=True) @lazyproperty @as_scalar def background(self): """ A 2D `~numpy.ndarray` cutout from the background array using the minimal bounding box of the source. """ if self._background is None: return self._null_objects return self._prepare_cutouts(self._background_cutouts, units=True, masked=False) @lazyproperty @as_scalar def background_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the background array. using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ if self._background is None: return self._null_objects return self._prepare_cutouts(self._background_cutouts, units=False, masked=True) @lazyproperty def _all_masked(self): """ True if all pixels over the source segment are masked. """ return np.array([np.all(mask) for mask in self._cutout_total_masks]) def _get_values(self, array): """ Get a 1D array of unmasked values from the input array within the source segment. An array with a single NaN is returned for completely-masked sources. """ if self.isscalar: array = (array,) return [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in array] @lazyproperty def _data_values(self): """ A 1D array of unmasked data values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.data_ma) @lazyproperty def _error_values(self): """ A 1D array of unmasked error values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.error_ma) @lazyproperty def _background_values(self): """ A 1D array of unmasked background values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.background_ma) @lazyproperty @use_detcat @as_scalar def moments(self): """ Spatial moments up to 3rd order of the source. """ return np.array([_moments(arr, order=3) for arr in self._moment_data_cutouts]) @lazyproperty @use_detcat @as_scalar def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ cutout_centroid = self.cutout_centroid if self.isscalar: cutout_centroid = cutout_centroid[np.newaxis, :] return np.array([_moments_central(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutouts, cutout_centroid[:, 0], cutout_centroid[:, 1])]) @lazyproperty @use_detcat @as_scalar def cutout_centroid(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the centroid within the isophotal source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ moments = self.moments if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((xcentroid, ycentroid)) @lazyproperty @use_detcat @as_scalar def centroid(self): """ The ``(x, y)`` coordinate of the centroid within the isophotal source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid + origin @lazyproperty @use_detcat def _xcentroid(self): """ The ``x`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: xcentroid = self.centroid[0:1] # scalar array else: xcentroid = self.centroid[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def xcentroid(self): """ The ``x`` coordinate of the `centroid` within the source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ return self._xcentroid @lazyproperty @use_detcat def _ycentroid(self): """ The ``y`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: ycentroid = self.centroid[1:2] # scalar array else: ycentroid = self.centroid[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def ycentroid(self): """ The ``y`` coordinate of the `centroid` within the source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ return self._ycentroid @lazyproperty @use_detcat @as_scalar def centroid_win(self): """ The ``(x, y)`` coordinate of the "windowed" centroid. The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE and YWIN_IMAGE parameters. Notes ----- On each iteration, the centroid is calculated using all pixels within a circular aperture of ``4 * sigma`` from the current position, weighting pixel values with a 2D Gaussian with a standard deviation of ``sigma``. ``sigma`` is the half-light radius (i.e., ``flucfrac_radius(0.5)``) times (2.0 / 2.35). A minimum half-light radius of 0.5 pixels is used. Iteration stops when the change in centroid position falls below a pre-defined threshold or a maximum number of iterations is reached. If the windowed centroid falls outside of the 1-sigma ellipse shape based on the image moments, then the isophotal `centroid` will be used instead. """ radius_hl = self.fluxfrac_radius(0.5).value if self.isscalar: radius_hl = np.array([radius_hl]) min_radius = 0.5 # define minimum half-light radius mask = (radius_hl < min_radius) | ~np.isfinite(radius_hl) radius_hl[mask] = min_radius kwargs = self._apermask_kwargs['cen_win'] labels = self.labels if self.progress_bar: # pragma: no cover desc = 'centroid_win' labels = add_progress_bar(labels, desc=desc) xcen_win = [] ycen_win = [] for label, xcen, ycen, rad_hl in zip(labels, self._xcentroid, self._ycentroid, radius_hl): if np.any(~np.isfinite((xcen, ycen))): xcen_win.append(np.nan) ycen_win.append(np.nan) continue sigma = 2.0 * rad_hl * gaussian_fwhm_to_sigma sigma2 = sigma**2 radius = 4.0 * sigma iter_ = 0 dcen = 1 max_iters = 16 centroid_threshold = 0.0001 while iter_ < max_iters and dcen > centroid_threshold: aperture = CircularAperture((xcen, ycen), radius) aperture_mask = aperture.to_mask(**kwargs) # for consistency with the isophotal centroid, a local # background is not subtracted here data, _, mask, cutout_xycen, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, 0.0, make_error=False) if data is None: # return NaN if centroid moves the aperture # completely off the image xcen = np.nan ycen = np.nan break aperture_weights = aperture_mask.data[slc_sm] # define a 2D Gaussian weight array xvals = np.arange(data.shape[1]) - cutout_xycen[0] yvals = np.arange(data.shape[0]) - cutout_xycen[1] xx, yy = np.meshgrid(xvals, yvals) rr2 = xx**2 + yy**2 gweight = np.exp(-rr2 / (2.0 * sigma2)) # ignore multiplication with non-finite values # and ignore divide-by-zero if moments[0, 0] = 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) data = data * aperture_weights * gweight data[mask] = 0.0 moments = _moments_central(data, center=cutout_xycen, order=1) dy = moments[1, 0] / moments[0, 0] dx = moments[0, 1] / moments[0, 0] dcen = np.sqrt(dx**2 + dy**2) xcen += dx * 2.0 ycen += dy * 2.0 iter_ += 1 xcen_win.append(xcen) ycen_win.append(ycen) xcen_win = np.array(xcen_win) ycen_win = np.array(ycen_win) # reset to the isophotal centroid if the windowed centroid is # outside of the 1-sigma ellipse dx = self._xcentroid - xcen_win dy = self._ycentroid - ycen_win cxx = self.cxx.value cxy = self.cxy.value cyy = self.cyy.value if self.isscalar: cxx = (cxx,) cxy = (cxy,) cyy = (cyy,) mask = ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1) mask |= (np.isnan(xcen_win) | np.isnan(ycen_win)) if np.any(mask): xcen_win[mask] = self._xcentroid[mask] ycen_win[mask] = self._ycentroid[mask] return np.transpose((xcen_win, ycen_win)) @lazyproperty @use_detcat @as_scalar def xcentroid_win(self): """ The ``x`` coordinate of the "windowed" centroid (`centroid_win`). The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE parameters. """ if self.isscalar: xcentroid = self.centroid_win[0] # scalar array else: xcentroid = self.centroid_win[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def ycentroid_win(self): """ The ``y`` coordinate of the "windowed" centroid (`centroid_win`). The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s YWIN_IMAGE parameters. """ if self.isscalar: ycentroid = self.centroid_win[1] # scalar array else: ycentroid = self.centroid_win[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def cutout_centroid_win(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the "windowed" centroid. The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE and YWIN_IMAGE parameters. See `centroid_win` for further details about the algorithm. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.centroid_win - origin @lazyproperty @use_detcat @as_scalar def cutout_centroid_quad(self): """ The ``(x, y)`` centroid coordinate, relative to the cutout data, calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. Notes ----- `~photutils.centroids.centroid_quadratic` is used to calculate the centroid with ``fit_boxsize=3``. Because this centroid is based on fitting data, it can fail for many reasons including: * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) In these cases, then the isophotal `centroid` will be used instead. Also note that a fit is not performed if the maximum data value is at the edge of the source segment. In this case, the position of the maximum pixel will be returned. """ centroid_quad = [] with warnings.catch_warnings(): # ignore fit warnings: # - quadratic fit failed # - quadratic fit does not have a maximum # - quadratic fit maximum falls outside image # - not enough unmasked data points (6 are required) # - maximum value is at the edge of the data warnings.simplefilter('ignore', AstropyUserWarning) cutouts = self._data_cutouts if self.progress_bar: # pragma: no cover desc = 'centroid_quad' cutouts = add_progress_bar(cutouts, desc=desc) for data, mask in zip(cutouts, self._cutout_total_masks): try: centroid = centroid_quadratic(data, mask=mask, fit_boxsize=3) except ValueError: centroid = (np.nan, np.nan) centroid_quad.append(centroid) centroid_quad = np.array(centroid_quad) # use the segment barycenter if fit returned NaN nan_mask = (np.isnan(centroid_quad[:, 0]) | np.isnan(centroid_quad[:, 1])) if np.any(nan_mask): centroid_quad[nan_mask] = self.cutout_centroid[nan_mask] return centroid_quad @lazyproperty @use_detcat @as_scalar def centroid_quad(self): """ The ``(x, y)`` centroid coordinate, calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. Notes ----- `~photutils.centroids.centroid_quadratic` is used to calculate the centroid with ``fit_boxsize=3``. Because this centroid is based on fitting data, it can fail for many reasons, returning (np.nan, np.nan): * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) Also note that a fit is not performed if the maximum data value is at the edge of the source segment. In this case, the position of the maximum pixel will be returned. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid_quad + origin @lazyproperty @use_detcat @as_scalar def xcentroid_quad(self): """ The ``x`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. """ if self.isscalar: xcentroid = self.centroid_quad[0] # scalar array else: xcentroid = self.centroid_quad[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def ycentroid_quad(self): """ The ``y`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. """ if self.isscalar: ycentroid = self.centroid_quad[1] # scalar array else: ycentroid = self.centroid_quad[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def sky_centroid(self): """ The sky coordinate of the `centroid` within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid, self.ycentroid) @lazyproperty @use_detcat @as_scalar def sky_centroid_icrs(self): """ The sky coordinate in the International Celestial Reference System (ICRS) frame of the `centroid` within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.sky_centroid.icrs @lazyproperty @use_detcat @as_scalar def sky_centroid_win(self): """ The sky coordinate of the "windowed" centroid (`centroid_win`) within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid_win, self.ycentroid_win) @lazyproperty @use_detcat @as_scalar def sky_centroid_quad(self): """ The sky coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid_quad, self.ycentroid_quad) @lazyproperty @use_detcat def _bbox(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region containing the source segment, always as an iterable. """ return [BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop, iymin=slc[0].start, iymax=slc[0].stop) for slc in self._slices_iter] @lazyproperty @use_detcat @as_scalar def bbox(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region containing the source segment. """ return self._bbox @lazyproperty @use_detcat @as_scalar def bbox_xmin(self): """ The minimum ``x`` pixel index within the minimal bounding box containing the source segment. """ return np.array([slc[1].start for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_xmax(self): """ The maximum ``x`` pixel index within the minimal bounding box containing the source segment. Note that this value is inclusive, unlike numpy slice indices. """ return np.array([slc[1].stop - 1 for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_ymin(self): """ The minimum ``y`` pixel index within the minimal bounding box containing the source segment. """ return np.array([slc[0].start for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_ymax(self): """ The maximum ``y`` pixel index within the minimal bounding box containing the source segment. Note that this value is inclusive, unlike numpy slice indices. """ return np.array([slc[0].stop - 1 for slc in self._slices_iter]) @lazyproperty @use_detcat def _bbox_corner_ll(self): """ Lower-left *outside* pixel corner location (not index). """ xypos = [] for bbox_ in self._bbox: xypos.append((bbox_.ixmin - 0.5, bbox_.iymin - 0.5)) return np.array(xypos) @lazyproperty @use_detcat def _bbox_corner_ul(self): """ Upper-left *outside* pixel corner location (not index). """ xypos = [] for bbox_ in self._bbox: xypos.append((bbox_.ixmin - 0.5, bbox_.iymax + 0.5)) return np.array(xypos) @lazyproperty @use_detcat def _bbox_corner_lr(self): """ Lower-right *outside* pixel corner location (not index). """ xypos = [] for bbox_ in self._bbox: xypos.append((bbox_.ixmax + 0.5, bbox_.iymin - 0.5)) return np.array(xypos) @lazyproperty @use_detcat def _bbox_corner_ur(self): """ Upper-right *outside* pixel corner location (not index). """ xypos = [] for bbox_ in self._bbox: xypos.append((bbox_.ixmax + 0.5, bbox_.iymax + 0.5)) return np.array(xypos) @lazyproperty @use_detcat @as_scalar def sky_bbox_ll(self): """ The sky coordinates of the lower-left corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ll)) @lazyproperty @use_detcat @as_scalar def sky_bbox_ul(self): """ The sky coordinates of the upper-left corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ul)) @lazyproperty @use_detcat @as_scalar def sky_bbox_lr(self): """ The sky coordinates of the lower-right corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_lr)) @lazyproperty @use_detcat @as_scalar def sky_bbox_ur(self): """ The sky coordinates of the upper-right corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ur)) @lazyproperty @as_scalar def min_value(self): """ The minimum pixel value of the ``data`` within the source segment. """ values = np.array([np.min(array) for array in self._data_values]) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty @as_scalar def max_value(self): """ The maximum pixel value of the ``data`` within the source segment. """ values = np.array([np.max(array) for array in self._data_values]) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty @as_scalar def cutout_minval_index(self): """ The ``(y, x)`` coordinate, relative to the cutout data, of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ data = self.data_ma if self.isscalar: data = (data,) idx = [] for arr in data: if np.all(arr.mask): idx.append((np.nan, np.nan)) else: idx.append(np.unravel_index(np.argmin(arr), arr.shape)) return np.array(idx) @lazyproperty @as_scalar def cutout_maxval_index(self): """ The ``(y, x)`` coordinate, relative to the cutout data, of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ data = self.data_ma if self.isscalar: data = (data,) idx = [] for arr in data: if np.all(arr.mask): idx.append((np.nan, np.nan)) else: idx.append(np.unravel_index(np.argmax(arr), arr.shape)) return np.array(idx) @lazyproperty @as_scalar def minval_index(self): """ The ``(y, x)`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ index = self.cutout_minval_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def maxval_index(self): """ The ``(y, x)`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ index = self.cutout_maxval_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def minval_xindex(self): """ The ``x`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ if self.isscalar: xidx = self.minval_index[1] else: xidx = self.minval_index[:, 1] return xidx @lazyproperty @as_scalar def minval_yindex(self): """ The ``y`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ if self.isscalar: yidx = self.minval_index[0] else: yidx = self.minval_index[:, 0] return yidx @lazyproperty @as_scalar def maxval_xindex(self): """ The ``x`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ if self.isscalar: xidx = self.maxval_index[1] else: xidx = self.maxval_index[:, 1] return xidx @lazyproperty @as_scalar def maxval_yindex(self): """ The ``y`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ if self.isscalar: yidx = self.maxval_index[0] else: yidx = self.maxval_index[:, 0] return yidx @lazyproperty @as_scalar def segment_flux(self): r""" The sum of the unmasked ``data`` values within the source segment. .. math:: F = \sum_{i \in S} I_i where :math:`F` is ``segment_flux``, :math:`I_i` is the background-subtracted ``data``, and :math:`S` are the unmasked pixels in the source segment. Non-finite pixel values (NaN and inf) are excluded (automatically masked). """ localbkg = self._local_background if self.isscalar: localbkg = localbkg[0] source_sum = np.array([np.sum(arr) for arr in self._data_values]) source_sum -= self.area.value * localbkg if self._data_unit is not None: source_sum <<= self._data_unit return source_sum @lazyproperty @as_scalar def segment_fluxerr(self): r""" The uncertainty of `segment_flux` , propagated from the input ``error`` array. ``segment_fluxerr`` is the quadrature sum of the total errors over the unmasked pixels within the source segment: .. math:: \Delta F = \sqrt{\sum_{i \in S} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `segment_flux`, :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total errors (``error``), and :math:`S` are the unmasked pixels in the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. """ if self._error is None: err = self._null_values else: err = np.sqrt(np.array([np.sum(arr**2) for arr in self._error_values])) if self._data_unit is not None: err <<= self._data_unit return err @lazyproperty @as_scalar def background_sum(self): """ The sum of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. """ if self._background is None: bkg_sum = self._null_values else: bkg_sum = np.array([np.sum(arr) for arr in self._background_values]) if self._data_unit is not None: bkg_sum <<= self._data_unit return bkg_sum @lazyproperty @as_scalar def background_mean(self): """ The mean of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. """ if self._background is None: bkg_mean = self._null_values else: bkg_mean = np.array([np.mean(arr) for arr in self._background_values]) if self._data_unit is not None: bkg_mean <<= self._data_unit return bkg_mean @lazyproperty @as_scalar def background_centroid(self): """ The value of the per-pixel ``background`` at the position of the isophotal (center-of-mass) `centroid`. If ``detection_cat`` is input, then its `centroid` will be used. The background value at fractional position values are determined using bilinear interpolation. """ if self._background is None: bkg = self._null_values else: from scipy.ndimage import map_coordinates xcen = self._xcentroid ycen = self._ycentroid bkg = map_coordinates(self._background, (xcen, ycen), order=1, mode='nearest') mask = np.isfinite(xcen) & np.isfinite(ycen) bkg[~mask] = np.nan if self._data_unit is not None: bkg <<= self._data_unit return bkg @lazyproperty @use_detcat @as_scalar def segment_area(self): """ The total area of the source segment in units of pixels**2. This area is simply the area of the source segment from the input ``segment_img``. It does not take into account any data masking (i.e., a ``mask`` input to `SourceCatalog` or invalid ``data`` values). """ areas = [] for label, slices in zip(self.labels, self._slices_iter): areas.append(np.count_nonzero(self._segment_img[slices] == label)) return np.array(areas) << (u.pix**2) @lazyproperty @use_detcat @as_scalar def area(self): """ The total unmasked area of the source in units of pixels**2. Note that the source area may be smaller than its `segment_area` if a mask is input to `SourceCatalog` or if the ``data`` within the segment contains invalid values (NaN and inf). """ areas = np.array([arr.size for arr in self._data_values]).astype(float) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @use_detcat @as_scalar def equivalent_radius(self): """ The radius of a circle with the same `area` as the source segment. """ return np.sqrt(self.area / np.pi) @lazyproperty @use_detcat @as_scalar def perimeter(self): """ The perimeter of the source segment, approximated as the total length of lines connecting the centers of the border pixels defined by a 4-pixel connectivity. If any masked pixels make holes within the source segment, then the perimeter around the inner hole (e.g., an annulus) will also contribute to the total perimeter. References ---------- .. [1] K. Benkrid, D. Crookes, and A. Benkrid. "Design and FPGA Implementation of a Perimeter Estimator". Proceedings of the Irish Machine Vision and Image Processing Conference, pp. 51-57 (2000). """ from scipy.ndimage import binary_erosion, convolve footprint = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) kernel = np.array([[10, 2, 10], [2, 1, 2], [10, 2, 10]]) size = 34 weights = np.zeros(size, dtype=float) weights[[5, 7, 15, 17, 25, 27]] = 1.0 weights[[21, 33]] = np.sqrt(2.0) weights[[13, 23]] = (1 + np.sqrt(2.0)) / 2.0 perimeter = [] for mask in self._cutout_total_masks: if np.all(mask): perimeter.append(np.nan) continue data = ~mask data_eroded = binary_erosion(data, footprint, border_value=0) border = np.logical_xor(data, data_eroded).astype(int) perimeter_data = convolve(border, kernel, mode='constant', cval=0) perimeter_hist = np.bincount(perimeter_data.ravel(), minlength=size) perimeter.append(perimeter_hist[0:size] @ weights) return np.array(perimeter) * u.pix @lazyproperty @use_detcat @as_scalar def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] mu_02 = moments[:, 0, 2] mu_11 = -moments[:, 1, 1] mu_20 = moments[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) * u.pix**2 @lazyproperty @use_detcat def _covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source, always as an iterable. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mu_norm = moments / moments[:, 0, 0][:, np.newaxis, np.newaxis] covar = np.array([mu_norm[:, 0, 2], mu_norm[:, 1, 1], mu_norm[:, 1, 1], mu_norm[:, 2, 0]]).swapaxes(0, 1) covar = covar.reshape((covar.shape[0], 2, 2)) # Modify the covariance matrix in the case of "infinitely" thin # detections. This follows SourceExtractor's prescription of # incrementally increasing the diagonal elements by 1/12. delta = 1.0 / 12 delta2 = delta**2 # ignore RuntimeWarning from NaN values in covar with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) # covariance should be positive semidefinite idx = np.where(covar_det < 0)[0] covar[idx] = np.array([[np.nan, np.nan], [np.nan, np.nan]]) idx = np.where(covar_det < delta2)[0] while idx.size > 0: covar[idx, 0, 0] += delta covar[idx, 1, 1] += delta covar_det = np.linalg.det(covar) idx = np.where(covar_det < delta2)[0] return covar @lazyproperty @use_detcat @as_scalar def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. """ return self._covariance * (u.pix**2) @lazyproperty @use_detcat @as_scalar def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ eigvals = np.empty((self.nlabels, 2)) eigvals.fill(np.nan) # np.linalg.eigvals requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvals(self._covariance[idx]) # check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) # pragma: no cover eigvals[idx2] = (np.nan, np.nan) # pragma: no cover # sort each eigenvalue pair in descending order eigvals.sort(axis=1) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @use_detcat @as_scalar def semimajor_sigma(self): """ The 1-sigma standard deviation along the semimajor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's A parameter return np.sqrt(eigvals[:, 0]) @lazyproperty @use_detcat @as_scalar def semiminor_sigma(self): """ The 1-sigma standard deviation along the semiminor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's B parameter return np.sqrt(eigvals[:, 1]) @lazyproperty @use_detcat @as_scalar def fwhm(self): r""" The circularized full width at half maximum (FWHM) of the 2D Gaussian function that has the same second-order central moments as the source. .. math:: \mathrm{FWHM} & = 2 \sqrt{2 \ln(2)} \sqrt{0.5 (a^2 + b^2)} \\ & = 2 \sqrt{\ln(2) \ (a^2 + b^2)} where :math:`a` and :math:`b` are the 1-sigma lengths of the semimajor (`semimajor_sigma`) and semiminor (`semiminor_sigma`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_sigma**2 + self.semiminor_sigma**2)) @lazyproperty @use_detcat @as_scalar def orientation(self): """ The angle between the ``x`` axis and the major axis of the 2D Gaussian function that has the same second-order moments as the source. The angle increases in the counter-clockwise direction. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return orient_radians * 180.0 / np.pi * u.deg @lazyproperty @use_detcat @as_scalar def eccentricity(self): r""" The eccentricity of the 2D Gaussian function that has the same second-order moments as the source. The eccentricity is the fraction of the distance along the semimajor axis at which the focus lies. .. math:: e = \sqrt{1 - \frac{b^2}{a^2}} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ semimajor_var, semiminor_var = np.transpose(self.covariance_eigvals) return np.sqrt(1.0 - (semiminor_var / semimajor_var)) @lazyproperty @use_detcat @as_scalar def elongation(self): r""" The ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{elongation} = \frac{a}{b} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return self.semimajor_sigma / self.semiminor_sigma @lazyproperty @use_detcat @as_scalar def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{ellipticity} = \frac{a - b}{a} = 1 - \frac{b}{a} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return 1.0 - (self.semiminor_sigma / self.semimajor_sigma) @lazyproperty @use_detcat @as_scalar def covar_sigx2(self): r""" The ``(0, 0)`` element of the `covariance` matrix, representing :math:`\sigma_x^2`, in units of pixel**2. """ return self._covariance[:, 0, 0] * u.pix**2 @lazyproperty @use_detcat @as_scalar def covar_sigy2(self): r""" The ``(1, 1)`` element of the `covariance` matrix, representing :math:`\sigma_y^2`, in units of pixel**2. """ return self._covariance[:, 1, 1] * u.pix**2 @lazyproperty @use_detcat @as_scalar def covar_sigxy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_x \sigma_y`, in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @lazyproperty @use_detcat @as_scalar def cxx(self): r""" `SourceExtractor`_'s CXX ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.cos(self.orientation) / self.semimajor_sigma)**2 + (np.sin(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @use_detcat @as_scalar def cyy(self): r""" `SourceExtractor`_'s CYY ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return ((np.sin(self.orientation) / self.semimajor_sigma)**2 + (np.cos(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @use_detcat @as_scalar def cxy(self): r""" `SourceExtractor`_'s CXY ellipse parameter in units of pixel**(-2). The ellipse is defined as .. math:: cxx (x - \bar{x})^2 + cxy (x - \bar{x}) (y - \bar{y}) + cyy (y - \bar{y})^2 = R^2 where :math:`R` is a parameter which scales the ellipse (in units of the axes lengths). `SourceExtractor`_ reports that the isophotal limit of a source is well represented by :math:`R \approx 3`. """ return (2.0 * np.cos(self.orientation) * np.sin(self.orientation) * ((1.0 / self.semimajor_sigma**2) - (1.0 / self.semiminor_sigma**2))) @lazyproperty @use_detcat @as_scalar def gini(self): r""" The `Gini coefficient `_ of the source. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over pixel values :math:`x_i` within the source segment. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. """ gini = [] for arr in self._data_values: if np.all(np.isnan(arr)): gini.append(np.nan) continue npix = np.size(arr) normalization = np.abs(np.mean(arr)) * npix * (npix - 1) kernel = ((2.0 * np.arange(1, npix + 1) - npix - 1) * np.abs(np.sort(arr))) gini.append(np.sum(kernel) / normalization) return np.array(gini) @lazyproperty def _local_background_apertures(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. """ if self.localbkg_width == 0: return self._null_objects apertures = [] for bbox_ in self._bbox: xpos = 0.5 * (bbox_.ixmin + bbox_.ixmax - 1) ypos = 0.5 * (bbox_.iymin + bbox_.iymax - 1) scale = 1.5 width_in = (bbox_.ixmax - bbox_.ixmin) * scale width_out = width_in + 2 * self.localbkg_width height_in = (bbox_.iymax - bbox_.iymin) * scale height_out = height_in + 2 * self.localbkg_width apertures.append(RectangularAnnulus((xpos, ypos), width_in, width_out, height_out, height_in, theta=0.0)) return apertures @lazyproperty @use_detcat @as_scalar def local_background_aperture(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. """ return self._local_background_apertures @lazyproperty def _local_background(self): """ The local background value (per pixel) estimated using a rectangular annulus aperture around the source. Pixels are masked where the input ``mask`` is `True`, where the input ``data`` is non-finite, and within any non-zero pixel label in the segmentation image. This property is always an `~numpy.ndarray` without units. """ if self.localbkg_width == 0: local_bkgs = np.zeros(self.nlabels) else: sigma_clip = SigmaClip(sigma=3.0, cenfunc='median', maxiters=20) bkg_func = SExtractorBackground(sigma_clip) bkg_apers = self._local_background_apertures local_bkgs = [] for aperture in bkg_apers: aperture_mask = aperture.to_mask(method='center') slc_lg, slc_sm = aperture_mask.get_overlap_slices( self._data.shape) data_cutout = self._data[slc_lg].astype(float, copy=True) # all non-zero segment labels are masked segm_mask_cutout = self._segment_img.data[slc_lg].astype(bool) if self._mask is None: mask_cutout = None else: mask_cutout = self._mask[slc_lg] data_mask_cutout = self._make_cutout_data_mask(data_cutout, mask_cutout) data_mask_cutout |= segm_mask_cutout aperweight_cutout = aperture_mask.data[slc_sm] good_mask = (aperweight_cutout > 0) & ~data_mask_cutout data_cutout *= aperweight_cutout data_values = data_cutout[good_mask] # 1D array # check not enough unmasked pixels if len(data_values) < 10: # pragma: no cover local_bkgs.append(0.0) continue local_bkgs.append(bkg_func(data_values)) local_bkgs = np.array(local_bkgs) local_bkgs[self._all_masked] = np.nan return local_bkgs @lazyproperty @as_scalar def local_background(self): """ The local background value (per pixel) estimated using a rectangular annulus aperture around the source. """ bkg = self._local_background if self._data_unit is not None: bkg <<= self._data_unit return bkg def _make_aperture_data(self, label, xcentroid, ycentroid, aperture_bbox, local_background, make_error=True): """ Make cutouts of data, error, and mask arrays for aperture photometry (e.g., circular or Kron). Neighboring sources can be included, masked, or corrected based on the ``apermask_method`` keyword. """ # make cutouts of the data based on the aperture bbox slc_lg, slc_sm = aperture_bbox.get_overlap_slices(self._data.shape) if slc_lg is None: return (None,) * 5 data = self._data[slc_lg].astype(float) - local_background if self._mask is None: mask_cutout = None else: mask_cutout = self._mask[slc_lg] data_mask = self._make_cutout_data_mask(data, mask_cutout) if make_error and self._error is not None: error = self._error[slc_lg] else: error = None # calculate cutout centroid position cutout_xycen = (xcentroid - max(0, aperture_bbox.ixmin), ycentroid - max(0, aperture_bbox.iymin)) # mask or correct neighboring sources if self.apermask_method != 'none': segment_img = self._segment_img.data[slc_lg] segm_mask = np.logical_and(segment_img != label, segment_img != 0) if self.apermask_method == 'mask': mask = data_mask | segm_mask else: mask = data_mask if self.apermask_method == 'correct': from photutils.segmentation.utils import _mask_to_mirrored_value data = _mask_to_mirrored_value(data, segm_mask, cutout_xycen, mask=mask) if error is not None: error = _mask_to_mirrored_value(error, segm_mask, cutout_xycen, mask=mask) return data, error, mask, cutout_xycen, slc_sm def _make_circular_apertures(self, radius): """ Make circular aperture for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, it will be used for the source centroids. Parameters ---------- radius : float, 1D `~numpy.ndarray` The radius of the circle in pixels. Returns ------- result : list of `~photutils.aperture.CircularAperture` A list of `~photutils.aperture.CircularAperture` instances. The aperture will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ radius = np.broadcast_to(radius, len(self._xcentroid)) if np.any(radius <= 0): raise ValueError('radius must be > 0') apertures = [] for (xcen, ycen, radius_, all_masked) in zip(self._xcentroid, self._ycentroid, radius, self._all_masked): if all_masked or np.any(~np.isfinite((xcen, ycen, radius_))): apertures.append(None) continue apertures.append(CircularAperture((xcen, ycen), r=radius_)) return apertures @as_scalar def make_circular_apertures(self, radius): """ Make circular aperture for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Parameters ---------- radius : float The radius of the circle in pixels. Returns ------- result : `~photutils.aperture.CircularAperture` or \ list of `~photutils.aperture.CircularAperture` The circular aperture for each source. The aperture will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ return self._make_circular_apertures(radius) @as_scalar def plot_circular_apertures(self, radius, ax=None, origin=(0, 0), **kwargs): """ Plot circular apertures for each source on a matplotlib `~matplotlib.axes.Axes` instance. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. An aperture will not be plotted for sources where the source `centroid` position is not finite or where the source is completely masked. Parameters ---------- radius : float The radius of the circle in pixels. ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.Patch` or \ list of `~matplotlib.patches.Patch` The matplotlib patch for each plotted aperture. The patches can be used, for example, when adding a plot legend. """ apertures = self._make_circular_apertures(radius) patches = [] for aperture in apertures: if aperture is not None: aperture.plot(ax=ax, origin=origin, **kwargs) patches.append(aperture._to_patch(origin=origin, **kwargs)) return patches def circular_photometry(self, radius, name=None, overwrite=False): """ Perform circular aperture photometry for each source. The circular aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. Parameters ---------- radius : float The radius of the circle in pixels. name : str or `None`, optional The prefix name which will be used to define attribute names for the flux and flux error. The attribute names ``[name]_flux`` and ``[name]_fluxerr`` will store the photometry results. For example, these names can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- flux, fluxerr : float or `~numpy.ndarray` of floats The aperture fluxes and flux errors. NaN will be returned where the aperture is `None` (e.g., where the source `centroid` position is not finite or the source is completely masked). """ if radius <= 0: raise ValueError('radius must be > 0') apertures = self._make_circular_apertures(radius) kwargs = self._apermask_kwargs['circ'] flux, fluxerr = self._aperture_photometry(apertures, desc='circular_photometry', **kwargs) if self._data_unit is not None: flux <<= self._data_unit fluxerr <<= self._data_unit if self.isscalar: flux = flux[0] fluxerr = fluxerr[0] if name is not None: flux_name = f'{name}_flux' fluxerr_name = f'{name}_fluxerr' self.add_extra_property(flux_name, flux, overwrite=overwrite) self.add_extra_property(fluxerr_name, fluxerr, overwrite=overwrite) return flux, fluxerr def _make_elliptical_apertures(self, scale=6.0): """ Return a list of elliptical apertures based on the scaled isophotal shape of the sources. If a ``detection_cat`` was input to `SourceCatalog`, then its source `centroid` and shape parameters will be used. If scale is zero (due to a minimum circular radius set in ``kron_params``) then a circular aperture will be returned with the minimum circular radius. Parameters ---------- scale : float or `~numpy.ndarray`, optional The scale factor to apply to the ellipse major and minor axes. The default value of 6.0 is roughly two times the isophotal extent of the source. A `~numpy.ndarray` input must be a 1D array of length ``nlabels``. Returns ------- result : list of `~photutils.aperture.EllipticalAperture` A list of `~photutils.aperture.EllipticalAperture` instances. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ xcen = self._xcentroid ycen = self._ycentroid major_size = self.semimajor_sigma.value * scale minor_size = self.semiminor_sigma.value * scale theta = self.orientation.to(u.radian).value if self.isscalar: major_size = (major_size,) minor_size = (minor_size,) theta = (theta,) aperture = [] for values in zip(xcen, ycen, major_size, minor_size, theta, self._all_masked): if values[-1] or np.any(~np.isfinite(values[:-1])): aperture.append(None) continue # kron_radius = 0 -> scale = 0 -> major/minor_size = 0 if values[2] == 0 and values[3] == 0: aperture.append(CircularAperture((values[0], values[1]), r=self.kron_params[2])) continue (xcen_, ycen_, major_, minor_, theta_) = values[:-1] aperture.append(EllipticalAperture((xcen_, ycen_), major_, minor_, theta=theta_)) return aperture @lazyproperty @use_detcat def _measured_kron_radius(self): r""" The *unscaled* first-moment Kron radius, always as an array (without units). The returned value is the measured Kron radius without applying any minimum Kron or circular radius. """ apertures = self._make_elliptical_apertures(scale=6.0) cxx = self.cxx.value cxy = self.cxy.value cyy = self.cyy.value if self.isscalar: cxx = (cxx,) cxy = (cxy,) cyy = (cyy,) labels = self.labels if self.progress_bar: # pragma: no cover desc = 'kron_radius' labels = add_progress_bar(labels, desc=desc) kron_radius = [] for (label, aperture, cxx_, cxy_, cyy_) in zip(labels, apertures, cxx, cxy, cyy): if aperture is None: kron_radius.append(np.nan) continue xcen, ycen = aperture.positions # use 'center' (whole pixels) to compute Kron radius aperture_mask = aperture.to_mask(method='center') # prepare cutouts of the data based on the aperture size # local background explicitly set to zero for SE agreement data, _, mask, xycen, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, 0.0, make_error=False) xval = np.arange(data.shape[1]) - xycen[0] yval = np.arange(data.shape[0]) - xycen[1] xx, yy = np.meshgrid(xval, yval) rr = np.sqrt(cxx_ * xx**2 + cxy_ * xx * yy + cyy_ * yy**2) aperture_weights = aperture_mask.data[slc_sm] pixel_mask = (aperture_weights > 0) & ~mask # good pixels # ignore RuntimeWarning for invalid data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux_numer = np.sum((aperture_weights * data * rr)[pixel_mask]) flux_denom = np.sum((aperture_weights * data)[pixel_mask]) # set Kron radius to the minimum Kron radius if numerator or # denominator is negative if flux_numer <= 0 or flux_denom <= 0: kron_radius.append(self.kron_params[1]) continue kron_radius.append(flux_numer / flux_denom) return np.array(kron_radius) @as_scalar def _calc_kron_radius(self, kron_params): """ Calculate the *unscaled* first-moment Kron radius, applying any minimum Kron or circular radius to the measured Kron radius. Returned as a Quantity array or scalar (if self isscalar) with pixel units. """ kron_radius = self._measured_kron_radius.copy() # set minimum (unscaled) kron radius kron_radius[kron_radius < kron_params[1]] = kron_params[1] # check for minimum circular radius if len(kron_params) == 3: major_sigma = self.semimajor_sigma.value minor_sigma = self.semiminor_sigma.value circ_radius = (kron_params[0] * kron_radius * np.sqrt(major_sigma * minor_sigma)) kron_radius[circ_radius <= kron_params[2]] = 0.0 return kron_radius << u.pix @lazyproperty @use_detcat @as_scalar def kron_radius(self): r""" The *unscaled* first-moment Kron radius. The *unscaled* first-moment Kron radius is given by: .. math:: r_k = \frac{\sum_{i \in A} \ r_i I_i}{\sum_{i \in A} I_i} where :math:`I_i` are the data values and the sum is over pixels in an elliptical aperture whose axes are defined by six times the semimajor (`semimajor_sigma`) and semiminor axes (`semiminor_sigma`) at the calculated `orientation` (all properties derived from the central image moments of the source). :math:`r_i` is the elliptical "radius" to the pixel given by: .. math:: r_i^2 = cxx (x_i - \bar{x})^2 + cxy (x_i - \bar{x})(y_i - \bar{y}) + cyy (y_i - \bar{y})^2 where :math:`\bar{x}` and :math:`\bar{y}` represent the source `centroid` and the coefficients are based on image moments (`cxx`, `cxy`, and `cyy`). The `kron_radius` value is the unscaled moment value. The minimum unscaled radius can be set using the second element of the `SourceCatalog` ``kron_params`` keyword. If either the numerator or denominator above is less than or equal to 0, then the minimum unscaled Kron radius (``kron_params[1]``) will be used. The Kron aperture is calculated for each source using its shape parameters, `kron_radius`, and the ``kron_params`` scaling and minimum values input into `SourceCatalog`. The Kron aperture is used to compute the Kron photometry. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to the minimum circular radius (``kron_params[2]``), then the Kron radius will be set to zero and the Kron aperture will be a circle with this minimum radius. If the source is completely masked, then ``np.nan`` will be returned for both the Kron radius and Kron flux (the Kron aperture will be `None`). If a ``detection_cat`` was input to `SourceCatalog`, then its ``kron_radius`` will be returned. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. """ return self._calc_kron_radius(self.kron_params) def _make_kron_apertures(self, kron_params): """ Make Kron apertures for each source, always returned as a list. """ # NOTE: if kron_radius = NaN, scale = NaN and kron_aperture = None kron_radius = self._calc_kron_radius(kron_params) scale = kron_radius.value * kron_params[0] kron_apertures = self._make_elliptical_apertures(scale=scale) return kron_apertures @lazyproperty @use_detcat @as_scalar def kron_aperture(self): r""" The elliptical (or circular) Kron aperture. The Kron aperture is calculated for each source using its shape parameters, `kron_radius`, and the ``kron_params`` scaling and minimum values input into `SourceCatalog`. The Kron aperture is used to compute the Kron photometry. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to the minimum circular radius (``kron_params[2]``), then the Kron aperture will be a circle with this minimum radius. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. If a ``detection_cat`` was input to `SourceCatalog`, then its ``kron_aperture`` will be returned. """ return self._make_kron_apertures(self.kron_params) @as_scalar def make_kron_apertures(self, kron_params=None): """ Make Kron apertures for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Note that changing ``kron_params`` from the values input into `SourceCatalog` does not change the Kron apertures (`kron_aperture`) and photometry (`kron_flux` and `kron_fluxerr`) in the source catalog. This method should be used only to explore alternative ``kron_params`` with a detection image. Parameters ---------- kron_params : list of 2 or 3 floats or `None`, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. If `None`, then the ``kron_params`` input into `SourceCatalog` will be used (the apertures will be the same as those in `kron_aperture`). Returns ------- result : `~photutils.aperture.PixelAperture` \ or list of `~photutils.aperture.PixelAperture` The Kron apertures for each source. Each aperture will either be a `~photutils.aperture.EllipticalAperture`, `~photutils.aperture.CircularAperture`, or `None`. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ if kron_params is None: return self.kron_aperture return self._make_kron_apertures(kron_params) @as_scalar def plot_kron_apertures(self, kron_params=None, ax=None, origin=(0, 0), **kwargs): """ Plot Kron apertures for each source on a matplotlib `~matplotlib.axes.Axes` instance. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. An aperture will not be plotted for sources where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. Note that changing ``kron_params`` from the values input into `SourceCatalog` does not change the Kron apertures (`kron_aperture`) and photometry (`kron_flux` and `kron_fluxerr`) in the source catalog. This method should be used only to visualize/explore alternative ``kron_params`` with a detection image. Parameters ---------- kron_params : list of 2 or 3 floats or `None`, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. If `None`, then the ``kron_params`` input into `SourceCatalog` will be used (the apertures will be the same as those in `kron_aperture`). ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : list of `~matplotlib.patches.Patch` A list of matplotlib patches for the plotted aperture. The patches can be used, for example, when adding a plot legend. """ if kron_params is None: apertures = self.kron_aperture if self.isscalar: apertures = (apertures,) else: apertures = self._make_kron_apertures(kron_params) patches = [] for aperture in apertures: if aperture is not None: aperture.plot(ax=ax, origin=origin, **kwargs) patches.append(aperture._to_patch(origin=origin, **kwargs)) return patches def _aperture_photometry(self, apertures, desc='', **kwargs): """ Perform aperture photometry on cutouts of the data and optional error arrays. The appropriate ``apermask_method`` is applied to the cutouts to handle neighboring sources. Parameters ---------- apertures : list of `PixelAperture` A list of the apertures. desc : str, optional The description displayed before the progress bar. **kwargs : dict Additional keyword arguments passed to the aperture ``to_mask`` method. Returns ------- flux, fluxerr : 1D `~numpy.ndaray` The flux and flux error arrays. """ labels = self.labels if self.progress_bar: # pragma: no cover labels = add_progress_bar(labels, desc=desc) flux = [] fluxerr = [] for label, aperture, bkg in zip(labels, apertures, self._local_background): # return NaN for completely masked sources or sources where # the centroid is not finite if aperture is None: flux.append(np.nan) fluxerr.append(np.nan) continue xcen, ycen = aperture.positions aperture_mask = aperture.to_mask(**kwargs) # prepare cutouts of the data based on the aperture size data, error, mask, _, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, bkg) aperture_weights = aperture_mask.data[slc_sm] pixel_mask = (aperture_weights > 0) & ~mask # good pixels # ignore RuntimeWarning for invalid data or error values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) values = (aperture_weights * data)[pixel_mask] if values.shape == (0,): flux_ = np.nan else: flux_ = np.sum(values) flux.append(flux_) if error is None: fluxerr_ = np.nan else: values = (aperture_weights * error**2)[pixel_mask] if values.shape == (0,): fluxerr_ = np.nan else: fluxerr_ = np.sqrt(np.sum(values)) fluxerr.append(fluxerr_) flux = np.array(flux) fluxerr = np.array(fluxerr) return flux, fluxerr def _calc_kron_photometry(self, kron_params=None): """ Calculate the flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. If ``detection_cat`` is input, then its `centroid` values will be used. Returns ------- kron_flux, kron_fluxerr : tuple of `~numpy.ndarray` The Kron flux and flux error. """ if kron_params is None: kron_aperture = self.kron_aperture if self.isscalar: kron_aperture = (kron_aperture,) else: kron_params = self._validate_kron_params(kron_params) kron_aperture = self._make_kron_apertures(kron_params) kwargs = self._apermask_kwargs['kron'] flux, fluxerr = self._aperture_photometry(kron_aperture, desc='kron_photometry', **kwargs) return flux, fluxerr def kron_photometry(self, kron_params, name=None, overwrite=False): """ Perform photometry for each source using an elliptical Kron aperture. This method can be used to calculate the Kron photometry using alternate ``kron_params`` (e.g., different scalings of the Kron radius). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. Parameters ---------- kron_params : list of 2 or 3 floats, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. name : str or `None`, optional The prefix name which will be used to define attribute names for the Kron flux and flux error. The attribute names ``[name]_flux`` and ``[name]_fluxerr`` will store the photometry results. For example, these names can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- flux, fluxerr : float or `~numpy.ndarray` of floats The aperture fluxes and flux errors. NaN will be returned where the aperture is `None` (e.g., where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked). """ kron_flux, kron_fluxerr = self._calc_kron_photometry(kron_params) if self._data_unit is not None: kron_flux <<= self._data_unit kron_fluxerr <<= self._data_unit if self.isscalar: kron_flux = kron_flux[0] kron_fluxerr = kron_fluxerr[0] if name is not None: flux_name = f'{name}_flux' fluxerr_name = f'{name}_fluxerr' self.add_extra_property(flux_name, kron_flux, overwrite=overwrite) self.add_extra_property(fluxerr_name, kron_fluxerr, overwrite=overwrite) return kron_flux, kron_fluxerr @lazyproperty def _kron_photometry(self): """ The flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ return np.transpose(self._calc_kron_photometry(kron_params=None)) @lazyproperty @as_scalar def kron_flux(self): """ The flux in the Kron aperture. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ kron_flux = self._kron_photometry[:, 0] if self._data_unit is not None: kron_flux <<= self._data_unit return kron_flux @lazyproperty @as_scalar def kron_fluxerr(self): """ The flux error in the Kron aperture. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ kron_fluxerr = self._kron_photometry[:, 1] if self._data_unit is not None: kron_fluxerr <<= self._data_unit return kron_fluxerr @lazyproperty @use_detcat def _max_circular_kron_radius(self): """ The maximum circular Kron radius used as the upper limit of fluxfrac_radius. """ semimajor_sig = self.semimajor_sigma.value kron_radius = self.kron_radius.value radius = semimajor_sig * kron_radius * self.kron_params[0] mask = radius == 0 if np.any(mask): radius[mask] = self.kron_params[2] if self.isscalar: radius = np.array([radius]) return radius @staticmethod def _fluxfrac_radius_fcn(radius, data, mask, aperture, normflux, kwargs): """ Function whose root is found to compute the fluxfrac_radius. """ aperture.r = radius flux, _ = aperture.do_photometry(data, mask=mask, **kwargs) return 1.0 - (flux[0] / normflux) @lazyproperty @use_detcat def _fluxfrac_optimizer_args(self): kron_flux = self._kron_photometry[:, 0] # unitless max_radius = self._max_circular_kron_radius kwargs = self._apermask_kwargs['fluxfrac'] labels = self.labels if self.progress_bar: # pragma: no cover desc = 'fluxfrac_radius prep' labels = add_progress_bar(labels, desc=desc) args = [] for label, xcen, ycen, kronflux, bkg, max_radius_ in zip( labels, self._xcentroid, self._ycentroid, kron_flux, self._local_background, max_radius): if (np.any(~np.isfinite((xcen, ycen, kronflux, max_radius_))) or kronflux == 0): args.append(None) continue aperture = CircularAperture((xcen, ycen), r=max_radius_) aperture_mask = aperture.to_mask(**kwargs) # prepare cutouts of the data based on the maximum aperture size data, _, mask, xycen, _ = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, bkg, make_error=False) aperture.positions = xycen args.append([data, mask, aperture, kronflux, kwargs, max_radius_]) return args @as_scalar def fluxfrac_radius(self, fluxfrac, name=None, overwrite=False): """ Calculate the circular radius that encloses the specified fraction of the Kron flux. To estimate the half-light radius, use ``fluxfrac = 0.5``. Parameters ---------- fluxfrac : float The fraction of the Kron flux at which to find the circular radius. name : str or `None`, optional The attribute name which will be assigned to the value of the output array. For example, this name can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- radius : 1D `~numpy.ndarray` The circular radius that encloses the specified fraction of the Kron flux. NaN is returned where no solution was found or where the Kron flux is zero or non-finite. """ if fluxfrac <= 0 or fluxfrac > 1: raise ValueError('fluxfrac must be > 0 and <= 1') from scipy.optimize import root_scalar args = self._fluxfrac_optimizer_args if self.progress_bar: # pragma: no cover desc = 'fluxfrac_radius' args = add_progress_bar(args, desc=desc) radius = [] for fluxfrac_args in args: if fluxfrac_args is None: radius.append(np.nan) continue max_radius = fluxfrac_args[-1] args = fluxfrac_args[:-1] args[3] *= fluxfrac args = tuple(args) # Try to find the root of self._fluxfrac_radius_fnc, which # is bracketed by a min and max radius. A ValueError is # raised if the bracket points do not have different signs, # indicating no solution or multiple solutions (e.g., a # multi-valued function). This can happen when at some # radius, flux starts decreasing with increasing radius (due # to negative data values), resulting in multiple possible # solutions. If no solution is found, we iteratively # decrease the max radius to narrow the bracket range until # the root is found. If max radius drops below the min # radius (0.1), then no solution is possible and NaN will be # returned as the result. found = False min_radius = 0.1 max_radius_delta = 1.0 while max_radius > min_radius and found is False: try: bracket = [min_radius, max_radius] result = root_scalar(self._fluxfrac_radius_fcn, args=args, bracket=bracket, method='brentq') result = result.root found = True except ValueError: # pragma: no cover # ValueError is raised if the bracket points do not # have different signs max_radius -= max_radius_delta # no solution found between min_radius and max_radius if found is False: result = np.nan radius.append(result) result = np.array(radius) << u.pix if name is not None: self.add_extra_property(name, result, overwrite=overwrite) return result @as_scalar def make_cutouts(self, shape, mode='partial', fill_value=np.nan): """ Make cutout arrays for each source. The cutout for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Parameters ---------- shape : 2-tuple The cutout shape along each axis in ``(ny, nx)`` order. mode : {'partial', 'trim'}, optional The mode used for extracting the cutout array. In ``'partial'`` mode, positions in the cutout array that do not overlap with the large array will be filled with ``fill_value``. In ``'trim'`` mode, only the overlapping elements are returned, thus the resulting small array may be smaller than the requested ``shape``. fill_value : number, optional If ``mode='partial'``, the value to fill pixels in the extracted cutout array that do not overlap with the input ``array_large``. ``fill_value`` will be changed to have the same ``dtype`` as the ``array_large`` array, with one exception. If ``array_large`` has integer type and ``fill_value`` is ``np.nan``, then a `ValueError` will be raised. Returns ------- cutouts : `~photutils.utils.CutoutImage` \ or list of `~photutils.utils.CutoutImage` The `~photutils.utils.CutoutImage` for each source. The cutout will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ if mode not in ('partial', 'trim'): raise ValueError('mode must be "partial" or "trim"') cutouts = [] for (xcen, ycen, all_masked) in zip(self._xcentroid, self._ycentroid, self._all_masked): if all_masked or np.any(~np.isfinite((xcen, ycen))): cutouts.append(None) continue cutouts.append(CutoutImage(self._data, (ycen, xcen), shape, mode=mode, fill_value=fill_value)) return cutouts ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/core.py0000644000175100001770000016650014637570305021310 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes for a segmentation image and a single segment within a segmentation image. """ import inspect import warnings from copy import copy, deepcopy import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import BoundingBox from photutils.utils._optional_deps import HAS_RASTERIO, HAS_SHAPELY from photutils.utils._parameters import as_pair from photutils.utils.colormaps import make_random_cmap __all__ = ['SegmentationImage', 'Segment'] __doctest_requires__ = {('SegmentationImage', 'SegmentationImage.*'): ['scipy']} class SegmentationImage: """ Class for a segmentation image. Parameters ---------- data : 2D int `~numpy.ndarray` A 2D segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. The segmentation image must have integer type. Notes ----- The `SegmentationImage` instance may be sliced, but note that the sliced `SegmentationImage` data array will be a view into the original `SegmentationImage` array (this is the same behavior as `numpy.ndarray`). Explicitly use the :meth:`SegmentationImage.copy` method to create a copy of the sliced `SegmentationImage`. """ def __init__(self, data): if not isinstance(data, np.ndarray): raise TypeError('input data must be a numpy ndarray') self.data = data def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' cls_info = [] params = ['shape', 'nlabels'] for param in params: cls_info.append((param, getattr(self, param))) cls_info.append(('labels', self.labels)) with np.printoptions(threshold=25, edgeitems=5): fmt = [f'{key}: {val}' for key, val in cls_info] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __getitem__(self, key): """ Slice the segmentation image, returning a new SegmentationImage object. """ if (isinstance(key, tuple) and len(key) == 2 and all(isinstance(key[i], slice) and (key[i].start != key[i].stop) for i in (0, 1))): return SegmentationImage(self.data[key]) else: raise TypeError(f'{key!r} is not a valid 2D slice object') def __array__(self): """ Array representation of the segmentation array (e.g., for matplotlib). """ return self._data @staticmethod def _get_labels(data): """ Return a sorted array of the non-zero labels in the segmentation image. Parameters ---------- data : array_like (int) A segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. Returns ------- result : `~numpy.ndarray` An array of non-zero label numbers. Notes ----- This is a static method so it can be used in :meth:`remove_masked_labels` on a masked version of the segmentation array. """ # np.unique also sorts elements return np.unique(data[data != 0]) @lazyproperty def segments(self): """ A list of `Segment` objects. The list starts with the *non-zero* label. The returned list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ segments = [] if HAS_RASTERIO and HAS_SHAPELY: for label, slc, bbox, area, polygon in zip(self.labels, self.slices, self.bbox, self.areas, self.polygons): segments.append(Segment(self.data, label, slc, bbox, area, polygon=polygon)) else: for label, slc, bbox, area in zip(self.labels, self.slices, self.bbox, self.areas): segments.append(Segment(self.data, label, slc, bbox, area)) return segments @property def data(self): """The segmentation array.""" return self._data @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def _reset_lazyproperties(self): for key in self._lazyproperties: self.__dict__.pop(key, None) @data.setter def data(self, value): if not np.issubdtype(value.dtype, np.integer): raise TypeError('data must be have integer type') labels = self._get_labels(value) # array([]) if value all zeros if labels.shape != (0,) and np.min(labels) < 0: raise ValueError('The segmentation image cannot contain ' 'negative integers.') if '_data' in self.__dict__: # reset cached properties when data is reassigned, but not on init self._reset_lazyproperties() self._data = value # pylint: disable=attribute-defined-outside-init self.__dict__['labels'] = labels @lazyproperty def data_ma(self): """ A `~numpy.ma.MaskedArray` version of the segmentation array where the background (label = 0) has been masked. """ return np.ma.masked_where(self.data == 0, self.data) @lazyproperty def shape(self): """The shape of the segmentation array.""" return self._data.shape @lazyproperty def _ndim(self): """The number of array dimensions of the segmentation array.""" return self._data.ndim @lazyproperty def labels(self): """The sorted non-zero labels in the segmentation array.""" if '_raw_slices' in self.__dict__: labels_all = np.arange(len(self._raw_slices)) + 1 labels = [] # if a label is missing, raw_slices will be None instead of a slice for label, slc in zip(labels_all, self._raw_slices): if slc is not None: labels.append(label) return np.array(labels) else: return self._get_labels(self.data) @lazyproperty def nlabels(self): """The number of non-zero labels in the segmentation array.""" return len(self.labels) @lazyproperty def max_label(self): """The maximum label in the segmentation array.""" if self.nlabels == 0: return 0 return np.max(self.labels) def get_index(self, label): """ Find the index of the input ``label``. Parameters ---------- label : int The label number to find. Returns ------- index : int The array index. Raises ------ ValueError If ``label`` is invalid. """ self.check_labels(label) # self.labels is always sorted return np.searchsorted(self.labels, label) def get_indices(self, labels): """ Find the indices of the input ``labels``. Parameters ---------- labels : int, array_like (1D, int) The label numbers(s) to find. Returns ------- indices : int `~numpy.ndarray` An integer array of indices with the same shape as ``labels``. If ``labels`` is a scalar, then the returned index will also be a scalar. Raises ------ ValueError If any input ``labels`` are invalid. """ self.check_labels(labels) # self.labels is always sorted return np.searchsorted(self.labels, labels) @lazyproperty def _raw_slices(self): from scipy.ndimage import find_objects return find_objects(self.data) @lazyproperty def slices(self): """ A list of tuples, where each tuple contains two slices representing the minimal box that contains the labeled region. The list starts with the *non-zero* label. The returned list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ return [slc for slc in self._raw_slices if slc is not None] @lazyproperty def bbox(self): """ A list of `~photutils.aperture.BoundingBox` of the minimal bounding boxes containing the labeled regions. """ if self._ndim != 2: raise ValueError('The "bbox" attribute requires a 2D ' 'segmentation image.') return [BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop, iymin=slc[0].start, iymax=slc[0].stop) for slc in self.slices] @lazyproperty def background_area(self): """The area (in pixel**2) of the background (label=0) region.""" return self._data.size - np.count_nonzero(self._data) @lazyproperty def areas(self): """ A 1D array of areas (in pixel**2) of the non-zero labeled regions. The `~numpy.ndarray` starts with the *non-zero* label. The returned array has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ areas = [] for label, slices in zip(self.labels, self.slices): areas.append(np.count_nonzero(self._data[slices] == label)) return np.array(areas) def get_area(self, label): """ The area (in pixel**2) of the region for the input label. Parameters ---------- label : int The label whose area to return. Label must be non-zero. Returns ------- area : float The area of the labeled region. """ return self.get_areas(label)[0] def get_areas(self, labels): """ The areas (in pixel**2) of the regions for the input labels. Parameters ---------- labels : int, 1D array_like (int) The label(s) for which to return areas. Label must be non-zero. Returns ------- areas : `~numpy.ndarray` The areas of the labeled regions. """ idx = self.get_indices(np.atleast_1d(labels)) return self.areas[idx] @lazyproperty def is_consecutive(self): """ Boolean value indicating whether or not the non-zero labels in the segmentation array are consecutive and start from 1. """ if self.nlabels == 0: return False return ((self.labels[-1] - self.labels[0] + 1) == self.nlabels and self.labels[0] == 1) @lazyproperty def missing_labels(self): """ A 1D `~numpy.ndarray` of the sorted non-zero labels that are missing in the consecutive sequence from one to the maximum label number. """ return np.array(sorted(set(range(0, self.max_label + 1)) .difference(np.insert(self.labels, 0, 0)))) def copy(self): """Return a deep copy of this class instance.""" return deepcopy(self) def check_label(self, label): """ Check that the input label is a valid label number within the segmentation array. Parameters ---------- label : int The label number to check. Raises ------ ValueError If the input ``label`` is invalid. """ self.check_labels(label) def check_labels(self, labels): """ Check that the input label(s) are valid label numbers within the segmentation array. Parameters ---------- labels : int, 1D array_like (int) The label(s) to check. Raises ------ ValueError If any input ``labels`` are invalid. """ labels = np.atleast_1d(labels) bad_labels = set() # check for positive label numbers idx = np.where(labels <= 0)[0] if idx.size > 0: bad_labels.update(labels[idx]) # check if label is in the segmentation array bad_labels.update(np.setdiff1d(labels, self.labels)) if bad_labels: # bad_labels is a set if len(bad_labels) == 1: raise ValueError(f'label {bad_labels} is invalid') raise ValueError(f'labels {bad_labels} are invalid') def _make_cmap(self, ncolors, background_color='#000000ff', seed=None): """ Define a matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. Parameters ---------- ncolors : int The number of the colors in the colormap. background_color : Matplotlib color, optional The color of the first color in the colormap. The color may be specified using any of the `Matplotlib color formats `_. This color will be used as the background color (label = 0) when plotting the segmentation image. The default color is black with alpha=1.0 ('#000000ff'). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with colors in RGBA format. """ if self.nlabels == 0: return None from matplotlib import colors cmap = make_random_cmap(ncolors, seed=seed) if background_color is not None: cmap.colors[0] = colors.to_rgba(background_color) return cmap def make_cmap(self, background_color='#000000ff', seed=None): """ Define a matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. Parameters ---------- background_color : Matplotlib color, optional The color of the first color in the colormap. The color may be specified using any of the `Matplotlib color formats `_. This color will be used as the background color (label = 0) when plotting the segmentation image. The default color is black with alpha=1.0 ('#000000ff'). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with colors in RGBA format. """ return self._make_cmap(self.max_label + 1, background_color=background_color, seed=seed) def reset_cmap(self, seed=None): """ Reset the colormap (`cmap` attribute) to a new random colormap. Parameters ---------- seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. """ self.cmap = self.make_cmap(background_color='#000000ff', seed=seed) @lazyproperty def cmap(self): """ A matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. """ return self.make_cmap(background_color='#000000ff', seed=0) def reassign_label(self, label, new_label, relabel=False): """ Reassign a label number to a new number. If ``new_label`` is already present in the segmentation array, then it will be combined with the input ``label`` number. Note that this can result in a label that is no longer pixel connected. Parameters ---------- label : int The label number to reassign. new_label : int The newly assigned label number. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=2) >>> segm.data array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=4) >>> segm.data array([[4, 4, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=4, relabel=True) >>> segm.data array([[2, 2, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 1, 1, 0, 0], [4, 0, 0, 0, 0, 3], [4, 4, 0, 3, 3, 3], [4, 4, 0, 0, 3, 3]]) """ self.reassign_labels(label, new_label, relabel=relabel) def reassign_labels(self, labels, new_label, relabel=False): """ Reassign one or more label numbers. Multiple input ``labels`` will all be reassigned to the same ``new_label`` number. If ``new_label`` is already present in the segmentation array, then it will be combined with the input ``labels``. Note that both of these can result in a label that is no longer pixel connected. Parameters ---------- labels : int, array_like (1D, int) The label numbers(s) to reassign. new_label : int The reassigned label number. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=2) >>> segm.data array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [2, 0, 0, 0, 0, 5], [2, 2, 0, 5, 5, 5], [2, 2, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=4) >>> segm.data array([[4, 4, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [4, 0, 0, 0, 0, 5], [4, 4, 0, 5, 5, 5], [4, 4, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=2, relabel=True) >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [1, 0, 0, 0, 0, 4], [1, 1, 0, 4, 4, 4], [1, 1, 0, 0, 4, 4]]) """ self.check_labels(labels) labels = np.atleast_1d(labels) if labels.size == 0: return idx = np.zeros(self.max_label + 1, dtype=int) idx[self.labels] = self.labels idx[labels] = new_label # reassign labels if relabel: labels = np.unique(idx[idx != 0]) if not len(labels) == 0: idx2 = np.zeros(max(labels) + 1, dtype=int) idx2[labels] = np.arange(len(labels)) + 1 idx = idx2[idx] data_new = idx[self.data] self._reset_lazyproperties() # reset all cached properties self._data = data_new # use _data to avoid validation def relabel_consecutive(self, start_label=1): """ Reassign the label numbers consecutively starting from a given label number. Parameters ---------- start_label : int, optional The starting label number, which should be a strictly positive integer. The default is 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.relabel_consecutive() >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [5, 0, 0, 0, 0, 4], [5, 5, 0, 4, 4, 4], [5, 5, 0, 0, 4, 4]]) """ if self.nlabels == 0: warnings.warn('Cannot relabel a segmentation image of all zeros', AstropyUserWarning) return if start_label <= 0: raise ValueError('start_label must be > 0.') if ((self.labels[0] == start_label) and (self.labels[-1] - self.labels[0] + 1) == self.nlabels): return old_slices = self.__dict__.get('slices', None) new_labels = np.arange(self.nlabels) + start_label new_label_map = np.zeros(self.max_label + 1, dtype=int) new_label_map[self.labels] = new_labels data_new = new_label_map[self.data] self._reset_lazyproperties() # reset all cached properties self._data = data_new # use _data to avoid validation self.__dict__['labels'] = new_labels if old_slices is not None: self.__dict__['slices'] = old_slices # slice order is unchanged def keep_label(self, label, relabel=False): """ Keep only the specified label. Parameters ---------- label : int The label number to keep. relabel : bool, optional If `True`, then the single segment will be assigned a label value of 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_label(label=3) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_label(label=3, relabel=True) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) """ self.keep_labels(label, relabel=relabel) def keep_labels(self, labels, relabel=False): """ Keep only the specified labels. Parameters ---------- labels : int, array_like (1D, int) The label number(s) to keep. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_labels(labels=[5, 3]) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 5], [0, 0, 0, 5, 5, 5], [0, 0, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_labels(labels=[5, 3], relabel=True) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 2], [0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 2, 2]]) """ self.check_labels(labels) labels = np.atleast_1d(labels) labels_tmp = list(set(self.labels) - set(labels)) self.remove_labels(labels_tmp, relabel=relabel) def remove_label(self, label, relabel=False): """ Remove the label number. The removed label is assigned a value of zero (i.e., background). Parameters ---------- label : int The label number to remove. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_label(label=5) >>> segm.data array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_label(label=5, relabel=True) >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [4, 0, 0, 0, 0, 0], [4, 4, 0, 0, 0, 0], [4, 4, 0, 0, 0, 0]]) """ self.remove_labels(label, relabel=relabel) def remove_labels(self, labels, relabel=False): """ Remove one or more labels. Removed labels are assigned a value of zero (i.e., background). Parameters ---------- labels : int, array_like (1D, int) The label number(s) to remove. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_labels(labels=[5, 3]) >>> segm.data array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 0, 0, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_labels(labels=[5, 3], relabel=True) >>> segm.data array([[1, 1, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 0, 0, 0, 0], [3, 0, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0]]) """ self.check_labels(labels) self.reassign_labels(labels, new_label=0, relabel=relabel) def remove_border_labels(self, border_width, partial_overlap=True, relabel=False): """ Remove labeled segments near the array border. Labels within the defined border region will be removed. Parameters ---------- border_width : int The width of the border region in pixels. partial_overlap : bool, optional If this is set to `True` (the default), a segment that partially extends into the border region will be removed. Segments that are completely within the border region are always removed. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_border_labels(border_width=1) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_border_labels(border_width=1, ... partial_overlap=False) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) """ if border_width >= min(self.shape) / 2: raise ValueError('border_width must be smaller than half the ' 'array size in any dimension') border_mask = np.zeros(self.shape, dtype=bool) for i in range(border_mask.ndim): border_mask = border_mask.swapaxes(0, i) border_mask[:border_width] = True border_mask[-border_width:] = True border_mask = border_mask.swapaxes(0, i) self.remove_masked_labels(border_mask, partial_overlap=partial_overlap, relabel=relabel) def remove_masked_labels(self, mask, partial_overlap=True, relabel=False): """ Remove labeled segments located within a masked region. Parameters ---------- mask : array_like (bool) A boolean mask, with the same shape as the segmentation array, where `True` values indicate masked pixels. partial_overlap : bool, optional If this is set to `True` (default), a segment that partially extends into a masked region will also be removed. Segments that are completely within a masked region are always removed. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> mask = np.zeros(segm.data.shape, dtype=bool) >>> mask[0, :] = True # mask the first row >>> segm.remove_masked_labels(mask) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_masked_labels(mask, partial_overlap=False) >>> segm.data array([[0, 0, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) """ if mask.shape != self.shape: raise ValueError('mask must have the same shape as the ' 'segmentation array') remove_labels = self._get_labels(self.data[mask]) if not partial_overlap: interior_labels = self._get_labels(self.data[~mask]) remove_labels = list(set(remove_labels) - set(interior_labels)) self.remove_labels(remove_labels, relabel=relabel) def make_source_mask(self, *, size=None, footprint=None): """ Make a source mask from the segmentation image. Use the ``size`` or ``footprint`` keyword to perform binary dilation on the segmentation image mask. Parameters ---------- size : int or tuple of int, optional The size along each axis of the rectangular footprint used for the source dilation. If ``size`` is a scalar, then a square footprint of ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` should have odd values for each axis. To perform source dilation, either ``size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``size``. footprint : 2D `~numpy.ndarray`, optional The local footprint used for the source dilation. Non-zero elements are considered `True`. ``size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. To perform source dilation, either ``size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``size``. Returns ------- mask : 2D bool `~numpy.ndarray` A 2D boolean image containing the source mask. Notes ----- When performing source dilation, using a square footprint will be much faster than using other shapes (e.g., a circular footprint). Source dilation also is slower for larger images and larger footprints. Examples -------- >>> import numpy as np >>> from photutils.segmentation import SegmentationImage >>> from photutils.utils import circular_footprint >>> data = np.zeros((7, 7), dtype=int) >>> data[3, 3] = 1 >>> segm = SegmentationImage(data) >>> segm.data 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, 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]]) >>> mask0 = segm.make_source_mask() >>> mask0 array([[False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, True, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False]]) >>> mask1 = segm.make_source_mask(size=3) >>> mask1 array([[False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, True, True, True, False, False], [False, False, True, True, True, False, False], [False, False, True, True, True, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False]]) >>> footprint = circular_footprint(radius=3) >>> mask2 = segm.make_source_mask(footprint=footprint) >>> mask2 array([[False, False, False, True, False, False, False], [False, True, True, True, True, True, False], [False, True, True, True, True, True, False], [ True, True, True, True, True, True, True], [False, True, True, True, True, True, False], [False, True, True, True, True, True, False], [False, False, False, True, False, False, False]]) """ mask = self._data.astype(bool) if footprint is None: if size is None: return mask else: size = as_pair('size', size, check_odd=False) footprint = np.ones(size, dtype=bool) footprint = footprint.astype(bool) if np.all(footprint): # With a rectangular footprint, scipy grey_dilation is # currently much faster than binary_dilation (separable # footprint). grey_dilation and binary_dilation are identical # for binary inputs (equivalent to a 2D maximum filter). from scipy.ndimage import grey_dilation return grey_dilation(mask, footprint=footprint) else: # Binary dilation is very slow, especially for large # footprints. The following is a faster implementation # using fast Fourier transforms (FFTs) that gives identical # results to binary_dilation. Based on the following paper: # "Dilation and Erosion of Gray Images with Spherical # Masks", J. Kukal, D. Majerova, A. Prochazka (Jan 2007). # https://www.researchgate.net/publication/238778666_DILATION_AND_EROSION_OF_GRAY_IMAGES_WITH_SPHERICAL_MASKS from scipy.signal import fftconvolve return fftconvolve(mask, footprint, 'same') > 0.5 @lazyproperty def _geo_polygons(self): """ A list of polygons representing each source segment. Each item in the list is tuple of (polygon, value) where the polygon is a GeoJSON-like dict and the value is the label from the segmentation image. Note that the coordinates of these polygon vertices are in a reference frame with the (0, 0) origin at the *lower-left* corner of the lower-left pixel. """ from rasterio.features import shapes polygons = list(shapes(self.data.astype('int32'), connectivity=8)) polygons.sort(key=lambda x: x[1]) # sort in label order # do not include polygons for background (label = 0) return polygons[1:] @lazyproperty def polygons(self): """ A list of `Shapely `_ polygons representing each source segment. """ from shapely import transform from shapely.geometry import shape polygons = [] for geo_poly in self._geo_polygons: polygons.append(shape(geo_poly[0])) # shift the vertices so that the (0, 0) origin is at the # center of the lower-left pixel polygons = transform(polygons, lambda x: x - [0.5, 0.5]) return polygons def to_patches(self, *, origin=(0, 0), scale=1.0, **kwargs): """ Return a list of `~matplotlib.patches.Polygon` objects representing each source segment. By default, the polygon patch will have a white edge color and no face color. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. This effectively translates the position of the polygons. scale : float, optional The scale factor applied to the polygon vertices. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Polygon`. """ from matplotlib.patches import Polygon origin = np.array(origin) patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'} patch_kwargs.update(kwargs) patches = [] for poly in self.polygons: xy = np.array(poly.exterior.coords) xy = scale * (xy + 0.5) - 0.5 xy -= origin patches.append(Polygon(xy, **patch_kwargs)) return patches def plot_patches(self, *, ax=None, origin=(0, 0), scale=1.0, labels=None, **kwargs): """ Plot the `~matplotlib.patches.Polygon` objects for the source segments on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. scale : float, optional The scale factor applied to the polygon vertices. labels : int or array of int, optional The label numbers whose polygons are to be plotted. If `None`, the polygons for all labels will be plotted. **kwargs : `dict` Any keyword arguments accepted by `matplotlib.patches.Polygon`. Returns ------- patches : list of `~matplotlib.patches.Polygon` A list of matplotlib polygon patches for the plotted polygons. The patches can be used, for example, when adding a plot legend. Examples -------- .. plot:: :include-source: import numpy as np from photutils.segmentation import SegmentationImage data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(data) segm.imshow(figsize=(5, 5)) segm.plot_patches(edgecolor='white', lw=2) """ import matplotlib.pyplot as plt if ax is None: ax = plt.gca() patches = self.to_patches(origin=origin, scale=scale, **kwargs) if labels is not None: patches = np.array(patches) indices = self.get_indices(labels) patches = patches[indices] if np.isscalar(labels): patches = [patches] for patch in patches: patch = copy(patch) ax.add_patch(patch) if labels is not None: patches = list(patches) return patches def imshow(self, ax=None, figsize=None, dpi=None, cmap=None, alpha=None): """ Display the segmentation image in a matplotlib `~matplotlib.axes.Axes` instance. The segmentation image will be displayed with "nearest" interpolation and with the origin set to "lower". Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then a new `~matplotlib.axes.Axes` instance will be created. figsize : 2-tuple of floats or `None`, optional The figure dimension (width, height) in inches when creating a new Axes. This keyword is ignored if ``axes`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``axes`` is input. cmap : `matplotlib.colors.Colormap`, str, or `None`, optional The `~matplotlib.colors.Colormap` instance or a registered matplotlib colormap name used to map scalar data to colors. If `None`, then the colormap defined by the `cmap` attribute will be used. alpha : float, array_like, or `None`, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If alpha is an array, the alpha blending values are applied pixel by pixel, and alpha must have the same shape as the segmentation image. Returns ------- result : `matplotlib.image.AxesImage` An image attached to an `matplotlib.axes.Axes`. Examples -------- .. plot:: :include-source: import numpy as np from photutils.segmentation import SegmentationImage data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(data) fig, ax = plt.subplots() im = segm.imshow(ax=ax) fig.colorbar(im, ax=ax) """ import matplotlib.pyplot as plt if ax is None: _, ax = plt.subplots(figsize=figsize, dpi=dpi) if cmap is None: cmap = self.cmap return ax.imshow(self.data, cmap=cmap, interpolation='nearest', origin='lower', alpha=alpha, vmin=-0.5, vmax=self.max_label + 0.5) def imshow_map(self, ax=None, figsize=None, dpi=None, cmap=None, alpha=None, max_labels=25, cbar_labelsize=None): """ Display the segmentation image in a matplotlib `~matplotlib.axes.Axes` instance with a colorbar. This method is useful for displaying segmentation images that have a small number of labels (e.g., from a cutout) that are not consecutive. It maps the labels to be consecutive integers starting from 1 before plotting. The plotted image values are not the label values, but the colorbar tick labels are used to show the original labels. The segmentation image will be displayed with "nearest" interpolation and with the origin set to "lower". Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then a new `~matplotlib.axes.Axes` instance will be created. figsize : 2-tuple of floats or `None`, optional The figure dimension (width, height) in inches when creating a new Axes. This keyword is ignored if ``axes`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``axes`` is input. cmap : `matplotlib.colors.Colormap`, str, or `None`, optional The `~matplotlib.colors.Colormap` instance or a registered matplotlib colormap name used to map scalar data to colors. If `None`, then the colormap defined by the `cmap` attribute will be used. alpha : float, array_like, or `None`, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If alpha is an array, the alpha blending values are applied pixel by pixel, and alpha must have the same shape as the segmentation image. max_labels : int, optional The maximum number of labels to display in the colorbar. If the number of labels is greater than ``max_labels``, then the colorbar will not be displayed. cbar_labelsize : `None` or float, optional The font size of the colorbar tick labels. Returns ------- result : `matplotlib.image.AxesImage` An image attached to an `matplotlib.axes.Axes`. cbar_info : tuple or `None` The colorbar information as a tuple containing the `~matplotlib.colorbar.Colorbar` instance, a `~numpy.ndarray` of tick positions, and a `~numpy.ndarray` of tick labels. `None` is returned if the colorbar was not plotted. Examples -------- .. plot:: :include-source: import numpy as np from photutils.segmentation import SegmentationImage data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) data *= 1000 segm = SegmentationImage(data) fig, ax = plt.subplots() im, cbar = segm.imshow_map(ax=ax) """ import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap if ax is None: _, ax = plt.subplots(figsize=figsize, dpi=dpi) data, idx = np.unique(self.data, return_inverse=True) idx = idx.reshape(self.data.shape) vmin = -0.5 vmax = np.max(idx) + 0.5 # keep the original cmap colors for the labels if cmap is None: cmap = ListedColormap(self.cmap.colors[data]) im = ax.imshow(idx, cmap=cmap, interpolation='nearest', origin='lower', alpha=alpha, vmin=vmin, vmax=vmax) cbar_info = None cbar_labels = np.hstack((0, self.labels)) if len(cbar_labels) <= max_labels: cbar_ticks = np.arange(len(cbar_labels)) cbar = plt.colorbar(im, ax=ax, ticks=cbar_ticks) cbar.ax.set_yticklabels(cbar_labels) if cbar_labelsize is not None: cbar.ax.yaxis.set_tick_params(labelsize=cbar_labelsize) cbar_info = (cbar, cbar_ticks, cbar_labels) else: warnings.warn('The colorbar was not plotted because the number of ' f'labels is greater than {max_labels=}.', AstropyUserWarning) return im, cbar_info class Segment: """ Class for a single labeled region (segment) within a segmentation image. Parameters ---------- segment_data : int `~numpy.ndarray` A segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. label : int The segment label number. slices : tuple of two slices A tuple of two slices representing the minimal box that contains the labeled region. bbox : `~photutils.aperture.BoundingBox` The minimal bounding box that contains the labeled region. area : float The area of the segment in pixels**2. polygon : Shapely polygon, optional The outline of the segment as a `Shapely `_ polygon. """ def __init__(self, segment_data, label, slices, bbox, area, *, polygon=None): self._segment_data = segment_data self.label = label self.slices = slices self.bbox = bbox self.area = area self.polygon = polygon def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' cls_info = [] params = ['label', 'slices', 'area'] for param in params: cls_info.append((param, getattr(self, param))) fmt = [f'{key}: {val}' for key, val in cls_info] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def _repr_svg_(self): # pragma: no cover if self.polygon is not None: print(repr(self)) return self.polygon._repr_svg_() return None def __array__(self): """ Array representation of the labeled region (e.g., for matplotlib). """ return self.data @lazyproperty def data(self): """ A cutout array of the segment using the minimal bounding box, where pixels outside of the labeled region are set to zero (i.e., neighboring segments within the rectangular cutout array are not shown). """ cutout = np.copy(self._segment_data[self.slices]) cutout[cutout != self.label] = 0 return cutout @lazyproperty def data_ma(self): """ A `~numpy.ma.MaskedArray` cutout array of the segment using the minimal bounding box. The mask is `True` for pixels outside of the source segment (i.e., neighboring segments within the rectangular cutout array are masked). """ mask = (self._segment_data[self.slices] != self.label) return np.ma.masked_array(self._segment_data[self.slices], mask=mask) def make_cutout(self, data, masked_array=False): """ Create a (masked) cutout array from the input ``data`` using the minimal bounding box of the segment (labeled region). If ``masked_array`` is `False` (default), then the returned cutout array is simply a `~numpy.ndarray`. The returned cutout is a view (not a copy) of the input ``data``. No pixels are altered (e.g., set to zero) within the bounding box. If ``masked_array`` is `True`, then the returned cutout array is a `~numpy.ma.MaskedArray`, where the mask is `True` for pixels outside of the segment (labeled region). The data part of the masked array is a view (not a copy) of the input ``data``. Parameters ---------- data : 2D `~numpy.ndarray` The data array from which to create the masked cutout array. ``data`` must have the same shape as the segmentation array. masked_array : bool, optional If `True` then a `~numpy.ma.MaskedArray` will be created where the mask is `True` for pixels outside of the segment (labeled region). If `False`, then a `~numpy.ndarray` will be generated. Returns ------- result : 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` The cutout array. """ if data.shape != self._segment_data.shape: raise ValueError('data must have the same shape as the ' 'segmentation array.') if masked_array: mask = (self._segment_data[self.slices] != self.label) return np.ma.masked_array(data[self.slices], mask=mask) else: return data[self.slices] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/deblend.py0000644000175100001770000005311314637570305021750 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for deblending overlapping sources labeled in a segmentation image. """ import warnings from multiprocessing import cpu_count, get_context import numpy as np from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning from photutils.segmentation.core import SegmentationImage from photutils.segmentation.detect import _detect_sources from photutils.segmentation.utils import _make_binary_structure from photutils.utils._progress_bars import add_progress_bar __all__ = ['deblend_sources'] def deblend_sources(data, segment_img, npixels, *, labels=None, nlevels=32, contrast=0.001, mode='exponential', connectivity=8, relabel=True, nproc=1, progress_bar=True): """ Deblend overlapping sources labeled in a segmentation image. Sources are deblended using a combination of multi-thresholding and `watershed segmentation `_. In order to deblend sources, there must be a saddle between them. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image here. This array should be the same array used in `~photutils.segmentation.detect_sources`. segment_img : `~photutils.segmentation.SegmentationImage` The segmentation image to deblend. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be deblended. ``npixels`` must be a positive integer. labels : int or array_like of int, optional The label numbers to deblend. If `None` (default), then all labels in the segmentation image will be deblended. nlevels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``nlevels`` levels spaced between its minimum and maximum values (non-inclusive). The ``mode`` keyword determines how the levels are spaced. contrast : float, optional The fraction of the total source flux that a local peak must have (at any one of the multi-thresholds) to be deblended as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast=0`` then every local peak will be made a separate object (maximum deblending). If ``contrast=1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and less near the source maximum. The ``'linear'`` mode evenly spaces the threshold levels between the source minimum and maximum. The ``'exponential'`` and ``'sinh'`` modes differ in that the ``'exponential'`` levels are dependent on the source maximum/minimum ratio (smaller ratios are more linear; larger ratios are more exponential), while the ``'sinh'`` levels are not. Also, the ``'exponential'`` mode will be changed to ``'linear'`` for sources with non-positive minimum data values. connectivity : {8, 4}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 8 (default) or 4. 8-connected pixels touch along their edges or corners. 4-connected pixels touch along their edges. The ``connectivity`` must be the same as that used to create the input segmentation image. relabel : bool, optional If `True` (default), then the segmentation image will be relabeled such that the labels are in consecutive order starting from 1. nproc : int, optional The number of processes to use for multiprocessing (if larger than 1). If set to 1, then a serial implementation is used instead of a parallel one. If `None`, then the number of processes will be set to the number of CPUs detected on the machine. Please note that due to overheads, multiprocessing may be slower than serial processing. This is especially true if one only has a small number of sources to deblend. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. progress_bar : bool, optional Whether to display a progress bar. Note that if multiprocessing is used (``nproc > 1``), the estimation times (e.g., time per iteration and time remaining, etc) may be unreliable. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` A segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. See Also -------- :func:`photutils.segmentation.detect_sources` :class:`photutils.segmentation.SourceFinder` """ if isinstance(data, Quantity): data = data.value if not isinstance(segment_img, SegmentationImage): raise ValueError('segment_img must be a SegmentationImage') if segment_img.shape != data.shape: raise ValueError('The data and segmentation image must have ' 'the same shape') if nlevels < 1: raise ValueError('nlevels must be >= 1') if contrast < 0 or contrast > 1: raise ValueError('contrast must be >= 0 and <= 1') if contrast == 1: # no deblending return segment_img.copy() if mode not in ('exponential', 'linear', 'sinh'): raise ValueError('mode must be "exponential", "linear", or "sinh"') if labels is None: labels = segment_img.labels else: labels = np.atleast_1d(labels) segment_img.check_labels(labels) # include only sources that have at least (2 * npixels); # this is required for it to be deblended into multiple sources, # each with a minimum of npixels mask = (segment_img.areas[segment_img.get_indices(labels)] >= (npixels * 2)) labels = labels[mask] footprint = _make_binary_structure(data.ndim, connectivity) if nproc is None: nproc = cpu_count() # pragma: no cover segm_deblended = object.__new__(SegmentationImage) segm_deblended._data = np.copy(segment_img.data) last_label = segment_img.max_label indices = segment_img.get_indices(labels) all_source_data = [] all_source_segments = [] all_source_slices = [] for label, idx in zip(labels, indices): source_slice = segment_img.slices[idx] source_data = data[source_slice] source_segment = object.__new__(SegmentationImage) source_segment._data = segment_img.data[source_slice] source_segment.keep_labels(label) # include only one label all_source_data.append(source_data) all_source_segments.append(source_segment) all_source_slices.append(source_slice) if nproc == 1: if progress_bar: desc = 'Deblending' all_source_data = add_progress_bar(all_source_data, desc=desc) # pragma: no cover all_source_deblends = [] for source_data, source_segment in zip(all_source_data, all_source_segments): deblender = _Deblender(source_data, source_segment, npixels, footprint, nlevels, contrast, mode) source_deblended = deblender.deblend_source() all_source_deblends.append(source_deblended) else: nlabels = len(labels) args_all = zip(all_source_data, all_source_segments, (npixels,) * nlabels, (footprint,) * nlabels, (nlevels,) * nlabels, (contrast,) * nlabels, (mode,) * nlabels) if progress_bar: desc = 'Deblending' args_all = add_progress_bar(args_all, total=nlabels, desc=desc) # pragma: no cover with get_context('spawn').Pool(processes=nproc) as executor: all_source_deblends = executor.starmap(_deblend_source, args_all) nonposmin_labels = [] nmarkers_labels = [] for (label, source_deblended, source_slice) in zip( labels, all_source_deblends, all_source_slices): if source_deblended is not None: # replace the original source with the deblended source segment_mask = (source_deblended.data > 0) segm_deblended._data[source_slice][segment_mask] = ( source_deblended.data[segment_mask] + last_label) last_label += source_deblended.nlabels if hasattr(source_deblended, 'warnings'): if source_deblended.warnings.get('nonposmin', None) is not None: nonposmin_labels.append(label) if source_deblended.warnings.get('nmarkers', None) is not None: nmarkers_labels.append(label) if nonposmin_labels or nmarkers_labels: segm_deblended.info = {'warnings': {}} warnings.warn('The deblending mode of one or more source labels from ' 'the input segmentation image was changed from ' f'"{mode}" to "linear". See the "info" attribute ' 'for the list of affected input labels.', AstropyUserWarning) if nonposmin_labels: warn = {'message': f'Deblending mode changed from {mode} to ' 'linear due to non-positive minimum data values.', 'input_labels': np.array(nonposmin_labels)} segm_deblended.info['warnings']['nonposmin'] = warn if nmarkers_labels: warn = {'message': f'Deblending mode changed from {mode} to ' 'linear due to too many potential deblended sources.', 'input_labels': np.array(nmarkers_labels)} segm_deblended.info['warnings']['nmarkers'] = warn if relabel: segm_deblended.relabel_consecutive() return segm_deblended def _deblend_source(source_data, source_segment, npixels, footprint, nlevels, contrast, mode): """ Convenience function to deblend a single labeled source with multiprocessing. """ deblender = _Deblender(source_data, source_segment, npixels, footprint, nlevels, contrast, mode) return deblender.deblend_source() class _Deblender: """ Class to deblend a single labeled source. Parameters ---------- source_data : 2D `~numpy.ndarray` The cutout data array for a single source. ``data`` should also already be smoothed by the same filter used in :func:`~photutils.segmentation.detect_sources`, if applicable. source_segment : `~photutils.segmentation.SegmentationImage` A cutout `~photutils.segmentation.SegmentationImage` object with the same shape as ``data``. ``segment_img`` should contain only *one* source label. npixels : int The number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. nlevels : int The number of multi-thresholding levels to use. Each source will be re-thresholded at ``nlevels`` levels spaced exponentially or linearly (see the ``mode`` keyword) between its minimum and maximum values within the source segment. contrast : float The fraction of the total (blended) source flux that a local peak must have (at any one of the multi-thresholds) to be considered as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast = 0`` then every local peak will be made a separate object (maximum deblending). If ``contrast = 1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. mode : {'exponential', 'linear', 'sinh'} The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword). The default is 'exponential'. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` A segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. Note that the returned `SegmentationImage` will have consecutive labels starting with 1. """ def __init__(self, source_data, source_segment, npixels, footprint, nlevels, contrast, mode): self.source_data = source_data self.source_segment = source_segment self.npixels = npixels self.footprint = footprint self.nlevels = nlevels self.contrast = contrast self.mode = mode self.warnings = {} self.segment_mask = source_segment.data.astype(bool) self.source_values = source_data[self.segment_mask] self.source_min = np.nanmin(self.source_values) self.source_max = np.nanmax(self.source_values) self.source_sum = np.nansum(self.source_values) self.label = source_segment.labels[0] # should only be 1 label # NOTE: this includes the source min/max, but we exclude those # later, giving nlevels thresholds between min and max # (noninclusive; i.e., nlevels + 1 parts) self.linear_thresholds = np.linspace(self.source_min, self.source_max, self.nlevels + 2) def normalized_thresholds(self): return ((self.linear_thresholds - self.source_min) / (self.source_max - self.source_min)) def compute_thresholds(self): """ Compute the multi-level detection thresholds for the source. """ if self.mode == 'exponential' and self.source_min <= 0: self.warnings['nonposmin'] = 'non-positive minimum' self.mode = 'linear' if self.mode == 'linear': thresholds = self.linear_thresholds elif self.mode == 'sinh': a = 0.25 minval = self.source_min maxval = self.source_max thresholds = self.normalized_thresholds() thresholds = np.sinh(thresholds / a) / np.sinh(1.0 / a) thresholds *= (maxval - minval) thresholds += minval elif self.mode == 'exponential': minval = self.source_min maxval = self.source_max thresholds = self.normalized_thresholds() thresholds = minval * (maxval / minval) ** thresholds return thresholds[1:-1] # do not include source min and max def multithreshold(self): """ Perform multithreshold detection for each source. """ thresholds = self.compute_thresholds() with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) segments = _detect_sources(self.source_data, thresholds, self.npixels, self.footprint, self.segment_mask, deblend_mode=True) return segments def make_markers(self, segments): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- segments : list of `~photutils.segmentation.SegmentationImage` A list of segmentation images, one for each threshold. Returns ------- markers : list of `~photutils.segmentation.SegmentationImage` A list of segmentation images that contain possible sources as markers. The last list element contains all of the potential source markers. """ from scipy.ndimage import label as ndi_label for i in range(len(segments) - 1): segm_lower = segments[i].data segm_upper = segments[i + 1].data markers = segm_lower.astype(bool) relabel = False # if the are more sources at the upper level, then # remove the parent source(s) from the lower level, # but keep any sources in the lower level that do not have # multiple children in the upper level for label in segments[i].labels: mask = (segm_lower == label) # find label mapping from the lower to upper level upper_labels = segm_upper[mask] upper_labels = np.unique(upper_labels[upper_labels != 0]) if upper_labels.size >= 2: relabel = True markers[mask] = segm_upper[mask].astype(bool) if relabel: segm_data, nlabels = ndi_label(markers, structure=self.footprint) segm_new = object.__new__(SegmentationImage) segm_new._data = segm_data segm_new.__dict__['labels'] = np.arange(nlabels) + 1 segments[i + 1] = segm_new else: segments[i + 1] = segments[i] return segments def apply_watershed(self, markers): """ Apply the watershed algorithm to the source markers. Parameters ---------- markers : list of `~photutils.segmentation.SegmentationImage` A list of segmentation images that contain possible sources as markers. The last list element contains all of the potential source markers. Returns ------- segment_data : 2D int `~numpy.ndarray` A 2D int array containing the deblended source labels. Note that the source labels may not be consecutive. """ from scipy.ndimage import sum_labels from skimage.segmentation import watershed # all markers are at the top level markers = markers[-1].data # Deblend using watershed. If any source does not meet the contrast # criterion, then remove the faintest such source and repeat until # all sources meet the contrast criterion. remove_marker = True while remove_marker: markers = watershed(-self.source_data, markers, mask=self.segment_mask, connectivity=self.footprint) labels = np.unique(markers[markers != 0]) if labels.size == 1: # only 1 source left remove_marker = False else: flux_frac = sum_labels(self.source_data, markers, index=labels) / self.source_sum remove_marker = any(flux_frac < self.contrast) if remove_marker: # remove only the faintest source (one at a time) # because several faint sources could combine to meet # the contrast criterion markers[markers == labels[np.argmin(flux_frac)]] = 0.0 return markers def deblend_source(self): """ Deblend a single labeled source. """ if self.source_min == self.source_max: # no deblending return None segments = self.multithreshold() if len(segments) == 0: # no deblending return None # define the markers (possible sources) for the watershed algorithm markers = self.make_markers(segments) # If there are too many markers (e.g., due to low threshold # and/or small npixels), the watershed step can be very slow # (the threshold of 200 is arbitrary, but seems to work well). # This mostly affects the "exponential" mode, where there are # many levels at low thresholds, so here we try again with # "linear" mode. if self.mode != 'linear' and markers[-1].nlabels > 200: self.warnings['nmarkers'] = 'too many markers' self.mode = 'linear' segments = self.multithreshold() if len(segments) == 0: # no deblending return None markers = self.make_markers(segments) # deblend using the watershed algorithm using the markers as seeds markers = self.apply_watershed(markers) if not np.array_equal(self.segment_mask, markers.astype(bool)): raise ValueError(f'Deblending failed for source "{self.label}". ' 'Please ensure you used the same pixel ' 'connectivity in detect_sources and ' 'deblend_sources.') labels = np.unique(markers[markers != 0]) if len(labels) == 1: # no deblending return None segm_new = object.__new__(SegmentationImage) segm_new._data = markers segm_new.__dict__['labels'] = labels segm_new.relabel_consecutive(start_label=1) if self.warnings: segm_new.warnings = self.warnings return segm_new ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/detect.py0000644000175100001770000003461314637570305021627 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for detecting sources in an image. """ import warnings import numpy as np from astropy.stats import SigmaClip from photutils.segmentation.core import SegmentationImage from photutils.segmentation.utils import _make_binary_structure from photutils.utils._quantity_helpers import process_quantities from photutils.utils._stats import nanmean, nanstd from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['detect_threshold', 'detect_sources'] def detect_threshold(data, nsigma, *, background=None, error=None, mask=None, sigma_clip=SigmaClip(sigma=3.0, maxiters=10)): """ Calculate a pixel-wise threshold image that can be used to detect sources. This is a simple convenience function that uses sigma-clipped statistics to compute a scalar background and noise estimate. In general, one should perform more sophisticated estimates, e.g., using `~photutils.background.Background2D`. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. nsigma : float The number of standard deviations per pixel above the ``background`` for which to consider a pixel as possibly being part of a source. background : float or 2D `~numpy.ndarray`, optional The background value(s) of the input ``data``. ``background`` may either be a scalar value or a 2D array with the same shape as the input ``data``. If the input ``data`` has been background-subtracted, then set ``background`` to ``0.0`` (this should be typical). If `None`, then a scalar background value will be estimated as the sigma-clipped image mean. error : float or 2D `~numpy.ndarray`, optional The Gaussian 1-sigma standard deviation of the background noise in ``data``. ``error`` should include all sources of "background" error, but *exclude* the Poisson error of the sources. If ``error`` is a 2D image, then it should represent the 1-sigma background error in each pixel of ``data``. If `None`, then a scalar background rms value will be estimated as the sigma-clipped image standard deviation. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when computing the image background statistics. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. Returns ------- threshold : 2D `~numpy.ndarray` A 2D image with the same shape (and units) as ``data`` containing the pixel-wise threshold values. See Also -------- :class:`photutils.background.Background2D` :func:`photutils.segmentation.detect_sources` :class:`photutils.segmentation.SourceFinder` Notes ----- The ``mask`` and ``sigma_clip`` inputs are used only if it is necessary to estimate ``background`` or ``error`` using sigma-clipped background statistics. If ``background`` and ``error`` are both input, then ``mask`` and ``sigma_clip`` are ignored. """ inputs = (data, background, error) names = ('data', 'background', 'error') inputs, unit = process_quantities(inputs, names) (data, background, error) = inputs if not isinstance(sigma_clip, SigmaClip): raise TypeError('sigma_clip must be a SigmaClip object') if background is None or error is None: if mask is not None: data = np.ma.MaskedArray(data, mask) clipped_data = sigma_clip(data, masked=False, return_bounds=False, copy=True) if background is None: background = nanmean(clipped_data) if not np.isscalar(background) and background.shape != data.shape: raise ValueError('If input background is 2D, then it must have the ' 'same shape as the input data.') if error is None: error = nanstd(clipped_data) if not np.isscalar(error) and error.shape != data.shape: raise ValueError('If input error is 2D, then it must have the same ' 'shape as the input data.') threshold = (np.broadcast_to(background, data.shape) + np.broadcast_to(error * nsigma, data.shape)) if unit: threshold <<= unit return threshold def _detect_sources(data, thresholds, npixels, footprint, inverse_mask, *, deblend_mode=False): """ Detect sources above a specified threshold value in an image. Detected sources must have ``npixels`` connected pixels that are each greater than the ``threshold`` value. If the filtering option is used, then the ``threshold`` is applied to the filtered image. The input ``mask`` can be used to mask pixels in the input data. Masked pixels will not be included in any source. This function does not deblend overlapping sources. First use this function to detect sources followed by :func:`~photutils.segmentation.deblend_sources` to deblend sources. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image here. thresholds : list of 2D `~numpy.ndarray` or 1D array of floats The data values (as a 1D array of floats) or pixel-wise data values (as a sequence of 2D arrays) to be used for the detection thresholds. If ``data`` is a `~astropy.units.Quantity` array, then ``thresholds`` must have the same units as ``data``. 2D threshold arrays must have the same shape as ``data``. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. footprint : array_like A footprint that defines feature connections. As an example, for connectivity along pixel edges only, the footprint is ``np.array([[0, 1, 0]], [1, 1, 1], [0, 1, 0]])``. inverse_mask : 2D bool `~numpy.ndarray` A boolean mask, with the same shape as the input ``data``, where `False` values indicate masked pixels (the inverse of usual pixel masks). Masked pixels will not be included in any source. deblend_mode : bool, optional If `True` do not include the segmentation image in the output list for any threshold level where the number of detected sources is less than 2. The deblend mode also does not relabel the output segmentation image to have consecutive label. This keyword improves performance of source deblending. Returns ------- segment_image : list of `~photutils.segmentation.SegmentationImage` A list of 2D segmentation images, one for each input threshold, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. If no sources are found for a given threshold, then the output list will contain `None` for that threshold. Also see the ``deblend_mode`` keyword. """ from scipy.ndimage import find_objects from scipy.ndimage import label as ndi_label segms = [] for threshold in thresholds: # RuntimeWarning caused by > comparison when data contains NaNs # is ignored when calling _detect_sources segment_img = data > threshold if inverse_mask is not None: segment_img &= inverse_mask # return if threshold was too high to detect any sources if np.count_nonzero(segment_img) == 0: if not deblend_mode: warnings.warn('No sources were found.', NoDetectionsWarning) segms.append(None) continue # recasting segment_img to int and using output=segment_img # gives similar performance segment_img, nlabels = ndi_label(segment_img, structure=footprint) labels = np.arange(nlabels) + 1 # remove objects with less than npixels # NOTE: making cutout images and setting their pixels to 0 is # ~10x faster than using segment_img directly and ~50% faster # than using ndimage.sum_labels. slices = find_objects(segment_img) segm_labels = [] segm_slices = [] for label, slc in zip(labels, slices): cutout = segment_img[slc] segment_mask = (cutout == label) if np.count_nonzero(segment_mask) < npixels: cutout[segment_mask] = 0 continue segm_labels.append(label) segm_slices.append(slc) if np.count_nonzero(segment_img) == 0: if not deblend_mode: warnings.warn('No sources were found.', NoDetectionsWarning) segms.append(None) continue if not deblend_mode: # relabel the segmentation image with consecutive numbers nlabels = len(segm_labels) if len(labels) != nlabels: label_map = np.zeros(np.max(labels) + 1, dtype=int) labels = np.arange(nlabels) + 1 label_map[segm_labels] = labels segment_img = label_map[segment_img] else: labels = segm_labels segm = object.__new__(SegmentationImage) segm._data = segment_img segm.__dict__['labels'] = labels segm.__dict__['slices'] = segm_slices if deblend_mode and segm.nlabels == 1: continue segms.append(segm) return segms def detect_sources(data, threshold, npixels, *, connectivity=8, mask=None): """ Detect sources above a specified threshold value in an image. Detected sources must have ``npixels`` connected pixels that are each greater than the ``threshold`` value. If the filtering option is used, then the ``threshold`` is applied to the filtered image. The input ``mask`` can be used to mask pixels in the input data. Masked pixels will not be included in any source. This function does not deblend overlapping sources. First use this function to detect sources followed by :func:`~photutils.segmentation.deblend_sources` to deblend sources. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image here. threshold : float or 2D `~numpy.ndarray` The data value or pixel-wise data values to be used for the detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` array must have the same shape as ``data``. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. connectivity : {4, 8}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as the input ``data``, where `True` values indicate masked pixels. Masked pixels will not be included in any source. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` or `None` A 2D segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. If no sources are found then `None` is returned. See Also -------- :func:`photutils.segmentation.deblend_sources` :class:`photutils.segmentation.SourceFinder` Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image from photutils.segmentation import (detect_sources, make_2dgaussian_kernel) # make a simulated image data = make_100gaussians_image() # use sigma-clipped statistics to (roughly) estimate the background # background noise levels mean, _, std = sigma_clipped_stats(data) # subtract the background data -= mean # detect the sources threshold = 3. * std kernel = make_2dgaussian_kernel(3.0, size=3) # FWHM = 3. convolved_data = convolve(data, kernel) segm = detect_sources(convolved_data, threshold, npixels=5) # plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 10)) norm = simple_norm(data, 'sqrt', percent=99.) ax1.imshow(data, origin='lower', interpolation='nearest', norm=norm) ax2.imshow(segm.data, origin='lower', interpolation='nearest', cmap=segm.make_cmap(seed=1234)) plt.tight_layout() """ _ = process_quantities((data, threshold), ('data', 'threshold')) if (npixels <= 0) or (int(npixels) != npixels): raise ValueError('npixels must be a positive integer, got ' f'"{npixels}"') if mask is not None: if mask.shape != data.shape: raise ValueError('mask must have the same shape as the input ' 'image.') if mask.all(): raise ValueError('mask must not be True for every pixel. There ' 'are no unmasked pixels in the image to detect ' 'sources.') inverse_mask = np.logical_not(mask) else: inverse_mask = None footprint = _make_binary_structure(data.ndim, connectivity) with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) return _detect_sources(data, (threshold,), npixels, footprint, inverse_mask, deblend_mode=False)[0] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/finder.py0000644000175100001770000002317714637570305021631 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for detecting sources in an image. """ from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.utils._parameters import as_pair from photutils.utils._repr import make_repr __all__ = ['SourceFinder'] class SourceFinder: """ Class to detect sources, including deblending, in an image using segmentation. This is a convenience class that combines the functionality of `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources`. Sources are deblended using a combination of multi-thresholding and `watershed segmentation `_. In order to deblend sources, there must be a saddle between them. Parameters ---------- npixels : int or array-like of 2 int The minimum number of connected pixels, each greater than a specified threshold, that an object must have to be detected. If ``npixels`` is an integer, then the value will be used for both source detection and deblending (which internally uses source detection at multiple thresholds). If ``npixels`` contains two values, then the first value will be used for source detection and the second value used for source deblending. ``npixels`` values must be positive integers. connectivity : {4, 8}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. deblend : bool, optional Whether to deblend overlapping sources. nlevels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``nlevels`` levels spaced exponentially or linearly (see the ``mode`` keyword) between its minimum and maximum values. This keyword is ignored unless ``deblend=True``. contrast : float, optional The fraction of the total source flux that a local peak must have (at any one of the multi-thresholds) to be deblended as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast=0`` then every local peak will be made a separate object (maximum deblending). If ``contrast=1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. This keyword is ignored unless ``deblend=True``. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and less near the source maximum. The ``'linear'`` mode evenly spaces the threshold levels between the source minimum and maximum. The ``'exponential'`` and ``'sinh'`` modes differ in that the ``'exponential'`` levels are dependent on the source maximum/minimum ratio (smaller ratios are more linear; larger ratios are more exponential), while the ``'sinh'`` levels are not. Also, the ``'exponential'`` mode will be changed to ``'linear'`` for sources with non-positive minimum data values. This keyword is ignored unless ``deblend=True``. relabel : bool, optional If `True` (default), then the segmentation image will be relabeled after deblending such that the labels are in consecutive order starting from 1. This keyword is ignored unless ``deblend=True``. nproc : int, optional The number of processes to use for multiprocessing (if larger than 1). If set to 1, then a serial implementation is used instead of a parallel one. If `None`, then the number of processes will be set to the number of CPUs detected on the machine. Please note that due to overheads, multiprocessing may be slower than serial processing. This is especially true if one only has a small number of sources to deblend. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. This keyword is ignored unless ``deblend=True``. progress_bar : bool, optional Whether to display a progress bar. Note that if multiprocessing is used (``nproc > 1``), the estimation times (e.g., time per iteration and time remaining, etc) may be unreliable. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. This keyword is ignored unless ``deblend=True``. See Also -------- :func:`photutils.segmentation.detect_sources` :func:`photutils.segmentation.deblend_sources` Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (SourceFinder, make_2dgaussian_kernel) # make a simulated image data = make_100gaussians_image() # subtract the background bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # convolve the data kernel = make_2dgaussian_kernel(3., size=5) # FWHM = 3. convolved_data = convolve(data, kernel) # detect the sources threshold = 1.5 * bkg.background_rms # per-pixel threshold finder = SourceFinder(npixels=10, progress_bar=False) segm = finder(convolved_data, threshold) # plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 10)) norm = simple_norm(data, 'sqrt', percent=99.) ax1.imshow(data, origin='lower', interpolation='nearest', norm=norm) ax2.imshow(segm.data, origin='lower', interpolation='nearest', cmap=segm.cmap) plt.tight_layout() """ def __init__(self, npixels, *, connectivity=8, deblend=True, nlevels=32, contrast=0.001, mode='exponential', relabel=True, nproc=1, progress_bar=True): self.npixels = as_pair('npixels', npixels, check_odd=False) self.deblend = deblend self.connectivity = connectivity self.nlevels = nlevels self.contrast = contrast self.mode = mode self.relabel = relabel self.nproc = nproc self.progress_bar = progress_bar def __repr__(self): params = ('npixels', 'deblend', 'connectivity', 'nlevels', 'contrast', 'mode', 'relabel', 'nproc', 'progress_bar') return make_repr(self, params) def __call__(self, data, threshold, mask=None): """ Detect sources, including deblending, in an image using segmentation. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array from which to detect sources. Typically, this array should be an image that has been convolved with a smoothing kernel. threshold : 2D `~numpy.ndarray` or float The data value or pixel-wise data values (as an array) to be used as the per-pixel detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` array must have the same shape as ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels will not be included in any source. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` or `None` A 2D segmentation image, with the same shape as the input data, where sources are marked by different positive integer values. A value of zero is reserved for the background. If no sources are found then `None` is returned. """ segment_img = detect_sources(data, threshold, self.npixels[0], mask=mask, connectivity=self.connectivity) if segment_img is None: return None # source deblending requires scikit-image if self.deblend: segment_img = deblend_sources(data, segment_img, self.npixels[1], nlevels=self.nlevels, contrast=self.contrast, mode=self.mode, connectivity=self.connectivity, relabel=self.relabel, nproc=self.nproc, progress_bar=self.progress_bar) return segment_img ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.042885 photutils-1.13.0/photutils/segmentation/tests/0000755000175100001770000000000014637570322021137 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/__init__.py0000644000175100001770000000000014637570305023237 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_catalog.py0000644000175100001770000011031114637570305024160 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the catalog module. """ from io import StringIO import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.coordinates import SkyCoord from astropy.modeling.models import Gaussian2D from astropy.table import QTable from numpy.testing import assert_allclose, assert_equal from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture) from photutils.background import Background2D, MedianBackground from photutils.datasets import (make_100gaussians_image, make_gwcs, make_noise_image, make_wcs) from photutils.segmentation.catalog import SourceCatalog from photutils.segmentation.core import SegmentationImage from photutils.segmentation.detect import detect_sources from photutils.segmentation.finder import SourceFinder from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import (HAS_GWCS, HAS_MATPLOTLIB, HAS_SCIPY, HAS_SKIMAGE) from photutils.utils.cutouts import CutoutImage @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestSourceCatalog: def setup_class(self): xcen = 51.0 ycen = 52.7 major_sigma = 8.0 minor_sigma = 3.0 theta = np.pi / 6.0 g1 = Gaussian2D(111.0, xcen, ycen, major_sigma, minor_sigma, theta=theta) g2 = Gaussian2D(50, 20, 80, 5.1, 4.5) g3 = Gaussian2D(70, 75, 18, 9.2, 4.5) g4 = Gaussian2D(111.0, 11.1, 12.2, major_sigma, minor_sigma, theta=theta) g5 = Gaussian2D(81.0, 61, 42.7, major_sigma, minor_sigma, theta=theta) g6 = Gaussian2D(107.0, 75, 61, major_sigma, minor_sigma, theta=-theta) g7 = Gaussian2D(107.0, 90, 90, 4, 2, theta=-theta) yy, xx = np.mgrid[0:101, 0:101] self.data = (g1(xx, yy) + g2(xx, yy) + g3(xx, yy) + g4(xx, yy) + g5(xx, yy) + g6(xx, yy) + g7(xx, yy)) threshold = 27.0 self.segm = detect_sources(self.data, threshold, npixels=5) self.error = make_noise_image(self.data.shape, mean=0, stddev=2.0, seed=123) self.background = np.ones(self.data.shape) * 5.1 self.mask = np.zeros(self.data.shape, dtype=bool) self.mask[0:30, 0:30] = True self.wcs = make_wcs(self.data.shape) self.cat = SourceCatalog(self.data, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24) unit = u.nJy self.unit = unit self.cat_units = SourceCatalog(self.data << unit, self.segm, error=self.error << unit, background=self.background << unit, mask=self.mask, wcs=self.wcs, localbkg_width=24) @pytest.mark.parametrize('with_units', (True, False)) def test_catalog(self, with_units): if with_units: cat1 = self.cat_units.copy() cat2 = self.cat_units.copy() else: cat1 = self.cat.copy() cat2 = self.cat.copy() props = self.cat.properties # add extra properties cat1.circular_photometry(5.0, name='circ5') cat1.kron_photometry((2.5, 1.4), name='kron2') cat1.fluxfrac_radius(0.5, name='r_hl') segment_snr = cat1.segment_flux / cat1.segment_fluxerr cat1.add_extra_property('segment_snr', segment_snr) props = list(props) props.extend(cat1.extra_properties) idx = 1 # no NaN values # evaluate (cache) catalog properties before slice obj = cat1[idx] for prop in props: assert_equal(getattr(cat1, prop)[idx], getattr(obj, prop)) # slice catalog before evaluating catalog properties obj = cat2[idx] obj.circular_photometry(5.0, name='circ5') obj.kron_photometry((2.5, 1.4), name='kron2') obj.fluxfrac_radius(0.5, name='r_hl') segment_snr = obj.segment_flux / obj.segment_fluxerr obj.add_extra_property('segment_snr', segment_snr) for prop in props: assert_equal(getattr(obj, prop), getattr(cat1, prop)[idx]) with pytest.raises(ValueError): cat1._prepare_cutouts(cat1._segment_img_cutouts, units=True, masked=True) @pytest.mark.parametrize('with_units', (True, False)) def test_catalog_detection_cat(self, with_units): """ Test aperture-based properties with an input detection catalog. """ error = 2.0 * self.error data2 = self.data + error if with_units: cat1 = self.cat_units.copy() cat2 = SourceCatalog(data2 << self.unit, self.segm, error=error << self.unit, background=self.background << self.unit, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=None) cat3 = SourceCatalog(data2 << self.unit, self.segm, error=error << self.unit, background=self.background << self.unit, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=cat1) else: cat1 = self.cat.copy() cat2 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=None) cat3 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=cat1) assert_equal(cat1.kron_radius, cat3.kron_radius) # assert not equal with pytest.raises(AssertionError): assert_equal(cat1.kron_radius, cat2.kron_radius) with pytest.raises(AssertionError): assert_equal(cat2.kron_flux, cat3.kron_flux) with pytest.raises(AssertionError): assert_equal(cat2.kron_fluxerr, cat3.kron_fluxerr) with pytest.raises(AssertionError): assert_equal(cat1.kron_flux, cat3.kron_flux) with pytest.raises(AssertionError): assert_equal(cat1.kron_fluxerr, cat3.kron_fluxerr) flux1, fluxerr1 = cat1.circular_photometry(1.0) flux2, fluxerr2 = cat2.circular_photometry(1.0) flux3, fluxerr3 = cat3.circular_photometry(1.0) with pytest.raises(AssertionError): assert_equal(flux2, flux3) with pytest.raises(AssertionError): assert_equal(fluxerr2, fluxerr3) with pytest.raises(AssertionError): assert_equal(flux1, flux2) with pytest.raises(AssertionError): assert_equal(fluxerr1, fluxerr2) flux1, fluxerr1 = cat1.kron_photometry((2.5, 1.4)) flux2, fluxerr2 = cat2.kron_photometry((2.5, 1.4)) flux3, fluxerr3 = cat3.kron_photometry((2.5, 1.4)) with pytest.raises(AssertionError): assert_equal(flux2, flux3) with pytest.raises(AssertionError): assert_equal(fluxerr2, fluxerr3) with pytest.raises(AssertionError): assert_equal(flux1, flux2) with pytest.raises(AssertionError): assert_equal(fluxerr1, fluxerr2) radius1 = cat1.fluxfrac_radius(0.5) radius2 = cat2.fluxfrac_radius(0.5) radius3 = cat3.fluxfrac_radius(0.5) with pytest.raises(AssertionError): assert_equal(radius2, radius3) with pytest.raises(AssertionError): assert_equal(radius1, radius2) cat4 = cat3[0:1] assert len(cat4.kron_radius) == 1 def test_minimal_catalog(self): cat = SourceCatalog(self.data, self.segm) obj = cat[4] props = ('background', 'background_ma', 'error', 'error_ma') for prop in props: assert getattr(obj, prop) is None arr_props = ('_background_cutouts', '_error_cutouts') for prop in arr_props: assert getattr(obj, prop)[0] is None props = ('background_mean', 'background_sum', 'background_centroid', 'segment_fluxerr', 'kron_fluxerr') for prop in props: assert np.isnan(getattr(obj, prop)) assert obj.local_background_aperture is None assert obj.local_background == 0.0 def test_slicing(self): self.cat.to_table() # evaluate and cache several properties obj1 = self.cat[0] assert obj1.nlabels == 1 obj1b = self.cat.get_label(1) assert obj1b.nlabels == 1 obj2 = self.cat[0:1] assert obj2.nlabels == 1 assert len(obj2) == 1 obj3 = self.cat[0:3] obj3b = self.cat.get_labels((1, 2, 3)) assert_equal(obj3.label, obj3b.label) obj4 = self.cat[[0, 1, 2]] assert obj3.nlabels == 3 assert obj3b.nlabels == 3 assert obj4.nlabels == 3 assert len(obj3) == 3 assert len(obj4) == 3 obj5 = self.cat[[3, 2, 1]] labels = [4, 3, 2] obj5b = self.cat.get_labels(labels) assert_equal(obj5.label, obj5b.label) assert obj5.nlabels == 3 assert len(obj5) == 3 assert_equal(obj5.label, labels) # test get_labels when labels are not sorted obj5 = self.cat[[3, 2, 1]] labels2 = (3, 4) obj5b = obj5.get_labels(labels2) assert_equal(obj5b.label, labels2) obj6 = obj5[0] assert obj6.label == labels[0] mask = self.cat.label > 3 obj7 = self.cat[mask] assert obj7.nlabels == 4 assert len(obj7) == 4 with pytest.raises(TypeError): obj1 = self.cat[0] obj2 = obj1[0] match = 'is invalid' with pytest.raises(ValueError, match=match): self.cat.get_label(1000) with pytest.raises(ValueError, match=match): self.cat.get_labels([1, 2, 1000]) def test_iter(self): labels = [] for obj in self.cat: labels.append(obj.label) assert len(labels) == len(self.cat) def test_table(self): columns = ['label', 'xcentroid', 'ycentroid'] tbl = self.cat.to_table(columns=columns) assert len(tbl) == 7 assert tbl.colnames == columns def test_invalid_inputs(self): segm = SegmentationImage(np.zeros(self.data.shape, dtype=int)) with pytest.raises(ValueError): SourceCatalog(self.data, segm) # test 1D arrays img1d = np.arange(4) segm = SegmentationImage(img1d) with pytest.raises(ValueError): SourceCatalog(img1d, segm) wrong_shape = np.ones((3, 3), dtype=int) with pytest.raises(ValueError): SourceCatalog(wrong_shape, self.segm) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, error=wrong_shape) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, background=wrong_shape) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, mask=wrong_shape) with pytest.raises(ValueError): segm = SegmentationImage(wrong_shape) SourceCatalog(self.data, segm) with pytest.raises(TypeError): SourceCatalog(self.data, wrong_shape) with pytest.raises(TypeError): obj = SourceCatalog(self.data, self.segm)[0] len(obj) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, localbkg_width=-1) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, localbkg_width=3.4) with pytest.raises(ValueError): apermask_method = 'invalid' SourceCatalog(self.data, self.segm, apermask_method=apermask_method) with pytest.raises(ValueError): kron_params = (0.0, 1.0) SourceCatalog(self.data, self.segm, kron_params=kron_params) with pytest.raises(ValueError): kron_params = (2.5, 0.0) SourceCatalog(self.data, self.segm, kron_params=kron_params) with pytest.raises(ValueError): kron_params = (-2.5, 0.0) SourceCatalog(self.data, self.segm, kron_params=kron_params) with pytest.raises(ValueError): kron_params = (2.5, -4.0) SourceCatalog(self.data, self.segm, kron_params=kron_params) with pytest.raises(ValueError): kron_params = (2.5, 1.4, -2.0) SourceCatalog(self.data, self.segm, kron_params=kron_params) def test_invalid_units(self): unit = u.uJy wrong_unit = u.km with pytest.raises(ValueError): SourceCatalog(self.data << unit, self.segm, error=self.error << wrong_unit) with pytest.raises(ValueError): SourceCatalog(self.data << unit, self.segm, background=self.background << wrong_unit) # all array inputs must have the same unit with pytest.raises(ValueError): SourceCatalog(self.data << unit, self.segm, error=self.error) with pytest.raises(ValueError): SourceCatalog(self.data, self.segm, background=self.background << unit) def test_wcs(self): mywcs = make_wcs(self.data.shape) cat = SourceCatalog(self.data, self.segm, wcs=mywcs) obj = cat[0] assert obj.sky_centroid is not None assert obj.sky_centroid_icrs is not None assert obj.sky_centroid_win is not None assert obj.sky_bbox_ll is not None assert obj.sky_bbox_ul is not None assert obj.sky_bbox_lr is not None assert obj.sky_bbox_ur is not None @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_gwcs(self): mywcs = make_gwcs(self.data.shape) cat = SourceCatalog(self.data, self.segm, wcs=mywcs) obj = cat[1] assert obj.sky_centroid is not None assert obj.sky_centroid_icrs is not None assert obj.sky_centroid_win is not None assert obj.sky_bbox_ll is not None assert obj.sky_bbox_ul is not None assert obj.sky_bbox_lr is not None assert obj.sky_bbox_ur is not None def test_nowcs(self): cat = SourceCatalog(self.data, self.segm, wcs=None) obj = cat[2] assert obj.sky_centroid is None assert obj.sky_centroid_icrs is None assert obj.sky_centroid_win is None assert obj.sky_bbox_ll is None assert obj.sky_bbox_ul is None assert obj.sky_bbox_lr is None assert obj.sky_bbox_ur is None def test_to_table(self): cat = SourceCatalog(self.data, self.segm) assert len(cat) == 7 tbl = cat.to_table() assert isinstance(tbl, QTable) assert len(tbl) == 7 obj = cat[0] assert obj.nlabels == 1 tbl = obj.to_table() assert len(tbl) == 1 def test_masks(self): """ Test masks, including automatic masking of all non-finite (e.g., NaN, inf) values in the data array. """ data = np.copy(self.data) error = np.copy(self.error) background = np.copy(self.background) data[:, 55] = np.nan data[16, :] = np.inf error[:, 55] = np.nan error[16, :] = np.inf background[:, 55] = np.nan background[16, :] = np.inf cat = SourceCatalog(data, self.segm, error=error, background=background, mask=self.mask) props = ('xcentroid', 'ycentroid', 'area', 'orientation', 'segment_flux', 'segment_fluxerr', 'kron_flux', 'kron_fluxerr', 'background_mean') obj = cat[0] for prop in props: assert np.isnan(getattr(obj, prop)) objs = cat[1:] for prop in props: assert np.all(np.isfinite(getattr(objs, prop))) # test that mask=None is the same as mask=np.ma.nomask cat1 = SourceCatalog(data, self.segm, mask=None) cat2 = SourceCatalog(data, self.segm, mask=np.ma.nomask) assert cat1[0].xcentroid == cat2[0].xcentroid def test_repr_str(self): cat = SourceCatalog(self.data, self.segm) assert repr(cat) == str(cat) lines = ('Length: 7', 'labels: [1 2 3 4 5 6 7]') for line in lines: assert line in repr(cat) def test_detection_cat(self): data2 = self.data - 5 cat1 = SourceCatalog(data2, self.segm) cat2 = SourceCatalog(data2, self.segm, detection_cat=self.cat) assert len(cat2.kron_aperture) == len(cat2) assert not np.array_equal(cat1.kron_radius, cat2.kron_radius) assert not np.array_equal(cat1.kron_flux, cat2.kron_flux) assert_allclose(cat2.kron_radius, self.cat.kron_radius) assert not np.array_equal(cat2.kron_flux, self.cat.kron_flux) with pytest.raises(TypeError): SourceCatalog(data2, self.segm, detection_cat=np.arange(4)) with pytest.raises(ValueError): segm = self.segm.copy() segm.remove_labels((6, 7)) cat = SourceCatalog(self.data, segm) SourceCatalog(self.data, self.segm, detection_cat=cat) def test_kron_minradius(self): kron_params = (2.5, 2.5) cat = SourceCatalog(self.data, self.segm, mask=self.mask, apermask_method='none', kron_params=kron_params) assert cat.kron_aperture[0] is None assert np.isnan(cat.kron_radius[0]) kronrad = cat.kron_radius.value kronrad = kronrad[~np.isnan(kronrad)] assert np.min(kronrad) == kron_params[1] assert isinstance(cat.kron_aperture[2], EllipticalAperture) assert isinstance(cat.kron_aperture[4], EllipticalAperture) assert isinstance(cat.kron_params, tuple) def test_kron_masking(self): apermask_method = 'none' cat1 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) apermask_method = 'mask' cat2 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) apermask_method = 'correct' cat3 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) idx = 2 # source with close neighbors assert cat1[idx].kron_flux > cat2[idx].kron_flux assert cat3[idx].kron_flux > cat2[idx].kron_flux assert cat1[idx].kron_flux > cat3[idx].kron_flux def test_kron_negative(self): cat = SourceCatalog(self.data - 10, self.segm) assert_allclose(cat.kron_radius.value, cat.kron_params[1]) def test_kron_photometry(self): flux0, fluxerr0 = self.cat.kron_photometry((2.5, 1.4)) assert_allclose(flux0, self.cat.kron_flux) assert_allclose(fluxerr0, self.cat.kron_fluxerr) flux1, fluxerr1 = self.cat.kron_photometry((1.0, 1.4), name='kron1') flux2, fluxerr2 = self.cat.kron_photometry((2.0, 1.4), name='kron2') assert_allclose(flux1, self.cat.kron1_flux) assert_allclose(fluxerr1, self.cat.kron1_fluxerr) assert_allclose(flux2, self.cat.kron2_flux) assert_allclose(fluxerr2, self.cat.kron2_fluxerr) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((fluxerr2 > fluxerr1) | (np.isnan(fluxerr2) & np.isnan(fluxerr1))) # test different min Kron radius flux3, fluxerr3 = self.cat.kron_photometry((2.5, 2.5)) assert np.all((flux3 > flux0) | (np.isnan(flux3) & np.isnan(flux0))) assert np.all((fluxerr3 > fluxerr0) | (np.isnan(fluxerr3) & np.isnan(fluxerr0))) obj = self.cat[1] flux1, fluxerr1 = obj.kron_photometry((1.0, 1.4), name='kron0') assert np.isscalar(flux1) assert np.isscalar(fluxerr1) assert_allclose(flux1, obj.kron0_flux) assert_allclose(fluxerr1, obj.kron0_fluxerr) cat = SourceCatalog(self.data, self.segm) _, fluxerr = cat.kron_photometry((2.0, 1.4)) assert np.all(np.isnan(fluxerr)) with pytest.raises(ValueError): self.cat.kron_photometry(2.5) with pytest.raises(ValueError): self.cat.kron_photometry((2.5, 0.0)) with pytest.raises(ValueError): self.cat.kron_photometry((0.0, 1.4)) with pytest.raises(ValueError): self.cat.kron_photometry((2.5, 0.0, 1.5)) def test_circular_photometry(self): flux1, fluxerr1 = self.cat.circular_photometry(1.0, name='circ1') flux2, fluxerr2 = self.cat.circular_photometry(5.0, name='circ5') assert_allclose(flux1, self.cat.circ1_flux) assert_allclose(fluxerr1, self.cat.circ1_fluxerr) assert_allclose(flux2, self.cat.circ5_flux) assert_allclose(fluxerr2, self.cat.circ5_fluxerr) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((fluxerr2 > fluxerr1) | (np.isnan(fluxerr2) & np.isnan(fluxerr1))) obj = self.cat[1] assert obj.isscalar flux1, fluxerr1 = obj.circular_photometry(1.0, name='circ0') assert np.isscalar(flux1) assert np.isscalar(fluxerr1) assert_allclose(flux1, obj.circ0_flux) assert_allclose(fluxerr1, obj.circ0_fluxerr) cat = SourceCatalog(self.data, self.segm) _, fluxerr = cat.circular_photometry(1.0) assert np.all(np.isnan(fluxerr)) # with "center" mode, tiny apertures that do not overlap any # center should return NaN cat2 = self.cat.copy() cat2._set_semode() # sets "center" mode flux1, fluxerr1 = cat2.circular_photometry(0.1) assert np.all(np.isnan(flux1[2:4])) assert np.all(np.isnan(fluxerr1[2:4])) with pytest.raises(ValueError): self.cat.circular_photometry(0.0) with pytest.raises(ValueError): self.cat.circular_photometry(-1.0) with pytest.raises(ValueError): self.cat.make_circular_apertures(0.0) with pytest.raises(ValueError): self.cat.make_circular_apertures(-1.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plots(self): from matplotlib.patches import Patch patches = self.cat.plot_circular_apertures(5.0) assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) patches = self.cat.plot_kron_apertures() assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) patches2 = self.cat.plot_kron_apertures((2.0, 1.2)) assert isinstance(patches2, list) for patch in patches2: assert isinstance(patch, Patch) # test scalar obj = self.cat[1] patch1 = obj.plot_kron_apertures() assert isinstance(patch1, Patch) patch2 = obj.plot_kron_apertures((2.0, 1.2)) assert isinstance(patch2, Patch) def test_fluxfrac_radius(self): radius1 = self.cat.fluxfrac_radius(0.1, name='fluxfrac_r1') radius2 = self.cat.fluxfrac_radius(0.5, name='fluxfrac_r5') assert_allclose(radius1, self.cat.fluxfrac_r1) assert_allclose(radius2, self.cat.fluxfrac_r5) assert np.all((radius2 > radius1) | (np.isnan(radius2) & np.isnan(radius1))) cat = SourceCatalog(self.data, self.segm) obj = cat[1] radius = obj.fluxfrac_radius(0.5) assert radius.isscalar # Quantity radius - can't use np.isscalar assert_allclose(radius.value, 7.899648) with pytest.raises(ValueError): radius = self.cat.fluxfrac_radius(0) with pytest.raises(ValueError): radius = self.cat.fluxfrac_radius(-1) cat = SourceCatalog(self.data - 50.0, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24) radius_hl = cat.fluxfrac_radius(0.5) assert np.isnan(radius_hl[0]) def test_cutout_units(self): obj = self.cat_units[0] quantities = (obj.data, obj.error, obj.background) ndarray = (obj.segment, obj.segment_ma, obj.data_ma, obj.error_ma, obj.background_ma) for arr in quantities: assert isinstance(arr, u.Quantity) for arr in ndarray: assert not isinstance(arr, u.Quantity) @pytest.mark.parametrize('scalar', (True, False)) def test_extra_properties(self, scalar): cat = SourceCatalog(self.data, self.segm) if scalar: cat = cat[1] segment_snr = cat.segment_flux / cat.segment_fluxerr with pytest.raises(ValueError): # built-in attribute cat.add_extra_property('_data', segment_snr) with pytest.raises(ValueError): # built-in property cat.add_extra_property('label', segment_snr) with pytest.raises(ValueError): # built-in lazyproperty cat.add_extra_property('area', segment_snr) cat.add_extra_property('segment_snr', segment_snr) with pytest.raises(ValueError): # already exists cat.add_extra_property('segment_snr', segment_snr) cat.add_extra_property('segment_snr', 2.0 * segment_snr, overwrite=True) assert len(cat.extra_properties) == 1 assert_equal(cat.segment_snr, 2.0 * segment_snr) with pytest.raises(ValueError): cat.remove_extra_property('invalid') cat.remove_extra_property(cat.extra_properties) assert len(cat.extra_properties) == 0 cat.add_extra_property('segment_snr', segment_snr) cat.add_extra_property('segment_snr2', segment_snr) cat.add_extra_property('segment_snr3', segment_snr) assert len(cat.extra_properties) == 3 cat.remove_extra_properties(cat.extra_properties) assert len(cat.extra_properties) == 0 cat.add_extra_property('segment_snr', segment_snr) new_name = 'segment_snr0' cat.rename_extra_property('segment_snr', new_name) assert new_name in cat.extra_properties # key in extra_properties, but not a defined attribute cat._extra_properties.append('invalid') with pytest.raises(ValueError): cat.add_extra_property('invalid', segment_snr) cat._extra_properties.remove('invalid') assert cat._has_len([1, 2, 3]) assert not cat._has_len('test_string') def test_extra_properties_invalid(self): cat = SourceCatalog(self.data, self.segm) with pytest.raises(ValueError): cat.add_extra_property('invalid', 1.0) with pytest.raises(ValueError): cat.add_extra_property('invalid', (1.0, 2.0)) obj = cat[1] with pytest.raises(ValueError): obj.add_extra_property('invalid', (1.0, 2.0)) with pytest.raises(ValueError): val = np.arange(2) << u.km obj.add_extra_property('invalid', val) with pytest.raises(ValueError): coord = SkyCoord([42, 43], [44, 45], unit='deg') obj.add_extra_property('invalid', coord) def test_properties(self): attrs = ('label', 'labels', 'slices', 'xcentroid', 'segment_flux', 'kron_flux') for attr in attrs: assert attr in self.cat.properties def test_copy(self): cat = SourceCatalog(self.data, self.segm) cat2 = cat.copy() _ = cat.kron_flux assert 'kron_flux' not in cat2.__dict__ tbl = cat2.to_table() assert len(tbl) == 7 def test_data_dtype(self): """ Regression test that input ``data`` with int dtype does not raise UFuncTypeError due to subtraction of float array from int array. """ data = np.zeros((25, 25), dtype=np.uint16) data[8:16, 8:16] = 10 segmdata = np.zeros((25, 25), dtype=int) segmdata[8:16, 8:16] = 1 segm = SegmentationImage(segmdata) cat = SourceCatalog(data, segm, localbkg_width=3) assert cat.min_value == 10 assert cat.max_value == 10 def test_make_circular_apertures(self): radius = 10 aper = self.cat.make_circular_apertures(radius) assert len(aper) == len(self.cat) assert isinstance(aper[1], CircularAperture) assert aper[1].r == radius obj = self.cat[1] aper = obj.make_circular_apertures(radius) assert isinstance(aper, CircularAperture) assert aper.r == radius def test_make_kron_apertures(self): aper = self.cat.make_kron_apertures() assert len(aper) == len(self.cat) assert isinstance(aper[1], EllipticalAperture) aper2 = self.cat.make_kron_apertures((2.0, 1.4)) assert len(aper2) == len(self.cat) obj = self.cat[1] aper = obj.make_kron_apertures() assert isinstance(aper, EllipticalAperture) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_make_cutouts(self): data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) shape = (100, 100) with pytest.raises(ValueError): cat.make_cutouts(shape, mode='strict') cutouts = cat.make_cutouts(shape, mode='trim') assert cutouts[0].data.shape != shape assert_equal(cutouts[0].xyorigin, np.array((186, 0))) assert (cutouts[0].bbox_original == BoundingBox(ixmin=186, ixmax=286, iymin=0, iymax=52)) cutouts = cat.make_cutouts(shape, mode='partial') assert_equal(cutouts[0].xyorigin, np.array((186, -48))) assert (cutouts[0].bbox_original == BoundingBox(ixmin=186, ixmax=286, iymin=0, iymax=52)) assert len(cutouts) == len(cat) assert isinstance(cutouts[1], CutoutImage) assert cutouts[1].data.shape == shape obj = cat[1] cut = obj.make_cutouts(shape) assert isinstance(cut, CutoutImage) assert cut.data.shape == shape cutouts = cat.make_cutouts(shape, mode='partial', fill_value=-100) assert cutouts[0].data[0, 0] == -100 # cutout will be None if source is completely masked cutouts = self.cat.make_cutouts(shape) assert cutouts[0] is None def test_meta(self): meta = self.cat.meta attrs = ['localbkg_width', 'apermask_method', 'kron_params'] for attr in attrs: assert attr in meta tbl = self.cat.to_table() assert tbl.meta == self.cat.meta out = StringIO() tbl.write(out, format='ascii.ecsv') tbl2 = QTable.read(out.getvalue(), format='ascii.ecsv') # check order of meta keys assert list(tbl2.meta.keys()) == list(tbl.meta.keys()) def test_semode(self): self.cat._set_semode() tbl = self.cat.to_table() assert len(tbl) == 7 def test_tiny_sources(self): data = np.zeros((11, 11)) data[5, 5] = 1.0 data[8, 8] = 1.0 segm = detect_sources(data, 0.1, 1) data[8, 8] = 0 cat = SourceCatalog(data, segm) assert_allclose(cat[0].covariance, [(1 / 12, 0), (0, 1 / 12)] * u.pix**2) assert_allclose(cat[1].covariance, [(np.nan, np.nan), (np.nan, np.nan)] * u.pix**2) assert_allclose(cat.fwhm, [0.67977799, np.nan] * u.pix) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_kron_params(): data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segm = finder(convolved_data, threshold) minrad = 1.4 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == minrad assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.293722, rtol=1e-6) minrad = 1.2 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == minrad assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.312618, rtol=1e-6) minrad = 0.2 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert_allclose(cat.kron_radius.value.min(), 0.677399, rtol=1e-6) assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.232554) kron_params = (2.5, 1.4, 7.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == 0.0 assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.288211, rtol=1e-6) assert isinstance(cat.kron_aperture[0], CircularAperture) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_centroid_win(): g1 = Gaussian2D(1621, 6.29, 10.95, 1.55, 1.29, 0.296706) g2 = Gaussian2D(3596, 13.81, 8.29, 1.44, 1.27, 0.628319) m = g1 + g2 yy, xx = np.mgrid[0:21, 0:21] data = m(xx, yy) noise = make_noise_image(data.shape, mean=0, stddev=65.0, seed=123) data += noise kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) threshold = 107.9 segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, apermask_method='none') assert cat.xcentroid[0] != cat.xcentroid_win[0] assert cat.ycentroid[0] != cat.ycentroid_win[0] # centroid_win moved beyond 1-sigma ellipse and was reset to # isophotal centroid assert cat.xcentroid[1] == cat.xcentroid_win[1] assert cat.ycentroid[1] == cat.ycentroid_win[1] @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_centroid_win_migrate(): """ Test that when the windowed centroid moves the aperture completely off the image the isophotal centroid is returned. """ g1 = Gaussian2D(1621, 76.29, 185.95, 1.55, 1.29, 0.296706) g2 = Gaussian2D(3596, 83.81, 182.29, 1.44, 1.27, 0.628319) m = g1 + g2 yy, xx = np.mgrid[0:256, 0:256] data = m(xx, yy) noise = make_noise_image(data.shape, mean=0, stddev=65.0, seed=123) data += noise segm = detect_sources(data, 98.0, npixels=5) cat = SourceCatalog(data, segm) indices = (0, 3, 14, 30) for idx in indices: assert_equal(cat.centroid_win[idx], cat.centroid[idx]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_core.py0000644000175100001770000004470514637570305023513 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import numpy as np import pytest from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.core import Segment, SegmentationImage from photutils.utils import circular_footprint from photutils.utils._optional_deps import (HAS_MATPLOTLIB, HAS_RASTERIO, HAS_SCIPY, HAS_SHAPELY) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestSegmentationImage: def setup_class(self): self.data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) self.segm = SegmentationImage(self.data) def test_array(self): assert_allclose(self.segm.data, self.segm.__array__()) def test_copy(self): segm = SegmentationImage(self.data.copy()) segm2 = segm.copy() assert segm.data is not segm2.data assert segm.labels is not segm2.labels segm.data[0, 0] = 100.0 assert segm.data[0, 0] != segm2.data[0, 0] def test_slicing(self): segm2 = self.segm[1:5, 2:5] assert segm2.shape == (4, 3) assert_equal(segm2.labels, [3, 5]) assert segm2.data.sum() == 16 with pytest.raises(TypeError): self.segm[1] with pytest.raises(TypeError): self.segm[1:10] with pytest.raises(TypeError): self.segm[1:1, 2:4] def test_data_all_zeros(self): data = np.zeros((5, 5), dtype=int) segm = SegmentationImage(data) assert segm.max_label == 0 assert not segm.is_consecutive assert segm.cmap is None with pytest.warns(AstropyUserWarning, match='segmentation image of all zeros'): segm.relabel_consecutive() def test_data_reassignment(self): segm = SegmentationImage(self.data.copy()) segm.data = self.data[0:3, :].copy() assert_equal(segm.labels, [1, 3, 4]) def test_invalid_data(self): # is float dtype data = np.zeros((3, 3), dtype=float) with pytest.raises(TypeError): SegmentationImage(data) # contains a negative value data = np.arange(-1, 8).reshape(3, 3).astype(int) with pytest.raises(ValueError): SegmentationImage(data) # is not ndarray data = [[1, 1], [0, 1]] with pytest.raises(TypeError): SegmentationImage(data) @pytest.mark.parametrize('label', [0, -1, 2]) def test_invalid_label(self, label): # test with scalar labels with pytest.raises(ValueError): self.segm.check_label(label) self.segm.check_labels(label) def test_invalid_label_array(self): # test with array of labels with pytest.raises(ValueError): self.segm.check_labels([0, -1, 2]) def test_data_ma(self): assert isinstance(self.segm.data_ma, np.ma.MaskedArray) assert np.ma.count(self.segm.data_ma) == 18 assert np.ma.count_masked(self.segm.data_ma) == 18 def test_segments(self): assert isinstance(self.segm.segments[0], Segment) assert_allclose(self.segm.segments[0].data, self.segm.segments[0].__array__()) assert (self.segm.segments[0].data_ma.shape == self.segm.segments[0].data.shape) assert (self.segm.segments[0].data_ma.filled(0.0).sum() == self.segm.segments[0].data.sum()) label = 4 idx = self.segm.get_index(label) assert self.segm.segments[idx].label == label assert self.segm.segments[idx].area == self.segm.areas[idx] assert self.segm.segments[idx].slices == self.segm.slices[idx] assert self.segm.segments[idx].bbox == self.segm.bbox[idx] def test_repr_str(self): assert repr(self.segm) == str(self.segm) props = ['shape', 'nlabels'] for prop in props: assert f'{prop}:' in repr(self.segm) def test_segment_repr_str(self): props = ['label', 'slices', 'area'] for prop in props: assert f'{prop}:' in repr(self.segm.segments[0]) def test_segment_data(self): assert_allclose(self.segm.segments[3].data.shape, (3, 3)) assert_allclose(np.unique(self.segm.segments[3].data), [0, 5]) def test_segment_make_cutout(self): cutout = self.segm.segments[3].make_cutout(self.data, masked_array=False) assert not np.ma.is_masked(cutout) assert_allclose(cutout.shape, (3, 3)) cutout = self.segm.segments[3].make_cutout(self.data, masked_array=True) assert np.ma.is_masked(cutout) assert_allclose(cutout.shape, (3, 3)) def test_segment_make_cutout_input(self): with pytest.raises(ValueError): self.segm.segments[0].make_cutout(np.arange(10)) def test_labels(self): assert_allclose(self.segm.labels, [1, 3, 4, 5, 7]) def test_nlabels(self): assert self.segm.nlabels == 5 def test_max_label(self): assert self.segm.max_label == 7 def test_areas(self): expected = np.array([2, 2, 3, 6, 5]) assert_allclose(self.segm.areas, expected) assert (self.segm.get_area(1) == self.segm.areas[self.segm.get_index(1)]) assert_allclose(self.segm.get_areas(self.segm.labels), self.segm.areas) def test_background_area(self): assert self.segm.background_area == 18 def test_is_consecutive(self): assert not self.segm.is_consecutive data = np.array([[2, 2, 0], [0, 3, 3], [0, 0, 4]]) segm = SegmentationImage(data) assert not segm.is_consecutive # does not start with label=1 segm.relabel_consecutive(start_label=1) assert segm.is_consecutive def test_missing_labels(self): assert_allclose(self.segm.missing_labels, [2, 6]) def test_check_labels(self): with pytest.raises(ValueError): self.segm.check_label(2) self.segm.check_labels([2]) with pytest.raises(ValueError): self.segm.check_labels([2, 6]) def test_bbox_1d(self): segm = SegmentationImage(np.array([0, 0, 1, 1, 0, 2, 2, 0])) with pytest.raises(ValueError): _ = segm.bbox @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_reset_cmap(self): segm = self.segm.copy() cmap = segm.cmap.copy() segm.reset_cmap(seed=123) assert not np.array_equal(cmap.colors, segm.cmap.colors) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_make_cmap(self): cmap = self.segm.make_cmap() assert len(cmap.colors) == (self.segm.max_label + 1) assert_allclose(cmap.colors[0], [0, 0, 0, 1]) assert_allclose(self.segm.cmap.colors, self.segm.make_cmap(background_color='#000000ff', seed=0).colors) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize('color, alpha', (('#00000000', 0.0), ('#00000040', 64 / 255), ('#00000080', 128 / 255), ('#000000C0', 192 / 255), ('#000000FF', 1.0))) def test_make_cmap_alpha(self, color, alpha): cmap = self.segm.make_cmap(background_color=color) assert_allclose(cmap.colors[0], (0, 0, 0, alpha)) def test_reassign_labels(self): segm = SegmentationImage(self.data.copy()) segm.reassign_labels(labels=[1, 7], new_label=2) ref_data = np.array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [2, 0, 0, 0, 0, 5], [2, 2, 0, 5, 5, 5], [2, 2, 0, 0, 5, 5]]) assert_allclose(segm.data, ref_data) assert segm.nlabels == len(segm.slices) - segm.slices.count(None) @pytest.mark.parametrize('start_label', [1, 5]) def test_relabel_consecutive(self, start_label): segm = SegmentationImage(self.data.copy()) ref_data = np.array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [5, 0, 0, 0, 0, 4], [5, 5, 0, 4, 4, 4], [5, 5, 0, 0, 4, 4]]) ref_data[ref_data != 0] += (start_label - 1) segm.relabel_consecutive(start_label=start_label) assert_allclose(segm.data, ref_data) # relabel_consecutive should do nothing if already consecutive segm.relabel_consecutive(start_label=start_label) assert_allclose(segm.data, ref_data) assert segm.nlabels == len(segm.slices) - segm.slices.count(None) # test slices caching segm = SegmentationImage(self.data.copy()) slc1 = segm.slices segm.relabel_consecutive() assert slc1 == segm.slices @pytest.mark.parametrize('start_label', [0, -1]) def test_relabel_consecutive_start_invalid(self, start_label): with pytest.raises(ValueError): segm = SegmentationImage(self.data.copy()) segm.relabel_consecutive(start_label=start_label) def test_keep_labels(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 5], [0, 0, 0, 5, 5, 5], [0, 0, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) segm.keep_labels([5, 3]) assert_allclose(segm.data, ref_data) def test_keep_labels_relabel(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 2], [0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 2, 2]]) segm = SegmentationImage(self.data.copy()) segm.keep_labels([5, 3], relabel=True) assert_allclose(segm.data, ref_data) def test_remove_labels(self): ref_data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 0, 0, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_labels(labels=[5, 3]) assert_allclose(segm.data, ref_data) def test_remove_labels_relabel(self): ref_data = np.array([[1, 1, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 0, 0, 0, 0], [3, 0, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_labels(labels=[5, 3], relabel=True) assert_allclose(segm.data, ref_data) def test_remove_border_labels(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_border_labels(border_width=1) assert_allclose(segm.data, ref_data) def test_remove_border_labels_border_width(self): with pytest.raises(ValueError): segm = SegmentationImage(self.data.copy()) segm.remove_border_labels(border_width=3) def test_remove_border_labels_no_remaining_segments(self): alt_data = self.data.copy() alt_data[alt_data == 3] = 0 segm = SegmentationImage(alt_data) segm.remove_border_labels(border_width=1, relabel=True) assert segm.nlabels == 0 def test_remove_masked_labels(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) mask = np.zeros(segm.data.shape, dtype=bool) mask[0, :] = True segm.remove_masked_labels(mask) assert_allclose(segm.data, ref_data) def test_remove_masked_labels_without_partial_overlap(self): ref_data = np.array([[0, 0, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) mask = np.zeros(segm.data.shape, dtype=bool) mask[0, :] = True segm.remove_masked_labels(mask, partial_overlap=False) assert_allclose(segm.data, ref_data) def test_remove_masked_segments_mask_shape(self): segm = SegmentationImage(np.ones((5, 5), dtype=int)) mask = np.zeros((3, 3), dtype=bool) with pytest.raises(ValueError): segm.remove_masked_labels(mask) def test_make_source_mask(self): segm_array = np.zeros((7, 7)).astype(int) segm_array[3, 3] = 1 segm = SegmentationImage(segm_array) mask = segm.make_source_mask() assert_equal(mask, segm_array.astype(bool)) mask = segm.make_source_mask(size=3) expected1 = 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]]) assert_equal(mask.astype(int), expected1) mask = segm.make_source_mask(footprint=np.ones((3, 3))) assert_equal(mask.astype(int), expected1) footprint = circular_footprint(radius=3) mask = segm.make_source_mask(footprint=footprint) expected2 = np.array([[0, 0, 0, 1, 0, 0, 0], [0, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1], [0, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 0], [0, 0, 0, 1, 0, 0, 0]]) assert_equal(mask.astype(int), expected2) mask = segm.make_source_mask(footprint=np.ones((3, 3)), size=5) assert_equal(mask, expected1) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow(self): from matplotlib.image import AxesImage axim = self.segm.imshow(figsize=(5, 5)) assert isinstance(axim, AxesImage) axim, cbar = self.segm.imshow_map(figsize=(5, 5)) assert isinstance(axim, AxesImage) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_polygons(self): from shapely.geometry.polygon import Polygon polygons = self.segm.polygons assert len(polygons) == self.segm.nlabels assert isinstance(polygons[0], Polygon) data = np.zeros((5, 5), dtype=int) data[2, 2] = 10 segm = SegmentationImage(data) polygons = segm.polygons assert len(polygons) == 1 verts = np.array(polygons[0].exterior.coords) expected_verts = np.array([[1.5, 1.5], [1.5, 2.5], [2.5, 2.5], [2.5, 1.5], [1.5, 1.5]]) assert_equal(verts, expected_verts) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_patches(self): from matplotlib.patches import Polygon patches = self.segm.to_patches(edgecolor='blue') assert isinstance(patches[0], Polygon) assert patches[0].get_edgecolor() == (0, 0, 1, 1) scale = 2.0 patches2 = self.segm.to_patches(scale=scale) v1 = patches[0].get_verts() v2 = patches2[0].get_verts() v3 = scale * (v1 + 0.5) - 0.5 assert_allclose(v2, v3) patches = self.segm.plot_patches(edgecolor='red') assert isinstance(patches[0], Polygon) assert patches[0].get_edgecolor() == (1, 0, 0, 1) patches = self.segm.plot_patches(labels=1) assert len(patches) == 1 assert isinstance(patches, list) assert isinstance(patches[0], Polygon) patches = self.segm.plot_patches(labels=(4, 7)) assert len(patches) == 2 assert isinstance(patches, list) assert isinstance(patches[0], Polygon) class CustomSegm(SegmentationImage): @lazyproperty def value(self): return np.median(self.data) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_subclass(): """ Test that cached properties are reset in SegmentationImage subclasses. """ data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = CustomSegm(data) _ = segm.slices, segm.labels, segm.value, segm.areas data2 = np.array([[10, 10, 0, 40], [0, 0, 0, 40], [70, 70, 0, 0], [70, 70, 0, 1]]) segm.data = data2 assert len(segm.__dict__) == 2 assert_equal(segm.areas, [1, 2, 2, 4]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_deblend.py0000644000175100001770000003310014637570305024143 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deblend module. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.core import SegmentationImage from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.utils._optional_deps import HAS_SCIPY, HAS_SKIMAGE @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') class TestDeblendSources: def setup_class(self): g1 = Gaussian2D(100, 50, 50, 5, 5) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(30, 70, 50, 5, 5) y, x = np.mgrid[0:100, 0:100] self.x = x self.y = y self.data = g1(x, y) + g2(x, y) self.data3 = self.data + g3(x, y) self.threshold = 10 self.npixels = 5 self.segm = detect_sources(self.data, self.threshold, self.npixels) self.segm3 = detect_sources(self.data3, self.threshold, self.npixels) @pytest.mark.parametrize('mode', ['exponential', 'linear', 'sinh']) def test_deblend_sources(self, mode): result = deblend_sources(self.data, self.segm, self.npixels, mode=mode, progress_bar=False) if mode == 'linear': # test multiprocessing result2 = deblend_sources(self.data, self.segm, self.npixels, mode=mode, progress_bar=False, nproc=2) assert_equal(result.data, result2.data) assert result.nlabels == 2 assert result.nlabels == len(result.slices) mask1 = (result.data == 1) mask2 = (result.data == 2) assert_allclose(len(result.data[mask1]), len(result.data[mask2])) assert_allclose(np.sum(self.data[mask1]), np.sum(self.data[mask2])) assert_allclose(np.nonzero(self.segm), np.nonzero(result)) def test_deblend_multiple_sources(self): g4 = Gaussian2D(100, 50, 15, 5, 5) g5 = Gaussian2D(100, 35, 15, 5, 5) g6 = Gaussian2D(100, 50, 85, 5, 5) g7 = Gaussian2D(100, 35, 85, 5, 5) x = self.x y = self.y data = self.data + g4(x, y) + g5(x, y) + g6(x, y) + g7(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, progress_bar=False) assert result.nlabels == 6 assert result.nlabels == len(result.slices) assert result.areas[0] == result.areas[1] assert result.areas[0] == result.areas[2] assert result.areas[0] == result.areas[3] assert result.areas[0] == result.areas[4] assert result.areas[0] == result.areas[5] def test_deblend_multiple_sources_with_neighbor(self): g1 = Gaussian2D(100, 50, 50, 20, 5, theta=45) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(100, 60, 20, 5, 5) x = self.x y = self.y data = (g1 + g2 + g3)(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, progress_bar=False) assert result.nlabels == 3 def test_deblend_labels(self): g1 = Gaussian2D(100, 50, 50, 20, 5, theta=45) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(100, 60, 20, 5, 5) x = self.x y = self.y data = (g1 + g2 + g3)(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, labels=1, progress_bar=False) assert result.nlabels == 2 @pytest.mark.parametrize('contrast, nlabels', ((0.001, 6), (0.017, 5), (0.06, 4), (0.1, 3), (0.15, 2), (0.45, 1))) def test_deblend_contrast(self, contrast, nlabels): y, x = np.mgrid[0:51, 0:151] y0 = 25 data = (Gaussian2D(9.5, 16, y0, 5, 5)(x, y) + Gaussian2D(51, 30, y0, 3, 3)(x, y) + Gaussian2D(30, 42, y0, 5, 5)(x, y) + Gaussian2D(80, 66, y0, 8, 8)(x, y) + Gaussian2D(71, 88, y0, 8, 8)(x, y) + Gaussian2D(18, 119, y0, 7, 7)(x, y)) npixels = 5 segm = detect_sources(data, 1.0, npixels) segm2 = deblend_sources(data, segm, npixels, mode='linear', nlevels=32, contrast=contrast, progress_bar=False) assert segm2.nlabels == nlabels def test_deblend_contrast_levels(self): # regression test for case where contrast = 1.0 y, x = np.mgrid[0:51, 0:151] y0 = 25 data = (Gaussian2D(9.5, 16, y0, 5, 5)(x, y) + Gaussian2D(51, 30, y0, 3, 3)(x, y) + Gaussian2D(30, 42, y0, 5, 5)(x, y) + Gaussian2D(80, 66, y0, 8, 8)(x, y) + Gaussian2D(71, 88, y0, 8, 8)(x, y) + Gaussian2D(18, 119, y0, 7, 7)(x, y)) npixels = 5 segm = detect_sources(data, 1.0, npixels) for contrast in np.arange(1, 11) / 10.0: segm3 = deblend_sources(data, segm, npixels, mode='linear', nlevels=32, contrast=contrast, progress_bar=False) assert segm3.nlabels >= 1 def test_deblend_connectivity(self): data = np.zeros((51, 51)) data[15:36, 15:36] = 10.0 data[14, 36] = 1.0 data[13, 37] = 10 data[14, 14] = 5.0 data[13, 13] = 10.0 data[36, 14] = 10.0 data[37, 13] = 10.0 data[36, 36] = 10.0 data[37, 37] = 10.0 segm = detect_sources(data, 0.1, 1, connectivity=4) assert segm.nlabels == 9 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) assert segm2.nlabels == 9 segm = detect_sources(data, 0.1, 1, connectivity=8) assert segm.nlabels == 1 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=8, progress_bar=False) assert segm2.nlabels == 3 with pytest.raises(ValueError): deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) def test_deblend_label_assignment(self): """ Regression test to ensure newly-deblended labels are unique. """ y, x = np.mgrid[0:201, 0:101] y0a = 35 y1a = 60 yshift = 100 y0b = y0a + yshift y1b = y1a + yshift data = (Gaussian2D(80, 36, y0a, 8, 8)(x, y) + Gaussian2D(71, 58, y1a, 8, 8)(x, y) + Gaussian2D(30, 36, y1a, 7, 7)(x, y) + Gaussian2D(30, 58, y0a, 7, 7)(x, y) + Gaussian2D(80, 36, y0b, 8, 8)(x, y) + Gaussian2D(71, 58, y1b, 8, 8)(x, y) + Gaussian2D(30, 36, y1b, 7, 7)(x, y) + Gaussian2D(30, 58, y0b, 7, 7)(x, y)) npixels = 5 segm1 = detect_sources(data, 5.0, npixels) segm2 = deblend_sources(data, segm1, npixels, mode='linear', nlevels=32, contrast=0.3, progress_bar=False) assert segm2.nlabels == 4 @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_sources_norelabel(self, mode): result = deblend_sources(self.data, self.segm, self.npixels, mode=mode, relabel=False, progress_bar=False) assert result.nlabels == 2 assert len(result.slices) <= result.max_label assert len(result.slices) == result.nlabels assert_allclose(np.nonzero(self.segm), np.nonzero(result)) @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_three_sources(self, mode): result = deblend_sources(self.data3, self.segm3, self.npixels, mode=mode, progress_bar=False) assert result.nlabels == 3 assert_allclose(np.nonzero(self.segm3), np.nonzero(result)) def test_segment_img(self): segm_wrong = np.ones((2, 2), dtype=int) # ndarray with pytest.raises(ValueError): deblend_sources(self.data, segm_wrong, self.npixels, progress_bar=False) segm_wrong = SegmentationImage(segm_wrong) # wrong shape with pytest.raises(ValueError): deblend_sources(self.data, segm_wrong, self.npixels, progress_bar=False) def test_invalid_nlevels(self): with pytest.raises(ValueError): deblend_sources(self.data, self.segm, self.npixels, nlevels=0, progress_bar=False) def test_invalid_contrast(self): with pytest.raises(ValueError): deblend_sources(self.data, self.segm, self.npixels, contrast=-1, progress_bar=False) def test_invalid_mode(self): with pytest.raises(ValueError): deblend_sources(self.data, self.segm, self.npixels, mode='invalid', progress_bar=False) def test_invalid_connectivity(self): with pytest.raises(ValueError): deblend_sources(self.data, self.segm, self.npixels, connectivity='invalid', progress_bar=False) def test_constant_source(self): data = self.data.copy() data[data.nonzero()] = 1.0 result = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert_allclose(result, self.segm) def test_source_with_negval(self): data = self.data.copy() data -= 20 with pytest.warns(AstropyUserWarning, match='The deblending mode'): segm = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_source_zero_min(self): data = self.data.copy() data -= data[self.segm.data > 0].min() with pytest.warns(AstropyUserWarning, match='The deblending mode'): segm = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_connectivity(self): """Regression test for #341.""" data = np.zeros((3, 3)) data[0, 0] = 2 data[1, 1] = 2 data[2, 2] = 1 segm = np.zeros(data.shape, dtype=int) segm[data.nonzero()] = 1 segm = SegmentationImage(segm) data = data * 100.0 segm_deblend = deblend_sources(data, segm, npixels=1, connectivity=8, progress_bar=False) assert segm_deblend.nlabels == 1 with pytest.raises(ValueError): deblend_sources(data, segm, npixels=1, connectivity=4, progress_bar=False) def test_data_nan(self): """ Test that deblending occurs even if the data within a segment contains one or more NaNs. Regression test for #658. """ data = self.data.copy() data[50, 50] = np.nan segm2 = deblend_sources(data, self.segm, 5, progress_bar=False) assert segm2.nlabels == 2 def test_watershed(self): """ Regression test to ensure watershed input mask is bool array. With scikit-image >= 0.13, the mask must be a bool array. In particular, if the mask array contains label 512, the watershed algorithm fails. """ segm = self.segm.copy() segm.reassign_label(1, 512) result = deblend_sources(self.data, segm, self.npixels, progress_bar=False) assert result.nlabels == 2 def test_nondetection(self): """ Test for case where no sources are detected at one of the threshold levels. For this case, a `NoDetectionsWarning` should not be raised when deblending sources. """ data = np.copy(self.data3) data[50, 50] = 1000.0 data[50, 70] = 500.0 self.segm = detect_sources(data, self.threshold, self.npixels) deblend_sources(data, self.segm, self.npixels, progress_bar=False) def test_nonconsecutive_labels(self): segm = self.segm.copy() segm.reassign_label(1, 1000) result = deblend_sources(self.data, segm, self.npixels, progress_bar=False) assert result.nlabels == 2 @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nmarkers_fallback(): """ If there are too many markers, a warning is raised. """ size = 51 data1 = np.resize([0, 0, 1, 1], size) data1 = np.abs(data1 - np.atleast_2d(data1).T) + 2 for i in range(size): if i % 2 == 0: data1[i, :] = 1 data1[:, i] = 1 data = np.zeros((101, 101)) data[25:25 + size, 25:25 + size] = data1 data[50:60, 50:60] = 10.0 segm = detect_sources(data, 0.01, 10) with pytest.warns(AstropyUserWarning, match='The deblending mode'): segm2 = deblend_sources(data, segm, 1, mode='exponential') assert segm2.info['warnings']['nmarkers']['input_labels'][0] == 1 mesg = segm2.info['warnings']['nmarkers']['message'] assert mesg.startswith('Deblending mode changed') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_detect.py0000644000175100001770000002165614637570305024033 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the detect module. """ import astropy.units as u import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.detect import detect_sources, detect_threshold from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_SCIPY from photutils.utils.exceptions import NoDetectionsWarning DATA = np.array([[0, 1, 0], [0, 2, 0], [0, 0, 0]]).astype(float) REF1 = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]]) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestDetectThreshold: def test_nsigma(self): """Test basic nsigma.""" threshold = detect_threshold(DATA, nsigma=0.1) ref = 0.4 * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.uJy, nsigma=0.1) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_nsigma_zero(self): """Test nsigma=0.""" threshold = detect_threshold(DATA, nsigma=0.0) ref = (1.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background(self): threshold = detect_threshold(DATA, nsigma=1.0, background=1) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background_image(self): background = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=1.0, background=background) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=1.0, background=background << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_background_badshape(self): wrong_shape = np.zeros((2, 2)) with pytest.raises(ValueError): detect_threshold(DATA, nsigma=2.0, background=wrong_shape) def test_error(self): threshold = detect_threshold(DATA, nsigma=1.0, error=1) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_error_image(self): error = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=1.0, error=error) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=1.0, error=error << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_error_badshape(self): wrong_shape = np.zeros((2, 2)) with pytest.raises(ValueError): detect_threshold(DATA, nsigma=2.0, error=wrong_shape) def test_background_error(self): threshold = detect_threshold(DATA, nsigma=2.0, background=10.0, error=1.0) ref = 12.0 * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0 * u.Jy, error=1.0 * u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) with pytest.raises(ValueError): detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0, error=1.0 * u.Jy) with pytest.raises(ValueError): detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0 * u.m, error=1.0 * u.Jy) def test_background_error_images(self): background = np.ones((3, 3)) * 10.0 error = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=2.0, background=background, error=error) ref = 12.0 * np.ones((3, 3)) assert_allclose(threshold, ref) def test_image_mask(self): """ Test detection with image_mask. Set sigma=10 and iters=1 to prevent sigma clipping after applying the mask. """ mask = REF1.astype(bool) sigma_clip = SigmaClip(sigma=10, maxiters=1) threshold = detect_threshold(DATA, nsigma=1.0, error=0, mask=mask, sigma_clip=sigma_clip) ref = (1.0 / 8.0) * np.ones((3, 3)) assert_equal(threshold, ref) def test_invalid_sigma_clip(self): with pytest.raises(TypeError): detect_threshold(DATA, 1.0, sigma_clip=10) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestDetectSources: def setup_class(self): self.data = np.array([[0, 1, 0], [0, 2, 0], [0, 0, 0]]).astype(float) self.refdata = np.array([[0, 1, 0], [0, 1, 0], [0, 0, 0]]) kernel = make_2dgaussian_kernel(2.0, size=3) self.kernel = kernel def test_detection(self): """Test basic detection.""" segm = detect_sources(self.data, threshold=0.9, npixels=2) assert_equal(segm.data, self.refdata) segm = detect_sources(self.data << u.uJy, threshold=0.9 * u.uJy, npixels=2) assert_equal(segm.data, self.refdata) with pytest.raises(ValueError): detect_sources(self.data << u.uJy, threshold=0.9, npixels=2) with pytest.raises(ValueError): detect_sources(self.data, threshold=0.9 * u.Jy, npixels=2) with pytest.raises(ValueError): detect_sources(self.data << u.uJy, threshold=0.9 * u.m, npixels=2) def test_small_sources(self): """Test detection where sources are smaller than npixels size.""" with pytest.warns(NoDetectionsWarning, match='No sources were found'): detect_sources(self.data, threshold=0.9, npixels=5) def test_npixels(self): """ Test removal of sources whose size is less than npixels. Regression tests for #663. """ data = np.zeros((8, 8)) data[0:4, 0] = 1 data[0, 0:4] = 1 data[3, 3:] = 2 data[3:, 3] = 2 segm = detect_sources(data, 0, npixels=8) assert segm.nlabels == 1 segm = detect_sources(data, 0, npixels=9) assert segm.nlabels == 1 data = np.zeros((8, 8)) data[0:4, 0] = 1 data[0, 0:4] = 1 data[3, 2:] = 2 data[3:, 2] = 2 data[5:, 3] = 2 npixels = np.arange(9, 14) for npixels in np.arange(9, 14): segm = detect_sources(data, 0, npixels=npixels) assert segm.nlabels == 1 assert segm.areas[0] == 13 with pytest.warns(NoDetectionsWarning, match='No sources were found'): detect_sources(data, 0, npixels=14) def test_zerothresh(self): """Test detection with zero threshold.""" segm = detect_sources(self.data, threshold=0.0, npixels=2) assert_equal(segm.data, self.refdata) def test_zerodet(self): """Test detection with large threshold giving no detections.""" with pytest.warns(NoDetectionsWarning, match='No sources were found'): detect_sources(self.data, threshold=7, npixels=2) def test_8connectivity(self): """Test detection with connectivity=8.""" data = np.eye(3) segm = detect_sources(data, threshold=0.9, npixels=1, connectivity=8) assert_equal(segm.data, data) def test_4connectivity(self): """Test detection with connectivity=4.""" data = np.eye(3) ref = np.diag([1, 2, 3]) segm = detect_sources(data, threshold=0.9, npixels=1, connectivity=4) assert_equal(segm.data, ref) def test_npixels_nonint(self): """Test if error raises if npixel is non-integer.""" with pytest.raises(ValueError): detect_sources(self.data, threshold=1, npixels=0.1) def test_npixels_negative(self): """Test if error raises if npixel is negative.""" with pytest.raises(ValueError): detect_sources(self.data, threshold=1, npixels=-1) def test_connectivity_invalid(self): """Test if error raises if connectivity is invalid.""" with pytest.raises(ValueError): detect_sources(self.data, threshold=1, npixels=1, connectivity=10) def test_mask(self): data = np.zeros((11, 11)) data[3:8, 3:8] = 5.0 mask = np.zeros(data.shape, dtype=bool) mask[4:6, 4:6] = True segm1 = detect_sources(data, 1.0, 1.0) segm2 = detect_sources(data, 1.0, 1.0, mask=mask) assert segm2.areas[0] == segm1.areas[0] - mask.sum() # mask with all True with pytest.raises(ValueError): mask = np.ones(data.shape, dtype=bool) detect_sources(data, 1.0, 1.0, mask=mask) def test_mask_shape(self): with pytest.raises(ValueError): detect_sources(self.data, 1.0, 1.0, mask=np.ones((5, 5))) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_finder.py0000644000175100001770000000572114637570305024025 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the finder module. """ import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.modeling.models import Gaussian2D from photutils.datasets import make_100gaussians_image from photutils.segmentation.finder import SourceFinder from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_SCIPY, HAS_SKIMAGE from photutils.utils.exceptions import NoDetectionsWarning @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestSourceFinder: data = make_100gaussians_image() - 5.0 # subtract background kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel, normalize_kernel=True) threshold = 1.5 * 2.0 npixels = 10 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_deblend(self): finder = SourceFinder(npixels=self.npixels, progress_bar=False) segm1 = finder(self.convolved_data, self.threshold) assert segm1.nlabels == 94 segm2 = finder(self.convolved_data << u.uJy, self.threshold * u.uJy) assert segm2.nlabels == 94 assert np.all(segm1.data == segm2.data) def test_invalid_units(self): finder = SourceFinder(npixels=self.npixels, progress_bar=False) with pytest.raises(ValueError): finder(self.convolved_data << u.uJy, self.threshold) with pytest.raises(ValueError): finder(self.convolved_data, self.threshold * u.uJy) with pytest.raises(ValueError): finder(self.convolved_data << u.uJy, self.threshold * u.m) def test_no_deblend(self): finder = SourceFinder(npixels=self.npixels, deblend=False, progress_bar=False) segm = finder(self.convolved_data, self.threshold) assert segm.nlabels == 87 def test_no_sources(self): finder = SourceFinder(npixels=self.npixels, deblend=True, progress_bar=False) with pytest.warns(NoDetectionsWarning, match='No sources were found'): segm = finder(self.convolved_data, 1000) assert segm is None def test_npixels_tuple(self): g1 = Gaussian2D(10, 35, 45, 5, 5) g2 = Gaussian2D(10, 50, 50, 5, 5) g3 = Gaussian2D(10, 66, 55, 5, 5) yy, xx = np.mgrid[0:101, 0:101] data = g1(xx, yy) + g2(xx, yy) + g3(xx, yy) sf1 = SourceFinder(npixels=200) segm1 = sf1(data, threshold=0.1) assert segm1.nlabels == 1 sf2 = SourceFinder(npixels=(200, 5)) segm2 = sf2(data, threshold=0.1) assert segm2.nlabels == 3 def test_repr(self): finder = SourceFinder(npixels=self.npixels, deblend=False, progress_bar=False) cls_repr = repr(finder) assert cls_repr.startswith(finder.__class__.__name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/tests/test_utils.py0000644000175100001770000001006214637570305023710 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _utils module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.utils import (_make_binary_structure, _mask_to_mirrored_value, make_2dgaussian_kernel) from photutils.utils._optional_deps import HAS_SCIPY def test_make_2dgaussian_kernel(): kernel = make_2dgaussian_kernel(1.0, size=3) expected = np.array([[0.01411809, 0.0905834, 0.01411809], [0.0905834, 0.58119403, 0.0905834], [0.01411809, 0.0905834, 0.01411809]]) assert_allclose(kernel.array, expected, atol=1.0e-6) assert_allclose(kernel.array.sum(), 1.0) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_2dgaussian_kernel_modes(): kernel = make_2dgaussian_kernel(3.0, 5) assert_allclose(kernel.array.sum(), 1.0) kernel = make_2dgaussian_kernel(3.0, 5, mode='center') assert_allclose(kernel.array.sum(), 1.0) kernel = make_2dgaussian_kernel(3.0, 5, mode='linear_interp') assert_allclose(kernel.array.sum(), 1.0) kernel = make_2dgaussian_kernel(3.0, 5, mode='integrate') assert_allclose(kernel.array.sum(), 1.0) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') def test_make_binary_structure(): footprint = _make_binary_structure(1, 4) assert_allclose(footprint, np.array([1, 1, 1])) footprint = _make_binary_structure(3, 4) assert_equal(footprint[0, 0], np.array([False, False, False])) expected = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]], [[0, 1, 0], [1, 1, 1], [0, 1, 0]], [[0, 0, 0], [0, 1, 0], [0, 0, 0]]]) assert_equal(footprint.astype(int), expected) def test_mask_to_mirrored_value(): center = (2.0, 2.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True data_ref = data.copy() data_ref[0, 0] = data[4, 4] data_ref[1, 1] = data[3, 3] mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_range(): """ Test mask_to_mirrored_value when mirrored pixels are outside of the image. """ center = (3.0, 3.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True mask[2, 2] = True data_ref = data.copy() data_ref[0, 0] = 0.0 data_ref[1, 1] = 0.0 data_ref[2, 2] = data[4, 4] mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_masked(): """ Test mask_to_mirrored_value when mirrored pixels are also in the replace_mask. """ center = (2.0, 2.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True mask[3, 3] = True mask[4, 4] = True data_ref = data.copy() data_ref[0, 0] = 0.0 data_ref[1, 1] = 0.0 data_ref[3, 3] = 0.0 data_ref[4, 4] = 0.0 mirror_data = _mask_to_mirrored_value(data, mask, center) mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_mask_keyword(): """ Test mask_to_mirrored_value when mirrored pixels are masked (via the mask keyword). """ center = (2.0, 2.0) data = np.arange(25.0).reshape(5, 5) replace_mask = np.zeros(data.shape, dtype=bool) mask = np.zeros(data.shape, dtype=bool) replace_mask[0, 2] = True data[4, 2] = np.nan mask[4, 2] = True result = _mask_to_mirrored_value(data, replace_mask, center, mask=mask) assert result[0, 2] == 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/segmentation/utils.py0000644000175100001770000001366514637570305021523 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides utility functions for image segmentation. """ import numpy as np from astropy.convolution import Gaussian2DKernel from astropy.stats import gaussian_fwhm_to_sigma from photutils.utils._parameters import as_pair __all__ = ['make_2dgaussian_kernel'] def make_2dgaussian_kernel(fwhm, size, mode='oversample', oversampling=10): """ Make a normalized 2D circular Gaussian kernel. The kernel must have odd sizes in both X and Y, be centered in the central pixel, and normalized to sum to 1. Parameters ---------- fwhm : float The full-width at half-maximum (FWHM) of the 2D circular Gaussian kernel. size : int or (2,) int array_like The size of the kernel along each axis. If ``size`` is a scalar then a square size of ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` (i.e., array shape) order. ``size`` must have odd values for both axes. mode : {'oversample', 'center', 'linear_interp', 'integrate'}, optional The mode to use for discretizing the 2D Gaussian model: * 'oversample' (default): Discretize model by taking the average on an oversampled grid. * 'center': Discretize model by taking the value at the center of the bin. * 'linear_interp': Discretize model by performing a bilinear interpolation between the values at the corners of the bin. * 'integrate': Discretize model by integrating the model over the bin. oversampling : int, optional The oversampling factor used when ``mode='oversample'``. Returns ------- kernel : `astropy.convolution.Kernel2D` The output smoothing kernel, normalized such that it sums to 1. """ ysize, xsize = as_pair('size', size, lower_bound=(0, 1), check_odd=True) kernel = Gaussian2DKernel(fwhm * gaussian_fwhm_to_sigma, x_size=xsize, y_size=ysize, mode=mode, factor=oversampling) kernel.normalize(mode='integral') # ensure kernel sums to 1 return kernel def _make_binary_structure(ndim, connectivity): """ Make a binary structure element. Parameters ---------- ndim : int The number of array dimensions. connectivity : {4, 8} For the case of ``ndim=2``, the type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. For reference, SourceExtractor uses 8-connected pixels. Returns ------- array : `~numpy.ndarray` The binary structure element. If ``ndim <= 2`` an array of int is returned, otherwise an array of bool is returned. """ if ndim == 1: footprint = np.array((1, 1, 1)) elif ndim == 2: if connectivity == 4: footprint = np.array(((0, 1, 0), (1, 1, 1), (0, 1, 0))) elif connectivity == 8: footprint = np.ones((3, 3), dtype=int) else: raise ValueError(f'Invalid connectivity={connectivity}. ' 'Options are 4 or 8.') else: from scipy.ndimage import generate_binary_structure footprint = generate_binary_structure(ndim, 1) return footprint def _mask_to_mirrored_value(data, replace_mask, xycenter, mask=None): """ Replace masked pixels with the value of the pixel mirrored across a given center position. If the mirror pixel is unavailable (i.e., it is outside of the image or masked), then the masked pixel value is set to zero. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array. replace_mask : 2D bool `~numpy.ndarray` A boolean mask where `True` values indicate the pixels that should be replaced, if possible, by mirrored pixel values. It must have the same shape as ``data``. xycenter : tuple of two int The (x, y) center coordinates around which masked pixels will be mirrored. mask : 2D bool `~numpy.ndarray` A boolean mask where `True` values indicate ``replace_mask`` *mirrored* pixels that should never be used to fix ``replace_mask`` pixels. In other words, if a pixel in ``replace_mask`` has a mirror pixel in this ``mask``, then the mirrored value is set to zero. Using this keyword prevents potential spreading of known non-finite or bad pixel values. Returns ------- result : 2D `~numpy.ndarray` A 2D array with replaced masked pixels. """ outdata = np.copy(data) ymasked, xmasked = np.nonzero(replace_mask) xmirror = 2 * int(xycenter[0] + 0.5) - xmasked ymirror = 2 * int(xycenter[1] + 0.5) - ymasked # Find mirrored pixels that are outside of the image badmask = ((xmirror < 0) | (ymirror < 0) | (xmirror >= data.shape[1]) | (ymirror >= data.shape[0])) # remove them from the set of replace_mask pixels and set them to # zero if np.any(badmask): outdata[ymasked[badmask], xmasked[badmask]] = 0.0 # remove the badmask pixels from pixels to be replaced goodmask = ~badmask ymasked = ymasked[goodmask] xmasked = xmasked[goodmask] xmirror = xmirror[goodmask] ymirror = ymirror[goodmask] outdata[ymasked, xmasked] = outdata[ymirror, xmirror] # Find mirrored pixels that are masked and replace_mask pixels that are # mirrored to other replace_mask pixels. Set them both to zero. mirror_mask = replace_mask[ymirror, xmirror] if mask is not None: mirror_mask |= mask[ymirror, xmirror] xbad = xmasked[mirror_mask] ybad = ymasked[mirror_mask] outdata[ybad, xbad] = 0.0 return outdata ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.042885 photutils-1.13.0/photutils/tests/0000755000175100001770000000000014637570322016442 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/tests/__init__.py0000644000175100001770000000000014637570305020542 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/tests/helper.py0000644000175100001770000000036714637570305020302 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides the tools used to run the test suite. """ import pytest from astropy.utils.introspection import minversion PYTEST_LT_80 = not minversion(pytest, '8.0.dev') ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.046885 photutils-1.13.0/photutils/utils/0000755000175100001770000000000014637570322016440 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/__init__.py0000644000175100001770000000067614637570305020563 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage provides general-purpose utility functions. """ from .colormaps import * # noqa: F401, F403 from .cutouts import * # noqa: F401, F403 from .depths import * # noqa: F401, F403 from .errors import * # noqa: F401, F403 from .exceptions import * # noqa: F401, F403 from .footprints import * # noqa: F401, F403 from .interpolation import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_convolution.py0000644000175100001770000000547514637570305021544 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for convolving images with a kernel. """ import warnings import numpy as np from astropy.convolution import Kernel2D from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning def _filter_data(data, kernel, mode='constant', fill_value=0.0, check_normalization=False): """ Convolve a 2D image with a 2D kernel. The kernel may either be a 2D `~numpy.ndarray` or a `~astropy.convolution.Kernel2D` object. Parameters ---------- data : array_like The 2D array of the image. kernel : array_like (2D) or `~astropy.convolution.Kernel2D` The 2D kernel used to filter the input ``data``. Filtering the ``data`` will smooth the noise and maximize detectability of objects with a shape similar to the kernel. mode : {'constant', 'reflect', 'nearest', 'mirror', 'wrap'}, optional The ``mode`` determines how the array borders are handled. For the ``'constant'`` mode, values outside the array borders are set to ``fill_value``. The default is ``'constant'``. fill_value : scalar, optional Value to fill data values beyond the array borders if ``mode`` is ``'constant'``. The default is ``0.0``. check_normalization : bool, optional If `True` then a warning will be issued if the kernel is not normalized to 1. Returns ------- result : `~numpy.ndarray` The convolved image. """ from scipy import ndimage if kernel is not None: if isinstance(kernel, Kernel2D): kernel_array = kernel.array else: kernel_array = kernel if check_normalization: if not np.allclose(np.sum(kernel_array), 1.0): warnings.warn('The kernel is not normalized.', AstropyUserWarning) # scipy.ndimage.convolve currently strips units, but be explicit # in case that behavior changes unit = None if isinstance(data, Quantity): unit = data.unit data = data.value # NOTE: if data is int and kernel is float, ndimage.convolve # will return an int image. If the data dtype is int, we make the # data float so that a float image is always returned if np.issubdtype(data.dtype, np.integer): data = data.astype(float) # NOTE: astropy.convolution.convolve fails with zero-sum kernels # (used in findstars) (cf. astropy #1647) result = ndimage.convolve(data, kernel_array, mode=mode, cval=fill_value) # reapply the input unit if unit is not None: result <<= unit return result else: return data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_coords.py0000644000175100001770000000462214637570305020447 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating random (x, y) coordinates. """ import warnings from collections import defaultdict import numpy as np from astropy.utils.exceptions import AstropyUserWarning def apply_separation(xycoords, min_separation): from scipy.spatial import KDTree tree = KDTree(xycoords) pairs = tree.query_pairs(min_separation, output_type='ndarray') # create a dictionary of nearest neighbors (within min_separation) nn = {} nn = defaultdict(set) for i, j in pairs: nn[i].add(j) nn[j].add(i) keep_idx = [] discard_idx = set() for idx in range(xycoords.shape[0]): if idx not in discard_idx: keep_idx.append(idx) # remove nearest neighbors from the output discard_idx.update(nn.get(idx, set())) return xycoords[keep_idx] def make_random_xycoords(size, x_range, y_range, min_separation=0.0, seed=None): """ Make random (x, y) coordinates. Parameters ---------- size : int The number of coordinates to generate. x_range : tuple The range of x values (min, max). y_range : tuple The range of y values (min, max). min_separation : float, optional The minimum separation in pixels between coordinates. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- xycoords : `~numpy.ndarray` The (x, y) random coordinates with shape ``(size, 2)``. """ if min_separation > 0: # scale the number of random coordinates to account for # some being discarded due to min_separation ncoords = size * 10 rng = np.random.default_rng(seed) xc = rng.uniform(x_range[0], x_range[1], ncoords) yc = rng.uniform(y_range[0], y_range[1], ncoords) xycoords = np.transpose(np.array((xc, yc))) xycoords = apply_separation(xycoords, min_separation) xycoords = xycoords[:size] if len(xycoords) < size: warnings.warn(f'Unable to produce {size!r} coordinates within the ' 'given shape and minimum separation. Only ' f'{len(xycoords)!r} coordinates were generated.', AstropyUserWarning) return xycoords ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_misc.py0000644000175100001770000000303214637570305020103 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to return the installed astropy and photutils versions. """ import sys from datetime import datetime, timezone def _get_version_info(): """ Return a dictionary of the installed version numbers for photutils and its dependencies. Returns ------- result : dict A dictionary containing the version numbers for photutils and its dependencies. """ versions = {'Python': sys.version.split()[0]} packages = ('photutils', 'astropy', 'numpy', 'scipy', 'skimage', 'sklearn', 'matplotlib', 'gwcs', 'bottleneck') for package in packages: try: pkg = __import__(package) version = pkg.__version__ except ImportError: version = None versions[package] = version return versions def _get_date(utc=False): """ Return a string of the current date/time. Parameters ---------- utc : bool, optional Whether to use the UTC timezone instead of the local timezone. Returns ------- result : str The current date/time. """ if not utc: now = datetime.now().astimezone() else: now = datetime.now(timezone.utc) return now.strftime('%Y-%m-%d %H:%M:%S %Z') def _get_meta(utc=False): """ Return a metadata dictionary with the package versions and current date/time. """ return {'date': _get_date(utc=utc), 'version': _get_version_info()} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_moments.py0000644000175100001770000000323614637570305020640 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provide tools for calculating image moments. """ import numpy as np __all__ = ['_moments_central', '_moments'] def _moments_central(data, center=None, order=1): """ Calculate the central image moments up to the specified order. Parameters ---------- data : 2D array_like The input 2D array. center : tuple of two floats or `None`, optional The ``(x, y)`` center position. If `None` it will calculated as the "center of mass" of the input ``data``. order : int, optional The maximum order of the moments to calculate. Returns ------- moments : 2D `~numpy.ndarray` The central image moments. """ data = np.asarray(data).astype(float) if data.ndim != 2: raise ValueError('data must be a 2D array.') if center is None: from photutils.centroids import centroid_com center = centroid_com(data) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] ypowers = (indices[0] - center[1]) ** np.arange(order + 1) xpowers = np.transpose(indices[1] - center[0]) ** np.arange(order + 1) return np.dot(np.dot(np.transpose(ypowers), data), xpowers) def _moments(data, order=1): """ Calculate the raw image moments up to the specified order. Parameters ---------- data : 2D array_like The input 2D array. order : int, optional The maximum order of the moments to calculate. Returns ------- moments : 2D `~numpy.ndarray` The raw image moments. """ return _moments_central(data, center=(0, 0), order=order) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_optional_deps.py0000644000175100001770000000156214637570305022016 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Checks for optional dependencies using lazy import from `PEP 562 `_. """ import importlib # This list is a duplicate of the dependencies in pyproject.toml "all". # Note that in some cases the package names are different from the # pip-install name (e.g.k scikit-image -> skimage). optional_deps = ['scipy', 'matplotlib', 'skimage', 'sklearn', 'gwcs', 'bottleneck', 'tqdm', 'rasterio', 'shapely'] deps = {key.upper(): key for key in optional_deps} __all__ = [f'HAS_{pkg}' for pkg in deps] def __getattr__(name): if name in __all__: try: importlib.import_module(deps[name[4:]]) except ImportError: return False return True raise AttributeError(f'Module {__name__!r} has no attribute {name!r}.') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_parameters.py0000644000175100001770000000530514637570305021320 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides parameter validation tools. """ import numpy as np def as_pair(name, value, lower_bound=None, upper_bound=None, check_odd=False): """ Define a pair of integer values as a 1D array. Parameters ---------- name : str The name of the parameter, which is used in error messages. value : int or int array_like The input value. lower_bound : int or int array_like, optional A tuple defining the allowed lower bound of the value. The first element is the bound and the second element indicates whether the bound is exclusive (0) or inclusive (1). upper_bound : (2,) int tuple, optional A tuple defining the allowed upper bounds of the value along each axis. For each axis, if ``value`` is larger than the bound, it is reset to the bound. ``upper_bound`` is typically set to an image shape. check_odd : bool, optional Whether to raise a `ValueError` if the values are not odd along both axes. Returns ------- result : (2,) `~numpy.ndarray` The pair as a 1D array of two integers. Examples -------- >>> from photutils.utils._parameters import as_pair >>> as_pair('myparam', 4) array([4, 4]) >>> as_pair('myparam', (3, 4)) array([3, 4]) >>> as_pair('myparam', 0, lower_bound=(0, 0)) array([0, 0]) """ value = np.atleast_1d(value) if np.any(~np.isfinite(value)): raise ValueError(f'{name} must be a finite value') if len(value) == 1: value = np.array((value[0], value[0])) if len(value) != 2: raise ValueError(f'{name} must have 1 or 2 elements') if value.ndim != 1: raise ValueError(f'{name} must be 1D') if value.dtype.kind != 'i': raise ValueError(f'{name} must have integer values') if check_odd and np.all(value % 2) != 1: raise ValueError(f'{name} must have an odd value for both axes') if lower_bound is not None: if len(lower_bound) != 2: raise ValueError('lower_bound must contain only 2 elements') bound, inclusive = lower_bound if inclusive == 1: oper = '>' mask = value <= bound else: oper = '>=' mask = value < bound if np.any(mask): raise ValueError(f'{name} must be {oper} {bound}') if upper_bound is not None: # if value is larger than upper_bound, set to upper_bound; # upper_bound is typically set to an image shape value = np.array((min(value[0], upper_bound[0]), min(value[1], upper_bound[1]))) return value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_progress_bars.py0000644000175100001770000000275714637570305022040 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for progress bars. """ # pylint: disable-next=E0611 from photutils.utils._optional_deps import HAS_TQDM def add_progress_bar(iterable=None, desc=None, total=None, text=False): """ Add a progress bar for an iterable. Parameters ---------- iterable : iterable, optional The iterable for which to add a progress bar. Set to `None` to manually manage the progress bar updates. desc : str, optional The prefix string for the progress bar. total : int, optional The number of expected iterations. If unspecified, len(iterable) is used if possible. text : bool, optional Whether to always use a text-based progress bar. Returns ------- result : tqdm iterable A tqdm progress bar. If in a notebook and ipywidgets is installed, it will return a ipywidgets-based progress bar. Otherwise it will return a text-based progress bar. """ if HAS_TQDM: if text: from tqdm import tqdm else: try: # pragma: no cover # pylint: disable-next=W0611 from ipywidgets import FloatProgress # noqa: F401 from tqdm.auto import tqdm except ImportError: # pragma: no cover from tqdm import tqdm iterable = tqdm(iterable=iterable, desc=desc, total=total) # pragma: no cover return iterable ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_quantity_helpers.py0000644000175100001770000000500714637570305022554 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides Quantity helper tools. """ import astropy.units as u import numpy as np def process_quantities(values, names): """ Check and remove units of input values. If any of the input values have units then they all must have units and the units must be the same. The returned values are the input values with units removed and the unit. Parameters ---------- values : list of scalar, `~numpy.ndarray`, or `~astropy.units.Quantity` A list of values. names : list of str A list of names corresponding to the input ``values``. Returns ------- values : list of scalar or `~numpy.ndarray` A list of values, where units have been removed. unit : `~astropy.unit.Unit` The common unit for the input values. `None` will be returned if all the input values do not have units. Raises ------ ValueError If the input values do not all have the same units. """ if len(values) != len(names): raise ValueError('The number of values must match the number of ' 'names.') all_units = {name: getattr(arr, 'unit', None) for arr, name in zip(values, names) if arr is not None} unit = set(all_units.values()) if len(unit) > 1: values = list(all_units.keys()) msg = [f'The inputs {values} must all have the same units:'] indent = ' ' * 4 for key, value in all_units.items(): if value is None: msg.append(f'{indent}{key} does not have units') else: msg.append(f'{indent}{key} has units of {value}') msg = '\n'.join(msg) raise ValueError(msg) # extract the unit and remove it from the return values unit = unit.pop() if unit is not None: values = [val.value if val is not None else val for val in values] return values, unit def isscalar(value): """ Check if a value is a scalar. This works for both `~astropy.units.Quantity` and scalars. `numpy.isscalar` always returns False for `~astropy.units.Quantity` objects. Parameters ---------- value : `~astropy.units.Quantity`, scalar, or array-like The value to check. Returns ------- isscalar : bool `True` if the value is a scalar, `False` otherwise. """ if isinstance(value, u.Quantity): return value.isscalar else: return np.isscalar(value) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_repr.py0000644000175100001770000000146214637570305020125 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for class __repr__ and __str__ strings. """ def make_repr(self, params, ellipsis=(), long=False): cls_name = f'{self.__class__.__name__}' if long: cls_name = f'{self.__class__.__module__}.{cls_name}' cls_info = [] for param in params: value = getattr(self, param) if param in ellipsis and value is not None: value = '...' cls_info.append((param, value)) if long: delim = ': ' join_str = '\n' else: delim = '=' join_str = ', ' fmt = [f'{key}{delim}{val!r}' for key, val in cls_info] fmt = f'{join_str}'.join(fmt) if long: return f'<{cls_name}>\n{fmt}' else: return f'{cls_name}({fmt})' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_round.py0000644000175100001770000000125414637570305020303 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to round numpy arrays. """ import numpy as np def py2intround(a): """ Round the input to the nearest integer. If two integers are equally close, rounding is done away from 0. Parameters ---------- a : float or array-like The input float or array. Returns ------- result : float or array-like The integer-rounded values. """ data = np.atleast_1d(a) value = np.where(data >= 0, np.floor(data + 0.5), np.ceil(data - 0.5)).astype(int) if np.isscalar(a): value = value[0] return value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_stats.py0000644000175100001770000000523314637570305020313 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines nan-ignoring statistical functions, using bottleneck for performance if available. """ import astropy.units as u import numpy as np from photutils.utils._optional_deps import HAS_BOTTLENECK if HAS_BOTTLENECK: import bottleneck as bn def move_tuple_axes_first(array, axis): """ Bottleneck can only take integer axis, not tuple, so this function takes all the axes to be operated on and combines them into the first dimension of the array so that we can then use axis=0. """ # Figure out how many axes we are operating over naxis = len(axis) # Add remaining axes to the axis tuple axis += tuple(i for i in range(array.ndim) if i not in axis) # The new position of each axis is just in order destination = tuple(range(array.ndim)) # Reorder the array so that the axes being operated on are at the # beginning array_new = np.moveaxis(array, axis, destination) # Collapse the dimensions being operated on into a single dimension # so that we can then use axis=0 with the bottleneck functions array_new = array_new.reshape((-1,) + array_new.shape[naxis:]) return array_new def nanmean(array, axis=None): """ A nanmean function that uses bottleneck if available. """ if HAS_BOTTLENECK: if isinstance(axis, tuple): array = move_tuple_axes_first(array, axis=axis) axis = 0 if isinstance(array, u.Quantity): return array.__array_wrap__(bn.nanmean(array, axis=axis)) else: return bn.nanmean(array, axis=axis) else: return np.nanmean(array, axis=axis) def nanmedian(array, axis=None): """ A nanmedian function that uses bottleneck if available. """ if HAS_BOTTLENECK: if isinstance(axis, tuple): array = move_tuple_axes_first(array, axis=axis) axis = 0 if isinstance(array, u.Quantity): return array.__array_wrap__(bn.nanmedian(array, axis=axis)) else: return bn.nanmedian(array, axis=axis) else: return np.nanmedian(array, axis=axis) def nanstd(array, axis=None, ddof=0): """ A nanstd function that uses bottleneck if available. """ if HAS_BOTTLENECK: if isinstance(axis, tuple): array = move_tuple_axes_first(array, axis=axis) axis = 0 if isinstance(array, u.Quantity): return array.__array_wrap__(bn.nanstd(array, axis=axis, ddof=ddof)) else: return bn.nanstd(array, axis=axis, ddof=ddof) else: return np.nanstd(array, axis=axis, ddof=ddof) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/_wcs_helpers.py0000644000175100001770000000402614637570305021472 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides WCS helper tools. """ import astropy.units as u import numpy as np def _pixel_scale_angle_at_skycoord(skycoord, wcs, offset=1 * u.arcsec): """ Calculate the pixel coordinate scale and WCS rotation angle at the position of a SkyCoord coordinate. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The SkyCoord coordinate. wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). offset : `~astropy.units.Quantity` A small angular offset to use to compute the pixel scale and position angle. Returns ------- xypos : tuple of float The (x, y) pixel coordinate. scale : `~astropy.units.Quantity` The pixel scale in arcsec/pixel. angle : `~astropy.units.Quantity` The angle (in degrees) measured counterclockwise from the positive x axis to the "North" axis of the celestial coordinate system. Notes ----- If distortions are present in the image, the x and y pixel scales likely differ. This function computes a single pixel scale along the North/South axis. """ # Convert to pixel coordinates xpos, ypos = wcs.world_to_pixel(skycoord) # We take a point directly North (i.e., latitude offset) the # input sky coordinate and convert it to pixel coordinates, # then we use the pixel deltas between the input and offset sky # coordinate to calculate the pixel scale and angle. skycoord_offset = skycoord.directional_offset_by(0.0, offset) x_offset, y_offset = wcs.world_to_pixel(skycoord_offset) dx = x_offset - xpos dy = y_offset - ypos scale = offset.to(u.arcsec) / (np.hypot(dx, dy) * u.pixel) angle = (np.arctan2(dy, dx) * u.radian).to(u.deg) return (xpos, ypos), scale, angle ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/colormaps.py0000644000175100001770000000242514637570305021015 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating matplotlib colormaps. """ import numpy as np __all__ = ['make_random_cmap'] def make_random_cmap(ncolors=256, seed=None): """ Make a matplotlib colormap consisting of (random) muted colors. A random colormap is very useful for plotting segmentation images. Parameters ---------- ncolors : int, optional The number of colors in the colormap. The default is 256. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with random colors in RGBA format. """ from matplotlib import colors rng = np.random.default_rng(seed) hue = rng.uniform(low=0.0, high=1.0, size=ncolors) sat = rng.uniform(low=0.2, high=0.7, size=ncolors) val = rng.uniform(low=0.5, high=1.0, size=ncolors) hsv = np.dstack((hue, sat, val)) rgb = np.squeeze(colors.hsv_to_rgb(hsv)) return colors.ListedColormap(colors.to_rgba_array(rgb)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/cutouts.py0000644000175100001770000001577314637570305020536 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating 2D image cutouts. """ import numpy as np from astropy.nddata import NoOverlapError, extract_array, overlap_slices from astropy.utils import lazyproperty from photutils.aperture import BoundingBox __all__ = ['CutoutImage'] def _overlap_slices(large_array_shape, small_array_shape, position, mode='partial'): slc_lg, slc_sm = overlap_slices(large_array_shape, small_array_shape, position, mode=mode) # TEMP: remove when required Astropy >= 6.1.1 # fix for https://github.com/astropy/astropy/pull/16544 for i in (0, 1): if slc_lg[i].stop - slc_lg[i].start == 0: raise NoOverlapError('Arrays do not overlap.') return slc_lg, slc_sm class CutoutImage: """ Create a cutout object from a 2D array. The returned object will contain a 2D cutout array. If ``copy=False`` (default), the cutout array is a view into the original ``data`` array, otherwise the cutout array will contain a copy of the original data. Parameters ---------- data : `~numpy.ndarray` The 2D data array from which to extract the cutout array. position : 2 tuple The ``(y, x)`` position of the center of the cutout array with respect to the ``data`` array. shape : 2 tuple of int The shape of the cutout array along each axis in ``(ny, nx)`` order. mode : {'trim', 'partial', 'strict'}, optional The mode used for creating the cutout data array. For the ``'partial'`` and ``'trim'`` modes, a partial overlap of the cutout array and the input ``data`` array is sufficient. For the ``'strict'`` mode, the cutout array has to be fully contained within the ``data`` array, otherwise an `~astropy.nddata.utils.PartialOverlapError` is raised. In all modes, non-overlapping arrays will raise a `~astropy.nddata.utils.NoOverlapError`. In ``'partial'`` mode, positions in the cutout array that do not overlap with the ``data`` array will be filled with ``fill_value``. In ``'trim'`` mode only the overlapping elements are returned, thus the resulting cutout array may be smaller than the requested ``shape``. fill_value : float or int, optional If ``mode='partial'``, the value to fill pixels in the cutout array that do not overlap with the input ``data``. ``fill_value`` must have the same ``dtype`` as the input ``data`` array. copy : bool, optional If `False` (default), then the cutout data will be a view into the original ``data`` array. If `True`, then the cutout data will hold a copy of the original ``data`` array. Examples -------- >>> import numpy as np >>> from photutils.utils import CutoutImage >>> data = np.arange(20.0).reshape(5, 4) >>> cutout = CutoutImage(data, (2, 2), (3, 3)) >>> print(cutout.data) # doctest: +FLOAT_CMP [[ 5. 6. 7.] [ 9. 10. 11.] [13. 14. 15.]] >>> cutout2 = CutoutImage(data, (0, 0), (3, 3), mode='partial') >>> print(cutout2.data) # doctest: +FLOAT_CMP [[nan nan nan] [nan 0. 1.] [nan 4. 5.]] """ def __init__(self, data, position, shape, mode='trim', fill_value=np.nan, copy=False): self.position = position self.input_shape = shape self.mode = mode self.fill_value = fill_value self.copy = copy data = np.asanyarray(data) self._overlap_slices = _overlap_slices(data.shape, shape, position, mode=mode) self.data = self._make_cutout(data) self.shape = self.data.shape def _make_cutout(self, data): cutout_data = extract_array(data, self.input_shape, self.position, mode=self.mode, fill_value=self.fill_value, return_position=False) if self.copy: cutout_data = np.copy(cutout_data) return cutout_data def __array__(self, dtype=None): """ Array representation of the cutout data array (e.g., for matplotlib). """ return np.asarray(self.data, dtype=dtype) def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' props = f'Shape: {self.data.shape}' return f'{cls_name}\n' + props def __repr__(self): return self.__str__() @lazyproperty def slices_original(self): """ A tuple of slice objects in axis order for the minimal bounding box of the cutout with respect to the original array. For ``mode='partial'``, the slices are for the valid (non-filled) cutout values. """ return self._overlap_slices[0] @lazyproperty def slices_cutout(self): """ A tuple of slice objects in axis order for the minimal bounding box of the cutout with respect to the cutout array. For ``mode='partial'``, the slices are for the valid (non-filled) cutout values. """ return self._overlap_slices[1] def _calc_bbox(self, slices): """ Calculate the `~photutils.aperture.BoundingBox` of the rectangular bounding box from the input slices. """ return BoundingBox(ixmin=slices[1].start, ixmax=slices[1].stop, iymin=slices[0].start, iymax=slices[0].stop) @lazyproperty def bbox_original(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region of the cutout array with respect to the original array. For ``mode='partial'``, the bounding box indices are for the valid (non-filled) cutout values. """ return self._calc_bbox(self.slices_original) @lazyproperty def bbox_cutout(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region of the cutout array with respect to the cutout array. For ``mode='partial'``, the bounding box indices are for the valid (non-filled) cutout values. """ return self._calc_bbox(self.slices_cutout) def _calc_xyorigin(self, slices): """ Calculate the (x, y) origin, taking into account partial overlaps. """ xorigin, yorigin = (slices[1].start, slices[0].start) if self.mode == 'partial': yorigin -= self.slices_cutout[0].start xorigin -= self.slices_cutout[1].start return np.array((xorigin, yorigin)) @lazyproperty def xyorigin(self): """ A `~numpy.ndarray` containing the ``(x, y)`` integer index of the origin pixel of the cutout with respect to the original array. The origin index will be negative for cutouts with partial overlaps. """ return self._calc_xyorigin(self.slices_original) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/depths.py0000644000175100001770000004402314637570305020305 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating limiting fluxes. """ import warnings import astropy.units as u import numpy as np from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._coords import apply_separation from photutils.utils._progress_bars import add_progress_bar from photutils.utils._repr import make_repr from photutils.utils.footprints import circular_footprint __all__ = ['ImageDepth'] __doctest_requires__ = {('ImageDepth', 'ImageDepth.*'): ['scipy', 'skimage']} class ImageDepth: r""" Class to calculate the limiting flux and magnitude of an image. Parameters ---------- aper_radius : float The radius (in pixels) of the circular apertures used to compute the image depth. nsigma : float, optional The number of standard deviations at which to compute the image depths. mask_pad : float, optional An additional padding (in pixels) to apply when dilating the input mask. napers : int, optional The number of circular apertures used to compute the image depth. niters : int, optional The number of iterations, each with randomly-generated apertures, for which the image depth will be calculated. overlap : bool, optional Whether to allow the apertures to overlap. overlap_maxiters : int, optional The maximum number of iterations that will be used when attempting to find additional non-overlapping apertures. This keyword has no effect unless ``overlap=False``. While increasing this number may generate more non-overlapping apertures in crowded cases, it will also run slower. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same results. zeropoint : float, optional The zeropoint used to calculate the magnitude limit from the flux limit: .. math:: m_{\mathrm{lim}} = -2.5 \log_{10} f_{\mathrm{lim}} + \mathrm{zeropoint} sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters to use when computing the limiting flux. If `None` then no sigma clipping will be performed. progress_bar : bool, optional Whether to display a progress bar. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Attributes ---------- apertures : list of `~photutils.aperture.CircularAperture` A list of circular apertures for each iteration. napers_used : int A list of the number of apertures used for each iteration. fluxes : list of `~numpy.ndarray` A list of arrays containing the flux measurements for each iteration. flux_limits : 1D `~numpy.ndarray` An array of the flux limits for each iteration. mag_limits : 1D `~numpy.ndarray` An array of the magnitude limits for each iteration. Notes ----- The image depth is calculated by placing random circular apertures with the specified radius on blank regions of the image. The number of apertures is specified by the ``napers`` keyword. The blank regions are calculated from an input mask, which should mask both sources in the image and areas without image coverage. The input mask will be dilated with a circular footprint with a radius equal to the input ``aper_radius`` plus ``mask_pad``. The image border is also masked with the same radius. The flux limit is calculated as the standard deviation of the aperture fluxes times the input ``nsigma`` significance level. The aperture flux values can be sigma clipped prior to computing the standard deviation using the ``sigma_clip`` keyword. The flux limit is calculated ``niters`` times, each with a randomly-generated set of circular apertures. The returned flux limit is the average of these flux limits. The magnitude limit is calculated from flux limit using the input ``zeropoint`` keyword as: .. math:: m_{\mathrm{lim}} = -2.5 \log_{10} f_{\mathrm{lim}} + \mathrm{zeropoint} Examples -------- >>> from astropy.convolution import convolve >>> from astropy.visualization import simple_norm >>> from photutils.datasets import make_100gaussians_image >>> from photutils.segmentation import SourceFinder, make_2dgaussian_kernel >>> from photutils.utils import ImageDepth >>> bkg = 5.0 >>> data = make_100gaussians_image() - bkg >>> kernel = make_2dgaussian_kernel(3.0, size=5) >>> convolved_data = convolve(data, kernel) >>> npixels = 10 >>> threshold = 3.2 >>> finder = SourceFinder(npixels=npixels, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> mask = segment_map.make_source_mask() >>> radius = 4 >>> depth = ImageDepth(radius, nsigma=5.0, napers=500, niters=2, ... mask_pad=5, overlap=False, seed=123, ... zeropoint=23.9, progress_bar=False) >>> limits = depth(data, mask) >>> print(np.array(limits)) # doctest: +FLOAT_CMP [68.7403149 19.30697121] .. plot:: :include-source: # plot the random apertures for the first iteration import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image from photutils.segmentation import SourceFinder, make_2dgaussian_kernel from photutils.utils import ImageDepth bkg = 5.0 data = make_100gaussians_image() - bkg kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 threshold = 3.2 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) mask = segment_map.make_source_mask() radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=500, niters=2, overlap=False, seed=123, progress_bar=False) limits = depth(data, mask) fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(9, 3)) norm = simple_norm(data, 'sqrt', percent=99.) ax[0].imshow(data, norm=norm) color = 'orange' depth.apertures[0].plot(ax[0], color=color) ax[0].set_title('Data with blank apertures') ax[1].imshow(mask, interpolation='none') depth.apertures[0].plot(ax[1], color=color) ax[1].set_title('Mask with blank apertures') plt.subplots_adjust(left=0.05, right=0.98, bottom=0.05, top=0.95, wspace=0.15) """ def __init__(self, aper_radius, *, nsigma=5.0, mask_pad=0, napers=1000, niters=10, overlap=True, overlap_maxiters=100, seed=None, zeropoint=0.0, sigma_clip=SigmaClip(sigma=3.0, maxiters=10), progress_bar=True): if aper_radius <= 0: raise ValueError('aper_radius must be > 0') if mask_pad < 0: raise ValueError('mask_pad must be >= 0') self.aper_radius = aper_radius self.nsigma = nsigma self.mask_pad = mask_pad self.napers = napers self.niters = niters self.overlap = overlap self.overlap_maxiters = overlap_maxiters self.seed = seed self.zeropoint = zeropoint self.sigma_clip = sigma_clip self.progress_bar = progress_bar self.rng = np.random.default_rng(self.seed) self.dilate_radius = int(np.ceil(self.aper_radius + self.mask_pad)) self.dilate_footprint = circular_footprint(radius=self.dilate_radius) self.apertures = [] self.napers_used = np.array([]) self.fluxes = [] self.flux_limits = np.array([]) self.mag_limits = np.array([]) def __repr__(self): params = ('aper_radius', 'nsigma', 'mask_pad', 'napers', 'niters', 'overlap', 'overlap_maxiters', 'seed', 'zeropoint', 'sigma_clip', 'progress_bar') return make_repr(self, params) def __call__(self, data, mask): """ Calculate the limiting flux and magnitude of an image. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array, which should be in flux units (not surface brightness units). mask : 2D bool `~numpy.ndarray` A 2D mask array with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. The input array should mask both sources (e.g., from a segmentation image) and regions without image coverage. If `None`, then the entire image will be used. Returns ------- flux_limit, mag_limit : float The flux and magnitude limits. The flux limit is returned in the same units as the input ``data``. The magnitude limit is calculated from the flux limit and the input ``zeropoint``. """ # prevent circular import from photutils.aperture import CircularAperture if mask is None or not np.any(mask): all_xycoords = self._make_all_coords_no_mask(data.shape) else: all_xycoords = self._make_all_coords(mask) if len(all_xycoords) == 0: raise ValueError('There are no unmasked pixel values (including ' 'the masked image borders).') napers = self.napers if not self.overlap: napers2 = 1.5 * self.napers napers = int(min(napers2, 0.1 * len(all_xycoords))) iter_range = range(self.niters) if self.progress_bar: desc = 'Image Depths' iter_range = add_progress_bar(iter_range, desc=desc) # pragma: no cover fluxes = [] flux_limits = [] apertures = [] for _ in iter_range: if self.overlap: xycoords = self._make_coords(all_xycoords, napers) else: # cut the number of coords (only need to input ~10x) xycoords = self._make_coords(all_xycoords, napers * 10) min_separation = self.aper_radius * 2.0 xycoords = apply_separation(xycoords, min_separation) xycoords = xycoords[0:self.napers] apers = CircularAperture(xycoords, r=self.aper_radius) apertures.append(apers) fluxes, _ = apers.do_photometry(data) if self.sigma_clip is not None: fluxes = self.sigma_clip(fluxes, masked=False) # ndarray self.fluxes.append(fluxes) flux_limits.append(self.nsigma * np.std(fluxes)) self.apertures = apertures napers_used = np.array([len(apers) for apers in apertures]) self.napers_used = napers_used if np.any(napers_used < self.napers): warnings.warn(f'Unable to generate {self.napers} non-overlapping ' 'apertures in unmasked regions. The number of ' f'apertures used was less than {self.napers} (see ' 'the "napers_used" ImageDepth object attribute). ' 'To fix this, decrease the number of apertures ' 'and/or aperture size, or increase ' '`overlap_maxiters`. Alternatively, you may set ' 'overlap=True', AstropyUserWarning) if isinstance(flux_limits[0], u.Quantity): units = True self.flux_limits = u.Quantity(flux_limits) else: units = False self.flux_limits = np.array(flux_limits) flux_limit = np.mean(self.flux_limits) if np.any(self.flux_limits == 0): warnings.warn('One or more flux_limit values was zero. This is ' 'likely due to constant image values. Check the ' 'input mask.', AstropyUserWarning) # ignore divide-by-zero RuntimeWarning in log10 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux_limits = self.flux_limits flux_limit_ = flux_limit if units: flux_limits = flux_limits.value flux_limit_ = flux_limit.value self.mag_limits = -2.5 * np.log10(flux_limits) + self.zeropoint mag_limit = -2.5 * np.log10(flux_limit_) + self.zeropoint return flux_limit, mag_limit @staticmethod def _find_slice_axis(data, axis): """ Calculate a slice for the minimal bounding box along an axis for the `True` values of a 2D boolean array. Parameters ---------- data : 2D bool `~numpy.ndarray` The boolean array. axis : int The axis to use (0 or 1). Returns ------- slice : slice object A slice object for the input axis. If the data values along the input axis are all `False`, then the slice object will include the entire axis range. """ xx = np.any(data, axis=axis) if np.all(~xx): idx = 0 if axis else 1 slc = slice(0, data.shape[idx]) else: x0, x1 = np.where(xx)[0][[0, -1]] slc = slice(x0, x1 + 1) return slc def _find_slices(self, data): """ Calculate a tuple slice for the minimal bounding box for the `True` values of a 2D boolean array. Parameters ---------- data : 2D bool `~numpy.ndarray` The boolean array. Returns ------- slices : tuple of slices A tuple of slice objects for each axis of the array. If the data is all `False`, then the slice tuple will include the entire image range. """ xslice = self._find_slice_axis(data, 0) yslice = self._find_slice_axis(data, 1) return yslice, xslice def _mask_border(self, mask): """ Mask pixels around the image border. Parameters ---------- mask : 2D bool `~numpy.ndarray` Boolean mask array. Returns ------- mask : 2D bool `~numpy.ndarray` Boolean mask array. """ mask[:self.dilate_radius, :] = True mask[-self.dilate_radius:, :] = True mask[:, :self.dilate_radius] = True mask[:, -self.dilate_radius:] = True return mask def _dilate_mask(self, mask): """ Dilate the input mask to ensure that apertures do not overlap the mask. The mask is dilated with a circular footprint with a radius equal to the input ``aper_radius`` plus ``mask_pad``. Border pixels are also masked with the same radius. Parameters ---------- mask : 2D bool `~numpy.ndarray` Boolean mask array. Returns ------- mask : 2D bool `~numpy.ndarray` Dilated boolean mask array. """ from scipy.ndimage import binary_dilation if np.any(mask): mask = binary_dilation(mask, structure=self.dilate_footprint) mask = self._mask_border(mask) return mask def _make_all_coords_no_mask(self, shape): """ Return an array of all possible (x, y) coordinates. Border pixels will be excluded. Parameters ---------- shape : 2 tuple of int The array shape. Returns ------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ ny, nx = shape # remove the image borders border = self.dilate_radius border2 = 2 * border ny -= border2 nx -= border2 yi, xi = np.mgrid[0:ny, 0:nx] xi = xi.ravel() yi = yi.ravel() # shift back to coordinates to the original image xi += border yi += border return np.column_stack((xi, yi)) def _make_all_coords(self, mask): """ Return an array of all possible unmasked (x, y) coordinates. Border pixels will be excluded. Parameters ---------- mask : 2D bool `~numpy.ndarray` The boolean source mask array. Returns ------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ mask_inv = ~self._dilate_mask(mask) mask_slc = self._find_slices(mask_inv) yi, xi = np.nonzero(mask_inv[mask_slc]) # shift back to coordinates to the original (unsliced) image xi += mask_slc[1].start yi += mask_slc[0].start return np.column_stack((xi, yi)) def _make_coords(self, xycoords, napers): """ Randomly choose ``napers`` (without replacement) coordinates from the input ``xycoords``. This function also adds < +/-0.5 pixel random shifts so that the coordinates are not all integers. Parameters ---------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. napers : int The number of aperture to make. Returns -------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ if napers > xycoords.shape[0]: raise ValueError('Too many apertures for given unmasked area') idx = self.rng.choice(xycoords.shape[0], napers, replace=False) xycoords = xycoords[idx, :].astype(float) shift = self.rng.uniform(-0.5, 0.5, size=xycoords.shape) xycoords += shift return xycoords ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/errors.py0000644000175100001770000001710114637570305020327 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating total error arrays. """ import astropy.units as u import numpy as np from astropy.utils.misc import isiterable __all__ = ['calc_total_error'] def calc_total_error(data, bkg_error, effective_gain): r""" Calculate a total error array, combining a background-only error array with the Poisson noise of sources. Parameters ---------- data : array_like or `~astropy.units.Quantity` The background-subtracted data array. bkg_error : array_like or `~astropy.units.Quantity` The 1-sigma background-only errors of the input ``data``. ``bkg_error`` should include all sources of "background" error but *exclude* the Poisson error of the sources. ``bkg_error`` must have the same shape as ``data``. If ``data`` and ``bkg_error`` are `~astropy.units.Quantity` objects, then they must have the same units. effective_gain : float, array_like, or `~astropy.units.Quantity` Ratio of counts (e.g., electrons or photons) to the units of ``data`` used to calculate the Poisson error of the sources. If ``effective_gain`` is zero (or contains zero values in an array), then the source Poisson noise component will not be included. In other words, the returned total error value will simply be the ``bkg_error`` value for pixels where ``effective_gain`` is zero. ``effective_gain`` cannot not be negative or contain negative values. Returns ------- total_error : `~numpy.ndarray` or `~astropy.units.Quantity` The total error array. If ``data``, ``bkg_error``, and ``effective_gain`` are all `~astropy.units.Quantity` objects, then ``total_error`` will also be returned as a `~astropy.units.Quantity` object with the same units as the input ``data``. Otherwise, a `~numpy.ndarray` will be returned. Notes ----- To use units, ``data``, ``bkg_error``, and ``effective_gain`` must *all* be `~astropy.units.Quantity` objects. ``data`` and ``bkg_error`` must have the same units. A `ValueError` will be raised if only some of the inputs are `~astropy.units.Quantity` objects or if the ``data`` and ``bkg_error`` units differ. The source Poisson error in countable units (e.g., electrons or photons) is: .. math:: \sigma_{\mathrm{src}} = \sqrt{g_{\mathrm{eff}} I} where :math:`g_{\mathrm{eff}}` is the effective gain (``effective_gain``; image or scalar) and :math:`I` is the ``data`` image. The total error is the combination of the background-only error and the source Poisson error. The total error array :math:`\sigma_{\mathrm{tot}}` in countable units (e.g., electrons or photons) is therefore: .. math:: \sigma_{\mathrm{tot}} = \sqrt{g_{\mathrm{eff}}^2 \sigma_{\mathrm{bkg}}^2 + g_{\mathrm{eff}} I} where :math:`\sigma_{\mathrm{bkg}}` is the background-only error image (``bkg_error``). Converting back to the input ``data`` units gives: .. math:: \sigma_{\mathrm{tot}} = \frac{1}{g_{\mathrm{eff}}} \sqrt{g_{\mathrm{eff}}^2 \sigma_{\mathrm{bkg}}^2 + g_{\mathrm{eff}} I} .. math:: \sigma_{\mathrm{tot}} = \sqrt{\sigma_{\mathrm{bkg}}^2 + \frac{I}{g_{\mathrm{eff}}}} ``effective_gain`` can either be a scalar value or a 2D image with the same shape as the ``data``. A 2D ``effective_gain`` image is useful when the input ``data`` has variable depths across the field (e.g., a mosaic image with non-uniform exposure times). For example, if your input ``data`` are in units of electrons/s then ideally ``effective_gain`` should be an exposure-time map. The Poisson noise component is not included in the output total error for pixels where ``data`` (:math:`I_i)` is negative. For such pixels, :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{bkg}, i}`. The Poisson noise component is also not included in the output total error for pixels where the effective gain (:math:`g_{\mathrm{eff}, i}`) is zero. For such pixels, :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{bkg}, i}`. To replicate `SourceExtractor`_ errors when it is configured to consider weight maps as gain maps (i.e., 'WEIGHT_GAIN=Y'; which is the default), one should input an ``effective_gain`` calculated as: .. math:: g_{\mathrm{eff}}^{\prime} = g_{\mathrm{eff}} \left( \frac{\mathrm{RMS_{\mathrm{median}}^2}}{\sigma_{\mathrm{bkg}}^2} \right) where :math:`g_{\mathrm{eff}}` is the effective gain, :math:`\sigma_{\mathrm{bkg}}` are the background-only errors, and :math:`\mathrm{RMS_{\mathrm{median}}}` is the median value of the low-resolution background RMS map generated by `SourceExtractor`_. When running `SourceExtractor`_, this value is printed to stdout as "(M+D) RMS: ". If you are using `~photutils.background.Background2D`, the median value of the low-resolution background RMS map is returned via the `~photutils.background.Background2D.background_rms_median` attribute. In that case the total error is: .. math:: \sigma_{\mathrm{tot}} = \sqrt{\sigma_{\mathrm{bkg}}^2 + \left(\frac{I}{g_{\mathrm{eff}}}\right) \left(\frac{\sigma_{\mathrm{bkg}}^2} {\mathrm{RMS_{\mathrm{median}}^2}}\right)} .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ data = np.asanyarray(data) bkg_error = np.asanyarray(bkg_error) inputs = [data, bkg_error, effective_gain] has_unit = [hasattr(x, 'unit') for x in inputs] use_units = all(has_unit) if any(has_unit) and not use_units: raise ValueError('If any of data, bkg_error, or effective_gain has ' 'units, then they all must all have units.') if use_units: if data.unit != bkg_error.unit: raise ValueError('data and bkg_error must have the same units.') count_units = [u.electron, u.photon] datagain_unit = data.unit * effective_gain.unit if datagain_unit not in count_units: raise u.UnitsError('(data * effective_gain) has units of ' f'{datagain_unit}, but it must have count ' 'units (e.g., u.electron or u.photon).') if not isiterable(effective_gain): effective_gain = np.zeros(data.shape) + effective_gain else: effective_gain = np.asanyarray(effective_gain) if effective_gain.shape != data.shape: raise ValueError('If input effective_gain is 2D, then it must ' 'have the same shape as the input data.') if np.any(effective_gain < 0): raise ValueError('effective_gain must be non-zero everywhere.') if use_units: unit = data.unit data = data.value effective_gain = effective_gain.value # do not include source variance where effective_gain = 0 source_variance = data.copy() mask = effective_gain != 0 source_variance[mask] /= effective_gain[mask] source_variance[~mask] = 0.0 # do not include source variance where data is negative (note that # effective_gain cannot be negative) source_variance = np.maximum(source_variance, 0) if use_units: # source_variance is calculated to have units of (data.unit)**2 # so that it can be added with bkg_error**2 below. The returned # total error will have units of data.unit. source_variance <<= unit**2 return np.sqrt(bkg_error**2 + source_variance) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/exceptions.py0000644000175100001770000000047714637570305021204 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides custom exceptions. """ from astropy.utils.exceptions import AstropyWarning __all__ = ['NoDetectionsWarning'] class NoDetectionsWarning(AstropyWarning): """ A warning class to indicate no sources were detected. """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/footprints.py0000644000175100001770000000256314637570305021230 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating footprints. """ import numpy as np __all__ = ['circular_footprint'] def circular_footprint(radius, dtype=int): """ Create a circular footprint. A pixel is considered to be entirely in or out of the footprint depending on whether its center is in or out of the footprint. The size of the output array is the minimal bounding box for the footprint. Parameters ---------- radius : int The radius of the circular footprint. dtype : data-type, optional The data type of the output `~numpy.ndarray`. Returns ------- footprint : `~numpy.ndarray` A footprint where array elements are 1 within the footprint and 0 otherwise. Examples -------- >>> from photutils.utils import circular_footprint >>> circular_footprint(2) array([[0, 0, 1, 0, 0], [0, 1, 1, 1, 0], [1, 1, 1, 1, 1], [0, 1, 1, 1, 0], [0, 0, 1, 0, 0]]) """ if ~np.isfinite(radius) or radius <= 0 or int(radius) != radius: raise ValueError('radius must be a positive, finite integer greater ' 'than 0') x = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(x, x) return np.array((xx**2 + yy**2) <= radius**2, dtype=dtype) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/interpolation.py0000644000175100001770000002616314637570305021712 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for interpolating data. """ import numpy as np __all__ = ['ShepardIDWInterpolator'] __doctest_requires__ = {'ShepardIDWInterpolator': ['scipy']} class ShepardIDWInterpolator: """ Class to perform Inverse Distance Weighted (IDW) interpolation. This interpolator uses a modified version of `Shepard's method `_ (see the Notes section for details). Parameters ---------- coordinates : float, 1D array_like, or NxM array_like Coordinates of the known data points. In general, it is expected that these coordinates are in a form of a NxM-like array where N is the number of points and M is dimension of the coordinate space. When M=1 (1D space), then the ``coordinates`` parameter may be entered as a 1D array or, if only one data point is available, ``coordinates`` can be a scalar number representing the 1D coordinate of the data point. .. note:: If the dimensionality of ``coordinates`` is larger than 2, e.g., if it is of the form N1 x N2 x N3 x ... x Nn x M, then it will be flattened to form an array of size NxM where N = N1 * N2 * ... * Nn. values : float or 1D array_like Values of the data points corresponding to each coordinate provided in ``coordinates``. In general a 1D array is expected. When a single data point is available, then ``values`` can be a scalar number. .. note:: If the dimensionality of ``values`` is larger than 1 then it will be flattened. weights : float or 1D array_like, optional Weights to be associated with each data value. These weights, if provided, will be combined with inverse distance weights (see the Notes section for details). When ``weights`` is `None` (default), then only inverse distance weights will be used. When provided, this input parameter must have the same form as ``values``. leafsize : float, optional The number of points at which the k-d tree algorithm switches over to brute-force. ``leafsize`` must be positive. See `scipy.spatial.cKDTree` for further information. Notes ----- This interpolator uses a slightly modified version of `Shepard's method `_. The essential difference is the introduction of a "regularization" parameter (``reg``) that is used when computing the inverse distance weights: .. math:: w_i = 1 / (d(x, x_i)^{power} + r) By supplying a positive regularization parameter one can avoid singularities at the locations of the data points as well as control the "smoothness" of the interpolation (e.g., make the weights of the neighbors less varied). The "smoothness" of interpolation can also be controlled by the power parameter (``power``). Examples -------- This class can be instantiated using the following syntax:: >>> from photutils.utils import ShepardIDWInterpolator as idw Example of interpolating 1D data:: >>> import numpy as np >>> rng = np.random.default_rng(0) >>> x = rng.random(100) # 100 random values >>> y = np.sin(x) >>> f = idw(x, y) >>> float(f(0.4)) # doctest: +FLOAT_CMP 0.38937843420912366 >>> float(np.sin(0.4)) # doctest: +FLOAT_CMP 0.3894183423086505 >>> xi = rng.random(4) # 4 random values >>> xi # doctest: +FLOAT_CMP array([0.47998792, 0.23237292, 0.80188058, 0.92353016]) >>> f(xi) # doctest: +FLOAT_CMP array([0.46577097, 0.22837422, 0.71856662, 0.80125391]) >>> np.sin(xi) # doctest: +FLOAT_CMP array([0.46176846, 0.23028731, 0.71866503, 0.7977353 ]) NOTE: In the last example, ``xi`` may be a ``Nx1`` array instead of a 1D vector. Example of interpolating 2D data:: >>> rng = np.random.default_rng(0) >>> pos = rng.random((1000, 2)) >>> val = np.sin(pos[:, 0] + pos[:, 1]) >>> f = idw(pos, val) >>> float(f([0.5, 0.6])) # doctest: +FLOAT_CMP 0.8948257014687874 >>> float(np.sin(0.5 + 0.6)) # doctest: +FLOAT_CMP 0.8912073600614354 """ def __init__(self, coordinates, values, weights=None, leafsize=10): from scipy.spatial import cKDTree coordinates = np.asarray(coordinates) if coordinates.ndim == 0: # scalar coordinate coordinates = np.atleast_2d(coordinates) if coordinates.ndim == 1: coordinates = np.transpose(np.atleast_2d(coordinates)) if coordinates.ndim > 2: coordinates = np.reshape(coordinates, (-1, coordinates.shape[-1])) values = np.asanyarray(values).ravel() ncoords = coordinates.shape[0] if ncoords < 1: raise ValueError('You must enter at least one data point.') if values.shape[0] != ncoords: raise ValueError('The number of values must match the number ' 'of coordinates.') if weights is not None: weights = np.asanyarray(weights).ravel() if weights.shape[0] != ncoords: raise ValueError('The number of weights must match the ' 'number of coordinates.') if np.any(weights < 0.0): raise ValueError('All weight values must be non-negative ' 'numbers.') self.coordinates = coordinates self.ncoords = ncoords self.coords_ndim = coordinates.shape[1] self.values = values self.weights = weights self.kdtree = cKDTree(coordinates, leafsize=leafsize) def __call__(self, positions, n_neighbors=8, eps=0.0, power=1.0, reg=0.0, conf_dist=1.0e-12, dtype=float): """ Evaluate the interpolator at the given positions. Parameters ---------- positions : float, 1D array_like, or NxM array_like Coordinates of the position(s) at which the interpolator should be evaluated. In general, it is expected that these coordinates are in a form of a NxM-like array where N is the number of points and M is dimension of the coordinate space. When M=1 (1D space), then the ``positions`` parameter may be input as a 1D-like array or, if only one data point is available, ``positions`` can be a scalar number representing the 1D coordinate of the data point. .. note:: If the dimensionality of the ``positions`` argument is larger than 2, e.g., if it is of the form N1 x N2 x N3 x ... x Nn x M, then it will be flattened to form an array of size NxM where N = N1 * N2 * ... * Nn. .. warning:: The dimensionality of ``positions`` must match the dimensionality of the ``coordinates`` used during the initialization of the interpolator. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. eps : float, optional Set to use approximate nearest neighbors; the kth neighbor is guaranteed to be no further than (1 + ``eps``) times the distance to the real *k*-th nearest neighbor. See `scipy.spatial.cKDTree.query` for further information. power : float, optional The power of the inverse distance used for the interpolation weights. See the Notes section for more details. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. See the Notes section for more details. conf_dist : float, optional The confusion distance below which the interpolator should use the value of the closest data point instead of attempting to interpolate. This is used to avoid singularities at the known data points, especially if ``reg`` is 0.0. dtype : data-type, optional The data type of the output interpolated values. If `None` then the type will be inferred from the type of the ``values`` parameter used during the initialization of the interpolator. """ n_neighbors = int(n_neighbors) if n_neighbors < 1: raise ValueError('n_neighbors must be a positive integer') if conf_dist is not None and conf_dist <= 0.0: conf_dist = None positions = np.asanyarray(positions) if positions.ndim == 0: # assume we have a single 1D coordinate if self.coords_ndim != 1: raise ValueError('The dimensionality of the input position ' 'does not match the dimensionality of the ' 'coordinates used to initialize the ' 'interpolator.') elif positions.ndim == 1: # assume we have a single point if self.coords_ndim not in (1, positions.shape[-1]): raise ValueError('The input position was provided as a 1D ' 'array, but its length does not match the ' 'dimensionality of the coordinates used ' 'to initialize the interpolator.') elif positions.ndim != 2: raise ValueError('The input positions must be an array-like ' 'object of dimensionality no larger than 2.') positions = np.reshape(positions, (-1, self.coords_ndim)) npositions = positions.shape[0] distances, idx = self.kdtree.query(positions, k=n_neighbors, eps=eps) if n_neighbors == 1: return self.values[idx] if dtype is None: dtype = self.values.dtype interp_values = np.zeros(npositions, dtype=dtype) for k in range(npositions): valid_idx = np.isfinite(distances[k]) idk = idx[k][valid_idx] dk = distances[k][valid_idx] if dk.shape[0] == 0: interp_values[k] = np.nan continue if conf_dist is not None: # check if we are close to a known data point confused = (dk <= conf_dist) if np.any(confused): interp_values[k] = self.values[idk[confused][0]] continue w = 1.0 / ((dk**power) + reg) if self.weights is not None: w *= self.weights[idk] wtot = np.sum(w) if wtot > 0.0: interp_values[k] = np.dot(w, self.values[idk]) / wtot else: interp_values[k] = np.nan if len(interp_values) == 1: return interp_values[0] else: return interp_values ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.050885 photutils-1.13.0/photutils/utils/tests/0000755000175100001770000000000014637570322017602 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/__init__.py0000644000175100001770000000000014637570305021702 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_colormaps.py0000644000175100001770000000112114637570305023206 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the colormaps module. """ import pytest from numpy.testing import assert_allclose from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.colormaps import make_random_cmap @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_colormap(): ncolors = 100 cmap = make_random_cmap(ncolors, seed=0) assert len(cmap.colors) == ncolors assert cmap.colors.shape == (100, 4) assert_allclose(cmap.colors[0], [0.36951484, 0.42125961, 0.65984082, 1.0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_convolution.py0000644000175100001770000000400014637570305023565 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the convolution module. """ import astropy.units as u import pytest from astropy.convolution import Gaussian2DKernel from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.utils._convolution import _filter_data from photutils.utils._optional_deps import HAS_SCIPY @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestFilterData: def setup_class(self): self.data = make_100gaussians_image() self.kernel = Gaussian2DKernel(3.0, x_size=3, y_size=3) def test_filter_data(self): filt_data1 = _filter_data(self.data, self.kernel) filt_data2 = _filter_data(self.data, self.kernel.array) assert_allclose(filt_data1, filt_data2) def test_filter_data_units(self): unit = u.electron filt_data = _filter_data(self.data * unit, self.kernel) assert isinstance(filt_data, u.Quantity) assert filt_data.unit == unit def test_filter_data_types(self): """ Test to ensure output is a float array for integer input data. """ filt_data = _filter_data(self.data.astype(int), self.kernel.array.astype(int)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(int), self.kernel.array.astype(float)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(float), self.kernel.array.astype(int)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(float), self.kernel.array.astype(float)) assert filt_data.dtype == float def test_filter_data_kernel_none(self): """ Test for kernel=None. """ kernel = None filt_data = _filter_data(self.data, kernel) assert_allclose(filt_data, self.data) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_cutouts.py0000644000175100001770000000614314637570305022726 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the cutouts module. """ import numpy as np import pytest from astropy.nddata.utils import PartialOverlapError from numpy.testing import assert_equal from photutils.aperture import BoundingBox from photutils.datasets import make_100gaussians_image from photutils.utils.cutouts import CutoutImage def test_cutout(): data = make_100gaussians_image() shape = (24, 57) yxpos = (100, 51) cutout = CutoutImage(data, yxpos, shape) assert cutout.position == yxpos assert cutout.input_shape == shape assert cutout.mode == 'trim' assert np.isnan(cutout.fill_value) assert not cutout.copy assert cutout.data.shape == shape assert_equal(cutout.__array__(), cutout.data) assert isinstance(cutout.bbox_original, BoundingBox) assert isinstance(cutout.bbox_cutout, BoundingBox) assert cutout.slices_original == (slice(88, 112, None), slice(23, 80, None)) assert cutout.slices_cutout == (slice(0, 24, None), slice(0, 57, None)) assert_equal(cutout.xyorigin, np.array((23, 88))) assert f'Shape: {shape}' in repr(cutout) assert f'Shape: {shape}' in str(cutout) def test_cutout_partial_overlap(): data = make_100gaussians_image() shape = (24, 57) # mode = 'trim' cutout = CutoutImage(data, (11, 10), shape) assert cutout.input_shape == shape assert cutout.shape == (23, 39) # mode = 'strict' with pytest.raises(PartialOverlapError): CutoutImage(data, (11, 10), shape, mode='strict') # mode = 'partial' cutout = CutoutImage(data, (11, 10), shape, mode='partial') assert cutout.input_shape == shape assert cutout.shape == shape assert (cutout.bbox_original == BoundingBox(ixmin=0, ixmax=39, iymin=0, iymax=23)) assert (cutout.bbox_cutout == BoundingBox(ixmin=18, ixmax=57, iymin=1, iymax=24)) assert cutout.slices_original == (slice(0, 23, None), slice(0, 39, None)) assert cutout.slices_cutout == (slice(1, 24, None), slice(18, 57, None)) assert_equal(cutout.xyorigin, np.array((-18, -1))) # regression test for xyorgin in partial mode when cutout extends # beyond right or top edge data = make_100gaussians_image() shape = (54, 57) cutout = CutoutImage(data, (281, 485), shape, mode='partial') assert_equal(cutout.xyorigin, np.array((457, 254))) assert (cutout.bbox_original == BoundingBox(ixmin=457, ixmax=500, iymin=254, iymax=300)) assert (cutout.bbox_cutout == BoundingBox(ixmin=0, ixmax=43, iymin=0, iymax=46)) assert cutout.slices_original == (slice(254, 300, None), slice(457, 500, None)) assert cutout.slices_cutout == (slice(0, 46, None), slice(0, 43, None)) def test_cutout_copy(): data = make_100gaussians_image() cutout1 = CutoutImage(data, (1, 1), (3, 3), copy=True) cutout1.data[0, 0] = np.nan assert not np.isnan(data[0, 0]) cutout2 = CutoutImage(data, (1, 1), (3, 3), copy=False) cutout2.data[0, 0] = np.nan assert np.isnan(data[0, 0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_depths.py0000644000175100001770000001264614637570305022514 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the depths module. """ import itertools import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.tests.helper import assert_quantity_allclose from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.segmentation import SourceFinder, make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_SCIPY, HAS_SKIMAGE from photutils.utils.depths import ImageDepth bool_vals = (True, False) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') class TestImageDepth: def setup_class(self): bkg = 5.0 data = make_100gaussians_image() - bkg kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 threshold = 3.2 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) self.data = data self.mask = segment_map.make_source_mask() @pytest.mark.parametrize(('units', 'overlap'), list(itertools.product(bool_vals, bool_vals))) def test_image_depth(self, units, overlap): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, mask_pad=5, overlap=overlap, seed=123, zeropoint=23.9, progress_bar=False) if overlap: exp_limits = (72.65695364143787, 19.246807037943814) else: exp_limits = (71.07332848526178, 19.27073336332396) data = self.data fluxlim = exp_limits[0] if units: data = self.data * u.Jy fluxlim *= u.Jy limits = depth(data, self.mask) assert_allclose(limits[1], exp_limits[1]) if not units: assert_allclose(limits[0], fluxlim) else: assert_quantity_allclose(limits[0], fluxlim) def test_mask_none(self): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) limits = depth(self.data, mask=None) assert_allclose(limits, (79.348118, 19.151158)) def test_many_apertures(self): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=5000, niters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 20:] = True with pytest.raises(ValueError): depth(self.data, mask) depth = ImageDepth(radius, nsigma=5.0, napers=250, niters=2, mask_pad=5, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 100:] = True with pytest.warns(AstropyUserWarning, match='Unable to generate'): depth(self.data, mask) # test for zero non-overlapping apertures before slow loop radius = 5 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 40:] = True with pytest.warns(AstropyUserWarning, match='Unable to generate'): depth(self.data, mask) def test_zero_data(self): radius = 4 depth = ImageDepth(radius, napers=500, niters=2, overlap=True, seed=123, progress_bar=False) data = np.zeros((300, 400)) mask = None with pytest.warns(AstropyUserWarning, match='One or more flux_limit values was zero'): limits = depth(data, mask) assert_allclose(limits, (0.0, np.inf)) def test_all_masked(self): radius = 4 depth = ImageDepth(radius, napers=500, niters=1, mask_pad=5, overlap=True, seed=123, progress_bar=False) data = np.zeros(self.data.shape) mask = np.zeros(data.shape, dtype=bool) mask[:, 10:] = True with pytest.raises(ValueError): depth(data, mask) def test_inputs(self): with pytest.raises(ValueError): ImageDepth(0.0, nsigma=5.0, napers=500, niters=2, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) with pytest.raises(ValueError): ImageDepth(-12.4, nsigma=5.0, napers=500, niters=2, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) with pytest.raises(ValueError): ImageDepth(12.4, nsigma=5.0, napers=500, niters=2, mask_pad=-7.1, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) def test_repr(self): depth = ImageDepth(aper_radius=4, nsigma=5.0, napers=100, niters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) cls_repr = repr(depth) assert cls_repr.startswith(f'{depth.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_errors.py0000644000175100001770000000477714637570305022547 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the errors module. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose from photutils.utils.errors import calc_total_error SHAPE = (5, 5) DATAVAL = 2.0 DATA = np.ones(SHAPE) * DATAVAL BKG_ERROR = np.ones(SHAPE) EFFGAIN = np.ones(SHAPE) * DATAVAL BACKGROUND = np.ones(SHAPE) WRONG_SHAPE = np.ones((2, 2)) def test_error_shape(): with pytest.raises(ValueError): calc_total_error(DATA, WRONG_SHAPE, EFFGAIN) def test_gain_shape(): with pytest.raises(ValueError): calc_total_error(DATA, BKG_ERROR, WRONG_SHAPE) @pytest.mark.parametrize('effective_gain', (-1, -100)) def test_gain_negative(effective_gain): with pytest.raises(ValueError): calc_total_error(DATA, BKG_ERROR, effective_gain) def test_gain_scalar(): error_tot = calc_total_error(DATA, BKG_ERROR, 2.0) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_array(): error_tot = calc_total_error(DATA, BKG_ERROR, EFFGAIN) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_zero(): error_tot = calc_total_error(DATA, BKG_ERROR, 0.0) assert_allclose(error_tot, BKG_ERROR) effgain = np.copy(EFFGAIN) effgain[0, 0] = 0 effgain[1, 1] = 0 mask = (effgain == 0) error_tot = calc_total_error(DATA, BKG_ERROR, effgain) assert_allclose(error_tot[mask], BKG_ERROR[mask]) assert_allclose(error_tot[~mask], np.sqrt(2)) def test_units(): units = u.electron / u.s error_tot1 = calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN * u.s) assert error_tot1.unit == units error_tot2 = calc_total_error(DATA, BKG_ERROR, EFFGAIN) assert_allclose(error_tot1.value, error_tot2) def test_error_units(): units = u.electron / u.s with pytest.raises(ValueError): calc_total_error(DATA * units, BKG_ERROR * u.electron, EFFGAIN * u.s) def test_effgain_units(): units = u.electron / u.s with pytest.raises(u.UnitsError): calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN * u.km) def test_missing_bkgerror_units(): units = u.electron / u.s with pytest.raises(ValueError): calc_total_error(DATA * units, BKG_ERROR, EFFGAIN * u.s) def test_missing_effgain_units(): units = u.electron / u.s with pytest.raises(ValueError): calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_footprints.py0000644000175100001770000000204714637570305023426 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the footprints module. """ import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils.footprints import circular_footprint def test_footprints(): footprint = circular_footprint(1) result = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) assert_equal(footprint, result) footprint = circular_footprint(2) result = np.array([[0, 0, 1, 0, 0], [0, 1, 1, 1, 0], [1, 1, 1, 1, 1], [0, 1, 1, 1, 0], [0, 0, 1, 0, 0]]) assert_equal(footprint, result) with pytest.raises(ValueError): circular_footprint(5.1) with pytest.raises(ValueError): circular_footprint(0) with pytest.raises(ValueError): circular_footprint(-1) with pytest.raises(ValueError): circular_footprint(np.inf) with pytest.raises(ValueError): circular_footprint(np.nan) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_interpolation.py0000644000175100001770000000744314637570305024113 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolation module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.utils import ShepardIDWInterpolator as idw from photutils.utils._optional_deps import HAS_SCIPY SHAPE = (5, 5) DATA = np.ones(SHAPE) * 2.0 MASK = np.zeros(DATA.shape, dtype=bool) MASK[2, 2] = True ERROR = np.ones(SHAPE) BACKGROUND = np.ones(SHAPE) WRONG_SHAPE = np.ones((2, 2)) @pytest.mark.skipif(not HAS_SCIPY, reason='scipy is required') class TestShepardIDWInterpolator: def setup_class(self): self.rng = np.random.default_rng(0) self.x = self.rng.random(100) self.y = np.sin(self.x) self.f = idw(self.x, self.y) @pytest.mark.parametrize('positions', [0.4, np.arange(2, 5) * 0.1]) def test_idw_1d(self, positions): f = idw(self.x, self.y) assert_allclose(f(positions), np.sin(positions), atol=1e-2) def test_idw_weights(self): weights = self.y * 0.1 f = idw(self.x, self.y, weights=weights) pos = 0.4 assert_allclose(f(pos), np.sin(pos), atol=1e-2) def test_idw_2d(self): pos = self.rng.random((1000, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = idw(pos, val) x = 0.5 y = 0.6 assert_allclose(f([x, y]), np.sin(x + y), atol=1e-2) def test_idw_3d(self): val = np.ones((3, 3, 3)) pos = np.indices(val.shape) f = idw(pos, val) assert_allclose(f([0.5, 0.5, 0.5]), 1.0) def test_no_coordinates(self): with pytest.raises(ValueError): idw([], 0) def test_values_invalid_shape(self): with pytest.raises(ValueError): idw(self.x, 0) def test_weights_invalid_shape(self): with pytest.raises(ValueError): idw(self.x, self.y, weights=10) def test_weights_negative(self): with pytest.raises(ValueError): idw(self.x, self.y, weights=-self.y) def test_n_neighbors_one(self): assert_allclose(self.f(0.5, n_neighbors=1), [0.479334], rtol=3e-7) def test_n_neighbors_negative(self): with pytest.raises(ValueError): self.f(0.5, n_neighbors=-1) def test_conf_dist_negative(self): assert_allclose(self.f(0.5, conf_dist=-1), self.f(0.5, conf_dist=None)) def test_dtype_none(self): result = self.f(0.5, dtype=None) assert result.dtype == float def test_positions_0d_nomatch(self): """test when position ndim doesn't match coordinates ndim""" pos = self.rng.random((10, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = idw(pos, val) with pytest.raises(ValueError): f(0.5) def test_positions_1d_nomatch(self): """test when position ndim doesn't match coordinates ndim""" pos = self.rng.random((10, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = idw(pos, val) with pytest.raises(ValueError): f([0.5]) def test_positions_3d(self): with pytest.raises(ValueError): self.f(np.ones((3, 3, 3))) def test_scalar_values_1d(self): value = 10.0 f = idw(2, value) assert_allclose(f(2), value) assert_allclose(f(-1), value) assert_allclose(f(0), value) assert_allclose(f(142), value) def test_scalar_values_2d(self): value = 10.0 f = idw([[1, 2]], value) assert_allclose(f([1, 2]), value) assert_allclose(f([-1, 0]), value) assert_allclose(f([142, 213]), value) def test_scalar_values_3d(self): value = 10.0 f = idw([[7, 4, 1]], value) assert_allclose(f([7, 4, 1]), value) assert_allclose(f([-1, 0, 7]), value) assert_allclose(f([142, 213, 5]), value) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_misc.py0000644000175100001770000000112214637570305022143 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _misc module. """ import pytest from photutils.utils._misc import _get_meta @pytest.mark.parametrize('utc', (False, True)) def test_get_meta(utc): meta = _get_meta(utc) keys = ('date', 'version') for key in keys: assert key in meta versions = meta['version'] assert isinstance(versions, dict) keys = ('Python', 'photutils', 'astropy', 'numpy', 'scipy', 'skimage', 'sklearn', 'matplotlib', 'gwcs', 'bottleneck') for key in keys: assert key in versions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_moments.py0000644000175100001770000000224514637570305022701 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the moments module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.utils._moments import _moments, _moments_central def test_moments(): data = np.array([[0, 1], [0, 1]]) moments = _moments(data, order=2) result = np.array([[2, 2, 2], [1, 1, 1], [1, 1, 1]]) assert_equal(moments, result) assert_allclose(moments[0, 1] / moments[0, 0], 1.0) assert_allclose(moments[1, 0] / moments[0, 0], 0.5) def test_moments_central(): data = np.array([[0, 1], [0, 1]]) moments = _moments_central(data, order=2) result = np.array([[2.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.5, 0.0, 0.0]]) assert_allclose(moments, result) def test_moments_central_nonsquare(): data = np.array([[0, 1], [0, 1], [0, 1]]) moments = _moments_central(data, order=2) result = np.array([[3.0, 0.0, 0.0], [0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) assert_allclose(moments, result) def test_moments_central_invalid_dim(): data = np.arange(27).reshape(3, 3, 3) with pytest.raises(ValueError): _moments_central(data, order=3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_parameters.py0000644000175100001770000000145414637570305023363 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the parameters module. """ import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils._parameters import as_pair def test_as_pair(): assert_equal(as_pair('myparam', 4), (4, 4)) assert_equal(as_pair('myparam', (3, 4)), (3, 4)) assert_equal(as_pair('myparam', 0), (0, 0)) with pytest.raises(ValueError): as_pair('myparam', 0, lower_bound=(0, 1)) with pytest.raises(ValueError): as_pair('myparam', (1, np.nan)) with pytest.raises(ValueError): as_pair('myparam', (1, np.inf)) with pytest.raises(ValueError): as_pair('myparam', (3, 4), check_odd=True) with pytest.raises(ValueError): as_pair('myparam', 4, check_odd=True) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_quantity_helpers.py0000644000175100001770000000362514637570305024622 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _quantity_helpers module. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils._quantity_helpers import isscalar, process_quantities @pytest.mark.parametrize('all_units', (False, True)) def test_units(all_units): if all_units: unit = u.Jy else: unit = 1.0 arrs = (np.ones(3) * unit, np.ones(3) * unit, np.ones(3) * unit) names = ('a', 'b', 'c') arrs2, unit2 = process_quantities(arrs, names) if all_units: assert unit2 == unit for (arr, arr2) in zip(arrs, arrs2): assert_equal(arr.value, arr2) else: assert unit2 is None assert arrs2 == arrs def test_mixed_units(): match = 'must all have the same units' arrs = (np.ones(3) * u.Jy, np.ones(3) * u.km) names = ('a', 'b') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) arrs = (np.ones(3) * u.Jy, np.ones(3)) names = ('a', 'b') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) unit = u.Jy arrs = (np.ones(3) * unit, np.ones(3), np.ones(3) * unit) names = ('a', 'b', 'c') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) unit = u.Jy arrs = (np.ones(3) * unit, np.ones(3), np.ones(3) * u.km) names = ('a', 'b', 'c') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) def test_inputs(): match = 'The number of values must match the number of names' with pytest.raises(ValueError, match=match): _, _ = process_quantities([1, 2, 3], ['a', 'b']) def test_isscalar(): assert isscalar(1) assert isscalar(1.0 * u.m) assert not isscalar([1, 2, 3]) assert not isscalar([1, 2, 3] * u.m) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/photutils/utils/tests/test_round.py0000644000175100001770000000117014637570305022342 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _round module. """ import numpy as np from numpy.testing import assert_equal from photutils.utils._round import py2intround def test_round(): a = np.arange(-2, 2, 0.5) ar = py2intround(a) result = np.array([-2, -2, -1, -1, 0, 1, 1, 2]) assert isinstance(ar, np.ndarray) assert ar.shape == a.shape assert_equal(ar, result) def test_round_scalar(): a = 0.5 ar = py2intround(a) assert np.isscalar(ar) assert ar == 1.0 a = -0.5 ar = py2intround(a) assert np.isscalar(ar) assert ar == -1.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils/version.py0000644000175100001770000000063514637570321017342 0ustar00runnerdocker# file generated by setuptools_scm # don't change, don't track in version control TYPE_CHECKING = False if TYPE_CHECKING: from typing import Tuple, Union VERSION_TUPLE = Tuple[Union[int, str], ...] else: VERSION_TUPLE = object version: str __version__: str __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE __version__ = version = '1.13.0' __version_tuple__ = version_tuple = (1, 13, 0) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.050885 photutils-1.13.0/photutils.egg-info/0000755000175100001770000000000014637570322016772 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/PKG-INFO0000644000175100001770000001442614637570321020075 0ustar00runnerdockerMetadata-Version: 2.1 Name: photutils Version: 1.13.0 Summary: An Astropy package for source detection and photometry Author-email: Photutils Developers License: Copyright (c) 2011-2023, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. 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. Project-URL: Homepage, https://github.com/astropy/photutils Project-URL: Documentation, https://photutils.readthedocs.io/en/stable/ Keywords: astronomy,astrophysics,photometry,aperture,psf,source detection,background,segmentation,centroids,isophote,morphology,radial profiles Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: BSD License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Cython Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Scientific/Engineering :: Astronomy Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE.rst Requires-Dist: numpy>=1.23 Requires-Dist: astropy>=5.1 Provides-Extra: all Requires-Dist: scipy>=1.8; extra == "all" Requires-Dist: matplotlib>=3.5; extra == "all" Requires-Dist: scikit-image>=0.20; extra == "all" Requires-Dist: gwcs>=0.18; extra == "all" Requires-Dist: bottleneck; extra == "all" Requires-Dist: tqdm; extra == "all" Requires-Dist: rasterio; extra == "all" Requires-Dist: shapely; extra == "all" Provides-Extra: test Requires-Dist: pytest-astropy>=0.11; extra == "test" Provides-Extra: docs Requires-Dist: photutils[all]; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx-astropy>=1.9; extra == "docs" Requires-Dist: tomli; python_version < "3.11" and extra == "docs" ========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |Astropy| |CI Status| |Codecov Status| |Latest RTD Status| Photutils is an `Astropy`_ package for detection and photometry of astronomical sources. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/badge/latestdoi/2640766 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/SOURCES.txt0000644000175100001770000002315114637570321020657 0ustar00runnerdocker.flake8 .gitignore .pep8speaks.yml .pre-commit-config.yaml .pycodestyle .readthedocs.yaml CHANGES.rst CITATION.md CODE_OF_CONDUCT.rst CONTRIBUTING.rst LICENSE.rst README.rst codecov.yml pyproject.toml tox.ini ./photutils/geometry/circular_overlap.pyx ./photutils/geometry/core.pyx ./photutils/geometry/elliptical_overlap.pyx ./photutils/geometry/rectangular_overlap.pyx .github/dependabot.yml .github/workflows/ci_cron_daily.yml .github/workflows/ci_cron_weekly.yml .github/workflows/ci_tests.yml .github/workflows/codeql.yml .github/workflows/publish.yml docs/Makefile docs/aperture.rst docs/background.rst docs/centroids.rst docs/changelog.rst docs/citation.rst docs/conf.py docs/contributing.rst docs/datasets.rst docs/detection.rst docs/epsf.rst docs/geometry.rst docs/getting_started.rst docs/grouping.rst docs/index.rst docs/install.rst docs/isophote.rst docs/isophote_faq.rst docs/license.rst docs/make.bat docs/morphology.rst docs/overview.rst docs/pixel_conventions.rst docs/profiles.rst docs/psf.rst docs/psf_matching.rst docs/segmentation.rst docs/utils.rst docs/_static/favicon.ico docs/_static/photutils.css docs/_static/photutils_banner-475x120.png docs/_static/photutils_banner.pdf docs/_static/photutils_banner.svg docs/_static/photutils_banner_original.svg docs/_static/photutils_logo-32x32.png docs/_static/photutils_logo.svg docs/dev/releasing.rst docs/psf_spec/background_estimator.rst docs/psf_spec/block_diagram.png docs/psf_spec/block_template.rst docs/psf_spec/culler_and_ender.rst docs/psf_spec/finder.rst docs/psf_spec/fitter.rst docs/psf_spec/group_maker.rst docs/psf_spec/index.rst docs/psf_spec/noise_data.rst docs/psf_spec/psf_model.rst docs/psf_spec/scene_maker.rst docs/psf_spec/single_object_model.rst docs/whats_new/1.1.rst docs/whats_new/1.10.rst docs/whats_new/1.11.rst docs/whats_new/1.12.rst docs/whats_new/1.13.rst docs/whats_new/1.2.rst docs/whats_new/1.3.rst docs/whats_new/1.4.rst docs/whats_new/1.5.rst docs/whats_new/1.6.rst docs/whats_new/1.7.rst docs/whats_new/1.8.rst docs/whats_new/1.9.rst docs/whats_new/index.rst photutils/CITATION.rst photutils/__init__.py photutils/_compiler.c photutils/conftest.py photutils/version.py photutils.egg-info/PKG-INFO photutils.egg-info/SOURCES.txt photutils.egg-info/dependency_links.txt photutils.egg-info/not-zip-safe photutils.egg-info/requires.txt photutils.egg-info/top_level.txt photutils/aperture/__init__.py photutils/aperture/attributes.py photutils/aperture/bounding_box.py photutils/aperture/circle.py photutils/aperture/core.py photutils/aperture/ellipse.py photutils/aperture/mask.py photutils/aperture/photometry.py photutils/aperture/rectangle.py photutils/aperture/stats.py photutils/aperture/tests/__init__.py photutils/aperture/tests/test_aperture_common.py photutils/aperture/tests/test_bounding_box.py photutils/aperture/tests/test_circle.py photutils/aperture/tests/test_ellipse.py photutils/aperture/tests/test_mask.py photutils/aperture/tests/test_photometry.py photutils/aperture/tests/test_rectangle.py photutils/aperture/tests/test_stats.py photutils/background/__init__.py photutils/background/background_2d.py photutils/background/core.py photutils/background/interpolators.py photutils/background/local_background.py photutils/background/tests/__init__.py photutils/background/tests/test_background_2d.py photutils/background/tests/test_core.py photutils/background/tests/test_interpolators.py photutils/background/tests/test_local_background.py photutils/centroids/__init__.py photutils/centroids/core.py photutils/centroids/gaussian.py photutils/centroids/tests/__init__.py photutils/centroids/tests/test_core.py photutils/centroids/tests/test_gaussian.py photutils/datasets/__init__.py photutils/datasets/examples.py photutils/datasets/images.py photutils/datasets/load.py photutils/datasets/make.py photutils/datasets/noise.py photutils/datasets/sources.py photutils/datasets/wcs.py photutils/datasets/data/100gaussians_params.ecsv photutils/datasets/data/4gaussians_params.ecsv photutils/datasets/tests/__init__.py photutils/datasets/tests/test_examples.py photutils/datasets/tests/test_images.py photutils/datasets/tests/test_load.py photutils/datasets/tests/test_noise.py photutils/datasets/tests/test_sources.py photutils/datasets/tests/test_wcs.py photutils/detection/__init__.py photutils/detection/core.py photutils/detection/daofinder.py photutils/detection/irafstarfinder.py photutils/detection/peakfinder.py photutils/detection/starfinder.py photutils/detection/tests/__init__.py photutils/detection/tests/conftest.py photutils/detection/tests/test_daofinder.py photutils/detection/tests/test_irafstarfinder.py photutils/detection/tests/test_peakfinder.py photutils/detection/tests/test_starfinder.py photutils/extern/__init__.py photutils/geometry/__init__.py photutils/geometry/circular_overlap.pyx photutils/geometry/core.pxd photutils/geometry/core.pyx photutils/geometry/elliptical_overlap.pyx photutils/geometry/rectangular_overlap.pyx photutils/geometry/tests/__init__.py photutils/geometry/tests/test_circular_overlap_grid.py photutils/geometry/tests/test_elliptical_overlap_grid.py photutils/geometry/tests/test_rectangular_overlap_grid.py photutils/isophote/__init__.py photutils/isophote/ellipse.py photutils/isophote/fitter.py photutils/isophote/geometry.py photutils/isophote/harmonics.py photutils/isophote/integrator.py photutils/isophote/isophote.py photutils/isophote/model.py photutils/isophote/sample.py photutils/isophote/tests/__init__.py photutils/isophote/tests/make_test_data.py photutils/isophote/tests/test_angles.py photutils/isophote/tests/test_ellipse.py photutils/isophote/tests/test_fitter.py photutils/isophote/tests/test_geometry.py photutils/isophote/tests/test_harmonics.py photutils/isophote/tests/test_integrator.py photutils/isophote/tests/test_isophote.py photutils/isophote/tests/test_model.py photutils/isophote/tests/test_regression.py photutils/isophote/tests/test_sample.py photutils/isophote/tests/data/M51_table.fits photutils/isophote/tests/data/README.rst photutils/isophote/tests/data/minimum_radius_test.fits photutils/isophote/tests/data/synth_highsnr_table.fits photutils/isophote/tests/data/synth_lowsnr_table.fits photutils/isophote/tests/data/synth_table.fits photutils/isophote/tests/data/synth_table_mean.fits photutils/isophote/tests/data/synth_table_mean.txt photutils/morphology/__init__.py photutils/morphology/core.py photutils/morphology/non_parametric.py photutils/morphology/tests/__init__.py photutils/morphology/tests/test_core.py photutils/morphology/tests/test_non_parametric.py photutils/profiles/__init__.py photutils/profiles/core.py photutils/profiles/curve_of_growth.py photutils/profiles/radial_profile.py photutils/profiles/tests/__init__.py photutils/profiles/tests/test_curve_of_growth.py photutils/profiles/tests/test_radial_profile.py photutils/psf/__init__.py photutils/psf/epsf.py photutils/psf/epsf_stars.py photutils/psf/griddedpsfmodel.py photutils/psf/groupers.py photutils/psf/models.py photutils/psf/photometry.py photutils/psf/utils.py photutils/psf/matching/__init__.py photutils/psf/matching/fourier.py photutils/psf/matching/windows.py photutils/psf/matching/tests/__init__.py photutils/psf/matching/tests/test_fourier.py photutils/psf/matching/tests/test_windows.py photutils/psf/tests/__init__.py photutils/psf/tests/test_epsf.py photutils/psf/tests/test_epsf_stars.py photutils/psf/tests/test_griddedpsfmodel.py photutils/psf/tests/test_groupers.py photutils/psf/tests/test_models.py photutils/psf/tests/test_photometry.py photutils/psf/tests/test_utils.py photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf4_mock.fits photutils/psf/tests/data/nircam_nrca5_f444w_fovp101_samp4_npsf4_mock.fits photutils/psf/tests/data/nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits photutils/segmentation/__init__.py photutils/segmentation/catalog.py photutils/segmentation/core.py photutils/segmentation/deblend.py photutils/segmentation/detect.py photutils/segmentation/finder.py photutils/segmentation/utils.py photutils/segmentation/tests/__init__.py photutils/segmentation/tests/test_catalog.py photutils/segmentation/tests/test_core.py photutils/segmentation/tests/test_deblend.py photutils/segmentation/tests/test_detect.py photutils/segmentation/tests/test_finder.py photutils/segmentation/tests/test_utils.py photutils/tests/__init__.py photutils/tests/helper.py photutils/utils/__init__.py photutils/utils/_convolution.py photutils/utils/_coords.py photutils/utils/_misc.py photutils/utils/_moments.py photutils/utils/_optional_deps.py photutils/utils/_parameters.py photutils/utils/_progress_bars.py photutils/utils/_quantity_helpers.py photutils/utils/_repr.py photutils/utils/_round.py photutils/utils/_stats.py photutils/utils/_wcs_helpers.py photutils/utils/colormaps.py photutils/utils/cutouts.py photutils/utils/depths.py photutils/utils/errors.py photutils/utils/exceptions.py photutils/utils/footprints.py photutils/utils/interpolation.py photutils/utils/tests/__init__.py photutils/utils/tests/test_colormaps.py photutils/utils/tests/test_convolution.py photutils/utils/tests/test_cutouts.py photutils/utils/tests/test_depths.py photutils/utils/tests/test_errors.py photutils/utils/tests/test_footprints.py photutils/utils/tests/test_interpolation.py photutils/utils/tests/test_misc.py photutils/utils/tests/test_moments.py photutils/utils/tests/test_parameters.py photutils/utils/tests/test_quantity_helpers.py photutils/utils/tests/test_round.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/dependency_links.txt0000644000175100001770000000000114637570321023037 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/not-zip-safe0000644000175100001770000000000114637570321021217 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/requires.txt0000644000175100001770000000035714637570321021376 0ustar00runnerdockernumpy>=1.23 astropy>=5.1 [all] scipy>=1.8 matplotlib>=3.5 scikit-image>=0.20 gwcs>=0.18 bottleneck tqdm rasterio shapely [docs] photutils[all] sphinx sphinx-astropy>=1.9 [docs:python_version < "3.11"] tomli [test] pytest-astropy>=0.11 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595217.0 photutils-1.13.0/photutils.egg-info/top_level.txt0000644000175100001770000000001214637570321021514 0ustar00runnerdockerphotutils ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/pyproject.toml0000644000175100001770000000715614637570305016173 0ustar00runnerdocker[project] name = 'photutils' description = 'An Astropy package for source detection and photometry' readme = 'README.rst' license = {file = 'LICENSE.rst'} authors = [ {name = 'Photutils Developers', email = 'photutils.team@gmail.com'}, ] keywords = [ 'astronomy', 'astrophysics', 'photometry', 'aperture', 'psf', 'source detection', 'background', 'segmentation', 'centroids', 'isophote', 'morphology', 'radial profiles', ] classifiers = [ 'Intended Audience :: Science/Research', 'License :: OSI Approved :: BSD License', 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Cython', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Topic :: Scientific/Engineering :: Astronomy', ] dynamic = ['version'] requires-python = '>=3.10' dependencies = [ 'numpy>=1.23', 'astropy>=5.1', ] [project.urls] Homepage = 'https://github.com/astropy/photutils' Documentation = 'https://photutils.readthedocs.io/en/stable/' [project.optional-dependencies] all = [ 'scipy>=1.8', 'matplotlib>=3.5', 'scikit-image>=0.20', 'gwcs>=0.18', 'bottleneck', 'tqdm', 'rasterio', 'shapely', ] test = [ 'pytest-astropy>=0.11', ] docs = [ 'photutils[all]', 'sphinx', 'sphinx-astropy>=1.9', 'tomli; python_version < "3.11"', ] [build-system] requires = [ 'setuptools>=61.2', 'setuptools_scm>=6.2', 'cython>=3.0.0,<3.1.0', 'numpy>=2.0.0rc1', 'extension-helpers==1.*', ] build-backend = 'setuptools.build_meta' [tool.extension-helpers] use_extension_helpers = true [tool.setuptools_scm] write_to = 'photutils/version.py' [tool.setuptools] zip-safe = false include-package-data = false [tool.setuptools.packages.find] namespaces = false [tool.setuptools.package-data] 'photutils' = [ 'CITATION.rst', ] 'photutils.datasets' = [ 'data/*', ] 'photutils.detection.tests' = [ 'data/*', ] 'photutils.isophote.tests' = [ 'data/*', ] 'photutils.psf.tests' = [ 'data/*', ] [tool.pytest.ini_options] minversion = 7.0 testpaths = [ 'photutils', 'docs', ] norecursedirs = [ 'docs/_build', 'extern', ] astropy_header = true doctest_plus = 'enabled' text_file_format = 'rst' addopts = '--color=yes --doctest-rst' xfail_strict = true remote_data_strict = true filterwarnings = [ 'error', # turn warnings into exceptions 'ignore:numpy.ufunc size changed:RuntimeWarning', 'ignore:numpy.ndarray size changed:RuntimeWarning', # photutils.datasets.make deprecation 'ignore:photutils.datasets.make is deprecated:DeprecationWarning', ] [tool.coverage.run] omit = [ 'photutils/_astropy_init*', 'photutils/conftest.py', 'photutils/*setup_package*', 'photutils/tests/*', 'photutils/*/tests/*', 'photutils/extern/*', 'photutils/version*', '*/photutils/_astropy_init*', '*/photutils/conftest.py', '*/photutils/*setup_package*', '*/photutils/tests/*', '*/photutils/*/tests/*', '*/photutils/extern/*', '*/photutils/version*', ] [tool.coverage.report] exclude_lines = [ 'pragma: no cover', 'except ImportError', 'raise AssertionError', 'raise NotImplementedError', 'def main\\(.*\\):', 'pragma: py{ignore_python_version}', 'def _ipython_key_completions_', ] [tool.build-sphinx] github_project = 'astropy/photutils' [tool.isort] skip_glob = [ 'photutils/*__init__.py*', ] known_first_party = [ 'photutils', 'extension_helpers', ] use_parentheses = true [tool.black] force-exclude = """ ( .* ) """ [tool.bandit.assert_used] skips = ['*_test.py', '*/test_*.py'] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1719595218.050885 photutils-1.13.0/setup.cfg0000644000175100001770000000004614637570322015066 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1719595205.0 photutils-1.13.0/tox.ini0000644000175100001770000001061514637570305014564 0ustar00runnerdocker[tox] envlist = py{310,311,312}-test{,-alldeps,-devdeps,-oldestdeps,-devinfra}{,-cov} py{310,311,312}-test-numpy{123,124,125,126} build_docs linkcheck codestyle pep517 bandit isolated_build = true [testenv] # Suppress display of matplotlib plots generated during docs build setenv = MPLBACKEND=agg devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/astropy/simple # Pass through the following environment variables which may be needed # for the CI passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI # Run the tests in a temporary directory to make sure that we don't # import this package from the source tree changedir = .tmp/{envname} # tox environments are constructed with so-called 'factors' (or terms) # separated by hyphens, e.g., test-devdeps-cov. Lines below starting # with factor: will only take effect if that factor is included in the # environment name. To see a list of example environments that can be run, # along with a description, run: # # tox -l -v # description = run tests alldeps: with all optional dependencies devdeps: with the latest developer version of key dependencies devinfra: like devdeps but also dev version of infrastructure oldestdeps: with the oldest supported version of key dependencies cov: and test coverage numpy123: with numpy 1.23.* numpy124: with numpy 1.24.* numpy125: with numpy 1.25.* numpy126: with numpy 1.26.* # The following provides some specific pinnings for key packages deps = cov: pytest-cov numpy123: numpy==1.23.* numpy124: numpy==1.24.* numpy125: numpy==1.25.* numpy126: numpy==1.26.* oldestdeps: numpy==1.23 oldestdeps: astropy==5.1 oldestdeps: scipy==1.8 oldestdeps: matplotlib==3.5 oldestdeps: scikit-image==0.20 oldestdeps: scikit-learn==1.1 oldestdeps: gwcs==0.18 oldestdeps: pytest-astropy==0.11 devdeps: numpy>=0.0.dev0 devdeps: scipy>=0.0.dev0 devdeps: scikit-image>=0.0.dev0 devdeps: scikit-learn>=0.0.dev0 devdeps: matplotlib>=0.0.dev0 devdeps: pyerfa>=0.0.dev0 devdeps: astropy>=0.0.dev0 devdeps: git+https://github.com/spacetelescope/gwcs.git # Latest developer version of infrastructure packages. devinfra: git+https://github.com/pytest-dev/pytest.git devinfra: git+https://github.com/astropy/extension-helpers.git devinfra: git+https://github.com/astropy/pytest-doctestplus.git devinfra: git+https://github.com/astropy/pytest-remotedata.git devinfra: git+https://github.com/astropy/pytest-astropy-header.git devinfra: git+https://github.com/astropy/pytest-arraydiff.git devinfra: git+https://github.com/astropy/pytest-filter-subpackage.git devinfra: git+https://github.com/astropy/pytest-astropy.git # The following indicates which [project.optional-dependencies] from # pyproject.toml will be installed extras = test: test alldeps: all build_docs: docs install_command = !devdeps: python -I -m pip install devdeps: python -I -m pip install -v --pre commands = # Force numpy-dev after matplotlib downgrades it # (https://github.com/matplotlib/matplotlib/issues/26847) devdeps: python -m pip install --pre --upgrade --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple numpy pip freeze pytest --pyargs photutils {toxinidir}/docs \ cov: --cov photutils --cov-config={toxinidir}/pyproject.toml --cov-report xml:{toxinidir}/coverage.xml --cov-report term-missing \ {posargs} [testenv:build_docs] changedir = docs description = invoke sphinx-build to build the HTML docs extras = docs commands = pip freeze sphinx-build -W -b html . _build/html [testenv:linkcheck] changedir = docs description = check the links in the HTML docs extras = docs commands = pip freeze sphinx-build -W -b linkcheck . _build/html [testenv:codestyle] skip_install = true changedir = . description = check code style with flake8 deps = flake8 commands = flake8 photutils --count --max-line-length=100 [testenv:pep517] skip_install = true changedir = . description = PEP 517 deps = build twine commands = python -m build --sdist . twine check dist/* --strict [testenv:bandit] skip_install = true changedir = . description = security check with bandit deps = bandit commands = bandit -r photutils -c pyproject.toml