pax_global_header00006660000000000000000000000064151705211140014507gustar00rootroot0000000000000052 comment=a89accd29395defa162eed71df462419859cbe22 astropy-photutils-3322558/000077500000000000000000000000001517052111400154365ustar00rootroot00000000000000astropy-photutils-3322558/.flake8000066400000000000000000000000571517052111400166130ustar00rootroot00000000000000[flake8] max-line-length = 79 exclude = extern astropy-photutils-3322558/.github/000077500000000000000000000000001517052111400167765ustar00rootroot00000000000000astropy-photutils-3322558/.github/dependabot.yml000066400000000000000000000010461517052111400216270ustar00rootroot00000000000000# 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: ".github/workflows/" schedule: interval: "monthly" cooldown: default-days: 7 groups: actions: patterns: - "*" astropy-photutils-3322558/.github/workflows/000077500000000000000000000000001517052111400210335ustar00rootroot00000000000000astropy-photutils-3322558/.github/workflows/check_changelog.yml000066400000000000000000000011231517052111400246370ustar00rootroot00000000000000name: Check PR change log on: pull_request: types: [opened, synchronize, labeled, unlabeled] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: pull-requests: read jobs: changelog_checker: name: Check if change log entry is correct runs-on: ubuntu-latest if: github.repository == 'astropy/photutils' steps: - uses: scientific-python/action-check-changelogfile@1fc669db9618167166d5a16c10282044f51805c0 # 0.3 env: CHANGELOG_FILENAME: CHANGES.rst GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} astropy-photutils-3322558/.github/workflows/check_milestone.yml000066400000000000000000000021751517052111400247170ustar00rootroot00000000000000name: Check PR milestone on: pull_request: types: [opened, reopened, synchronize, milestoned, demilestoned] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: pull-requests: read jobs: # https://stackoverflow.com/questions/69434370/how-can-i-get-the-latest-pr-data-specifically-milestones-when-running-yaml-jobs milestone_checker: runs-on: ubuntu-latest steps: - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 if: github.repository == 'astropy/photutils' with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { data } = await github.request("GET /repos/{owner}/{repo}/pulls/{pr}", { owner: context.repo.owner, repo: context.repo.repo, pr: context.payload.pull_request.number }); if (data.milestone) { core.info(`This pull request has a milestone set: ${data.milestone.title}`); } else { core.setFailed(`A maintainer needs to set the milestone for this pull request.`); } astropy-photutils-3322558/.github/workflows/ci_cron_daily.yml000066400000000000000000000050401517052111400243530ustar00rootroot00000000000000name: 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.x' tox_env: 'linkcheck' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.14' tox_env: 'py314-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python }} allow-prereleases: true - 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: python -m tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} - name: Upload coverage to codecov if: ${{ contains(matrix.tox_env, '-cov') }} uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: ./coverage.xml verbose: true astropy-photutils-3322558/.github/workflows/ci_cron_weekly.yml000066400000000000000000000112651517052111400245570ustar00rootroot00000000000000name: 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.14' tox_env: 'py314-test-alldeps-devinfra' allow_failure: false steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 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: python -m 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-latest 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: s390x - arch: ppc64le # isophote.rst fails on armv7 with # "RuntimeWarning: Mean of empty slice." # - arch: armv7 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - uses: uraimo/run-on-arch-action@d94c13912ea685de38fccc1109385b83fd79427d # v3.0.1 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 setuptools packaging 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 astropy-photutils-3322558/.github/workflows/ci_tests.yml000066400000000000000000000076411517052111400234030ustar00rootroot00000000000000name: 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.11' tox_env: 'py311-test-oldestdeps' 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' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.13' tox_env: 'py313-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.14' tox_env: 'py314-test-alldeps-cov' toxposargs: --remote-data=any allow_failure: false prefix: '' # test without all dependencies - os: ubuntu-latest python: '3.14' tox_env: 'py314-test' toxposargs: --remote-data=any allow_failure: false prefix: '' - os: ubuntu-24.04-arm python: '3.14' tox_env: 'py314-test' allow_failure: false prefix: '' - os: macos-latest python: '3.14' tox_env: 'py314-test-alldeps' allow_failure: false prefix: '' - os: windows-latest python: '3.14' tox_env: 'py314-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.x' tox_env: 'codestyle' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.x' tox_env: 'pep517' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.x' tox_env: 'bandit' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.14' tox_env: 'py314-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python }} allow-prereleases: true - 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: python -m tox -e ${{ matrix.tox_env }} -- -n=2 ${{ matrix.toxposargs }} - name: Upload coverage to codecov if: ${{ contains(matrix.tox_env, '-cov') }} uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: ./coverage.xml verbose: true astropy-photutils-3322558/.github/workflows/codeql.yml000066400000000000000000000063171517052111400230340ustar00rootroot00000000000000# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@5618c9fc1e675841ca52c1c6b1304f5255a905a0 # codeql-bundle-v2.19.0 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@5618c9fc1e675841ca52c1c6b1304f5255a905a0 # codeql-bundle-v2.19.0 # â„šī¸ 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@5618c9fc1e675841ca52c1c6b1304f5255a905a0 # codeql-bundle-v2.19.0 with: category: "/language:${{matrix.language}}" astropy-photutils-3322558/.github/workflows/publish.yml000066400000000000000000000036661517052111400232370ustar00rootroot00000000000000name: Wheel building on: schedule: # run every day at 4:30am UTC - cron: '30 4 * * *' 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@e97344095b099e1d729fe97429078c9975921d8a # v2.6.2 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 - cp*-manylinux_x86_64 # MacOS X wheels - cp*-macosx_x86_64 - cp*-macosx_arm64 # Windows wheels - cp*-win_amd64 # Developer wheels upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} anaconda_user: astropy anaconda_package: photutils anaconda_keep_n_latest: 10 secrets: pypi_token: ${{ secrets.pypi_token }} anaconda_token: ${{ secrets.anaconda_token }} astropy-photutils-3322558/.github/zizmor.yml000066400000000000000000000004741517052111400210600ustar00rootroot00000000000000rules: secrets-outside-env: ignore: # CODECOV_TOKEN is a low-risk CI-only token; no GitHub Environment is # needed. This rule will be demoted to --persona=auditor only in # zizmor v1.24.0, at which point this ignore block can be removed. - ci_cron_daily.yml:76 - ci_tests.yml:144 astropy-photutils-3322558/.gitignore000066400000000000000000000015011517052111400174230ustar00rootroot00000000000000# 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 # Virtual environments .venv # uv (Python package manager) uv.lock # pipenv Pipfile.lock Pipfile # poetry poetry.lock # 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 astropy-photutils-3322558/.pre-commit-config.yaml000066400000000000000000000074221517052111400217240ustar00rootroot00000000000000ci: autofix_prs: false autoupdate_schedule: 'monthly' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.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/astral-sh/ruff-pre-commit rev: "v0.15.9" hooks: - id: ruff-check args: ["--fix", "--show-fixes"] - repo: https://github.com/scientific-python/cookie rev: 2026.04.04 hooks: - id: sp-repo-review - repo: https://github.com/pycqa/isort rev: 8.0.1 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.3.0 hooks: - id: flake8 args: ["--ignore", "E501,W503"] - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell args: ["--write-changes"] additional_dependencies: - tomli - repo: https://github.com/numpy/numpydoc rev: v1.10.0 hooks: - id: numpydoc-validation exclude: "extern/" # TMP: disabled until new release (Python 3.14 support and without # requiring untokenize) and various regressions are fixed # Formats docstrings to follow PEP 257 # - repo: https://github.com/PyCQA/docformatter # rev: v1.7.7 # hooks: # - id: docformatter # args: [--in-place, --config, ./pyproject.toml] # exclude: "extern/" - repo: https://github.com/woodruffw/zizmor-pre-commit rev: v1.23.1 hooks: - id: zizmor - repo: https://github.com/sphinx-contrib/sphinx-lint rev: v1.0.2 hooks: - id: sphinx-lint exclude: "(docs/_build/|photutils/extern/)" astropy-photutils-3322558/.pycodestyle000066400000000000000000000000641517052111400200030ustar00rootroot00000000000000[pycodestyle] max-line-length = 79 exclude = extern astropy-photutils-3322558/.readthedocs.yaml000066400000000000000000000007021517052111400206640ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 apt_packages: - graphviz tools: python: "3.13" 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: [] astropy-photutils-3322558/CHANGES.rst000066400000000000000000004174031517052111400172510ustar00rootroot000000000000003.0.0 (2026-04-17) ------------------ General ^^^^^^^ - The minimum required NumPy is now 2.0. [#2115] - The minimum required SciPy is now 1.13. [#2121] - The minimum required Matplotlib is now 3.9. [#2115] - The minimum required scikit-image is now 0.23. [#2115] - The minimum required astropy is now 6.1.4. [#2130] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``to_sky`` and ``to_pixel`` methods for all pixel and sky aperture types now use the local WCS Jacobian to accurately compute pixel scale factors and rotation angles. This correctly handles WCS with distortions (e.g., SIP polynomial corrections) and non-square pixels. [#2240] - ``photutils.background`` - Added a ``to_aperture`` method to ``LocalBackground``. [#2118] - ``photutils.centroids`` - Added a ``CentroidQuadratic`` class to provide an object-oriented interface to the ``centroid_quadratic`` function. [#2163, #2169] - ``photutils.detection`` - Added a public ``StarFinderCatalogBase`` class that provides a common base for the catalogs in the ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` classes. [#2149, #2201] - Added ``__repr__`` and ``__str__`` methods to ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``. [#2201] - Significantly improved the performance of ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` by vectorizing cutout extraction and image moment computation. [#2201] - The ``threshold`` parameter in ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` now accepts a 2D array in addition to a scalar value, allowing for spatially varying detection thresholds. [#2202] - Added a ``scale_threshold`` parameter to ``DAOStarFinder``. When set to `False`, the input ``threshold`` is applied directly to the convolved data without the default kernel-based scaling. [#2202] - Added a ``min_separation`` keyword to ``find_peaks``. The implementation uses fast separable box filters and is approximately 10-400x faster than using an explicit circular footprint with ``scipy.ndimage.maximum_filter`` (depending on the radius), while producing identical results. [#2246] - ``photutils.profiles`` - Added a ``EnsquaredCurveOfGrowth`` class to compute a curve of growth using concentric square apertures. [#2184] - Added a ``EllipticalCurveOfGrowth`` class to compute a curve of growth using concentric elliptical apertures with a fixed axis ratio and orientation. [#2184] - Added ``moffat_fit``, ``moffat_profile``, and ``moffat_fwhm`` properties to ``RadialProfile`` for fitting a 1D Moffat model to the radial profile. [#2185] - ``photutils.psf`` - Added a ``SourceGroups`` class that stores the results of grouping sources and provides methods to analyze and plot the groupings. [#2116] - Added ``remove_invalid`` and ``reset_ids`` keywords to the ``results_to_init_params`` and ``results_to_model_params`` methods of ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#2131] - Added a ``decode_flags`` convenience method to ``PSFPhotometry`` and ``IterativePSFPhotometry`` classes to decode the bitwise flags from the results table. [#2132, #2136] - Added a ``return_bit_flags`` keyword to the ``decode_psf_flags`` function. [#2136] - Added ``__repr__`` methods to ``ImagePSF`` and ``GriddedPSFModel``. [#2134] - Added a ``shape`` property to ``ImagePSF``. [#2158] - ``EPSFBuilder`` now automatically excludes stars that repeatedly fail fitting and emits warnings with specific failure reasons. [#2158, #2165] - Added validation and automatic shape handling for ``fit_shape=None`` in ``fit_2dgaussian`` and ``fit_fwhm``. The functions now require explicit ``fit_shape`` for multiple sources and emit an informative warning for single-source fitting. [#2164] - The ``fit_fwhm`` and ``fit_2dgaussian`` ``xypos`` value can now be input as a ``zip`` object. [#2164] - ``photutils.psf_matching`` - Added a ``regularization`` keyword to ``make_kernel`` to regularize division by near-zero values in the source Optical Transfer Function. [#2170, #2171, #2175] - Added ``make_wiener_kernel`` function that uses Wiener regularization to make a PSF matching kernel. [#2171, #2172, #2175] - ``make_kernel`` now validates input PSFs (2D, odd dimensions, centered) and the window function output. [#2170] - ``resize_psf`` now validates input PSFs and pixel scales. [#2170] - Added ``__repr__`` methods to ``CosineBellWindow``, ``HanningWindow``, ``SplitCosineBellWindow``, ``TopHatWindow``, and ``TukeyWindow`` classes. [#2176] - ``photutils.segmentation`` - The ``SourceCatalog`` ``fluxfrac_radius`` now caches results by ``fluxfrac`` value. Repeated calls with the same ``fluxfrac`` return the cached result without recomputation. [#2197] - The radial step used when searching for a bracketing interval in ``SourceCatalog`` ``fluxfrac_radius`` is now set to 10% of the current ``max_radius``, bounding the fallback loop to at most ~10 iterations regardless of source size. [#2197] - Improved the performance (~6-9x speedup) of ``SourceCatalog`` ``min_value``, ``max_value``, ``segment_flux``, ``segment_flux_err``, ``background_sum``, and ``background_mean``. [#2199] - Added ``SegmentationImage`` ``get_segment`` and ``get_segments`` methods to return the ``Segment`` object(s) for a given label or list of labels. [#2228, #2256] - Added the ability to input ``RegionVisual`` keywords to the ``SegmentationImage`` ``to_regions`` method. [#2228] - Added ``SegmentationImage`` ``get_polygon``, ``get_polygons``, ``get_patch``, ``get_patches``, ``get_region``, and ``get_regions`` methods to return the polygon, patch, or region for a given segment label or list of labels. [#2232] - Improved the performance of the ``SegmentationImage`` ``keep_labels`` method and ``missing_labels`` attribute. [#2234] - Improved the performance of ``SourceCatalog`` ``centroid_win`` (~3.5x speedup), ``centroid_quad``, ``fluxfrac_radius`` (~6x speedup), ``kron_radius`` (~2x speedup), ``moments``, ``moments_central``, ``perimeter``, and Kron photometry. [#2238] - ``photutils.utils`` - Significantly improved the performance (by factors of 3 or more) of ``ShepardIDWInterpolator`` [#2187]. - Added a ``use_future_column_names`` context manager for temporarily enabling future column names in a scoped, thread-safe, and async-safe way without modifying the global ``photutils.future_column_names`` flag. [#2258] Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed ``Background2D`` raising ``AttributeError`` when passed a function as ``bkg_estimator`` or ``bkgrms_estimator`` instead of a class instance with a ``sigma_clip`` attribute. [#2181] - Fixed silent truncation of background estimates when integer input data were provided to ``Background2D``. Integer inputs now produce ``np.float32`` output to preserve precision from interpolation. [#2181] - ``photutils.centroids`` - Fixed a bug in ``centroid_sources`` where the input error array could be ignored if more than one source was input. [#2179] - ``photutils.datasets`` - Fixed a bug in ``apply_poisson_noise`` where the returned image could have a different dtype than the input. [#2173] - ``photutils.detection`` - Fixed ``StarFinder`` mutating the input ``kernel`` array during normalization. [#2201] - Fixed an edge case of ``find_peaks`` not excluding NaN pixel locations from peak detection, which could produce false peaks when the NaN fill value was a local maximum. [#2247] - ``photutils.isophote`` - ``build_ellipse_model`` now integrates over all angles instead of stopping once it hits the edge of the output image. [#2156] - ``photutils.morphology`` - Fixed issues with negative pixel values input to ``gini``. [#2178] - ``photutils.profiles`` - Fixed an issue where the mask (input and non-finite values) was not applied to the raw data profile. [#2184] - ``photutils.psf`` - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now handle non-finite (NaN or inf) local background values instead of raising an error. Three new flags have been added to identify sources with non-finite values: flag 512 for non-finite fitted positions, flag 1024 for non-finite fitted flux, and flag 2048 for non-finite local background. [#2131] - Fixed a bug in ``EPSFBuilder`` where the ``recentering_boxsize`` was being applied in oversampled space instead of the original star pixel space. [#2168] - ``photutils.segmentation`` - Fixed a bug in ``SourceCatalog`` where the (x, y) coordinates were swapped in the ``map_coordinates`` call used to interpolate the background at source centroids, causing ``background_centroid`` to return incorrect values. [#2198] - Fixed an issue in ``SourceCatalog`` where incorrect masking behavior could occur when ``apermask_method='none'``. [#2198] - Fixed an issue in ``SourceCatalog`` where unrealistically large ``kron_radius`` values could cause out-of-memory issues. [#2237] API Changes ^^^^^^^^^^^ - ``photutils`` - Passing optional arguments positionally to all functions, classes, and methods in ``photutils`` is now deprecated. In the future, all optional arguments must be passed as keyword arguments. [#2219] - ``photutils.aperture`` - The ``ApertureStats`` ``covar_sigx2``, ``covar_sigxy``, and ``covar_sigy2`` attributes have been renamed to ``covariance_xx``, ``covariance_xy``, and ``covariance_yy``, respectively. The old names are deprecated. [#2241] - The ``ApertureStats`` ``cxx``, ``cxy``, and ``cyy`` attributes have been renamed to ``ellipse_cxx``, ``ellipse_cxy``, and ``ellipse_cyy``, respectively. The old names are deprecated. [#2241] - The ``ApertureStats`` ``data_sumcutout`` and ``error_sumcutout`` attributes have been renamed to ``data_sum_cutout`` and ``error_sum_cutout``, respectively. The old names are deprecated. [#2241] - The ``ApertureStats`` ``get_id`` and ``get_ids`` methods have been renamed to ``select_id`` and ``select_ids``, respectively. The old names are deprecated. [#2241] - The ``ApertureStats`` ``semimajor_sigma`` and ``semiminor_sigma`` attributes/columns have been renamed to ``semimajor_axis`` and ``semiminor_axis``, respectively. The old names are deprecated. [#2241] - The ``ApertureStats`` ``xcentroid`` and ``ycentroid`` attributes/columns have been renamed to ``x_centroid`` and ``y_centroid``, respectively. The old names are deprecated. [#2241] - The ``xcenter`` and ``ycenter`` column names in the table returned by ``aperture_photometry`` have been renamed to ``x_center`` and ``y_center``, respectively. The old names are deprecated. [#2241] - The ``CircularMaskMixin``, ``EllipticalMaskMixin``, and ``RectangularMaskMixin`` classes are now deprecated. The mask generation is now handled internally by the ``PixelAperture`` base class. [#2242] - ``photutils.background`` - Removed the deprecated ``edge_method`` keyword from ``Background2D``. [#2102] - Removed the deprecated ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties from ``Background2D``. [#2102] - Removed the deprecated ``grid_mode`` keyword from ``BkgZoomInterpolator``. [#2102] - The ``interpolator`` keyword argument for ``Background2D`` is now deprecated. When ``interpolator`` is eventually removed, the ``scipy.ndimage.zoom`` cubic spline interpolator will always be used to resize the low-resolution arrays. The behavior will be identical to the current default. [#2108] - The ``Background2D`` ``npixels_mesh`` and ``npixels_map`` properties have been renamed to ``n_pixels_mesh`` and ``n_pixels_map``, respectively. The old names are deprecated. [#2241] - The ``BkgIDWInterpolator`` and ``BkgZoomInterpolator`` classes are now deprecated. [#2108] - The ``Background2D`` ``bkgrms_estimator`` keyword argument has been renamed to ``bkg_rms_estimator``. The old name is deprecated. [#2241] - ``photutils.centroids`` - The ``xpeak``, ``ypeak``, and ``search_boxsize`` keyword arguments for ``centroid_quadratic`` are now deprecated. Use ``centroid_sources`` to centroid sources at specific positions. [#2160] - ``photutils.datasets`` - The ``get_path``, ``load_spitzer_image``, ``load_spitzer_catalog``, and ``load_star_image`` functions are now deprecated and will be removed in a future version. [#2135] - ``photutils.detection`` - ``find_peaks`` now returns a ``QTable`` instead of a ``Table``. [#2201] - The ``sharplo`` and ``sharphi`` keyword arguments for ``DAOStarFinder`` and ``IRAFStarFinder`` are now deprecated. Use the ``sharpness_range=(lower, upper)`` tuple keyword instead. Set ``sharpness_range=None`` to disable sharpness filtering. [#2216] - The ``roundlo`` and ``roundhi`` keyword arguments for ``DAOStarFinder`` and ``IRAFStarFinder`` are now deprecated. Use the ``roundness_range=(lower, upper)`` tuple keyword instead. Set ``roundness_range=None`` to disable roundness filtering. [#2216] - The ``minsep_fwhm`` keyword argument for ``IRAFStarFinder`` is now deprecated. Use ``min_separation`` instead. [#2216] - The ``peakmax`` keyword argument for ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` is now deprecated. Use ``peak_max`` instead. [#2216] - The ``brightest`` keyword argument for ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` is now deprecated. Use ``n_brightest`` instead. [#2216] - The ``npeaks`` keyword argument for ``find_peaks`` is now deprecated. Use ``n_peaks`` instead. [#2241] - The default ``min_separation`` for ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` is now ``None``, which computes a default separation of ``2.5 * fwhm`` (or ``2.5 * (min(kernel.shape) // 2)`` for ``StarFinder``) consistent across all three star finders. [#2216] - The ``xcentroid`` and ``ycentroid`` column/attribute names for ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` catalog classes are deprecated. Use ``x_centroid`` and ``y_centroid`` instead. The ``cutout_xcentroid`` and ``cutout_ycentroid`` attributes are also deprecated in favor of ``cutout_x_centroid`` and ``cutout_y_centroid``. [#2241] - The ``IRAFStarFinder`` and ``StarFinder`` ``pa`` attribute/column has been renamed to ``orientation``. The old name is deprecated. [#2241] - The ``npix`` column/attribute in ``DAOStarFinder`` and ``IRAFStarFinder`` catalogs has been renamed to ``n_pixels``. The old name is deprecated. [#2241] - The ``IRAFStarFinder`` and ``StarFinder`` ``orientation`` (was ``pa``) values are now always returned as a ``Quantity`` array in the range [0, 360) degrees. [#2224, #2225] - ``photutils.isophote`` - The ``Isophote`` and ``IsophoteList`` ``grad_error`` and ``grad_r_error`` attributes have been renamed to ``gradient_err`` and ``gradient_rel_err``, respectively. The old names are deprecated. [#2241] - The ``EllipseSample`` ``gradient_error`` and ``gradient_relative_error`` attributes have been renamed to ``gradient_err`` and ``gradient_rel_err``, respectively. The old names are deprecated. [#2241] - The ``grad_error`` and ``grad_rerror`` column names in the isophote output table have been renamed to ``gradient_err`` and ``gradient_rel_err``, respectively. The old names are deprecated. [#2241] - The ``nclip`` parameter in ``Ellipse.fit_image``, ``Ellipse.fit_isophote``, and ``EllipseSample`` has been renamed to ``n_clip``. The old name is deprecated. [#2241] - The ``Isophote`` and ``IsophotList`` ``niter``, ``ndata``, and ``nflag`` attributes have been renamed to ``n_iter``, ``n_data``, and ``n_flag``, respectively. The old names are deprecated. [#2241] - ``photutils.profiles`` - Cached Gaussian fits to the radial profile are now automatically invalidated when the profile normalization changes, so the fit is always consistent with the current profile. [#2185] - ``photutils.psf`` - The ``photutils.psf.matching`` subpackage has been moved to ``photutils.psf_matching``. Importing from the old location is deprecated. [#2167] - Removed the deprecated ``IntegratedGaussianPRF`` and ``PRFAdapter`` classes. [#2103] - The ``grid_from_epsfs`` helper function is now deprecated. Instead, use ``GriddedPSFModel`` directly. [#2111] - The ``EPSFFitter`` class is now deprecated. Use the ``fitter``, ``fit_shape``, and ``fitter_maxiters`` parameters of ``EPSFBuilder`` instead. [#2159] - Removed the ``ModelImageMixin`` class. [#2133] - Removed the ``ModelGridPlotMixin`` class. [#2137] - Removed the ``norm_radius`` keyword from ``EPSFBuilder``. [#2158] - Removed the deprecated ``FittableImageModel`` and ``EPSFModel`` classes. Use ``ImagePSF`` instead. [#2158] - ``EPSFBuilder`` now returns an ``EPSFBuildResult`` dataclass containing the ePSF, fitted stars, iteration count, convergence status, and excluded star diagnostics. Tuple unpacking is still supported for backward compatibility. [#2158] - ``LinkedEPSFStar`` no longer inherits from ``EPSFStars``. [#2158] - The ``npixfit`` column in ``PSFPhotometry`` results has been renamed to ``n_pixels_fit``. The old name is deprecated. [#2241] - The ``NPIXFIT_PARTIAL`` (``npixfit_partial``) PSF flag has been renamed to ``N_PIXELS_FIT_PARTIAL`` (``n_pixels_fit_partial``). The old name is deprecated. [#2241] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``localbkg_estimator`` keyword argument has been renamed to ``local_bkg_estimator``. The old name is deprecated. [#2241] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``include_localbkg`` keyword argument in ``make_model_image`` and ``make_residual_image`` has been renamed to ``include_local_bkg``. The old name is deprecated. [#2241] - ``photutils.psf_matching`` - Renamed ``create_matching_kernel`` to ``make_kernel``. The old name is deprecated. [#2171] - ``make_kernel`` now raises ``ValueError`` if PSFs are not 2D, have even dimensions, or do not have the same shape. [#2170] - ``resize_psf`` now raises ``ValueError`` if the PSF is not 2D, has even dimensions, or if pixel scales are not positive. [#2170] - ``make_kernel`` now raises ``TypeError`` if the ``window`` parameter is not callable. [#2170] - ``photutils.segmentation`` - The ``SourceCatalog`` ``orientation`` property is now always returned in the range [0, 360) degrees. [#2224] - The ``SegmentationImage`` ``nlabels``, ``data_ma``, ``deblended_labels_map``, and ``deblended_labels_inverse_map`` attributes have been renamed to ``n_labels``, ``data_masked``, ``deblended_label_to_parent``, and ``parent_to_deblended_labels``, respectively. The old names are deprecated. [#2241] - The ``Segment`` ``data_ma`` attribute has been renamed to ``data_masked``. The old name is deprecated. [#2241] - The ``SourceCatalog`` ``data``, ``error``, ``background``, and ``segment`` attributes have been renamed to ``data_cutout``, ``error_cutout``, ``background_cutout``, and ``segment_cutout``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``data_ma``, ``error_ma``, ``background_ma``, and ``segment_ma`` attributes have been renamed to ``data_cutout_masked``, ``error_cutout_masked``, ``background_cutout_masked``, and ``segment_cutout_masked``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``convdata`` and ``convdata_ma`` attributes have been renamed to ``conv_data_cutout`` and ``conv_data_cutout_masked``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``cutout_minval_index`` and ``cutout_maxval_index`` attributes have been renamed to ``cutout_min_value_index`` and ``cutout_max_value_index``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``minval_index``, ``maxval_index``, ``minval_xindex``, ``minval_yindex``, ``maxval_xindex``, and ``maxval_yindex`` attributes have been renamed to ``min_value_index``, ``max_value_index``, ``min_value_xindex``, ``min_value_yindex``, ``max_value_xindex``, and ``max_value_yindex``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``covar_sigx2``, ``covar_sigxy``, and ``covar_sigy2`` attributes/columns have been renamed to ``covariance_xx``, ``covariance_xy``, and ``covariance_yy``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``cxx``, ``cxy``, and ``cyy`` attributes/columns have been renamed to ``ellipse_cxx``, ``ellipse_cxy``, and ``ellipse_cyy``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``segment_fluxerr`` and ``kron_fluxerr`` attributes/columns have been renamed to ``segment_flux_err`` and ``kron_flux_err``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``fluxfrac_radius`` attribute has been renamed to ``flux_radius``. The old name is deprecated. [#2241] - The ``SourceCatalog`` ``get_label`` and ``get_labels`` methods have been renamed to ``select_label`` and ``select_labels``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``add_extra_property``, ``remove_extra_property``, ``remove_extra_properties``, and ``rename_extra_property`` methods have been renamed to ``add_property``, ``remove_property``, ``remove_properties``, and ``rename_property``, respectively. The ``extra_properties`` attribute has been renamed to ``custom_properties``. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``nlabels`` and ``localbkg_width`` attributes have been renamed to ``n_labels`` and ``local_bkg_width``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``semimajor_sigma`` and ``semiminor_sigma`` attributes/columns have been renamed to ``semimajor_axis`` and ``semiminor_axis``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``xcentroid``, ``ycentroid``, ``xcentroid_win``, ``ycentroid_win``, ``xcentroid_quad``, and ``ycentroid_quad`` attributes/columns have been renamed to ``x_centroid``, ``y_centroid``, ``x_centroid_win``, ``y_centroid_win``, ``x_centroid_quad``, and ``y_centroid_quad``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``segment_img``, ``localbkg_width``, ``apermask_method``, and ``detection_cat`` keyword arguments have been renamed to ``segmentation_image``, ``local_bkg_width``, ``aperture_mask_method``, and ``detection_catalog``, respectively. The old names are deprecated. [#2241] - The ``SourceFinder`` ``npixels``, ``nlevels``, and ``nproc`` attributes have been renamed to ``n_pixels``, ``n_levels``, and ``n_processes``, respectively. The old names are deprecated. [#2241] - The ``nsigma`` parameter in ``detect_threshold`` has been renamed to ``n_sigma``. The old name is deprecated. [#2241] - The ``npixels`` parameter in ``detect_sources`` has been renamed to ``n_pixels``. The old name is deprecated. [#2241] - The ``segment_img``, ``npixels``, ``nlevels``, and ``nproc`` parameters in ``deblend_sources`` have been renamed to ``segmentation_image``, ``n_pixels``, ``n_levels``, and ``n_processes``, respectively. The old names are deprecated. [#2241] - The ``npixels``, ``nlevels``, and ``nproc`` parameters in ``SourceFinder`` have been renamed to ``n_pixels``, ``n_levels``, and ``n_processes``, respectively. The old names are deprecated. [#2241] - The ``SourceCatalog`` ``to_table`` default columns no longer includes ``local_background``. [#2252] - ``photutils.utils`` - The ``ImageDepth`` ``nsigma``, ``napers``, and ``niters`` parameters have been renamed to ``n_sigma``, ``n_apertures``, and ``n_iters``, respectively. The old names are deprecated. The ``napers_used`` attribute has also been renamed to ``n_apertures_used``. [#2241] - The ``make_random_cmap`` ``ncolors`` parameter has been renamed to ``n_colors``. The old name is deprecated. [#2241] - The ``ShepardIDWInterpolator`` ``reg`` parameter in ``__call__`` has been renamed to ``regularization``. The old name is deprecated. [#2241] 2.3.0 (2025-09-15) ------------------ General ^^^^^^^ - The minimum required NumPy is now 1.25. [#2043] - The minimum required SciPy is now 1.11.1. [#2043] - The minimum required Matplotlib is now 3.8. [#2043] - The minimum required scikit-image is now 0.21. [#2043] New Features ^^^^^^^^^^^^ - ``photutils.isophote`` - ``build_ellipse_model`` is now Cythonized and considerably faster. [#2046] - ``build_ellipse_model`` also has an additional optional keyword argument ``sma_interval``, which was previously hardcoded. [#2046] - ``photutils.psf`` - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise an error if the input ``error`` array contains non-finite or zero values. [#2022] - ``GriddedPSFModel`` can now be used with a single input ePSF model, which will be equivalent to ``ImagePSF``. [#2034] - The ``finder`` callable input to ``PSFPhotometry`` and ``IterativePSFPhotometry`` is no longer restricted to have x and y column names of ``'xcentroid'`` and ``'ycentroid'``. The allowed column names are now the same as those allowed in the ``init_params`` table. [#2072] - Added a ``group_warning_threshold`` keyword to ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#2081] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` classes no longer fail for invalid sources, defined as those that have no overlap with the input data, are completely masked, or have too few unmasked pixels for a fit. These classes have new flags (64, 128, 256, respectively) for these invalid conditions. [#2084, #2085] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` classes have new ``results_to_init_params`` and ``results_to_model_params`` methods for outputting fit results in different formats. [#2084] - When using Astropy 7.0+, the ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``fitter`` object now modifies the PSF model in place instead of creating a copy, improving performance and significantly reducing memory usage in some cases. [#2093] - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now return a reduced chi-squared statistic (``reduced_chi2`` column in the results table). [#2086] - The PSF photometry classes now use a dynamically generated "flat" model instead of a compound model for grouped sources. This eliminates recursion limits and significantly reduces memory usage for large groups. [#2100] - ``photutils.segmentation`` - An optional ``array`` keyword was added to the ``SourceCatalog`` ``make_cutouts`` method. [#2023] - Added a ``group`` keyword to the ``SegmentationImage`` ``to_regions`` method. [#2060, #2065] - Added a ``decode_psf_flags`` utility function for decoding PSF photometry bit flags. [#2090] - Added a ``PSF_FLAGS`` object to hold all PSF photometry bit flags in one place. PSF_FLAGS provides readable, named constants for each bit flag and helper utilities for decoding bit flags. [#2091] Bug Fixes ^^^^^^^^^ - ``photutils.centroids`` - Fixed an issue with the initial Gaussian theta units in ``centroid_2dg``. [#2013] - Fixed a corner-case issue where zero-sum arrays with ndim > 2 input to ``centroid_com`` would return only two np.nan coordinates instead of matching the dimensionality of the input array. [#2045] - ``photutils.datasets`` - Fixed a bug in ``make_model_image`` where the output image would not have units in the case where the input params had units and none of the models overlapped the image shape. [#2082] - Fixed a bug in ``make_model_image`` where an error would be raised if any row in the input parameters table contained non-finite model parameters. Such sources are now silently ignored. [#2083] - ``photutils.psf`` - Fixed a bug in ``fit_2dgaussian`` and ``fit_fwhm`` where the fit would fail if there were NaN values in the input data. [#2030] - Fixed the check in ``GriddedPSFModel`` for rectangular pixel grids. [#2035] - Fixed a bug in ``PSFPhotometry`` where the ``'group_id'`` column would be ignored if included in the ``init_params`` table. [#2070] - Fixed a bug in ``PSFPhotometry`` where the output ``flux_err`` column would not have units if the input data had units and the flux model parameter was fixed in value. [#2072] - Fixed a bug in ``PSFPhotometry`` and ``IterativePSFPhotometry`` where an error would be raised if the x or y columns in ``init_params`` had units. [#2079] - Fixed a bug in ``PSFPhotometry`` and ``IterativePSFPhotometry`` for the boundary conditions where flag=2 would be set. [#2080] - Fixed a bug in ``EPSFBuilder`` where the output PSF would have the wrong shape if the input ``stars`` were non-square cutouts. [#2089, #2092] - Fixed a bug in the calculation of the ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``qfit`` and ``cfit`` to not include the fit weights. [#2099] - ``photutils.segmentation`` - Fixed an issue where a newly-defined extra property of a ``SourceCatalog`` with ``overwrite=True`` would not be added to the ``extra_properties`` attribute. [#2039] - Fixed an issue where the ``SegmentationImage`` ``segments`` attribute would fail if any source segment was non-contiguous. [#2060] API Changes ^^^^^^^^^^^ - ``photutils.background`` - An explicit ``ValueError`` is now raised if the input ``data`` to ``Background2D`` contains all non-finite values. [#2062] - ``photutils.psf`` - The ``GriddedPSFModel`` ``data`` and ``grid_xypos`` attributes are now read-only. [#2036] - The ``PSFPhotometry`` ``fit_param`` attribute is now deprecated. Use the new ``results_to_init_params`` method instead. [#2084] - The deprecated ``PSFPhotometry`` ``fit_results`` attribute has been removed. [#2084] - ``photutils.segmentation`` - The ``SegmentationImage`` ``polygons`` list may now include either Shapely ``Polygon`` or ``MultiPolygon`` (non-contiguous) objects. [#2060] - The ``SegmentationImage`` ``to_patches`` and ``plot_patches`` methods now return ``matplotlib.patches.PathPatch`` objects. [#2060] - The ``SegmentationImage`` ``to_regions`` method now returns ``PolygonPixelRegion`` regions that have the segment label stored in the object ``meta`` dictionary. [#2060] 2.2.0 (2025-02-18) ------------------ New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Add an ``aperture_to_region`` function to convert an Aperture object to an astropy ``Region`` or ``Regions`` object. [#2009] - ``photutils.profiles`` - Added ``data_radius`` and ``data_profile`` attributes to the ``RadialProfile`` class for calculating the raw radial profile. [#2001] - ``photutils.segmentation`` - Added a ``to_regions`` method to ``SegmentationImage`` that converts the segment outlines to a ``regions.Regions`` object. [#2010] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - Fixed an issue where the ``SegmentationImage`` ``polygons`` attribute would raise an error if any source segment contained a hole. [#2005] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``theta`` attribute of ``EllipticalAperture``, ``EllipticalAnnulus``, ``RectangularAperture``, and ``RectangularAnnulus`` is now always returned as an angular ``Quantity``. [#2008] 2.1.0 (2025-01-06) ------------------ General ^^^^^^^ - The minimum required Python is now 3.11. [#1958] - The minimum required gwcs is now 0.20. [#1961] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``aperture_photometry`` output table will now include a ``sky_center`` column if ``wcs`` is input, even if the input aperture is not a sky aperture. [#1965] - ``photutils.datasets`` - A ``params_map`` keyword was added to ``make_model_image`` to allow a custom mapping between model parameter names and column names in the parameter table. [#1994] - ``photutils.detection`` - The ``find_peaks`` ``border_width`` keyword can now accept two values, indicating the border width along the y and x edges, respectively. [#1957] - ``photutils.morphology`` - An optional ``mask`` keyword was added to the ``gini`` function. [#1979] - ``photutils.segmentation`` - Added ``deblended_labels``, ``deblended_labels_map``, and ``deblended_labels_inverse_map`` properties to ``SegmentationImage`` to identify and map any deblended labels. [#1988] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - Fixed a bug where the table output from the ``SourceCatalog`` ``to_table`` method could have column names with a ``np.str_`` representation instead of ``str`` representation when using NumPy 2.0+. [#1956] - Fixed a bug to ensure that the dtype of the ``SegmentationImage`` ``labels`` always matches the image dtype. [#1986] - Fixed an issue with the source labels after source deblending when using ``relabel=False``. [#1988] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``xcenter`` and ``ycenter`` columns in the table returned by ``aperture_photometry`` no longer have (pixel) units for consistency with other tools. [#1993] - ``photutils.detection`` - When ``exclude_border`` is set to ``True`` in the ``DAOStarFinder`` and ``StarFinder`` classes, the excluded border region can be different along the x and y edges if the kernel shape is rectangular. [#1957] - Detected sources that match interval ends for sharpness, roundness, and maximum peak values (``sharplo``, ``sharphi``, ``roundlo``, ``roundhi``, and ``peakmax``) are now included in the returned table of detected sources by ``DAOStarFinder`` and ``IRAFStarFinder``. [#1978] - Detected sources that match the maximum peak value (``peakmax``) are now included in the returned table of detected sources by ``StarFinder``. [#1990] - ``photutils.morphology`` - The ``gini`` function now returns zero instead of NaN if the (unmasked) data values sum to zero. [#1979] - ``photutils.psf`` - The ``'viridis'`` color map is now the default in the ``GriddedPSFModel`` ``plot_grid`` method when ``deltas=True``. [#1954] - The ``GriddedPSFModel`` ``plot_grid`` color bar now matches the height of the displayed image. [#1955] 2.0.2 (2024-10-24) ------------------ Bug Fixes ^^^^^^^^^ - Due to an upstream bug in ``bottleneck`` with ``float32`` arrays, ``bottleneck`` nan-functions are now used internally only for ``float64`` arrays. Performance may be impacted for computations involving arrays with dtype other than ``float64``. Affected functions are used in the ``aperture``, ``background``, ``detection``, ``profiles``, ``psf``, and ``segmentation`` subpackages. This change has no impact if ``bottleneck`` is not installed. - ``photutils.background`` - Fixed a bug in ``Background2D`` where an error would be raised when using the ``BkgIDWInterpolator`` interpolator when any mesh was excluded, e.g., due to an input mask. [#1940] - ``photutils.detection`` - Fixed a bug in the star finders (``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``) when ``exclude_border=True``. Also, fixed an issue with ``exclude_border=True`` where if all sources were in the border region then an error would be raised. [#1943] 2.0.1 (2024-10-16) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed a bug in ``SExtractorBackground`` where the dimensionality of the returned value would not be preserved if the output was a single value. [#1934] - Fixed an issue in ``Background2D`` where if the ``box_size`` equals the input array shape, the input data array could be modified. [#1935] 2.0.0 (2024-10-14) ------------------ General ^^^^^^^ - The ``regions`` package is now an optional dependency. [#1813] - The minimum required Astropy is now 5.3. [#1839] - SciPy is now a required dependency. [#1880] - The minimum required SciPy is now 1.10. [#1880] - The minimum required NumPy is now 1.24. [#1881] - The minimum required Matplotlib is now 3.7. [#1881] - The minimum required GWCS is now 0.19. [#1881] - Importing tools from all subpackages now requires including the subpackage name. Also, PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf``. [#1879, #1904] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The metadata in the tables generated by ``aperture_photometry`` and ``ApertureStats`` now include the aperture name and shape parameters. [#1849] - ``aperture_photometry`` and ``ApertureStats`` now accept supported ``regions.Region`` objects, i.e., those corresponding to circular, elliptical, and rectangular apertures. [#1813, #1852] - A new ``region_to_aperture`` convenience function has been added to convert supported ``regions.Region`` objects to ``Aperture`` objects. [#1813, #1852] - ``photutils.background`` - The ``Background2D`` class has been refactored to significantly reduce its memory usage. In some cases, it is also significantly faster. [#1870, #1872, #1873] - A new ``npixels_mesh`` property was added to ``Background2D`` that gives a 2D array of the number of pixels used to compute the statistics in the low-resolution grid. [#1870] - A new ``npixels_map`` property was added to ``Background2D`` that gives a 2D array of the number of pixels used to compute the statistics in each mesh, resized to the shape of the input data. [#1871] - ``photutils.centroids`` - ``Quantity`` arrays can now be input to ``centroid_1dg`` and ``centroid_2dg``. [#1861] - ``photutils.datasets`` - Added a new ``params_table_to_models`` function to create a list of models from a table of model parameters. [#1896] - ``photutils.psf`` - Added new ``xy_bounds`` keyword to ``PSFPhotometry`` and ``IterativePSFPhotometry`` to allow one to bound the x and y model parameters during the fitting. [#1805] - The ``extract_stars`` function can now accept ``NDData`` inputs with uncertainty types other than ``weights``. [#1821] - Added new ``GaussianPSF``, ``CircularGaussianPSF``, ``GaussianPRF``, ``CircularGaussianPRF``, and ``MoffatPSF`` PSF model classes. [#1838, #1898, #1918] - Added new ``AiryDiskPSF`` PSF model class. [#1843, #1918] - Added new ``CircularGaussianSigmaPRF`` PSF model class. [#1845, #1918] - The ``IntegratedGaussianPRF`` model now supports units. [#1838] - A new ``results`` attribute was added to ``PSFPhotometry`` to store the returned table of fit results. [#1858] - Added new ``fit_fwhm`` convenience function to estimate the FWHM of one or more sources in an image by fitting a circular 2D Gaussian PSF model. [#1859, #1887, #1899, #1918] - Added new ``fit_2dgaussian`` convenience function to fit a circular 2D Gaussian PSF to one or more sources in an image. [#1859, #1887, #1899] - Added new ``ImagePSF`` model class to represent a PSF model as an image. [#1890] - The ``GriddedPSFModel`` model now has a ``bounding_box`` method to return the bounding box of the model. [#1891] - The ``GriddedPSFModel`` class has been refactored to significantly improve its performance. In typical PSF photometry use cases, it is now about 4 times faster than previous versions. [#1903] - ``photutils.segmentation`` - Reduced the memory usage and improved the performance of source deblending with ``deblend_sources`` and ``SourceFinder``. [#1924, #1925, #1926] - Improved the accuracy of the progress bar in ``deblend_sources`` and ``SourceFinder`` when using multiprocessing. Also added the source ID label number to the progress bar. [#1925, #1926] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug checking that the ``subpixels`` keyword is a strictly positive integer. [#1816] - ``photutils.datasets`` - Fixed an issue in ``make_model_image`` where if the ``bbox_factor`` was input and the model bounding box did not have a ``factor`` keyword then an error would be raised. [#1921] - ``photutils.detection`` - Fixed an issue where ``DAOStarFinder`` would not return any sources if the input ``threshold`` was set to zero due to the ``flux`` being non-finite. [#1882] - ``photutils.isophote`` - Fixed a bug in ``build_ellipse_model`` where if ``high_harmonics=True``, the harmonics were not correctly added to the model. [#1810] - ``photutils.psf`` - Fixed a bug in ``make_psf_model`` where if the input model had amplitude units, an error would be raised. [#1894] API Changes ^^^^^^^^^^^ - The ``sklearn`` version information has been removed from the meta attribute in output tables. ``sklearn`` was removed as an optional dependency in 1.13.0. [#1807] - ``photutils.background`` - The ``Background2D`` ``background_mesh`` and ``background_rms_mesh`` properties will have units if the input data has units. [#1870] - The ``Background2D`` ``edge_method`` keyword is now deprecated. When ``edge_method`` is eventually removed, the ``'pad'`` option will always be used. [#1870] - The ``Background2D`` ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties are now deprecated. [#1870] - To reduce memory usage, ``Background2D`` no longer keeps a cached copy of the returned ``background`` and ``background_rms`` properties. [#1870] - The ``Background2D`` ``data``, ``mask``, ``total_mask``, ``nboxes``, ``box_npixels``, and ``nboxes_tot`` attributes have been removed. [#1870] - The ``BkgZoomInterpolator`` ``grid_mode`` keyword is now deprecated. When ``grid_mode`` is eventually removed, the `True` option will always be used. [#1870] - The ``Background2D`` ``background``, ``background_rms``, ``background_mesh``, and ``background_rms_mesh`` properties now have the same ``dtype`` as the input data. [#1922] - ``photutils.centroids`` - For consistency with other fitting functions (including PSF fitting), the ``centroid_1dg`` and ``centroid_2dg`` functions now fit only a 1D or 2D Gaussian model, respectively, excluding any constant component. The input data are required to be background-subtracted. [#1861] - The fitter used in ``centroid_1dg`` and ``centroid_2dg`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1917] - ``photutils.datasets`` - The deprecated ``make`` module has been removed. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. [#1884] - The deprecated ``make_model_sources_image``, ``make_gaussian_prf_sources_image``, ``make_gaussian_sources_table``, ``make_test_psf_data``, ``make_random_gaussians_table``, and ``make_imagehdu`` functions have been removed. [#1884] - ``photutils.detection`` - The deprecated ``sky`` keyword in ``DAOStarFinder`` and ``IRAFStarFinder`` has been removed. Also, there will no longer be a ``sky`` column in the output table. [#1884] - The ``DAOStarFinder`` ``flux`` and ``mag`` columns were changed to give sensible values. Previously, the ``flux`` value was defined by the original DAOFIND algorithm as a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. A ``daofind_mag`` column was added for comparison to the original IRAF DAOFIND algorithm. [#1885] - ``photutils.isophote`` - The ``build_ellipse_model`` function now raises a ``ValueError`` if the input ``isolist`` is empty. [#1809] - ``photutils.profiles`` - The fitter used in ``RadialProfile`` to fit the profile with a Gaussian was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1899] - ``photutils.psf`` - The ``IntegratedGaussianPRF`` class now must be initialized using keyword-only arguments. [#1838] - The ``IntegratedGaussianPRF`` class has been moved to the new ``functional_models`` module. [#1838] - The ``models`` and ``griddedpsfmodel`` modules have been renamed to ``image_models`` and ``gridded_models``, respectively. [#1838] - The ``IntegratedGaussianPRF`` model class has been renamed to ``CircularGaussianPRF``. ``IntegratedGaussianPRF`` is now deprecated. [#1845] - Some PSF tools have moved to new modules. The ``PRFAdapter`` class and the ``make_psf_model`` and ``grid_from_epsfs`` functions have been moved to the new ``model_helpers`` module. The ``make_psf_model_image`` function has been moved to the new ``simulations`` module. It is recommended that all of these tools be imported from ``photutils.psf`` without using the submodule name. [#1854, #1901] - The ``PSFPhotometry`` ``fit_results`` attribute has been renamed to ``fit_info``. ``fit_results`` is now deprecated. [#1858] - The ``PRFAdapter`` class has been deprecated. Instead, use a ``ImagePSF`` model derived from the ``discretize_model`` function in ``astropy.convolution``. [#1865] - The ``FittableImageModel`` and ``EPSFModel`` classes have been deprecated. Instead, use the new ``ImagePSF`` model class. [#1890] - The default fitter for ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1899] - ``psf_shape`` is now an optional keyword in the ``make_model_image`` and ``make_residual_image`` methods of ``PSFPhotometry`` and ``IterativePSFPhotometry``. The value defaults to using the model bounding box to define the shape and is required only if the PSF model does not have a bounding box attribute. [#1921] - ``photutils.psf.matching`` - PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf``. [#1904] - ``photutils.segmentation`` - The ``SegmentationImage`` ``relabel_consecutive``, ``resassign_label(s)``, ``keep_label(s)``, ``remove_label(s)``, ``remove_border_labels``, and ``remove_masked_labels`` methods now keep the original dtype of the segmentation image instead of always changing it to ``int`` (``int64``). [#1878, #1923] - The ``detect_sources`` and ``deblend_sources`` functions now return a ``SegmentationImage`` instance whose data dtype is ``np.int32`` instead of ``int`` (``int64``) unless more than (2**32 - 1) labels are needed. [#1878] 1.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, fluxes, or other parameters 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. astropy-photutils-3322558/CITATION.cff000066400000000000000000000110021517052111400173220ustar00rootroot00000000000000cff-version: 1.2.0 title: Photutils message: 'If you use this software, please cite it as below.' type: software authors: - family-names: Bradley given-names: Larry orcid: 'https://orcid.org/0000-0002-7908-9284' affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Sipőcz given-names: Brigitta M. orcid: 'https://orcid.org/0000-0002-3713-6337' affiliation: >- IPAC, MC 100-22, Caltech, 1200E. California Blvd. Pasadena, CA 91125 - family-names: Robitaille given-names: T. P. orcid: 'https://orcid.org/0000-0002-8642-1329' affiliation: >- Aperio Software Ltd., Headingley Enterprise and Arts Centre, Bennett Road, Leeds, LS6 3HN, United Kingdom - family-names: Tollerud given-names: E. J. orcid: 'https://orcid.org/0000-0002-9599-310X' affiliation: >- Space Telescope Science Institute, 3700 San Martin Dr., Baltimore, MD 21218, USA - family-names: Vinícius given-names: ZÊ - family-names: Deil given-names: Christoph orcid: 'https://orcid.org/0000-0002-4198-4005' - family-names: Barbary given-names: Kyle orcid: 'https://orcid.org/0000-0002-2532-3696' - family-names: Wilson given-names: Tom J. orcid: 'https://orcid.org/0000-0001-6352-9735' - family-names: Busko given-names: Ivo - family-names: Donath given-names: Axel orcid: 'https://orcid.org/0000-0003-4568-7005' affiliation: >- Harvard-Smithsonian Center for Astrophysics, 60 Garden St., Cambridge, MA, 02138, USA - family-names: GÃŧnther given-names: Hans Moritz orcid: 'https://orcid.org/0000-0003-4243-2840' affiliation: >- MIT Kavli Institute for Astrophysics and Space Research, 77 Massachusetts Avenue, Cambridge, MA 02139, USA - family-names: Cara given-names: Mihai orcid: 'https://orcid.org/0000-0002-9294-6551' affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Lim given-names: P. L. orcid: 'https://orcid.org/0000-0003-0079-4114' affiliation: >- Space Telescope Science Institute, 3700 San Martin Dr., Baltimore, MD 21218, USA - family-names: Meßlinger given-names: Sebastian - family-names: Conseil given-names: Simon orcid: 'https://orcid.org/0000-0002-3657-4191' - family-names: Droettboom given-names: Michael - family-names: Bostroem given-names: K. Azalee orcid: 'https://orcid.org/0000-0002-4924-444X' - family-names: Bray given-names: E. M. - family-names: Bratholm given-names: Lars Andersen orcid: 'https://orcid.org/0000-0002-3565-5926' - family-names: Burnett given-names: Zach affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Jamieson given-names: William orcid: 'https://orcid.org/0000-0001-5976-4492' affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Ginsburg given-names: Adam orcid: 'https://orcid.org/0000-0001-6431-9633' - family-names: Taranu given-names: Dan orcid: 'https://orcid.org/0000-0001-6268-1882' - family-names: Barentsen given-names: Geert orcid: 'https://orcid.org/0000-0002-3306-3484' - family-names: Craig given-names: Matthew W. orcid: 'https://orcid.org/0000-0001-7988-8919' - family-names: Morris given-names: Brett M. orcid: 'https://orcid.org/0000-0003-2528-3409' affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Perrin given-names: Marshall orcid: 'https://orcid.org/0000-0002-3191-8151' affiliation: >- Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA - family-names: Rathi given-names: Shivangee orcid: 'https://orcid.org/0000-0002-4859-8203' identifiers: - type: doi value: 10.5281/zenodo.596036 description: Zenodo - all versions - type: url value: 'https://ascl.net/1609.011' description: ASCL entry repository-code: 'https://github.com/astropy/photutils' url: 'https://photutils.readthedocs.io/en/latest/' abstract: Astropy package for source detection and photometry keywords: - Astronomy software - Astronomy data analysis - Photometry - Image Data Analysis - Open source software license: BSD-3-Clause version: 3.0.0 date-released: '2026-04-17' astropy-photutils-3322558/CODE_OF_CONDUCT.rst000066400000000000000000000002571517052111400204510ustar00rootroot00000000000000Photutils is an `Astropy `_ affiliated package. We follow the `Astropy Community Code of Conduct `_. astropy-photutils-3322558/CONTRIBUTING.rst000066400000000000000000000117071517052111400201050ustar00rootroot00000000000000Contributing 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? astropy-photutils-3322558/LICENSE.rst000066400000000000000000000027471517052111400172640ustar00rootroot00000000000000Copyright (c) 2011-2026, 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. astropy-photutils-3322558/README.rst000066400000000000000000000066651517052111400171420ustar00rootroot00000000000000========= Photutils ========= |PyPI Version| |Conda Version| |Astropy| |CI Status| |Codecov Status| |Latest RTD Status| Photutils is a Python library that provides commonly-used tools and key functionality for detecting and performing photometry of astronomical sources. Tools are provided for background estimation, star finding, source detection and extraction, aperture photometry, PSF photometry, image segmentation, centroids, radial profiles, and elliptical isophote fitting. It is a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. 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/doi/10.5281/zenodo.596036 :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/ astropy-photutils-3322558/codecov.yml000066400000000000000000000003151517052111400176020ustar00rootroot00000000000000comment: off codecov: branch: main coverage: status: project: default: target: auto # this allows a small drop from the previous base commit coverage threshold: 0.05% astropy-photutils-3322558/docs/000077500000000000000000000000001517052111400163665ustar00rootroot00000000000000astropy-photutils-3322558/docs/Makefile000066400000000000000000000012511517052111400200250ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile clean clean: rm -rf $(BUILDDIR) rm -rf api # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) astropy-photutils-3322558/docs/_static/000077500000000000000000000000001517052111400200145ustar00rootroot00000000000000astropy-photutils-3322558/docs/_static/custom.css000066400000000000000000000012561517052111400220440ustar00rootroot00000000000000.field-list ul { padding-left: 2em; } .bd-article { padding-top: 0.75rem; } .bd-content h1 { margin-top: 0; } .bd-content h2 { border-bottom: 1px solid var(--pst-color-border); padding-bottom: 0.3rem; } /* Light and Dark Theme Specific Styles */ /* html[data-theme="light"] .bd-content h2 { border-bottom: 1px solid #d0d0d0; } html[data-theme="dark"] .bd-content h2 { border-bottom: 1px solid #444; } */ :root { --pst-font-size-h1: 2.0rem; --pst-font-size-h2: 1.6rem; --pst-font-size-h3: 1.35rem; --pst-font-size-h4: 1.15rem; --pst-font-size-h5: 1.0rem; --pst-font-size-h6: 0.9rem; } .section { margin-bottom: 1.6rem; } p { margin-bottom: 0.7rem; } astropy-photutils-3322558/docs/_static/photutils_logo.ico000066400000000000000000000404661517052111400235750ustar00rootroot00000000000000@? A(@~ ?ÃÃĒp/Ēp/ŗw1Ēp/Šp/Šo/¨o.¨o.§n.§n-Ļm-Ļm-Ĩl,Ĩl,¤k,Ŗk+Ŗj+ĸj+ i.ĸj,Ąi)Ēr0Ēp0Ēq0Ēq0Ēp/Ēp/§n0Ēq/Šo/Šo.¨o.§n.§n-Ļm-Ļm-Ĩl,Ĩl,¤k,¤k+Ŗj,Ąi)Ąi* h) h)Ąh(Ąh)Ÿg)ĒU+Ģq0Ģq0Ģr0Ģq0Ēq0ą€@Ēp0Ēp/¨o0Ēq/Šo/<Šo.`¨o.}¨n.‘§n.›Ļm-Ļm-—Ĩl,ˆĨl,p¤k,Q¤k+.Ŗj, Ąi*Ąi* h)Ļm.žg(f(™e#•ef'šf)Ģq0Ģq0Ģq0Ģq0Ģq0Ŧr1Ģq0ĄUĒp0=Ēp/ŠĒp/ĘŠo/ōŠo.˙¨o.˙§n.˙§n-˙Ļm-˙Ļm-˙Ĩl,˙Ĩl,˙¤k,˙¤k+˙Ŗj+˙ĸj+čĸi*ģĄi*| h)6ʄLžg(žf(œe'œd&“^!˜b#›d&šc%ĒUUĢq0Šp.Ģq0Ģq0¯t2Ģq0Ŧr1Ģq0tĒp0ÖĒp/˙Šp/˙Šo/˙¨o.˙§n.ũ§n.ûĻm-ûĻm-ûĨl,ûĨl,û¤k,û¤k+ûŖj+ûĸj+üĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙Ÿg)מg(ƒžf(&œe'œe&šc&™c%œe%f%™b%˜c&Ŧr1Ģq0Ģq0Ģq0Ēp0Ģq0˛y4Ģq0wĒq0ėĒp/˙Šp/˙Šo/ũ¨o.û¨n.ü§n.ū§m-˙Ļm-˙Ĩl-˙Ĩl,˙¤l,˙¤k,˙Ŗk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙ h)ū h)üŸg)ûŸg(ūžg(˙žf(˙f'ūœe'¯œe';šc&šc%˜b$—a$˜a$˜a$—a$Šo/Ēp0Ēp0Ēq0Šn-Ēp0Ģq0=Ģq0ŲĒp/˙Ēp/˙Šo/ú¨o.ũ¨n.˙Ļn/˙Ĩm/˙¤m/˙¤l.˙Ĩl-˙¤l,˙¤k,˙Ŗk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙Ąh*˙ h)˙Ÿh)˙Ÿg)˙žg(˙žf(˙f'ũe'ûœe'˙œd&˙›d&˙šc&´šc%/˜b$˜b%–`#˜b$§r'•_#“`"Ģq0Ēp/Ēp/Ēp/Ŧq1Ēp/Ģq0}Ēp0˙Ēp/˙Šo/úŠo.ū¨o.˙Ĩn1˙Ļn.˙Ŧn%˙°n ˙°n˙­m!˙§l)˙Ąk.˙ĸj,˙ĸj+˙ĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙Ÿg)˙žg(˙žf(˙f(˙e'˙œe'˙œd'˙›d&˙šd&ûšc&ũ™c%˙™b%˙˜b$’˜b% –`#•_"“]"”^"”^"“^!Ļo,Šp/Ēp/Ēp/Ŧq/Šr-Ēp0¨Ēp/˙Šp/ūŠo/ü¨o.˙§n/˙Ļn/˙˛o˙Šn)˙ŒhN˙wdf˙sck˙e[˙™i8˙Žl˙§j$˙Ÿi-˙Ąi*˙ h)˙ h)˙Ÿg)˙Ÿg(˙žf(˙f(˙f'˙œe'˙œe'˙›d&˙›d&˙šc&˙šc%˙™b%˙˜b%û˜a$ū—a$˙—`$ã–`#E•_"“^“]"“]!’]!‘\ Šo/Šp/Ēp/Ģr/Ĩn2Ēp/ˇŠp/˙Šo/ú¨o.ū¨n.˙Ļn0˙Ēn)˙­n$˙jd{˙=Z°˙G\¤˙W^˙Z_Š˙O]˜˙=Z¯˙J\ž˙fB˙Ģj˙h,˙Ÿh)˙Ÿg(˙žg(˙žf(˙f'˙e'˙œe'˙›d&˙›d&˙šc&˙šc%˙™b%˙™b%˙˜b$˙—a$˙—a$˙–`#ú–`#˙•_#˙•_"†“] “^"’]!’]!‘\ \§o/Ēp0§n,Šo/Šp/ĢŠo/˙¨o.ų¨n.˙§n.˙Ĩm/˙Ģn(˙ k3˙=\ĩ˙NWŒ˙–h<˙Ŧl ˙°m˙°n˙Žn˙¤k*˙ubd˙2WŊ˙m`m˙Ēi˙œf+˙žf(˙f(˙e'˙œe'˙œd&˙›d&˙šc&˙šc%˙™c%˙™b%˙˜b$˙˜a$˙—a$˙–`#˙–`#˙•_#˙•_"ü”^"ũ”^"˙“^!š“^" ’\!‘\ [ ZZ¨n.§n-¨o.Šo.Šo/„Šo/˙¨o.ú§n.˙§n-˙Ļm.˙§m+˙¤l.˙6[ž˙oYY˙ŌX˙íŨÉ˙ĸl1˙œd'˙—]˙–[˙š_˙Ģj˙Ąi(˙6Xĩ˙k_m˙¨g˙›e*˙œe'˙œe'˙›d&˙›d&˙šc&˙™c%˙™b%˙˜b%˙˜a$˙—a$˙—`#˙–`#˙–`#˙•_"˙”_"˙”^"˙“^!˙“]!ú’]!˙’\ ؑ\ [ ZZŽYY§n-¨n.¨o.¨o.¨o.F¨o.˙¨n.ũ§n.ūĻm-˙Ļm-˙Ŗl/˙°n˙K^ĸ˙f[m˙Ɖ<˙÷ųū˙Ŋ—k˙™\˙ŦzB˙Édž˙Îą‘˙ē‘d˙˜a$˙Ÿc˙Ąh'˙1Vŧ˙ŠcA˙Ąe ˙šd(˙›d&˙šc&˙šc%˙™b%˙˜b%˙˜a$˙—a$˙—a$˙–`#˙–`#˙•_#˙•_"˙”^"˙“^"˙“]!˙’]!˙’\ ˙‘\ ú‘\ ˙[į[ ZŽYXŒX‹V§l,Ĩl,¨o.Šo/Šo/¨n.â§n.˙§m-üĻm-˙Ļm-˙¤l.˙Ģm#˙‡fR˙@\Ž˙Šd˙éß×˙ÍŽŒ˙N˙ĮĨ€˙ú÷ô˙äÔÂ˙ŨĘ´˙ņéā˙íâÖ˙¤t@˙¤a˙uaa˙EYž˙Ļe˙˜c)˙šc%˙™b%˙™b%˙˜b$˙˜a$˙—a$˙–`#˙–`#˙•_#˙•_"˙”^"˙”^"˙“]!˙’]!˙’]!˙‘\ ˙‘\ ˙[ ˙[úZ˙ZéŽYYXŒX‹WŽXĻm-Ĩm-§n.§n.§n.…§n-˙Ļm-úĻm-˙Ĩl,˙Ĩl,˙ĸk/˙°m˙U_’˙n]b˙ģƒ>˙ōíč˙Ÿe%˙˛…R˙ûų÷˙­}H˙N˙Q ˙•Y˙Ōē˙ķėä˙g,˙c ˙;X­˙b5˙œc!˙˜b%˙˜b$˙˜a$˙—a$˙—`#˙–`#˙•_#˙•_"˙”_"˙”^"˙“^!˙“]!˙’]!˙’\ ˙‘\!˙Ž[#˙Ž["˙ŽZ ˙Z˙ŽYúŽY˙YߍXŒXŒWŠV‰U¤l+§m-§m-§m-Ļm-öĻm-˙Ĩl,ūĨl,˙¤k,˙¤k+˙ĸj-˙¨k#˙E]Š˙‰\3˙Ěj˙éŨĐ˙’R ˙ŲÃĒ˙Đļ˜˙ĸl0˙ßÍš˙âŌĀ˙Ŧ~I˙ŒM˙äÖÅ˙Ė´š˙W˙N\–˙t^Z˙Ąc˙–a&˙—a$˙—`$˙–`#˙–`#˙•_#˙•_"˙”^"˙“^!˙“]!˙’]!˙’\ ˙‘\ ˙[!˙‘[˙™[˙™[˙“Z˙ŒY!˙Y˙XúŒX˙ŒWŌXŒV‹W‹UˆT¨o,Ĩm+Ļm-Ļm-Ļm-…Ĩl-˙Ĩl,ú¤l,˙¤k,˙Ŗk+˙ĸj+˙ĸj*˙Ąi+˙B\Ģ˙“^&˙ž•f˙ëāÔ˙’S ˙Ķš˙ëāĶ˙ôíæ˙˙˙˙˙˙˙˙˙ëßĶ˙”Y˙¸‘e˙čß×˙ ] ˙X[„˙g\j˙ĸb˙•`&˙–`#˙–`#˙•_#˙”_#˙’_&˙’^%˙’]"˙’]!˙’\!˙‘\ ˙‘\ ˙[ ˙‘[˙ŽZ ˙LUˆ˙?T›˙zX<˙”Y˙ŠX ˙ŒW˙‹WúŠW˙ŠV—ŠV‰V€P†SˆSŖk+Ĩm,Ļm,Ļn,Ĩl,ã¤l,˙¤k,ũŖk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙ĸi(˙AZŠ˙“c2˙Šs6˙öōí˙Ĩr9˙¤p6˙ũüû˙˙˙˙˙öđę˙žšq˙īįŨ˙ži.˙Ē}I˙ėäŪ˙¤d˙SY†˙j\f˙ a˙”`%˙•_#˙•_"˙“_%˙—]˙Ŗe˙Ąc˙”\˙\"˙‘\ ˙[ ˙[˙Z"˙—Z˙AT˜˙PU€˙qWJ˙)Rž˙qVI˙“X˙‰W˙ŠV˙ŠVü‰U˙‰UWˆUˆT†S…R f,¤l,¤l,¤l,M¤k,˙Ŗk+üŖj+˙ĸj+˙ĸi*˙Ąi*˙Ąi*˙žh,˙Ši˙FZĄ˙…eP˙œ[˙ÔŧŖ˙ėâÖ˙”Y˙œf(˙ĩ`˙“Y˙Åσ˙ęßĶ˙ŽR˙ĩŽb˙éāØ˙\˙DXž˙{]K˙œ`˙”_$˙”_"˙’^$˙š^˙ƒ[8˙W^‹˙Y]ƒ˙ˆ[-˙•\˙[!˙Z˙ŽZ ˙“Z˙yX?˙6S¨˙˜Y ˙—X ˙€W/˙)Rŧ˙„V'˙ŒV˙‰U˙‰UũˆU˙‡Tņ‡T‡T‡T‰T„P¤k(Ŗk+Ŗk+¤k+œŖk+˙ĸj+ûĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙g,˙Ēi˙b^y˙[]„˙Ēg˙—a&˙ß͸˙ôīč˙ČLj˙¸’g˙ÚĮ°˙øõņ˙Š|I˙‡G˙ÛČŗ˙ÕŊ ˙…Q˙8X˛˙–_"˙•_"˙”^"˙’^#˙˜]˙y[I˙%HĢ˙rrŽ˙ei˙'JĢ˙‚Z5˙’Z˙Y˙ŒY!˙˜Y ˙WUt˙UUu˙—Y˙†W&˙—Y ˙_Tc˙>R˜˙“V ˙†U˙ˆT˙‡Tú†S˙†SІS…SrD ƒPŖj+Ļl-Šn0Ŗj+Øĸj+˙ĸi*ũĄi*˙ h)˙ h)˙Ÿg)˙Ÿg(˙žf)˙Ÿf%˙—e0˙3Wš˙˜e.˙d"˙”[˙ē”i˙Ūˏ˙å×Į˙Ōģ ˙ m5˙”\˙ŲÆ¯˙ņīđ˙¯p ˙MT„˙ZZ|˙Ÿ_˙‘^%˙“^!˙‘]$˙›`˙>YĒ˙|bU˙æÄ•˙Ū´{˙i^j˙GX˜˙š]˙ŒY"˙‹X!˙–X ˙ET˙kVR˙P˙‡S˙L˙’W˙4R¨˙pTB˙T˙†S˙†S˙†Rü…R˙…RE„R„RƒQPĸi*ĸj*ĸj*#ĸi*ûĄi*˙Ąh*ū h)˙Ÿh)˙Ÿg)˙žg(˙žf(˙f'˙›e*˙Ļf˙j^k˙;WŦ˙§f˙e$˙X˙U˙‘V˙V˙ŒP˙ŋĄ˙˙˙˙˙āēˆ˙kRJ˙4Wļ˙˜^˙“^"˙’]!˙’]"˙’^#˙šg,˙?^ĩ˙ļ—v˙õņí˙ķčŲ˙ĸŒ~˙B]Ŧ˙d ˙ŒY"˙‹X ˙’W˙>UŸ˙qM/˙š“g˙ÜÍŊ˙ą‘m˙‰K˙gUV˙Bn˙Ā—c˙˛†Q˙2B„˙[Vo˙”W ˙‰W˙ŠW˙ŽX˙4L˜˙’sW˙ÚÆŽ˙‰W˙ĶÁĢ˙­‹f˙ƒH˙4SŦ˙vR2˙ˆR˙„Q˙„Q˙ƒPû‚P˙‚PS‚P‚P‚P€L h) h) h)l h)˙Ÿg)ûŸg(˙žf(˙f(˙f'˙œe'˙œe'˙›d&˙›d&˙šc&˙˜c(˙¤d˙g\k˙1V¸˙YZ˙€^E˙`.˙](˙ŽgB˙o_c˙6L–˙>Vĸ˙](˙—]˙\#˙‘\ ˙‘\ ˙[˙[ ˙Z˙Z˙DT’˙?_¸˙:\¸˙MT‚˙‘X˙‹W˙ŠW˙ŠV˙ŒX˙0G“˙Ē‘z˙¯Š_˙y@˙‹Y!˙×Éģ˙•Z˙QOm˙MQz˙ŽQ˙Q˙ƒP˙‚PüO˙OĮNN‚P~LŸg)Ÿg)Ÿg)†Ÿg(˙žg(ûžf(˙f'˙e'˙œe'˙›d&˙›d&˙šc&˙™c&˙™b%˙™b%˙–a'˙ĸb˙`.˙[Zy˙@W ˙˙/T¸˙^(˙•^ ˙“]"˙’]!˙’]!˙‘\ ˙‘\ ˙[ ˙[˙Ž["˙’X˙Ÿc˙ĸg˙’V˙‹X ˙ŒX˙‹W˙‹W˙ŠV˙ŠV˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†S˙ƒR˙ŽR˙AQŽ˙fQL˙ˆN ˙ŅĀŽ˙†U˙Ęļž˙¤W˙Įą™˙}H ˙3PĨ˙uM&˙€L˙}L˙}K˙|Kû{J˙{J”{J{JyOšc&šc&šc&}šc&˙™c%û™b%˙˜b$˙–a'˙ĸb˙b[r˙FU’˙ą|6˙­¨Ļ˙¯€C˙OUƒ˙dZi˙^˙]#˙’\ ˙‘\ ˙[ ˙[˙Z˙Z!˙–Z˙xV<˙S\‹˙R_–˙jTN˙“X˙ŠW˙ŠV˙ŠV˙‰U˙‰U˙ˆT˙‡T˙‡T˙†S˙†S˙…R˙…R˙‚Q˙ŽQ˙JQ~˙WN^˙‘X˙Đŋ­˙w@˙Įą˜˙ŊŖ†˙ĀĢ“˙’[˙R˜˙MTƒ˙•W ˙ˆV˙ŠV˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†R˙„R˙„S˙ƒS˙‚P˙ƒP˙‚P˙O˙O˙€N˙€N˙}M˙ˆM˙\MT˙#I­˙Yc˙•Ž˙‰ta˙(KĢ˙rK$˙{I˙‚H˙;O“˙R;5˙ÃĨ~˙ [˙h2˙sB ˙sC˙…Q˙^PV˙:H‚˙|C˙qD˙rC ˙rB ûqB ˙qB …qB qB ’]!ŽYZZZȏZ˙ŒY!ü•Y˙fV]˙1R°˙3RĒ˙PT}˙’W˙‰V˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†S˙…R˙„R˙„Q˙„R˙„R˙‚O˙‚O˙O˙N˙€N˙M˙}M˙‡M˙WM\˙'LŽ˙ŸŒz˙áØĮ˙ááŪ˙–‚n˙*Gž˙zJ˙xI˙€G˙UL[˙7Bv˙žo2˙Åŗ ˙m:˙zK˙m<˙‚N˙m^`˙4Eƒ˙{C˙pC˙qB ˙qB ûpA ˙pA lpA pA ‘Z"ŽYŽYŽYŽYTŽY˙Yû‹X˙“X˙‡W$˙„W(˙“W ˙‰V˙‰U˙‰U˙ˆT˙‡T˙‡T˙†S˙†S˙…R˙„R˙„Q˙ƒQ˙ƒP˙‚P˙‚O˙O˙O˙€N˙€N˙M˙~M˙L˙rM+˙&K¯˙ĄŽz˙æęė˙ëëé˙°˜z˙*MĢ˙VJW˙H˙wH˙yG ˙qG˙-N§˙q?˙ĪžĢ˙ˆ_1˙m:˙„[.˙ÃĻ‚˙vik˙3Fˆ˙zB˙oB˙pA ˙pA üo@ ˙o@ Jo@ o@ [ŒWŒXŒXXˌX˙ŒWü‰W˙ŒV˙ŒV˙‡U˙‰U˙ˆU˙ˆT˙‡T˙†S˙†S˙…R˙„R˙‚Q˙„Q˙†P˙†P˙…P˙‚O˙€O˙~N˙N˙M˙M˙~L˙|L˙‡L˙JKp˙PTy˙Ę­~˙ø¨˙ƒ{˙&K­˙LJg˙H˙vG˙wG˙uG˙E˙9M’˙O<<˙ŖzG˙ȡ¤˙\%˙›zV˙˙˙÷˙fTR˙3E†˙yA˙nA˙oA ūo@ ˙n@ ûn? #n? n? YXŒWŒW‹WE‹W˙ŠWüŠV˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙…R˙…R˙ƒR˙‰Q˙ŒQ˙€P˙vP.˙tR4˙xR.˙€Q˙†N ˙‰M˙…M ˙~M˙|L˙}L˙|K˙K ˙eK=˙*O°˙IWŒ˙4Ož˙*F›˙aJ@˙H˙vG˙wG˙vF˙uF˙{E˙ZGE˙5M˜˙s7˙IJ ˙Ŧo˙†_4˙˙øę˙fZ`˙4C˙xA˙m@˙n@ ũn? ˙m? Øi:k=m? YŸg.‹WŠVŠVNJV˙‰UúˆU˙ˆT˙‡T˙‡S˙†S˙†S˙…R˙…R˙ƒQ˙‹Q ˙kPB˙:P˜˙4QĨ˙5Nœ˙.C‹˙+B˙)B’˙5M˜˙HO|˙dMD˙L˙†K˙}K˙zJ˙zJ˙I ˙kJ/˙SHV˙eI6˙I˙}G˙uG˙wF˙vF˙uE˙uE˙sE˙xC˙,LĨ˙bD,˙uA˙žŠ‘˙ËŧĢ˙ŲÂŖ˙YTh˙:Dt˙x?˙l@˙n? ûm> ˙l> ›m> m> i= ŒY‡T‰U‰U‰V‰UōˆT˙‡Tũ‡T˙†S˙†S˙…R˙…R˙„Q˙‚Q˙ŠP ˙YPa˙*P˛˙fPJ˙yI˙†R˙ąŒ^˙ÁĨƒ˙ŊŠ•˙y_O˙RLc˙9Qš˙/O¨˙KMq˙wJ˙„I˙yI˙xI˙|H ˙H˙}G˙vG˙vF˙vF˙uE˙uE˙tE˙tD˙rD˙zB˙QFS˙4J’˙y?˙h9˙i:˙‹Y˙IQ|˙EC^˙v>˙l? ˙m> ül> ˙l= Ml= l= rC ‹WŠVˆUˆTˆTW‡T˙†Sü†S˙…R˙…R˙„Q˙„Q˙ƒQ˙…P˙zP%˙+P˛˙P˙†J˙Œ^*˙ÕÅ´˙¸Ÿ‚˙ |T˙™qC˙|E˙z;˙{>˙nF!˙HO{˙*Oą˙LKk˙H˙|H ˙vH˙vG˙vG˙vF˙vF˙uE˙uE˙tD˙sD˙sC˙rC ˙qC˙wA˙*KĨ˙ZE=˙xA˙kB˙t=˙1G˙XB:˙r=˙k> ũl= ˙k= ãj< j< k= m> ŠT‰VZ‡T†S†S—†R˙…Rú„R˙„Q˙ƒQ˙ƒP˙P˙ˆO ˙dOK˙@O‹˙‹P˙r@ ˙ÁŠŽ˙Ļ„^˙n6˙r<˙‰\*˙ļ›}˙˙Ĩ†e˙Š[%˙z;˙qC˙>O‹˙+MĒ˙nH$˙}F˙tF˙vF˙uE˙uE˙tD˙tD˙sC˙rC ˙rB ˙qB ˙pB˙tA˙bC*˙!Kˇ˙k@˙z=˙hA˙&JŠ˙m>˙l= ˙k= úk=˙j<…j<j<j< j= ˆU†S…R…Q…R…RńQ˙ƒQúƒP˙‚P˙‚O˙O˙„N˙pN3˙4O ˙‡P ˙s?˙ŋϊ˙Ą~U˙p9˙”l>˙ÔÃą˙ }U˙Ÿ{S˙°“s˙ĖēĻ˙ÅŗŸ˙‹_.˙y9˙^KH˙!Mŧ˙aH:˙|E˙sE˙tD˙tD˙sD˙sC ˙rC ˙qB ˙qB ˙pA ˙pA ˙nA˙v?˙QDJ˙"K´˙RCG˙)IŖ˙ICW˙r<˙i= ūj<˙j<öi<i<i<k= ‡S†R„Q„Q„QƒP߂P˙‚PúO˙O˙€N˙~N˙‡M˙4O ˙bNL˙‚F˙Ša4˙ÚĖŧ˙{J˙Š^-˙ßŌÅ˙ŋ¨˙ž§Œ˙¤ƒ^˙ƒV$˙—rG˙×Éē˙ŠŽq˙u9˙mH&˙!Mģ˙dF1˙yD˙rD˙sC ˙rC ˙rB ˙qB ˙qA ˙pA ˙o@ ˙o@ ˙n@ ˙l?˙t>˙]A-˙5G‡˙MBO˙p<˙i= ˙j<úi;˙i;†i;i;j=j<‰P…R„QƒQƒP‚POéO˙€Nú€N˙M˙~M˙‚L ˙pL.˙%Oē˙zM˙y@˙™wQ˙ÛΞ˙Že6˙ }U˙ōîé˙˙˙˙˙˙˙˙˙ûų÷˙“mB˙m:˙Îŧ¨˙Ŧ’w˙r7˙eH4˙%Lą˙sC ˙sC ˙rB ˙qB ˙qA ˙pA ˙pA ˙o@ ˙n@ ˙n? ˙m? ˙m> ˙k> ˙o=˙t;˙p<˙i< ˙j<üi;˙h;âg;g:h:k=h= †S„QƒQ‚PON €NįM˙Mú~M˙~L˙{L˙„K˙^LL˙$Nš˙vM#˙{>˙ˆa5˙ÎŊĒ˙ŊĨŠ˙™uK˙—rH˙—rH˙­o˙ØĘģ˙o<˙p?˙ĶIJ˙„^5˙z<˙@Jz˙FHj˙zA˙oB˙pA ˙pA ˙o@ ˙o@ ˙n? ˙m? ˙m> ˙l> ˙l> ˙k= ˙j= ˙h< ˙h< ˙i;ūh;ũh:˙h:Fh:h:h;i;ƒP‚POO€NM~MØ~L˙}Lú}K˙|K˙yJ˙„J˙`KF˙#Nē˙]MQ˙~A˙u>˙˜vQ˙Ŋ§˙į—˙ÂŦ”˙Į´ž˙‘k@˙tD˙e1˙Ќk˙ĒŽo˙j3˙cG3˙1J–˙vA˙oA˙o@ ˙o@ ˙n? ˙n? ˙m? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:úg:˙g:„g:h:i;h;OO€NMM}K }Kš|K˙|Kũ{Jü{J˙xI˙H˙pI#˙.Mĸ˙6N—˙kJ-˙y<˙r8˙vG˙wJ˙n> ˙vH˙l;˙xK˙Ōô˙Šg@˙o9˙eD)˙.J›˙u@˙n@˙n@ ˙n? ˙m? ˙m> ˙l> ˙l= ˙k= ˙j<˙j<˙i;˙i;˙h;˙h:ųg:˙g9Ģg:i; f9 g:NM~L~L}L}L{J†{J˙zI˙zIúyH˙wH˙{G ˙G˙TIV˙+L¨˙8M“˙\HD˙U#˙—f*˙Ŋ›n˙˙ūō˙āĐē˙Õ¨˙Ŗ}N˙r7˙zB˙0J—˙HF_˙v?˙m? ˙m? ˙m> ˙l> ˙l= ˙k= ˙k=˙j<˙i<˙i;˙h;˙h:ūg:úg9˙f9ˇj<e7f9g9g:}M~L}L}L~K{JzIDyIãyH˙xHūxGûvG˙uF˙~E˙yE˙VHN˙4K“˙,Kĸ˙DXš˙XYw˙~w‚˙ĄœŖ˙mTE˙O6*˙JId˙(J¨˙ ˙l> ˙l> ˙k= ˙k=˙j<˙j<˙i;˙h;˙h:˙g:üg9ũf9˙f9¨e8e8f9f9g9l; |J|K|K|K{JzIxH xG’wG˙wF˙vFũvEûtE˙tE˙{C˙{C˙lD˙SCF˙?Dm˙/>{˙':˙0G‘˙6K˙@Fr˙^B.˙u>˙m? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:˙g:ūg:úf9˙f9˙e8~f9e8f9f9f9g8zM{Jp;xHzIxHxGwF/vF´uE˙tE˙tD˙sDûrCũqC˙sB ˙xB˙zA˙yB˙xC˙u@˙v?˙v>˙q>˙k> ˙l> ˙l= ˙k= ˙j<˙j<˙i<˙i;˙h;˙h:ũg:úg9˙f9˙e8Ųe8>f8g:f8f9f9f:yHxHxHyHxGvFvFtE;tDŽsCūrC ˙rB ˙qB ūqB ûoAünAūm@˙m@˙m? ˙l? ˙k? ˙l> ˙l= ˙k= ˙k=˙j<˙i<˙i;ūh;üh:ûg:ũg9˙f9˙f8íe8x`3e8f9e8e8f8f9wFwGtCuDwFvFtEtDrC &rB ƒqB ×pA ˙pA ˙o@ ˙o@ ˙n? ˙n? üm? ûl> ûl> ûk= ûk= ûj<ûj<ûi;ûi;ũh:˙g:˙g9˙f9˙f9Öe8td7e8b5e8e8g9e8f3vFuExG|KtEtDrC rB V%pA 6o@ |n@ ģn? čm? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:˙g:˙g:ķf9Ęf9Šf9=~iGe8d7e8e8e8e8e8uE sC}ExDsC rB k;pA o@ o@ m> l> -l> Qk= qk= ˆj<—j<ži;œh;‘h:}g:ag:=f8h:f9f9_-f8e8e8e8e8mIpB pA qA qA pA o@ oA l> l> l> k= k= j<j<i;h;h:g:g:f8j;f9f9e8e8f8e8nA n> r> n? m? m> l> k= k= j<j<i;i;h:g:g:g9f9c8g9f9˙˙€˙˙˙˙ü?˙˙˙đ˙˙˙Ā˙˙˙˙˙ū˙ü˙ø˙đ˙đ˙ā˙ā˙Ā€?€?€€€€ĀĀāāđđøüüū˙˙€˙Ā˙ā˙đ˙ø?˙ū˙˙˙˙˙Ā˙˙˙đ˙˙˙ü?˙˙˙˙Ā˙˙astropy-photutils-3322558/docs/_static/photutils_logo_dark_plain_path.svg000066400000000000000000002170551517052111400270220ustar00rootroot00000000000000 Photutils logoimage/svg+xmlPhotutils logoLarry Bradley astropy-photutils-3322558/docs/_static/photutils_logo_light_plain_path.svg000066400000000000000000002170551517052111400272100ustar00rootroot00000000000000 Photutils logoimage/svg+xmlPhotutils logoLarry Bradley astropy-photutils-3322558/docs/changelog.rst000066400000000000000000000001131517052111400210420ustar00rootroot00000000000000.. _changelog: ********* Changelog ********* .. include:: ../CHANGES.rst astropy-photutils-3322558/docs/conf.py000066400000000000000000000164201517052111400176700ustar00rootroot00000000000000# 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 import tomllib from datetime import UTC, datetime from importlib import metadata from pathlib import Path from sphinx.util import logging logger = logging.getLogger(__name__) try: from sphinx_astropy.conf.v2 import * # noqa: F403 from sphinx_astropy.conf.v2 import extensions # noqa: E402 except ImportError: msg = ('The documentation requires the sphinx-astropy package to be ' 'installed. Please install the "docs" requirements.') logger.error(msg) sys.exit(1) # Get configuration information from pyproject.toml with (Path(__file__).parents[1] / 'pyproject.toml').open('rb') as fh: project_meta = tomllib.load(fh)['project'] # -- Plot configuration ------------------------------------------------------- plot_rcparams = { 'axes.labelsize': 'large', 'figure.figsize': (6, 6), 'figure.subplot.hspace': 0.5, 'savefig.bbox': 'tight', 'savefig.facecolor': 'none', } plot_apply_rcparams = True plot_html_show_source_link = True plot_formats = ['png', 'hires.png', 'pdf', 'svg'] # Don't use the default - which includes a numpy and matplotlib import plot_pre_code = '' # -- General configuration ---------------------------------------------------- # By default, highlight as Python 3. highlight_language = 'python3' # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '8.2' # keep in sync with pyproject.toml # Extend astropy intersphinx_mapping with packages we use here intersphinx_mapping.update( # noqa: F405 {'gwcs': ('https://gwcs.readthedocs.io/en/latest/', None), 'regions': ('https://astropy-regions.readthedocs.io/en/stable/', None), 'shapely': ('https://shapely.readthedocs.io/en/stable/', None), 'skimage': ('https://scikit-image.org/docs/stable/', None), }) # 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. # .inc.rst mean *include* files, don't have sphinx process them # exclude_patterns += ["_templates", "_pkgtemplate.rst", "**/*.inc.rst"] extensions += [ 'sphinx_design', 'sphinx_reredirects', ] redirects = { 'user_guide/epsf': 'epsf_building.html', 'user_guide/profiles': 'radial_profiles.html', } # 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/ """ # -- Project information ------------------------------------------------------ project = project_meta['name'] author = project_meta['authors'][0]['name'] project_copyright = f'2011-{datetime.now(tz=UTC).year}, {author}' github_project = 'astropy/photutils' # 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. # The full version, including alpha/beta/rc tags. release = metadata.version(project) # The short X.Y version. version = '.'.join(release.split('.')[:2]) dev = 'dev' in release # -- Options for HTML output -------------------------------------------------- html_theme_options = { 'header_links_before_dropdown': 6, 'collapse_navigation': True, 'navigation_depth': 2, 'show_nav_level': 2, 'navigation_with_keys': False, 'use_edit_page_button': False, 'logo': { 'image_light': 'photutils_logo_light_plain_path.svg', 'image_dark': 'photutils_logo_dark_plain_path.svg', }, # alternate way to set the logo # 'github_url': 'https://github.com/astropy/photutils', 'icon_links': [ {'name': 'GitHub', 'url': 'https://github.com/astropy/photutils', 'icon': 'fa-brands fa-github', 'type': 'fontawesome', }, ], } html_title = f'{project} {release}' html_show_sourcelink = False html_favicon = os.path.join('_static', 'photutils_logo.ico') html_static_path = ['_static'] html_css_files = ['custom.css'] # path relative to _static # Output file base name for HTML help builder. htmlhelp_basename = project + 'doc' # Set canonical URL from the Read the Docs Domain html_baseurl = os.environ.get('READTHEDOCS_CANONICAL_URL', '') # A dictionary of values to pass into the template engine's context for # all pages. html_context = { 'default_mode': 'light', 'to_be_indexed': ['stable', 'latest'], 'is_development': dev, 'github_user': 'astropy', 'github_repo': 'photutils', 'github_version': 'main', 'doc_path': 'docs', # Tell Jinja2 templates the build is running on Read the Docs 'READTHEDOCS': os.environ.get('READTHEDOCS', '') == 'True', } # fix size of inheritance diagrams (e.g., PSF diagram was cut off) inheritance_graph_attrs = {'size': '""'} # -- 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_issues_url = f'https://github.com/{github_project}/issues/' # -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- nitpicky = True # 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_ignore = [] nitpick_filename = 'nitpick-exceptions.txt' if os.path.isfile(nitpick_filename): with open(nitpick_filename) as fh: for line in fh: 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://github\.com/astropy/photutils/(?:issues|pull)/\d+', # Zenodo/doi: 403 Client Error: Forbidden for url r'https://zenodo.org/records/*', r'https://doi.org/*', ] linkcheck_timeout = 180 astropy-photutils-3322558/docs/development/000077500000000000000000000000001517052111400207105ustar00rootroot00000000000000astropy-photutils-3322558/docs/development/contributing.rst000066400000000000000000000023431517052111400241530ustar00rootroot00000000000000Reporting 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. astropy-photutils-3322558/docs/development/contributors.rst000066400000000000000000000002651517052111400242020ustar00rootroot00000000000000Contributors ============ For the complete list of contributors please see the `Photutils contributors page on GitHub `_. astropy-photutils-3322558/docs/development/index.rst000066400000000000000000000004051517052111400225500ustar00rootroot00000000000000.. _development: *********** Development *********** For Contributors ---------------- .. toctree:: :maxdepth: 1 contributing.rst contributors.rst For Maintainers --------------- .. toctree:: :maxdepth: 1 releasing.rst license.rst astropy-photutils-3322558/docs/development/license.rst000066400000000000000000000001751517052111400230670ustar00rootroot00000000000000.. _photutils_license: License ======= Photutils is licensed under a 3-clause BSD license: .. include:: ../../LICENSE.rst astropy-photutils-3322558/docs/development/releasing.rst000066400000000000000000000151721517052111400234210ustar00rootroot00000000000000.. 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 release from a bugfix branch. To release from a bugfix branch, 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 a "What's New" page is added to the documentation for the new release. This page should be added to the ``docs/whats_new`` directory and should be named ``.rst``. Update the "What's New" link on the main page (``docs/index.rst``) to the new version. #. 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 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 .. _resume_release: #. 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``, adding new sections 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 tox -e build_docs Optionally, install and test the source distribution in a virtual environment:: pip install -e '.[all,test]' pytest --remote-data or:: pip install '../.tar.gz[all,test]' cd pytest --pyargs photutils --remote-data #. Check out the ``main`` branch, go back to the package root directory, and remove the generated files with:: git checkout main cd ../.. git clean -dfx #. Go back to the :ref:`release steps ` where you left off. astropy-photutils-3322558/docs/getting_started/000077500000000000000000000000001517052111400215555ustar00rootroot00000000000000astropy-photutils-3322558/docs/getting_started/citation.rst000066400000000000000000000012701517052111400241210ustar00rootroot00000000000000.. _citation: Citing 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. astropy-photutils-3322558/docs/getting_started/importing.rst000066400000000000000000000033231517052111400243200ustar00rootroot00000000000000.. doctest-skip-all .. _importing: Importing from Photutils ======================== Photutils is organized into subpackages covering different topics. Importing only ``photutils`` will not import the tools in the subpackages. There are no tools available in the top-level ``photutils`` namespace. For example, the following will **not** work:: >>> import photutils >>> aper = photutils.CircularAperture((10, 20), r=4) AttributeError: module 'photutils' has no attribute 'CircularAperture' The tools in each subpackage must be imported separately. For example, to import the aperture photometry tools, use:: >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture((10, 20), r=4) or:: >>> from photutils import aperture >>> aper = aperture.CircularAperture((10, 20), r=4) .. warning:: **Do not import from specific modules of packages.** This is unnecessary and the internal organization of the package may change without notice. All public tools are available in the package top-level namespace. For example, do **not** import from the ``circle`` module within the ``aperture`` package:: >>> from photutils.aperture.circle import CircularAperture >>> aper = CircularAperture((10, 20), r=4) .. warning:: Modules, functions, classes, methods, and attributes whose names begin with a leading underscore are considered private objects and should not be imported or accessed. If a module name in a package begins with a leading underscore, then none of its members are public, regardless of whether they begin with a leading underscore. **Private objects are not intended for public use and may change without notice.** astropy-photutils-3322558/docs/getting_started/index.rst000066400000000000000000000003051517052111400234140ustar00rootroot00000000000000.. _getting_started: *************** Getting Started *************** .. toctree:: :maxdepth: 1 install overview importing pixel_conventions performance_tips citation astropy-photutils-3322558/docs/getting_started/install.rst000066400000000000000000000113231517052111400237550ustar00rootroot00000000000000************ Installation ************ Requirements ============ Photutils has the following strict requirements: * `Python `_ 3.11 or later * `NumPy `_ 2.0 or later * `SciPy `_ 1.13 or later * `Astropy`_ 6.1.4 or later Photutils also optionally depends on other packages for some features: * `Matplotlib `_ 3.9 or later: Used to power a variety of plotting features (e.g., plotting apertures). * `Regions `_ 0.9 or later: Required to perform aperture photometry using region objects. * `scikit-image `_ 0.23 or later: Required to deblend segmented sources. * `GWCS `_ 0.20 or later: Required in `~photutils.datasets.make_gwcs` to create a simple celestial gwcs object. * `Bottleneck `_ 1.4 or later: Improves the performance of sigma clipping and other functionality that may require computing statistics on arrays with NaN values. * `tqdm `_ 4.66 or later: Required to display optional progress bars. * `Rasterio `_ 1.4 or later: Required to convert source segments into polygon objects. * `Shapely `_ 2.0 or later: Required to convert source segments into polygon objects. Installing the latest released version ====================================== Using pip --------- To install Photutils with `pip`_, run:: python -m pip install photutils If you want to install Photutils along with all of its optional dependencies, you can instead run:: python -m 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). Using conda ----------- Photutils can also be installed using the ``conda`` package manager. There are several methods for installing ``conda`` and many different ways to set up your Python environment, but that is beyond the scope of this documentation. We recommend installing `miniforge `__. Once you have installed ``conda``, you can install Photutils using the ``conda-forge`` channel:: 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 python -m pip install ".[all]" Alternatively, `pip`_ can be used to retrieve and install the latest development wheel (with all optional dependencies):: python -m pip install --upgrade --extra-index-url https://pypi.anaconda.org/astropy/simple "photutils[all]" --pre 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/ .. _GitHub: https://github.com/astropy/photutils .. _Xcode: https://developer.apple.com/xcode/ .. _pytest: https://docs.pytest.org/en/latest/ astropy-photutils-3322558/docs/getting_started/overview.rst000066400000000000000000000023041517052111400241540ustar00rootroot00000000000000Overview ======== Introduction ------------ Photutils contains tools 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 * fitting elliptical isophotes to galaxies * creating simulated astronomical 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. astropy-photutils-3322558/docs/getting_started/performance_tips.rst000066400000000000000000000044461517052111400256570ustar00rootroot00000000000000.. _performance-tips: **************** Performance Tips **************** .. _bottleneck-performance: Bottleneck ========== The optional `Bottleneck `_ package provides fast, NaN-aware replacements for NumPy's ``nansum``, ``nanmin``, ``nanmax``, ``nanmean``, ``nanmedian``, ``nanstd``, and ``nanvar`` functions. When Bottleneck is installed, Photutils will automatically use it for these operations, improving performance for any workflow that computes statistics on arrays containing NaN values (e.g., masked pixels). Bottleneck acceleration is used internally by the following Photutils packages: * `~photutils.background` — background and background RMS estimation (e.g., `~photutils.background.Background2D`) * `~photutils.detection` — source detection peak finding * `~photutils.profiles` — radial-profile and curve-of-growth calculations * `~photutils.psf` — ePSF building (e.g., `~photutils.psf.EPSFBuilder`) * `~photutils.segmentation` — source detection and deblending .. note:: Due to known accuracy issues in Bottleneck with ``float32`` arrays (see `bottleneck #379 `_ and `bottleneck #462 `_), Photutils uses Bottleneck only for ``float64`` arrays and falls back to NumPy for other dtypes. To install Bottleneck:: python -m pip install bottleneck .. _byteorder-performance: Array Byte Order (Endianness) ============================= Bottleneck requires that the byte order of the input data array matches the native byte order of the operating system (typically little-endian on modern processors). Arrays loaded by `astropy.io.fits` are stored as big-endian. If the byte order does not match, Bottleneck will not be used and the code will fall back to NumPy. You can convert a big-endian FITS array to native byte order *in place*, without allocating additional memory, using:: >>> data.byteswap(inplace=True) # doctest: +SKIP >>> data.dtype = data.dtype.newbyteorder('=') # doctest: +SKIP Alternatively, you can create a native-endian copy with:: >>> data = data.astype(float) # doctest: +SKIP The first approach is preferred for large arrays because it avoids allocating a temporary copy of the entire array. astropy-photutils-3322558/docs/getting_started/pixel_conventions.rst000066400000000000000000000021731517052111400260600ustar00rootroot00000000000000Pixel Coordinate Conventions ============================ In Photutils, integer pixel coordinates are located 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, two-dimensional arrays are indexed as ``image[yi, xi]``, with 0 being the first index. The ``xi`` (column) index corresponds to the second (fast) array index and the ``yi`` (row) index corresponds to the first (slow) index. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ .. _ds9: http://ds9.si.edu/ astropy-photutils-3322558/docs/index.rst000066400000000000000000000054511517052111400202340ustar00rootroot00000000000000 :tocdepth: 3 .. |br| raw:: html
.. image:: _static/photutils_logo_light_plain_path.svg :class: only-light :width: 55% :align: center :alt: Photutils logo .. image:: _static/photutils_logo_dark_plain_path.svg :class: only-dark :width: 55% :align: center :alt: Photutils logo ********* Photutils ********* | **Version**: |release| | **Date**: |today| | **Useful links**: :doc:`getting_started/install` | :doc:`release_notes/index` **Photutils** is a Python library that provides commonly-used tools and key functionality for detecting and performing photometry of astronomical sources. Tools are provided for background estimation, star finding, source detection and extraction, aperture photometry, PSF photometry, image segmentation, centroids, radial profiles, and elliptical isophote fitting. It is a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. .. admonition:: 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 :ref:`acknowledgment and/or citation `. |br| .. toctree:: :maxdepth: 1 :hidden: getting_started/index user_guide/index reference/index development/index Release Notes .. grid:: 3 :gutter: 2 3 4 4 .. grid-item-card:: :text-align: center **Getting Started** ^^^^^^^^^^^^^^^^^^^ New to Photutils? Check out the getting started guides. They contain an overview of Photutils and an introduction to its main concepts. +++ .. button-ref:: getting_started/index :expand: :color: primary :click-parent: To the getting started guides .. grid-item-card:: :text-align: center **User Guide** ^^^^^^^^^^^^^^ The user guide provides in-depth information on the key concepts of Photutils with useful background information and explanation. +++ .. button-ref:: user_guide/index :expand: :color: primary :click-parent: To the user guide .. grid-item-card:: :text-align: center **API Reference** ^^^^^^^^^^^^^^^^^ The reference guide contains a detailed description of the functions, modules, and objects included in Photutils. It assumes that you have an understanding of the key concepts. +++ .. button-ref:: reference/index :expand: :color: primary :click-parent: To the reference guide astropy-photutils-3322558/docs/make.bat000066400000000000000000000014401517052111400177720ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd astropy-photutils-3322558/docs/reference/000077500000000000000000000000001517052111400203245ustar00rootroot00000000000000astropy-photutils-3322558/docs/reference/aperture_api.rst000066400000000000000000000004251517052111400235370ustar00rootroot00000000000000 =============================================== Aperture Photometry (:mod:`photutils.aperture`) =============================================== .. automodapi:: photutils.aperture :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/aperture` astropy-photutils-3322558/docs/reference/background_api.rst000066400000000000000000000004071517052111400240270ustar00rootroot00000000000000 ========================================= Backgrounds (:mod:`photutils.background`) ========================================= .. automodapi:: photutils.background :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/background` astropy-photutils-3322558/docs/reference/centroids_api.rst000066400000000000000000000003741517052111400237050ustar00rootroot00000000000000 ====================================== Centroids (:mod:`photutils.centroids`) ====================================== .. automodapi:: photutils.centroids :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/centroids` astropy-photutils-3322558/docs/reference/datasets_api.rst000066400000000000000000000004411517052111400235160ustar00rootroot00000000000000 =================================================== Datasets and Simulation (:mod:`photutils.datasets`) =================================================== .. automodapi:: photutils.datasets :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/datasets` astropy-photutils-3322558/docs/reference/detection_api.rst000066400000000000000000000004621517052111400236670ustar00rootroot00000000000000 ======================================================== Point-like Source Detection (:mod:`photutils.detection`) ======================================================== .. automodapi:: photutils.detection :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/detection` astropy-photutils-3322558/docs/reference/geometry_api.rst000066400000000000000000000004221517052111400235400ustar00rootroot00000000000000 ============================================== Geometry Functions (:mod:`photutils.geometry`) ============================================== .. automodapi:: photutils.geometry :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/geometry` astropy-photutils-3322558/docs/reference/index.rst000066400000000000000000000023211517052111400221630ustar00rootroot00000000000000.. _api_reference: ************* API Reference ************* The API reference guide contains detailed descriptions of the functions, modules, and objects included in each of the Photutils subpackages. It assumes that you have an understanding of the key concepts presented in the :ref:`user_guide`. Please see :ref:`importing` for information on how to import these tools. * :doc:`photutils.aperture ` * :doc:`photutils.background ` * :doc:`photutils.centroids ` * :doc:`photutils.datasets ` * :doc:`photutils.detection ` * :doc:`photutils.geometry ` * :doc:`photutils.isophote ` * :doc:`photutils.morphology ` * :doc:`photutils.profiles ` * :doc:`photutils.psf ` * :doc:`photutils.psf_matching ` * :doc:`photutils.segmentation ` * :doc:`photutils.utils ` .. toctree:: :maxdepth: 1 :hidden: aperture_api background_api centroids_api datasets_api detection_api geometry_api isophote_api morphology_api profiles_api psf_api psf_matching_api segmentation_api utils_api astropy-photutils-3322558/docs/reference/isophote_api.rst000066400000000000000000000004601517052111400235410ustar00rootroot00000000000000 ======================================================== Elliptical Isophote Analysis (:mod:`photutils.isophote`) ======================================================== .. automodapi:: photutils.isophote :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/isophote` astropy-photutils-3322558/docs/reference/morphology_api.rst000066400000000000000000000004561517052111400241130ustar00rootroot00000000000000 ====================================================== Morphological Properties (:mod:`photutils.morphology`) ====================================================== .. automodapi:: photutils.morphology :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/morphology` astropy-photutils-3322558/docs/reference/profiles_api.rst000066400000000000000000000004421517052111400235320ustar00rootroot00000000000000 ==================================== Profiles (:mod:`photutils.profiles`) ==================================== .. automodapi:: photutils.profiles :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/radial_profiles` :doc:`../user_guide/curves_of_growth` astropy-photutils-3322558/docs/reference/psf_api.rst000066400000000000000000000003551517052111400225020ustar00rootroot00000000000000 ===================================== PSF Photometry (:mod:`photutils.psf`) ===================================== .. automodapi:: photutils.psf :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/psf` astropy-photutils-3322558/docs/reference/psf_matching_api.rst000066400000000000000000000004241517052111400243510ustar00rootroot00000000000000 ============================================ PSF Matching (:mod:`photutils.psf_matching`) ============================================ .. automodapi:: photutils.psf_matching :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/psf_matching` astropy-photutils-3322558/docs/reference/segmentation_api.rst000066400000000000000000000004461517052111400244100ustar00rootroot00000000000000 ================================================== Image Segmentation (:mod:`photutils.segmentation`) ================================================== .. automodapi:: photutils.segmentation :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/segmentation` astropy-photutils-3322558/docs/reference/utils_api.rst000066400000000000000000000004001517052111400230410ustar00rootroot00000000000000 ========================================== Utility Functions (:mod:`photutils.utils`) ========================================== .. automodapi:: photutils.utils :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/utils` astropy-photutils-3322558/docs/release_notes/000077500000000000000000000000001517052111400212165ustar00rootroot00000000000000astropy-photutils-3322558/docs/release_notes/index.rst000066400000000000000000000023151517052111400230600ustar00rootroot00000000000000************* Release Notes ************* Detailed Release Notes ====================== The complete changelog contains detailed information about all changes, bug fixes, and improvements in each release. .. toctree:: :maxdepth: 1 Changelog <../changelog> What's New ========== The "What's New" pages highlight major new features and improvements in recent releases. Development Version ------------------- .. toctree:: :maxdepth: 1 ../whats_new/3.0.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 ../whats_new/2.3.rst ../whats_new/2.2.rst ../whats_new/2.1.rst ../whats_new/2.0.rst ../whats_new/1.13.rst ../whats_new/1.12.rst ../whats_new/1.11.rst ../whats_new/1.10.rst ../whats_new/1.9.rst ../whats_new/1.8.rst ../whats_new/1.7.rst ../whats_new/1.6.rst ../whats_new/1.5.rst ../whats_new/1.4.rst ../whats_new/1.3.rst ../whats_new/1.2.rst ../whats_new/1.1.rst astropy-photutils-3322558/docs/user_guide/000077500000000000000000000000001517052111400205215ustar00rootroot00000000000000astropy-photutils-3322558/docs/user_guide/aperture.rst000066400000000000000000001020631517052111400231040ustar00rootroot00000000000000.. _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-apertures: 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. The :func:`~photutils.aperture.aperture_photometry` function and the :class:`~photutils.aperture.ApertureStats` class both accept `~photutils.aperture.Aperture` objects. They can also accept a supported `regions.Region` object, i.e. a region that corresponds to the above aperture classes, as input. The :func:`~photutils.aperture.aperture_photometry` function also accepts a list of `~photutils.aperture.Aperture` or `regions.Region` objects if each aperture/region has identical positions. The :func:`~photutils.aperture.region_to_aperture` convenience function can also be used to convert a `regions.Region` object to a `~photutils.aperture.Aperture` object. 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. For this example, we'll use a sample WCS object:: >>> from photutils.datasets import make_wcs >>> wcs = make_wcs((100, 100)) >>> aperture = CircularAperture((10, 20), r=4.0) >>> sky_aperture = aperture.to_sky(wcs) >>> sky_aperture # doctest: +FLOAT_CMP , r=0.39999999985539925 arcsec)> and the :meth:`~photutils.aperture.SkyAperture.to_pixel` method for sky apertures, e.g.,:: >>> position = SkyCoord(197.893, -1.366, unit='deg', frame='icrs') >>> aperture = SkyCircularAperture(position, r=0.4 * u.arcsec) >>> pix_aperture = aperture.to_pixel(wcs) >>> pix_aperture # doctest: +FLOAT_CMP .. note:: Aperture objects require scalar shape parameters (e.g., radius, semi-axes, angle), so only a single reference position can be used for computing local WCS properties (pixel scale, rotation angle). For apertures with multiple positions, the first position is used. Apertures with multiple positions used with a WCS that has spatially-varying distortions may produce inaccurate shape conversions for positions far from the first position. 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 x_center y_center aperture_sum --- -------- -------- ------------ 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'``, ``'x_center'``, ``'y_center'``, 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 x_center y_center aperture_sum --- -------- -------- ------------ 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 x_center y_center aperture_sum_0 aperture_sum_1 aperture_sum_2 --- -------- -------- -------------- -------------- -------------- 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=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 x_center y_center aperture_sum --- -------- -------- ------------ 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 x_center y_center aperture_sum_0 aperture_sum_1 aperture_sum_2 --- -------- -------- -------------- -------------- -------------- 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_axis`, :attr:`~photutils.aperture.ApertureStats.semiminor_axis`, :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_sum_cutout``, and ``error_sum_cutout``. 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.x_centroid) # doctest: +FLOAT_CMP 149.98963482915323 >>> print(aperstats.y_centroid) # 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.x_centroid) # 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=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) fig, ax = plt.subplots() ax.imshow(data, norm=norm, origin='lower') ax.set_xlim(0, 170) ax.set_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]) ax.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 x_center y_center aperture_sum --- -------- -------- ------------ 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 x_center y_center aperture_sum total_bkg aperture_sum_bkgsub --- -------- -------- ------------ --------- ------------------- 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 x_center y_center aperture_sum aperture_sum_err --- -------- -------- ------------ ---------------- 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 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 >>> fig, ax = plt.subplots() >>> ax.imshow(masks[0], origin='lower') .. 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') fig, ax = plt.subplots() ax.imshow(masks[0], origin='lower') Let's now use the "center" aperture mask method and plot the resulting aperture mask: .. doctest-skip:: >>> masks2 = aperture.to_mask(method='center') >>> fig, ax = plt.subplots() >>> ax.imshow(masks2[0], origin='lower') .. 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') fig, ax = plt.subplots() ax.imshow(masks2[0], origin='lower') 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) >>> fig, ax = plt.subplots() >>> ax.imshow(data_weighted, origin='lower') .. 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') fig, ax = plt.subplots() ax.imshow(masks[0].multiply(data), origin='lower') 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 ``bbox`` 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. * ``bbox``: 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. API Reference ------------- :doc:`../reference/aperture_api` astropy-photutils-3322558/docs/user_guide/background.rst000066400000000000000000000501041517052111400233720ustar00rootroot00000000000000.. _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 simple_norm >>> norm = simple_norm(data, 'sqrt', percent=99.5) >>> fig, ax = plt.subplots() >>> ax.imshow(data, norm=norm, origin='lower') .. plot:: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() norm = simple_norm(data, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(data, norm=norm, origin='lower') ax.set_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:: >>> 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, n_sigma=2.0, sigma_clip=sigma_clip) >>> segment_img = detect_sources(data, threshold, n_pixels=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. 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 image will be padded along the top and/or right edges. 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 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 ``bkg_rms_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 ``bkg_rms_estimator`` keywords. By default, the ``bkg_estimator`` and ``bkg_rms_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 spline interpolation via `scipy.ndimage.zoom`. .. note:: Prior to version 3.0, the interpolation method could be customized via the ``interpolator`` keyword using :class:`~photutils.background.BkgZoomInterpolator` or :class:`~photutils.background.BkgIDWInterpolator` classes. These are now deprecated. The ``BkgIDWInterpolator`` is not well-suited for resizing images on a regular grid to larger sizes. It is also significantly slower than the default interpolator based on ``scipy.ndimage.zoom``. 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 >>> fig, ax = plt.subplots() # doctest: +SKIP >>> ax.imshow(data2, norm=norm, origin='lower') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm 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 = simple_norm(data2, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(data2, norm=norm, origin='lower') ax.set_title('Data with added background gradient') We start by creating a `~photutils.background.Background2D` object using a box size of 15x15 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`:: >>> from astropy.stats import SigmaClip >>> from photutils.background import Background2D, MedianBackground >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg_estimator = MedianBackground() >>> bkg = Background2D(data2, (15, 15), 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:: >>> print(bkg.background_median) # doctest: +FLOAT_CMP 10.822232525276007 >>> print(round(bkg.background_rms_median, 4)) # doctest: +FLOAT_CMP 2.0367 Let's plot the background image: .. doctest-skip:: >>> fig, ax = plt.subplots() >>> ax.imshow(bkg.background, origin='lower') .. 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, (15, 15), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) fig, ax = plt.subplots() ax.imshow(bkg.background, origin='lower') ax.set_title('Estimated Background') and the background-subtracted image: .. doctest-skip:: >>> data2_sub = data2 - bkg.background >>> fig, ax = plt.subplots() >>> ax.imshow(data2_sub, norm=norm, origin='lower') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import SigmaClip from astropy.visualization import simple_norm 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, (15, 15), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) data2_sub = data2 - bkg.background norm = simple_norm(data2_sub, 'sqrt', percent=99.9) fig, ax = plt.subplots() ax.imshow(data2_sub, norm=norm, origin='lower') ax.set_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: .. doctest-skip:: >>> from astropy.visualization import simple_norm >>> from scipy.ndimage import rotate >>> data3 = rotate(data2, -45.0) >>> norm = simple_norm(data3, 'sqrt', percent=99.5) >>> fig, ax = plt.subplots() >>> ax.imshow(data3, norm=norm, origin='lower') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image from scipy.ndimage 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 = simple_norm(data3, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(data3, norm=norm, origin='lower') ax.set_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-skip:: >>> 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-skip:: >>> norm = simple_norm(bkg3.background, 'sqrt', percent=99.5) >>> fig, ax = plt.subplots() >>> ax.imshow(bkg3.background, norm=norm, origin='lower') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage 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 = simple_norm(bkg3.background, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(bkg3.background, norm=norm, origin='lower') ax.set_title('Estimated Background') Finally, let's subtract the background from the image and plot it: .. doctest-skip:: >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> data_sub = data3 - bkg3.background >>> norm = simple_norm(data_sub, 'sqrt', percent=99.5) >>> fig, ax = plt.subplots() # doctest: +SKIP >>> ax.imshow(data_sub, norm=norm, origin='lower') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage 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) data_sub = data3 - bkg3.background norm = simple_norm(data_sub, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(data_sub, norm=norm, origin='lower') ax.set_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:: >>> fig, ax = plt.subplots() >>> ax.imshow(data3, norm=norm, origin='lower') >>> bkg3.plot_meshes(outlines=True, marker='.', color='cyan', alpha=0.3) >>> ax.set_xlim(0, 250) >>> ax.set_ylim(0, 250) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage 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 = simple_norm(data3, 'sqrt', percent=99.5) fig, ax = plt.subplots() ax.imshow(data3, norm=norm, origin='lower') bkg3.plot_meshes(outlines=True, marker='.', color='white', alpha=0.5) ax.set_xlim(0, 250) ax.set_ylim(0, 250) API Reference ------------- :doc:`../reference/background_api` astropy-photutils-3322558/docs/user_guide/centroids.rst000066400000000000000000000155571517052111400232620ustar00rootroot00000000000000Centroids (`photutils.centroids`) ================================= Introduction ------------ `photutils.centroids` provides several functions to calculate the centroid of one or more sources. The following functions calculate the centroid of a single source: * :func:`~photutils.centroids.centroid_com`: Calculates the centroid as the flux-weighted center of mass derived from 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. Non-finite values (e.g., NaN or inf) in the data or error arrays are automatically masked. 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. Centroid of single source ------------------------- Let's extract a single object from a synthetic dataset and find its centroid with each of these methods. First, let's create the data:: >>> 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() .. plot:: import matplotlib.pyplot as plt from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() fig, ax = plt.subplots(figsize=(8, 4)) ax.imshow(data, origin='lower') fig.tight_layout() Next, we need to subtract the background from the data. For this example, we'll estimate the background by taking the median of a blank part of the image:: >>> data -= np.median(data[0:30, 0:125]) The data is a 2D image of four Gaussian sources. Let's extract a single object from the data:: >>> data = data[40:80, 70:110] Now we can calculate the centroid of the object using each of the centroiding functions:: >>> x1, y1 = centroid_com(data) >>> print(np.array((x1, y1))) # doctest: +FLOAT_CMP [19.9796724 20.00992593] :: >>> x2, y2 = centroid_quadratic(data) >>> print(np.array((x2, y2))) # doctest: +FLOAT_CMP [19.94009505 20.06884997] :: >>> x3, y3 = centroid_1dg(data) >>> print(np.array((x3, y3))) # doctest: +FLOAT_CMP [19.96553246 20.04952841] :: >>> x4, y4 = centroid_2dg(data) >>> print(np.array((x4, y4))) # doctest: +FLOAT_CMP [19.9851944 20.01490157] The measured centroids are all very close to the object's true centroid at position ``(20, 20)`` in the cutout image. 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:: import matplotlib.pyplot as plt import numpy as np 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() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] 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=(8, 8)) ax.imshow(data, origin='lower') marker = '+' ms = 60 colors = ('white', 'cyan', 'red', 'blue') labels = ('Center of Mass', 'Quadratic', '1D Gaussian', '2D Gaussian') for xycen, color, label in zip(xycens, colors, labels): ax.scatter(*xycen, color=color, marker=marker, s=ms, label=label) ax.legend(loc='lower right', fontsize=12) ax2 = zoomed_inset_axes(ax, zoom=6, loc=9) ax2.imshow(data, vmin=190, vmax=220, origin='lower') ms = 1000 for xycen, color in zip(xycens, colors): ax2.scatter(*xycen, color=color, marker=marker, s=ms) ax2.set_xlim(19, 21) ax2.set_ylim(19, 21) mark_inset(ax, ax2, loc1=3, loc2=4, fc='none', ec='black') 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 central positions. This function can be used with any of the above centroiding functions or a custom user-defined centroiding function. For each source, a cutout image of size ``box_size`` is made centered at each initial position. Optionally, a non-rectangular local ``footprint`` mask can be input instead of ``box_size``. The centroids for each source are then calculated within their cutout images:: >>> import numpy as np >>> from photutils.centroids import centroid_2dg, centroid_sources >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_2dg) >>> print(x) # doctest: +FLOAT_CMP [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) # doctest: +FLOAT_CMP [40.03657613 60.01836631 24.96777946 69.80208702] The measured centroids are all very close to the true centroids of the simulated objects in the image, which have ``(x, y)`` values of ``(25, 40)``, ``(90, 60)``, ``(150, 25)``, and ``(160, 70)``. Let's plot the results: .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg, centroid_sources from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) x, y = centroid_sources(data, x_init, y_init, box_size=25, centroid_func=centroid_2dg) fig, ax = plt.subplots(figsize=(8, 4)) ax.imshow(data, origin='lower') ax.scatter(x, y, marker='+', s=80, color='red', label='Centroids') ax.legend() fig.tight_layout() API Reference ------------- :doc:`../reference/centroids_api` astropy-photutils-3322558/docs/user_guide/curves_of_growth.rst000066400000000000000000000634611517052111400246520ustar00rootroot00000000000000.. _curves_of_growth: Curves of Growth (`photutils.profiles`) ======================================== Introduction ------------ `photutils.profiles` provides tools to calculate radial profiles (mean flux in concentric circular annular bins) and curves of growth (cumulative flux within concentric circular, square, or elliptical apertures). This page covers curves of growth. See :ref:`radial_profiles` for radial profiles. 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) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig .. 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig fig, ax = plt.subplots(figsize=(5, 5)) norm = simple_norm(data, 'sqrt') ax.imshow(data, norm=norm, origin='lower') 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. First, we'll find the source centroid using the `~photutils.centroids.centroid_2dg` function:: >>> from photutils.centroids import centroid_2dg >>> xycen = centroid_2dg(data) >>> print(xycen) # doctest: +FLOAT_CMP [47.76934534 52.3884076 ] 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) 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 [ 135.14750208 514.49674293 1076.4617132 1771.53866121 2510.94382666 3238.51695898 3907.08459943 4456.90125492 4891.00892262 5236.59326527 5473.66400376 5643.72239573 5738.24972738 5803.31693644 5842.00525018 5850.45854739 5855.76123671 5844.9631235 5847.72359025 5843.23189459 5852.05251106 5875.32009699 5869.86235184 5880.64741302 5872.16333953] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 3.72215309 7.44430617 11.16645926 14.88861235 18.61076543 22.33291852 26.05507161 29.7772247 33.49937778 37.22153087 40.94368396 44.66583704 48.38799013 52.11014322 55.8322963 59.55444939 63.27660248 66.99875556 70.72090865 74.44306174 78.16521482 81.88736791 85.609521 89.33167409 93.05382717] Normalization ^^^^^^^^^^^^^ Typically, the normalized curve of growth is of interest, where the profile is scaled so that its maximum value is 1 at the largest input "radii" value. This normalization is commonly used to calculate the encircled energy fraction at a specific radius. 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 curve-of-growth profile back to the original values prior to running any calls to the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method:: >>> cog.unnormalize() Plotting ^^^^^^^^ 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. Here, we plot the normalized curve of growth and its error: .. doctest-skip:: >>> cog.normalize(method='max') >>> cog.plot(label='Curve of Growth') >>> cog.plot_error() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) # Plot the curve of growth fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax, label='Curve of Growth') cog.plot_error(ax=ax) ax.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_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') cog.apertures[5].plot(ax=ax, color='C0', lw=2) cog.apertures[10].plot(ax=ax, color='C1', lw=2) cog.apertures[15].plot(ax=ax, color='C3', lw=2) .. _encircled_energy: 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 accuracy of these methods is dependent on the quality of the curve-of-growth profile (e.g., it's better to have a curve-of-growth profile with high signal-to-noise and more radial bins). Also, if the curve-of-growth profile is not monotonically increasing, the interpolation may fail. Let's compute the encircled energy values at a few radii for the curve of growth we created above:: >>> cog.normalize(method='max') >>> ee_rads = np.array([5, 7, 10, 15]) >>> ee_vals = cog.calc_ee_at_radius(ee_rads) >>> ee_vals # doctest: +FLOAT_CMP array([0.42698425, 0.66439702, 0.89047904, 0.99342893]) >>> cog.calc_radius_at_ee(ee_vals) # doctest: +FLOAT_CMP array([ 5., 7., 10., 15.]) Here we plot the encircled energy values. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) cog.normalize(method='max') ee_rads = np.array([5, 7, 10, 15]) ee_vals = cog.calc_ee_at_radius(ee_rads) # Plot the curve of growth fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax, label='Curve of Growth') cog.plot_error(ax=ax) ax.legend() xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() ax.vlines(ee_rads, ymin, ee_vals, colors='C1', linestyles='dashed') ax.hlines(ee_vals, xmin, ee_rads, colors='C1', linestyles='dashed') ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) for ee_rad, ee_val in zip(ee_rads, ee_vals): ax.text(ee_rad/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') Creating an Ensquared Curve of Growth ------------------------------------- In addition to the encircled (circular) curve of growth, one can also compute an ensquared curve of growth using concentric square apertures. This is done using the `~photutils.profiles.EnsquaredCurveOfGrowth` class, which accepts ``half_sizes`` (the half side lengths of the square apertures) instead of ``radii``. The full side length of each square aperture is ``2 * half_sizes``. Let's create an ensquared curve of growth for the same source we created above:: >>> from photutils.profiles import EnsquaredCurveOfGrowth >>> half_sizes = np.arange(1, 26) >>> ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) The ensquared curve of growth profile represents the total flux within the square aperture as a function of the square half-size:: >>> print(ecog.half_size) # 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.EnsquaredCurveOfGrowth.profile` and `~photutils.profiles.EnsquaredCurveOfGrowth.profile_error` attributes contain output 1D `~numpy.ndarray` objects containing the ensquared curve-of-growth profile and propagated errors:: >>> print(ecog.profile) # doctest: +FLOAT_CMP [ 171.35182895 640.63717997 1328.55725483 2142.84258293 2954.12152275 3717.5208724 4356.82277842 4844.97997426 5199.74452363 5480.78438494 5641.63617089 5751.92491894 5790.90751883 5819.30778391 5832.38652883 5825.14679788 5833.55196333 5833.54737611 5851.79194687 5856.58494602 5869.76637039 5872.91078217 5868.62195688 5850.11085443 5838.889818 ] >>> print(ecog.profile_error) # doctest: +FLOAT_CMP [ 4.2 8.4 12.6 16.8 21. 25.2 29.4 33.6 37.8 42. 46.2 50.4 54.6 58.8 63. 67.2 71.4 75.6 79.8 84. 88.2 92.4 96.6 100.8 105. ] Here, we plot the normalized ensquared curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EnsquaredCurveOfGrowth # 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the ensquared curve of growth half_sizes = np.arange(1, 26) ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) ecog.normalize(method='max') # Plot the ensquared curve of growth fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax) ecog.plot_error(ax=ax) We can also plot a few of the square 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_2dg from photutils.datasets import make_noise_image from photutils.profiles import EnsquaredCurveOfGrowth # 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the ensquared curve of growth half_sizes = np.arange(1, 26) ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') ecog.apertures[5].plot(ax=ax, color='C0', lw=2) ecog.apertures[10].plot(ax=ax, color='C1', lw=2) ecog.apertures[15].plot(ax=ax, color='C3', lw=2) Ensquared Energy ^^^^^^^^^^^^^^^^ Similar to the encircled energy, the ensquared energy is defined as the fraction of total energy enclosed within a square aperture of a given half-side length, and is commonly used to describe PSF characteristics. The `~photutils.profiles.EnsquaredCurveOfGrowth` class provides two convenience methods to calculate the ensquared energy at a given half-size (or half-sizes) and the half-size corresponding to the given ensquared energy (or energies). These methods are :meth:`~photutils.profiles.EnsquaredCurveOfGrowth.calc_ee_at_half_size` and :meth:`~photutils.profiles.EnsquaredCurveOfGrowth.calc_half_size_at_ee`, respectively. Let's compute the ensquared energy values at a few half-sizes for the ensquared curve of growth we created above:: >>> ecog.normalize(method='max') >>> ee_half_sizes = np.array([3, 6, 9]) >>> ee_vals = ecog.calc_ee_at_half_size(ee_half_sizes) >>> ee_vals # doctest: +FLOAT_CMP array([0.22621785, 0.63299461, 0.88537775]) >>> ecog.calc_half_size_at_ee(ee_vals) # doctest: +FLOAT_CMP array([3., 6., 9.]) Here, we plot the ensquared energy values. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EnsquaredCurveOfGrowth # 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the ensquared curve of growth half_sizes = np.arange(1, 26) ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) ecog.normalize(method='max') ee_half_sizes = np.array([3, 6, 9]) ee_vals = ecog.calc_ee_at_half_size(ee_half_sizes) # Plot the ensquared curve of growth fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax, label='Ensquared Curve of Growth') ecog.plot_error(ax=ax) ax.legend() xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() ax.vlines(ee_half_sizes, ymin, ee_vals, colors='C1', linestyles='dashed') ax.hlines(ee_vals, xmin, ee_half_sizes, colors='C1', linestyles='dashed') ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) for ee_hs, ee_val in zip(ee_half_sizes, ee_vals): ax.text(ee_hs/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') Creating an Elliptical Curve of Growth -------------------------------------- One can also compute a curve of growth using concentric elliptical apertures with a fixed axis ratio and orientation. This is done using the `~photutils.profiles.EllipticalCurveOfGrowth` class, which accepts ``radii`` (the semimajor-axis lengths of the elliptical apertures), ``axis_ratio`` (the ratio of the semiminor axis to the semimajor axis, ``b / a``), and ``theta`` (the rotation angle from the positive ``x`` axis). Let's create an elliptical curve of growth for an elliptical source with an axis ratio of 0.5 and a rotation angle of 42 degrees:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_2dg >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import EllipticalCurveOfGrowth >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig >>> xycen = centroid_2dg(data) >>> radii = np.arange(1, 40) >>> ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, ... theta=np.deg2rad(42), error=error) The elliptical curve of growth profile represents the total flux within the elliptical aperture as a function of semimajor-axis length:: >>> print(ecog.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 26 27 28 29 30 31 32 33 34 35 36 37 38 39] The `~photutils.profiles.EllipticalCurveOfGrowth.profile` and `~photutils.profiles.EllipticalCurveOfGrowth.profile_error` attributes contain output 1D `~numpy.ndarray` objects containing the elliptical curve-of-growth profile and propagated errors:: >>> print(ecog.profile) # doctest: +FLOAT_CMP [ 67.39762867 267.711181 588.47524874 1021.31994307 1546.53867489 2152.12698084 2824.97954482 3541.64650208 4284.363828 5040.93586551 5777.06397177 6488.33779084 7179.10371288 7826.17773764 8418.08027957 8948.7310004 9418.56480323 9810.0373925 10163.11467352 10477.42357537 10731.89184641 10920.11723061 11092.34059512 11235.12552706 11347.43721424 11454.03577845 11520.64656354 11555.89668261 11571.27935302 11583.89774142 11605.79810845 11639.93073462 11648.27293403 11660.34772581 11662.89065496 11643.07787619 11630.36674411 11636.61537567 11636.60448497] >>> print(ecog.profile_error) # doctest: +FLOAT_CMP [ 2.63195969 5.26391938 7.89587907 10.52783875 13.15979844 15.79175813 18.42371782 21.05567751 23.6876372 26.31959688 28.95155657 31.58351626 34.21547595 36.84743564 39.47939533 42.11135501 44.7433147 47.37527439 50.00723408 52.63919377 55.27115346 57.90311314 60.53507283 63.16703252 65.79899221 68.4309519 71.06291159 73.69487127 76.32683096 78.95879065 81.59075034 84.22271003 86.85466972 89.4866294 92.11858909 94.75054878 97.38250847 100.01446816 102.64642785] Here, we plot the normalized elliptical curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EllipticalCurveOfGrowth # Create an artificial elliptical source gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the elliptical curve of growth radii = np.arange(1, 40) ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, theta=np.deg2rad(42), error=error) ecog.normalize(method='max') # Plot the elliptical curve of growth fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax) ecog.plot_error(ax=ax) We can also plot a few of the elliptical 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_2dg from photutils.datasets import make_noise_image from photutils.profiles import EllipticalCurveOfGrowth # Create an artificial elliptical source gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the elliptical curve of growth radii = np.arange(1, 40) ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, theta=np.deg2rad(42), error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') ecog.apertures[5].plot(ax=ax, color='C0', lw=2) ecog.apertures[10].plot(ax=ax, color='C1', lw=2) ecog.apertures[15].plot(ax=ax, color='C3', lw=2) Elliptical Encircled Energy ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Similar to the encircled energy for circular apertures, one can compute the enclosed energy fraction within elliptical apertures. See the `Encircled Energy`_ section above for details on normalization. The `~photutils.profiles.EllipticalCurveOfGrowth` class provides two convenience methods to calculate the enclosed energy at a given semimajor-axis length (or lengths) and the semimajor-axis length corresponding to the given enclosed energy (or energies). These methods are :meth:`~photutils.profiles.EllipticalCurveOfGrowth.calc_ee_at_radius` and :meth:`~photutils.profiles.EllipticalCurveOfGrowth.calc_radius_at_ee`, respectively. Let's compute the enclosed energy values at a few semimajor-axis lengths for the elliptical curve of growth we created above:: >>> ecog.normalize(method='max') >>> ee_rads = np.array([5, 10, 15, 20]) >>> ee_vals = ecog.calc_ee_at_radius(ee_rads) >>> ee_vals # doctest: +FLOAT_CMP array([0.13260338, 0.43222011, 0.72178335, 0.89835564]) >>> ecog.calc_radius_at_ee(ee_vals) # doctest: +FLOAT_CMP array([ 5., 10., 15., 20.]) Here we plot the enclosed energy values. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EllipticalCurveOfGrowth # Create an artificial elliptical source gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the elliptical curve of growth radii = np.arange(1, 40) ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, theta=np.deg2rad(42), error=error) ecog.normalize(method='max') ee_rads = np.array([5, 10, 15, 20]) ee_vals = ecog.calc_ee_at_radius(ee_rads) # Plot the elliptical curve of growth fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax, label='Elliptical Curve of Growth') ecog.plot_error(ax=ax) ax.legend() xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() ax.vlines(ee_rads, ymin, ee_vals, colors='C1', linestyles='dashed') ax.hlines(ee_vals, xmin, ee_rads, colors='C1', linestyles='dashed') ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) for ee_rad, ee_val in zip(ee_rads, ee_vals): ax.text(ee_rad/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') API Reference ------------- :doc:`../reference/profiles_api` astropy-photutils-3322558/docs/user_guide/datasets.rst000066400000000000000000000113421517052111400230640ustar00rootroot00000000000000.. _datasets: Datasets and Simulation (`photutils.datasets`) ============================================== Introduction ------------ `photutils.datasets` provides tools for loading datasets or making simulated data. These tools mostly involve astronomical images, but they also include PSF models and source catalogs. These datasets are useful for the Photutils documentation examples, tests, and benchmarks. However, they can also be used for general data analysis or for users that would like to try out or implement new methods for Photutils. Functions that start with ``make_*`` generate simulated data. Typically one would need to use a combination of these functions to create a simulated image. For example, one might use :func:`~photutils.datasets.make_model_params` to create a table of source parameters, then use :func:`~photutils.datasets.make_model_image` to create an image of the sources, add noise using :func:`~photutils.datasets.make_noise_image`, and finally create a world coordinate system (WCS) using :func:`~photutils.datasets.make_wcs`. An example of this process is shown below. Functions that start with ``load_*`` load datasets, either from within the Photutils package or remotely from a GitHub repository. Very small data files are bundled with Photutils and are guaranteed to be available. Larger datasets are available from the `astropy-data`_ repository. On first load, these larger datasets will be downloaded and placed into the Astropy cache on the user's machine. Simulating Images ----------------- For this example, let's simulate an image of 2D Gaussian sources on a constant background with Gaussian noise. First, we'll create a table of 2D Gaussian source parameters with random positions, fluxes, and shapes using :func:`~photutils.datasets.make_model_params`:: >>> from photutils.datasets import make_model_params >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF() >>> shape = (500, 500) >>> n_sources = 500 >>> params = make_model_params(shape, n_sources, x_name='x_0', ... y_name='y_0', min_separation=5, ... flux=(100, 500), x_fwhm=(1, 3), ... y_fwhm=(1, 3), theta=(0, 90), seed=123) Next, we'll create a simulated image of the sources using the table of model parameters using :func:`~photutils.datasets.make_model_image`:: >>> from photutils.datasets import make_model_image >>> model_shape = (25, 25) >>> data = make_model_image(shape, model, params, model_shape=model_shape, ... x_name='x_0', y_name='y_0') Next, let's add a constant background (``mean = 5``) and Gaussian noise (``stddev = 2``) to the image:: >>> from photutils.datasets import make_noise_image >>> noise = make_noise_image(shape, distribution='gaussian', mean=5, ... stddev=2, seed=123) >>> data += noise Finally, let's plot the simulated image: .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import (make_model_image, make_model_params, make_noise_image) from photutils.psf import GaussianPSF model = GaussianPSF() shape = (500, 500) n_sources = 500 params = make_model_params(shape, n_sources, x_name='x_0', y_name='y_0', min_separation=5, flux=(100, 500), x_fwhm=(1, 3), y_fwhm=(1, 3), theta=(0, 90), seed=123) model_shape = (25, 25) data = make_model_image(shape, model, params, model_shape=model_shape, x_name='x_0', y_name='y_0') noise = make_noise_image(shape, distribution='gaussian', mean=5, stddev=2, seed=123) data += noise fig, ax = plt.subplots() norm = simple_norm(data, 'sqrt', percent=99) ax.imshow(data, norm=norm, origin='lower') ax.set_title('Simulated image') We can also create a simulated world coordinate system (WCS) for the image using :func:`~photutils.datasets.make_wcs`:: >>> from photutils.datasets import make_wcs >>> wcs = make_wcs(shape) >>> wcs.pixel_to_world(0, 0) or a generalized WCS using :func:`~photutils.datasets.make_gwcs`: .. doctest-requires:: gwcs >>> from photutils.datasets import make_gwcs >>> gwcs = make_gwcs(shape) >>> gwcs.pixel_to_world(0, 0) API Reference ------------- :doc:`../reference/datasets_api` .. _astropy-data: https://github.com/astropy/astropy-data/ .. _skymaker: https://github.com/astromatic/skymaker astropy-photutils-3322558/docs/user_guide/detection.rst000066400000000000000000000320261517052111400232340ustar00rootroot00000000000000.. _source_detection: Point-like Source Detection (`photutils.detection`) =================================================== Introduction ------------ One generally needs to identify astronomical sources in the data before performing photometry or other measurements. The `photutils.detection` subpackage provides tools to detect point-like (stellar) sources in an image. This subpackage also provides tools to find 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 for detecting 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` implements the `DAOFIND`_ algorithm (`Stetson 1987, PASP 99, 191 `_). It searches images for local density maxima that have a peak amplitude above a specified threshold (applied to a convolved image) and with size and shape similar to a defined 2D Gaussian kernel. To match the original DAOFIND algorithm, the input ``threshold`` is internally scaled by a factor derived from the convolution kernel. To apply the threshold exactly as given (e.g., when supplying a spatial background-RMS map), set ``scale_threshold=False``. The class also computes roundness and sharpness statistics for detected sources, with configurable lower and upper bounds. :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.IRAFStarFinder` always uses a circular Gaussian kernel whereas :class:`~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. Another difference is that :class:`~photutils.detection.IRAFStarFinder` 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. The usage of :class:`~photutils.detection.IRAFStarFinder` and :class:`~photutils.detection.StarFinder` follows the same pattern as :class:`~photutils.detection.DAOStarFinder` shown below. Replace the class name and adjust the parameters (e.g., ``fwhm`` and ``kernel``) as needed. Note that the ``scale_threshold`` parameter is specific to :class:`~photutils.detection.DAOStarFinder`. Note also that each class returns different output columns. For example, :class:`~photutils.detection.DAOStarFinder` includes ``daofind_mag`` and ``sharpness`` columns, while :class:`~photutils.detection.IRAFStarFinder` includes ``fwhm`` and ``pa`` (position angle) columns. See each class's API documentation for the full list of output columns. As an example, let's load a simulated HST star image and add Gaussian noise. We will then 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_simulated_hst_star_image, ... make_noise_image) >>> hdu = load_simulated_hst_star_image() # doctest: +REMOTE_DATA >>> data = hdu.data + make_noise_image(hdu.data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=0) # 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 [10.44410657 10.39699777 5.09141794] 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 2.5 pixels and have peaks approximately 5 times the background standard deviation above the background (i.e., the threshold is ``5 * std``). The stars in the image are undersampled, so we will slightly relax the ``sharpness_range`` to allow for a wider range of values. Running this class on the data yields an astropy `~astropy.table.QTable` containing the results of the star finder:: >>> from photutils.detection import DAOStarFinder >>> threshold = 5.0 * std # doctest: +REMOTE_DATA >>> daofind = DAOStarFinder(threshold, fwhm=2.5, ... sharpness_range=(0.2, 1.5)) # doctest: +REMOTE_DATA By default, :class:`~photutils.detection.DAOStarFinder` internally scales the input threshold by a factor derived from the convolution kernel to match the original `DAOFIND`_ algorithm. To apply the threshold exactly as given (e.g., when supplying a spatial background-RMS map), set ``scale_threshold=False``:: >>> daofind_unscaled = DAOStarFinder(threshold, fwhm=2.5, ... sharpness_range=(0.2, 1.5), ... scale_threshold=False) # doctest: +REMOTE_DATA Running the finder on the background-subtracted data:: >>> sources = daofind(data - median) # doctest: +REMOTE_DATA >>> for col in sources.colnames: # doctest: +REMOTE_DATA ... if col not in ('id', 'n_pixels'): ... sources[col].info.format = '%.2f' # for consistent table output >>> sources.pprint(max_lines=12, max_width=76) # doctest: +REMOTE_DATA, +FLOAT_CMP id x_centroid y_centroid sharpness ... peak flux mag daofind_mag --- ---------- ---------- --------- ... ------- ------- ------ ----------- 1 848.57 2.15 0.89 ... 1051.78 3999.02 -9.00 -3.80 2 181.85 3.75 0.97 ... 1711.87 5568.78 -9.36 -4.28 3 323.88 3.70 0.96 ... 3005.97 9992.14 -10.00 -4.90 4 99.89 8.95 1.07 ... 1134.12 3236.12 -8.78 -3.77 ... ... ... ... ... ... ... ... ... 497 114.16 993.47 0.84 ... 1577.91 6550.22 -9.54 -4.26 498 298.44 993.87 0.83 ... 644.97 2719.64 -8.59 -3.31 499 207.21 998.15 0.97 ... 2800.62 8406.16 -9.81 -4.83 500 691.03 998.77 1.15 ... 2600.83 5612.72 -9.37 -4.64 Length = 500 rows Let's plot the image and mark the location of detected sources: .. 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 (load_simulated_hst_star_image, make_noise_image) from photutils.detection import DAOStarFinder hdu = load_simulated_hst_star_image() data = hdu.data + make_noise_image(hdu.data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=0) mean, median, std = sigma_clipped_stats(data, sigma=3.0) threshold = 5.0 * std daofind = DAOStarFinder(threshold, fwhm=2.5, sharpness_range=(0.2, 1.5)) sources = daofind(data - median) positions = np.transpose((sources['x_centroid'], sources['y_centroid'])) apertures = CircularAperture(positions, r=10.0) norm = simple_norm(data, 'sqrt', percent=99) fig, ax = plt.subplots() axim = ax.imshow(data, norm=norm, origin='lower') patches = apertures.plot(ax=ax, color='red') Masking Regions ^^^^^^^^^^^^^^^ Regions of the input image can be masked by using the ``mask`` keyword with the :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, or :class:`~photutils.detection.StarFinder` instance. This simple example 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(threshold, fwhm=2.5, sharpness_range=(0.2, 1.5)) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[650:851, 600:851] = True >>> mask[250:451, 150:551] = 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 simple_norm from photutils.aperture import CircularAperture, RectangularAperture from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import DAOStarFinder hdu = load_simulated_hst_star_image() data = hdu.data + make_noise_image(hdu.data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=0) mean, median, std = sigma_clipped_stats(data, sigma=3.0) threshold = 5.0 * std daofind = DAOStarFinder(threshold, fwhm=2.5, sharpness_range=(0.2, 1.5)) mask = np.zeros(data.shape, dtype=bool) mask[650:851, 600:851] = True mask[250:451, 150:551] = True sources = daofind(data - median, mask=mask) positions = np.transpose((sources['x_centroid'], sources['y_centroid'])) apertures = CircularAperture(positions, r=10.0) fig, ax = plt.subplots() norm = simple_norm(data, 'sqrt', percent=99) axim = ax.imshow(data, norm=norm, origin='lower') ax.set_title('Star finder with a mask to exclude regions') p1 = apertures.plot(ax=ax, color='red') rect1 = RectangularAperture((725, 750), 250, 200, theta=0) rect2 = RectangularAperture((350, 350), 400, 200, theta=0) p2 = rect1.plot(ax=ax, color='white', ls='dashed') p3 = rect2.plot(ax=ax, color='white', 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. The ``box_size`` parameter also effectively imposes a minimum separation between detected peaks, since only one peak can be found within each box of that size. Specifically, two peaks must differ by at least ``box_size // 2 + 1`` pixels along each axis. For example, a ``box_size`` of 11 imposes a minimum separation of 6 pixels. As a simple example, let's find the local peaks in the image above that are 5 sigma above the background using a box size of 11 pixels:: >>> from photutils.detection import find_peaks >>> threshold = median + (5.0 * std) # doctest: +REMOTE_DATA >>> tbl = find_peaks(data, threshold, box_size=11) # doctest: +REMOTE_DATA >>> tbl['peak_value'].info.format = '%.8g' # doctest: +REMOTE_DATA >>> print(tbl) # doctest: +REMOTE_DATA, +FLOAT_CMP id x_peak y_peak peak_value --- ------ ------ ---------- 1 849 2 1062.1752 2 182 4 1722.2687 3 324 4 3016.3684 4 100 9 1144.5217 5 824 9 1311.2049 ... ... ... ... 497 889 992 194.27323 498 114 994 1588.3073 499 299 994 655.36699 500 207 998 2811.0195 501 691 999 2611.2233 Length = 501 rows Let's plot the location of the detected peaks in the image: .. 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 (load_simulated_hst_star_image, make_noise_image) from photutils.detection import find_peaks hdu = load_simulated_hst_star_image() data = hdu.data + make_noise_image(hdu.data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=0) 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=10.0) fig, ax = plt.subplots() norm = simple_norm(data, 'sqrt', percent=99) axim = ax.imshow(data, norm=norm, origin='lower') patches = apertures.plot(color='red') API Reference ------------- :doc:`../reference/detection_api` .. _DAOFIND: https://iraf.readthedocs.io/en/latest/tasks/noao/digiphot/apphot/daofind.html .. _starfind: https://iraf.readthedocs.io/en/latest/tasks/images/imcoords/starfind.html astropy-photutils-3322558/docs/user_guide/epsf_building.rst000066400000000000000000000471021517052111400240710ustar00rootroot00000000000000.. _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. The oversampling in the ePSF is crucial because it captures the PSF pixel phase effect. Since stars can land at fractional pixel positions on the detector, the PSF appearance varies depending on the star's position within a pixel. By building an oversampled ePSF, we capture this phase information across the full pixel-to-pixel variation. This allows for more accurate PSF modeling and improved photometric measurements, as the PSF can be interpolated to the exact position of any star. 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 iteratively refines the ePSF model and star positions: the current ePSF is fitted to the stars to improve their centers, and then the ePSF is rebuilt using the improved star positions. 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.). To produce a good ePSF, one should have a reasonably large sample of stars (e.g., several hundred) in order to fully sample the PSF over the oversampled grid and to help reduce the effects of noise. Otherwise, the resulting ePSF may have holes or may be noisy. 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=0) # 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=0) fig, ax = plt.subplots(figsize=(8, 8)) norm = simple_norm(data, 'sqrt', percent=99.0) ax.imshow(data, norm=norm, origin='lower') For this example we'll use the :class:`~photutils.detection.DAOStarFinder` class to identify the brighter stars and their initial positions:: >>> from photutils.detection import DAOStarFinder >>> finder = DAOStarFinder(threshold=100.0, fwhm=1.5) # doctest: +REMOTE_DATA >>> sources = finder(data) # doctest: +REMOTE_DATA >>> for col in sources.colnames: # doctest: +REMOTE_DATA ... if col not in ('id', 'n_pixels'): ... sources[col].info.format = '%.2f' # for consistent table output >>> sources.pprint(max_width=76) # doctest: +REMOTE_DATA id x_centroid y_centroid sharpness ... peak flux mag daofind_mag --- ---------- ---------- --------- ... ------- -------- ------ ----------- 1 848.53 2.15 0.87 ... 1062.18 4258.95 -9.07 -2.41 2 181.85 3.74 0.91 ... 1722.27 5828.71 -9.41 -2.93 3 323.87 3.69 0.91 ... 3016.37 10252.06 -10.03 -3.55 4 99.89 8.95 0.96 ... 1144.52 3496.04 -8.86 -2.47 5 824.12 9.36 0.90 ... 1311.20 4685.32 -9.18 -2.64 ... ... ... ... ... ... ... ... ... 478 888.44 991.86 0.85 ... 194.27 1005.88 -7.51 -0.52 479 114.16 993.40 0.84 ... 1588.31 6810.15 -9.58 -2.84 480 298.36 993.87 0.84 ... 655.37 2979.57 -8.69 -1.88 481 207.21 998.17 0.91 ... 2811.02 8614.10 -9.84 -3.48 482 691.02 998.77 0.98 ... 2611.22 5768.68 -9.40 -3.39 Length = 482 rows Let's show the detected stars overlaid on the image: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import DAOStarFinder hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=0) finder = DAOStarFinder(threshold=100.0, fwhm=1.5) sources = finder(data) fig, ax = plt.subplots(figsize=(8, 8)) norm = simple_norm(data, 'sqrt', percent=99.0) ax.imshow(data, norm=norm, origin='lower') ax.scatter(sources['x_centroid'], sources['y_centroid'], s=80, edgecolor='red', facecolor='none', lw=1.5) 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. Extracting Star Cutouts ----------------------- 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 pixel coordinates, which need to be in table columns called ``x`` and ``y``. We'll extract 25 x 25 pixel cutouts of our selected stars. Let's explicitly exclude stars that are too close to the image boundaries (because they cannot be extracted):: >>> size = 25 >>> hsize = (size - 1) / 2 >>> x = sources['x_centroid'] # doctest: +REMOTE_DATA >>> y = sources['y_centroid'] # 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:: >>> 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` function can also extract stars from multiple images using a separate catalog for each image or a single catalog. When using a single catalog with multiple images, 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:: >>> from photutils.psf import extract_stars >>> stars = extract_stars(nddata, stars_tbl, size=25) # doctest: +REMOTE_DATA The function returns an `~photutils.psf.EPSFStars` object containing the cutouts of our selected stars that will be used to build the 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') .. 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 DAOStarFinder 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=0) finder = DAOStarFinder(threshold=100.0, fwhm=1.5) sources = finder(data) size = 25 hsize = (size - 1) / 2 x = sources['x_centroid'] y = sources['y_centroid'] 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') Constructing the ePSF --------------------- With the star cutouts, 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. 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 options to control the ePSF build process, including changing the recentering function, the smoothing kernel, and the convergence 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:: >>> from photutils.psf import EPSFBuilder >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... progress_bar=False) # doctest: +REMOTE_DATA >>> result = epsf_builder(stars) # doctest: +REMOTE_DATA The :class:`~photutils.psf.EPSFBuilder` returns an `~photutils.psf.EPSFBuildResult` object containing the constructed ePSF, the fitted stars, and detailed information about the build process. This result object supports tuple unpacking, so both of the following work:: >>> # New style: access result attributes >>> epsf = result.epsf # doctest: +REMOTE_DATA >>> fitted_stars = result.fitted_stars # doctest: +REMOTE_DATA >>> # Old style: tuple unpacking still works >>> epsf, fitted_stars = epsf_builder(stars) # doctest: +REMOTE_DATA The `~photutils.psf.EPSFBuildResult` object provides useful diagnostic information about the build process:: >>> result.converged # doctest: +REMOTE_DATA np.False_ >>> result.iterations # doctest: +REMOTE_DATA 3 >>> result.n_excluded_stars # doctest: +REMOTE_DATA 0 The returned ``epsf`` is an `~photutils.psf.ImagePSF` object, and ``fitted_stars`` is a new `~photutils.psf.EPSFStars` object with the updated star positions and fluxes from fitting the final ePSF model. Finally, let's show the constructed ePSF: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> fig, ax = plt.subplots(figsize=(8, 8)) >>> norm = simple_norm(epsf.data, 'log', percent=99.0) >>> axim = ax.imshow(epsf.data, norm=norm, origin='lower') >>> fig.colorbar(axim) .. 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 DAOStarFinder 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=0) finder = DAOStarFinder(threshold=100.0, fwhm=1.5) sources = finder(data) size = 25 hsize = (size - 1) / 2 x = sources['x_centroid'] y = sources['y_centroid'] 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) fig, ax = plt.subplots(figsize=(8, 8)) norm = simple_norm(epsf.data, 'log', percent=99.0) axim = ax.imshow(epsf.data, norm=norm, origin='lower') fig.colorbar(axim) The `~photutils.psf.ImagePSF` object can be used as a PSF model for :ref:`PSF Photometry ` (i.e., `~photutils.psf.PSFPhotometry` or `~photutils.psf.IterativePSFPhotometry`). Customizing the ePSF Builder ---------------------------- The :class:`~photutils.psf.EPSFBuilder` class provides several options to customize the ePSF build process. Smoothing Kernel ^^^^^^^^^^^^^^^^ The ``smoothing_kernel`` parameter controls the smoothing applied to the ePSF during each iteration. The smoothing helps to reduce noise in the ePSF, especially when the number of stars is small. The default is ``'quartic'``, which uses a fourth-degree polynomial kernel. This kernel was initial developed by Anderson and King for HST data with an ePSF oversampling factor of 4. It is designed to provide a good balance between smoothing and preserving the shape of the ePSF. You can also use ``'quadratic'`` for a second-degree polynomial kernel, provide a custom 2D array, or set it to `None` for no smoothing:: >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... smoothing_kernel='quadratic', ... progress_bar=False) # doctest: +REMOTE_DATA Customizing the ePSF Fitting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The :class:`~photutils.psf.EPSFBuilder` class allows you to customize the fitting process using the ``fit_shape`` parameter. This parameter specifies the size of the box (in pixels) centered on each star used for fitting. Using a smaller box can speed up the fitting process while still capturing the core of the PSF:: >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... fit_shape=7, ... progress_bar=False) # doctest: +REMOTE_DATA You can also customize the fitter itself by passing a `~astropy.modeling.fitting.Fitter` instance:: >>> from astropy.modeling.fitting import LMLSQFitter >>> fitter = LMLSQFitter() # doctest: +REMOTE_DATA >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... fitter=fitter, fit_shape=7, ... progress_bar=False) # doctest: +REMOTE_DATA Sigma Clipping ^^^^^^^^^^^^^^ The ``sigma_clip`` parameter controls the sigma clipping applied when stacking the ePSF residuals in each iteration. The default uses sigma clipping with ``sigma=3.0`` and ``maxiters=10``. You can provide your own `~astropy.stats.SigmaClip` instance to customize this behavior:: >>> from astropy.stats import SigmaClip >>> sigclip = SigmaClip(sigma=2.5, maxiters=5) # doctest: +REMOTE_DATA >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... sigma_clip=sigclip, ... progress_bar=False) # doctest: +REMOTE_DATA Including Weights ----------------- If your input `~astropy.nddata.NDData` object contains uncertainty information, the :func:`~photutils.psf.extract_stars` function will automatically create weights for each star cutout. These weights are used during the ePSF fitting process to give more weight to pixels with lower uncertainties. To include weights, provide an ``uncertainty`` attribute in your `~astropy.nddata.NDData` object. The uncertainty can be any of the `~astropy.nddata.NDUncertainty` subclasses (e.g., `~astropy.nddata.StdDevUncertainty`):: >>> from astropy.nddata import StdDevUncertainty >>> uncertainty = StdDevUncertainty(np.sqrt(np.abs(data))) # doctest: +REMOTE_DATA, +SKIP >>> nddata = NDData(data=data, uncertainty=uncertainty) # doctest: +REMOTE_DATA, +SKIP Linked Stars for Dithered Images -------------------------------- When building an ePSF from multiple dithered images, you can link stars across images to ensure they are constrained to have the same sky coordinates. This is done by providing a single catalog with sky coordinates and multiple `~astropy.nddata.NDData` objects, each with a valid WCS. The :func:`~photutils.psf.extract_stars` function will create `~photutils.psf.LinkedEPSFStar` objects that link the corresponding star cutouts from each image. During the ePSF building process, linked stars are constrained to have the same sky coordinate across all images. .. doctest-skip:: >>> from astropy.coordinates import SkyCoord >>> catalog = Table() >>> catalog['skycoord'] = SkyCoord(ra=[...]*u.deg, dec=[...]*u.deg) >>> stars = extract_stars([nddata1, nddata2], catalog, size=25) astropy-photutils-3322558/docs/user_guide/geometry.rst000066400000000000000000000007161517052111400231120ustar00rootroot00000000000000Geometry Functions (`photutils.geometry`) ========================================= Introduction ------------ The `photutils.geometry` package contains low-level geometry functions used by aperture photometry to calculate the overlap of aperture shapes with a pixel grid. These functions are not intended to be used directly by users, but are used by the higher-level `photutils.aperture` tools. API Reference ------------- :doc:`../reference/geometry_api` astropy-photutils-3322558/docs/user_guide/grouping.rst000066400000000000000000000266361517052111400231220ustar00rootroot00000000000000.. _source-grouping: Source Grouping =============== Introduction ------------ In Point Spread Function (PSF) photometry, the accuracy of measuring a source's brightness can be compromised by the light from nearby sources. When sources are close to each other, their individual light profiles overlap, affecting the fit of the PSF model used for measurement. To address this, a grouping algorithm can be employed to combine neighboring sources into distinct sets that are then analyzed simultaneously. The primary objective of this grouping is to ensure that the light from any source within one group does not significantly spill over into the area where a source in another group is being measured. This method of creating and analyzing smaller groups of sources is more computationally efficient than attempting to fit a model to all the sources in an image at once, a task that is often impractical, especially in densely populated star fields. A straightforward method for this grouping was introduced by `Stetson (1987) `_. This algorithm determines whether a given source's light profile interferes with that of any other source by using a "critical separation" parameter. This parameter sets the minimum distance required between two source for them to be placed in separate groups. Typically, this critical separation is defined as a multiple of the stellar full width at half maximum (FWHM), which is a measure of the source's apparent size. Getting Started --------------- To group sources, Photutils includes a tool called :class:`~photutils.psf.SourceGrouper`. This class organizes sources into groups by applying a technique known as hierarchical agglomerative clustering, which uses a distance-based criterion. This functionality is implemented using the `scipy.cluster.hierarchy.fclusterdata` function from the SciPy library. Typically, to group sources during PSF fitting, one would provide a :class:`~photutils.psf.SourceGrouper` object, configured with a minimum separation distance, directly to one of the PSF photometry classes. However, for the purpose of illustration, we will show how to use the :class:`~photutils.psf.SourceGrouper` class independently to group stars within a sample image. The first step is to generate a simulated astronomical image that contains sources modeled as 2D Gaussians, which we will accomplish using the `~photutils.psf.make_psf_model_image` function:: >>> from photutils.datasets import make_noise_image >>> from photutils.psf import CircularGaussianPRF, make_psf_model_image >>> shape = (256, 256) >>> fwhm = 4.7 >>> psf_model = CircularGaussianPRF(fwhm=fwhm) >>> 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) >>> noise = make_noise_image(shape, mean=0, stddev=2, seed=123) >>> data += noise The `~photutils.psf.make_psf_model_image` provides two outputs: the image itself, which we call ``data``, and a table containing the positions and fluxes of the stars, which we call ``stars``. The x and y coordinates of the stars are located in the ``x_0`` and ``y_0`` columns of this table. Let's display the image: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> fig, ax = plt.subplots(figsize=(8, 8)) >>> ax.imshow(data, origin='lower') .. plot:: import matplotlib.pyplot as plt from photutils.datasets import make_noise_image from photutils.psf import CircularGaussianPRF, make_psf_model_image shape = (256, 256) fwhm = 4.7 psf_model = CircularGaussianPRF(fwhm=fwhm) 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) noise = make_noise_image(shape, mean=0, stddev=2, seed=123) data += noise fig, ax = plt.subplots(figsize=(8, 8)) ax.imshow(data, origin='lower') With the simulated data ready, we can now identify groups of stars. The first step is to create a `~photutils.psf.SourceGrouper` object. For this example, we will define the minimum separation between stars in different groups (``min_separation``) as 2.5 times the full width at half maximum (FWHM). The FWHM value is known because it was used to create the simulated stars. In a real-world scenario, you would first need to measure the FWHM from the actual star images:: >>> from photutils.psf import SourceGrouper >>> fwhm = 4.7 >>> min_separation = 2.5 * fwhm >>> grouper = SourceGrouper(min_separation) After initializing the `~photutils.psf.SourceGrouper`, we apply it to the x and y coordinates of the stars. While we are using the known, true positions from our simulated data, you would typically use a source detection tool to find the star positions in an actual image:: >>> import numpy as np >>> x = np.array(stars['x_0']) >>> y = np.array(stars['y_0']) >>> group_ids = grouper(x, y) >>> print(group_ids[:20]) # first 20 group IDs [ 1 2 3 4 5 6 7 8 9 10 11 4 6 3 12 13 14 15 16 17] The result of this process is an array of integers, ``group_id``, where each integer represents the group to which the corresponding star. Stars that share the same group ID are considered part of the same group. When performing PSF photometry, you can add the group IDs to the initial parameters table (``init_params``) that is passed to the photometry tool. If you provide these group IDs, a `~photutils.psf.SourceGrouper` does not need to be passed to the photometry class, as the grouping will already be defined. Returning a SourceGroups Object ------------------------------- Alternatively, you can set the ``return_groups_object`` keyword to `True` when calling the `~photutils.psf.SourceGrouper` object, and it will return a `~photutils.psf.SourceGroups` object instead of an array of integers:: >>> groups = grouper(x, y, return_groups_object=True) >>> print(type(groups)) In this case, ``groups`` is a `~photutils.psf.SourceGroups` object that contains the grouping results and provides convenient methods for analysis. This object stores the source coordinates, group IDs, and provides properties and methods to analyze the grouping. The grouping algorithm separated the 100 stars into 65 distinct groups:: >>> print(groups.n_groups) 65 You can access the group IDs directly from the ``groups`` attribute, which is an array of integers corresponding to the input star coordinates. Stars with the same group ID belong to the same group:: >>> print(groups.groups[:20]) # first 20 group IDs [ 1 2 3 4 5 6 7 8 9 10 11 4 6 3 12 13 14 15 16 17] Similar to above, you can add the group IDs from ``groups.groups`` to the initial parameters table (``init_params``) that is passed to the photometry tool to define the source grouping. To find the positions of the stars in group 3, you can use the `~photutils.psf.SourceGroups.get_group_sources` method:: >>> x_group3, y_group3 = groups.get_group_sources(3) >>> print(x_group3, y_group3) [60.32708921 58.73063714] [147.24184586 158.0612346 ] The `~photutils.psf.SourceGroups` object also provides useful properties and methods to analyze the grouping results:: >>> # Get the size of each group for each source >>> sizes = groups.sizes >>> print(f'Group sizes: {sizes[:5]}') # first 5 Group sizes: [1 2 2 5 2] >>> # Get the mapping of group IDs to group sizes >>> size_map = groups.size_map >>> print(f'Size map: {list(size_map.items())[:5]}') # first 5 Size map: [(1, 1), (2, 2), (3, 2), (4, 5), (5, 2)] >>> print(f'Largest group size: {max(size_map.values())}') Largest group size: 5 >>> # Get a list of group IDs that have the largest group size >>> largest_group_ids = ([gid for gid, size in size_map.items() ... if size == max(size_map.values())]) >>> print(f'Largest group IDs: {largest_group_ids}') Largest group IDs: [4] >>> # Get the centroid of group 5 >>> xy_center = groups.group_centers[5] >>> print(f'Group 5 center: {xy_center}') # doctest: +FLOAT_CMP Group 5 center: (48.35899721341876, 73.85258893310564) To visualize the results, we can use the `~photutils.psf.SourceGroups.plot` method, which draws color-coded circles around each star to show which stars have been grouped together: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> fig, ax = plt.subplots(figsize=(8, 8)) >>> ax.imshow(data, origin='lower') >>> groups.plot(radius=fwhm, ax=ax, lw=2, seed=123) .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.datasets import make_noise_image from photutils.psf import (CircularGaussianPRF, SourceGrouper, make_psf_model_image) shape = (256, 256) psf_shape = (11, 11) border_size = (7, 7) flux = (500, 1000) fwhm = 4.7 psf_model = CircularGaussianPRF(fwhm=fwhm) 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) noise = make_noise_image(shape, mean=0, stddev=2, seed=123) data += noise 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, return_groups_object=True) fig, ax = plt.subplots(figsize=(8, 8)) ax.imshow(data, origin='lower') groups.plot(radius=fwhm, ax=ax, lw=2, seed=123) You can also label each group with its ID by setting the ``label_groups`` keyword: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> fig, ax = plt.subplots(figsize=(8, 8)) >>> ax.imshow(data, origin='lower') >>> groups.plot(radius=fwhm, ax=ax, lw=2, seed=123, label_groups=True, label_offset=(6, 6)) .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.datasets import make_noise_image from photutils.psf import (CircularGaussianPRF, SourceGrouper, make_psf_model_image) shape = (256, 256) psf_shape = (11, 11) border_size = (7, 7) flux = (500, 1000) fwhm = 4.7 psf_model = CircularGaussianPRF(fwhm=fwhm) 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) noise = make_noise_image(shape, mean=0, stddev=2, seed=123) data += noise 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, return_groups_object=True) fig, ax = plt.subplots(figsize=(8, 8)) ax.imshow(data, origin='lower') groups.plot(radius=fwhm, ax=ax, lw=2, seed=123, label_groups=True, label_offset=(6, 6)) astropy-photutils-3322558/docs/user_guide/index.rst000066400000000000000000000026111517052111400223620ustar00rootroot00000000000000.. _user_guide: ********** User Guide ********** Photutils is a Python library that provides commonly-used tools and key functionality for detecting and performing photometry of astronomical sources. Photutils is organized into subpackages covering different topics, which are listed below. Backgrounds ----------- .. toctree:: :maxdepth: 1 background.rst Centroids --------- .. toctree:: :maxdepth: 1 centroids.rst Source Detection ---------------- .. toctree:: :maxdepth: 1 detection.rst General Source Detection and Extraction (photutils.segmentation) Segmentation and Source Measurements ------------------------------------ .. toctree:: :maxdepth: 1 segmentation.rst morphology.rst Aperture Photometry ------------------- .. toctree:: :maxdepth: 1 aperture.rst PSF Photometry -------------- .. toctree:: :maxdepth: 1 psf.rst epsf_building.rst grouping.rst PSF Matching ------------ .. toctree:: :maxdepth: 1 psf_matching.rst Profiles -------- .. toctree:: :maxdepth: 1 radial_profiles.rst curves_of_growth.rst Elliptical Isophotes -------------------- .. toctree:: :maxdepth: 1 isophote.rst Datasets and Simulation ----------------------- .. toctree:: :maxdepth: 1 datasets.rst Utilities --------- .. toctree:: :maxdepth: 1 utils.rst geometry.rst astropy-photutils-3322558/docs/user_guide/isophote.rst000066400000000000000000000244251517052111400231140ustar00rootroot00000000000000Elliptical 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=np.deg2rad(40.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=np.deg2rad(40.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 fig, ax = plt.subplots() ax.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=np.deg2rad(20.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), ... theta=geometry.pa) >>> fig, ax = plt.subplots() >>> ax.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=np.deg2rad(40.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=np.deg2rad(20.0)) aper = EllipticalAperture((geometry.x0, geometry.y0), geometry.sma, geometry.sma * (1 - geometry.eps), theta=geometry.pa) fig, ax = plt.subplots() ax.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=geometry) To perform the elliptical isophote fit, we run the :meth:`~photutils.isophote.Ellipse.fit_image` method:: >>> 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):: >>> 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``):: >>> print(isolist.to_table()) # doctest: +SKIP sma intens intens_err ... flag n_iter 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=np.deg2rad(40.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=np.deg2rad(20.0)) ellipse = Ellipse(data, geometry=geometry) isolist = ellipse.fit_image() fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(8, 8)) fig.subplots_adjust(hspace=0.35, wspace=0.35) ax1.errorbar(isolist.sma, isolist.eps, yerr=isolist.ellip_err, fmt='o', markersize=4) ax1.set_xlabel('Semimajor Axis Length (pix)') ax1.set_ylabel('Ellipticity') ax2.errorbar(isolist.sma, np.rad2deg(isolist.pa), yerr=np.rad2deg(isolist.pa_err), fmt='o', markersize=4) ax2.set_xlabel('Semimajor Axis Length (pix)') ax2.set_ylabel('PA (deg)') ax3.errorbar(isolist.sma, isolist.x0, yerr=isolist.x0_err, fmt='o', markersize=4) ax3.set_xlabel('Semimajor Axis Length (pix)') ax3.set_ylabel('x0') ax4.errorbar(isolist.sma, isolist.y0, yerr=isolist.y0_err, fmt='o', markersize=4) ax4.set_xlabel('Semimajor Axis Length (pix)') ax4.set_ylabel('y0') We can build an elliptical model image from the `~photutils.isophote.IsophoteList` object using the :func:`~photutils.isophote.build_ellipse_model` function:: >>> 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=np.deg2rad(40.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=np.deg2rad(20.0)) ellipse = Ellipse(data, geometry=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 `_ API Reference ------------- :doc:`../reference/isophote_api` .. toctree:: :hidden: isophote_faq.rst astropy-photutils-3322558/docs/user_guide/isophote_faq.rst000066400000000000000000000225011517052111400237340ustar00rootroot00000000000000.. _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. astropy-photutils-3322558/docs/user_guide/morphology.rst000066400000000000000000000145421517052111400234600ustar00rootroot00000000000000Morphological Properties (`photutils.morphology`) ================================================= Introduction ------------ The `photutils.morphology` subpackage provides tools to calculate the morphological properties of sources in an image. These properties include the shape, size, and orientation of sources, as well as the Gini coefficient of the flux distribution. The morphological properties can be used to characterize sources and to define apertures for photometry. For example, the shape and orientation of a source can be used to define an elliptical aperture that approximates the isophotal extent of the source. The two main functions in the `photutils.morphology` subpackage are :func:`~photutils.morphology.data_properties` and :func:`~photutils.morphology.gini`. The former calculates the basic morphological properties of a source in a cutout image, while the latter calculates the Gini coefficient of the distribution of absolute flux values in a cutout image. Both functions can be used with an optional boolean mask to exclude pixels from the calculation. Data Properties --------------- The :func:`~photutils.morphology.data_properties` function can be used to calculate the basic morphological properties (e.g., centroid, semimajor and semiminor axis lengths, orientation) of a single source in a cutout image. :func:`~photutils.morphology.data_properties` returns a scalar :class:`~photutils.segmentation.SourceCatalog` object (single source). Please see :class:`~photutils.segmentation.SourceCatalog` for the list of the many properties that are calculated. Let's extract a single object from a synthetic dataset and 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()[40:80, 75:105] >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> data -= median # subtract background Then, use :func:`~photutils.morphology.data_properties` to calculate its properties. We define a mask to isolate the source pixels by excluding pixels below a flux threshold:: >>> from photutils.morphology import data_properties >>> mask = data < 50 # isolate source pixels >>> cat = data_properties(data, mask=mask) The morphological properties are stored in a scalar :class:`~photutils.segmentation.SourceCatalog` object, which can be converted to an `astropy.table.Table` object for easier access and display. For example, we can display the centroid, semimajor and semiminor axis lengths, and orientation of the source:: >>> columns = ['label', 'x_centroid', 'y_centroid', 'semimajor_axis', ... 'semiminor_axis', 'orientation'] >>> tbl = cat.to_table(columns=columns) >>> tbl['x_centroid'].info.format = '.6f' # optional format >>> tbl['y_centroid'].info.format = '.6f' >>> tbl['semimajor_axis'].info.format = '.6f' >>> tbl['semiminor_axis'].info.format = '.6f' >>> tbl['orientation'].info.format = '.6f' >>> print(tbl) label x_centroid y_centroid semimajor_axis semiminor_axis orientation pix pix deg ----- ---------- ---------- -------------- -------------- ----------- 1 15.020335 20.087603 5.597273 3.226091 59.689629 Now let's use the measured morphological properties to define an approximate isophotal ellipse for the source: .. plot:: import astropy.units as u import matplotlib.pyplot as plt import numpy as np from photutils.aperture import EllipticalAperture from photutils.datasets import make_4gaussians_image from photutils.morphology import data_properties slc = np.s_[40:80, 75:105] data = make_4gaussians_image()[slc] # extract single object mask = data < 50 cat = data_properties(data, mask=mask) columns = ['label', 'x_centroid', 'y_centroid', 'semimajor_axis', 'semiminor_axis', 'orientation'] tbl = cat.to_table(columns=columns) r = 2.5 # approximate isophotal extent xypos = (cat.x_centroid, cat.y_centroid) a = cat.semimajor_axis.value * r b = cat.semiminor_axis.value * r theta = cat.orientation.to(u.rad).value apertures = EllipticalAperture(xypos, a, b, theta=theta) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower') apertures.plot(ax=ax, color='C3', lw=2) dx_major = a * np.cos(theta) dy_major = a * np.sin(theta) color = 'C1' width = 0.2 ax.arrow(cat.x_centroid, cat.y_centroid, dx_major, dy_major, color=color, length_includes_head=True, width=width) theta2 = theta + np.pi / 2 dx_minor = b * np.cos(theta2) dy_minor = b * np.sin(theta2) ax.arrow(cat.x_centroid, cat.y_centroid, dx_minor, dy_minor, color=color, length_includes_head=True, width=width) Gini Coefficient ---------------- The :func:`~photutils.morphology.gini` function can be used to calculate the Gini coefficient of a source in an image. The Gini coefficient is a measure of the inequality in the distribution of flux values in an image. The Gini coefficient ranges from 0 to 1, where 0 indicates that the flux is equally distributed among all pixels and 1 indicates that the flux is concentrated in a single pixel. The Gini coefficient can be used to characterize the concentration of flux in a source and to compare the morphological properties of different sources. For example, a source with a high Gini coefficient may be more compact and have a more concentrated flux distribution than a source with a low Gini coefficient. The :func:`~photutils.morphology.gini` function calculates the Gini coefficient of the distribution of absolute flux values of a single source using the values in a cutout image. The input array may be 1D or 2D. Negative pixel values are used via their absolute value. Invalid values (NaN and inf) are automatically excluded from the calculation. An optional boolean mask can be used to exclude pixels from the calculation. Let's calculate the Gini coefficient of the source in the above example:: >>> from photutils.morphology import gini >>> g = gini(data, mask=mask) >>> print(g) 0.21943786993407582 API Reference ------------- :doc:`../reference/morphology_api` astropy-photutils-3322558/docs/user_guide/psf.rst000066400000000000000000001075061517052111400220540ustar00rootroot00000000000000.. _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 in slightly varied ways, especially when colloquial usage is taken into account. The `photutils.psf` package aims to be 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. The pixel response function is sometimes called the "PRF", but we do not use that acronym here to avoid confusion with the "Point Response Function" (see below). The convolution of the PSF and pixel response function, when discretized onto the detector (i.e., a rectilinear grid), is the effective PSF (ePSF) or Point Response Function (PRF). The PRF terminology is sometimes used to emphasize that the model function describes the response of the detector to a point source, rather than the intrinsic instrumental PSF (e.g., see the `Spitzer Space Telescope MOPEX documentation `_). In many cases the PSF/PRF/ePSF distinction is unimportant, and the PSF/PRF/ePSF is simply called the "PSF" model. However, 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 `_. 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. In the ``photutils.psf`` package, we use "PSF photometry" in this way, as a shorthand for the general approach. PSF Photometry Overview ----------------------- 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 new sources are detected in the residual image after the fit sources are subtracted. The iterative process can be useful for crowded fields where sources are blended. A ``mode`` keyword is provided to control the behavior of the iterative process, where either all sources or only the newly-detected sources are fit in subsequent iterations. 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 source-finding step is controlled by the ``finder`` keyword, where one inputs a callable function or class instance. Typically, this would be one of the source-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. Sources that grouped are fit simultaneously. The reason behind the construction of groups and not fitting all sources simultaneously is illustrated as follows: imagine that one would like to fit 300 sources and the model for each source has three parameters to be fitted. If one constructs a single model to fit the 300 sources 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 sources effectively reduces the dimension of the parameter space, which facilitates the optimization process. For more details see :ref:`source-grouping`. The local background around each source can optionally be subtracted using the ``local_bkg_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.TRFLSQFitter`, 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 part of the source 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``. Alternatively, the initial positions and fluxes can be 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 is reached). 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. .. _psf-models: PSF Models ---------- As mentioned above, PSF photometry fundamentally involves fitting models to data. As such, the PSF model is a critical component of PSF photometry. For accurate results, both for photometry and astrometry, the PSF model should be a good representation of the actual data. The PSF model can be a simple analytic function, such as a 2D Gaussian or Moffat profile, or it can be a more complex model derived from a 2D PSF image, e.g., an effective PSF (ePSF). The PSF model can also encapsulate changes in the PSF across the detector, e.g., due to optical aberrations. For image-based PSF models, the PSF model is typically derived from observed data or from detailed optical modeling. The PSF model can be a single PSF model for the entire image or a grid of PSF models at fiducial detector positions. Image-based PSF models are also often oversampled with respect to the pixel grid to increase the accuracy of fitting the PSF model. The observatory that obtained the data may provide tools for creating PSF models for their data or an empirical library of PSF models based on previous observations. For example, the `Hubble Space Telescope `_ provides libraries of empirical PSF models for ACS and WFC3 (e.g., `WFC3 PSF Search `_). Similarly, the `James Webb Space Telescope `_ and the `Nancy Grace Roman Space Telescope `_ provide the `STPSF `_ Python software for creating PSF models. In particular, WebbPSF outputs gridded PSF models directly as Photutils `~photutils.psf.GriddedPSFModel` instances. If you cannot obtain a PSF model from an empirical library or observatory-provided tool, Photutils provides tools for creating an empirical PSF model from the data itself, provided you have a large number of isolated stars. Please see :ref:`build-epsf` for more information and an example. The `photutils.psf` subpackage provides several PSF models that can be used for PSF photometry. The PSF models are based on the :ref:`Astropy models and fitting ` framework. The PSF models are used as input (via the ``psf_model`` parameter) to the PSF photometry classes `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. The PSF models are fitted to the data using an Astropy fitter class. Typically, the model position (``x_0`` and ``y_0``) and flux (``flux``) parameters are varied during the fitting process. The PSF model can also include additional parameters, such as the full width at half maximum (FWHM) or sigma of a Gaussian PSF or the alpha and beta parameters of a Moffat PSF. By default, these additional parameters are "fixed" (i.e., not varied during the fitting process). The user can choose to also vary these parameters by setting the ``fixed`` attribute on the model parameter to `False`. The position and/or flux parameters can also be fixed during the fitting process if needed, e.g., for forced photometry (see :ref:`psf-forced-photometry`). Any of the model parameters can also be bounded during the fitting process (see :ref:`psf-bounded-parameters`). You can also create your own custom PSF model using the Astropy modeling framework. The PSF model must be a 2D model that is a subclass of `~astropy.modeling.Fittable2DModel`. It must have parameters called ``x_0``, ``y_0``, and ``flux``, specifying the central position and total integrated flux. Analytic PSF Models ^^^^^^^^^^^^^^^^^^^ The `photutils.psf` subpackage provides the following analytic PSF models: - `~photutils.psf.GaussianPSF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and full width at half maximum (FWHM) along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPSF`: a circular 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.GaussianPRF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.CircularGaussianSigmaPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and sigma (standard deviation). - `~photutils.psf.MoffatPSF`: a 2D Moffat PSF model parameterized in terms of the position, total flux, :math:`\alpha`, and :math:`\beta` parameters. - `~photutils.psf.AiryDiskPSF`: a 2D Airy disk PSF model parameterized in terms of the position, total flux, and radius of the first dark ring. Note there are two types of defined models, PSF and PRF models. The PSF models are evaluated by sampling the analytic function at the input (x, y) coordinates. The PRF models are evaluated by integrating the analytic function over the pixel areas. If one needs a custom PRF model based on an analytical PSF model, an efficient option is to first discretize the model on a grid using :func:`~astropy.convolution.discretize_model` with the ``'oversample'`` or ``'integrate'`` mode. The resulting 2D image can then be used as the input to `~photutils.psf.ImagePSF` (see :ref:`psf-image_models` below) to create an ePSF model. Note that the non-circular Gaussian and Moffat models above have additional parameters beyond the standard PSF model parameters of position and flux (``x_0``, ``y_0``, and ``flux``). By default, these other parameters are "fixed" (i.e., not varied during the fitting process). The user can choose to also vary these parameters by setting the ``fixed`` attribute on the model parameter to `False`. Photutils also provides a convenience function called :func:`~photutils.psf.make_psf_model` that creates a PSF model from an Astropy fittable 2D model. However, it is recommended that one use the PSF models provided by `photutils.psf` as they are optimized for PSF photometry. If a custom PSF model is needed, one can be created using the Astropy modeling framework that will provide better performance than using :func:`~photutils.psf.make_psf_model`. .. _psf-image_models: Image-based PSF Models ^^^^^^^^^^^^^^^^^^^^^^ Image-based PSF models are typically derived from observed data or from detailed optical modeling. The PSF model can be a single PSF model for the entire image or a grid of PSF models at fiducial detector positions, which are then interpolated for specific locations. The model classes below provide the tools needed to perform PSF photometry within Photutils using the Astropy modeling and fitting framework. The user must provide the image-based PSF model as an input to these classes. The input image(s) can be oversampled to increase the accuracy of the PSF model. - `~photutils.psf.ImagePSF`: a general class for image-based PSF models that allows for intensity scaling and translations. - `~photutils.psf.GriddedPSFModel`: a PSF model that contains a grid of image-based ePSF models at fiducial detector positions. .. _psf-photometry-examples: PSF Photometry Examples ----------------------- 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:: >>> import numpy as np >>> from photutils.datasets import make_noise_image >>> from photutils.psf import CircularGaussianPRF, make_psf_model_image >>> psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) >>> 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 CircularGaussianPRF, make_psf_model_image psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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 fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') ax.set_title('Simulated Data') fig.colorbar(axim) Fitting multiple sources ^^^^^^^^^^^^^^^^^^^^^^^^ Now let's use `~photutils.psf.PSFPhotometry` to perform PSF photometry on the sources 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 step 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 source will be fit using an `~photutils.psf.CircularGaussianPRF` PSF model. First, let's create an instance of the `~photutils.psf.PSFPhotometry` class:: >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import PSFPhotometry >>> psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) >>> 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:: >>> 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:: >>> 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.0091 2 29.0865 25.6111 536.5793 3 79.6281 28.7487 618.7642 4 63.2340 48.6408 563.3437 5 88.8848 54.1202 619.8904 6 79.8763 61.1380 648.1658 7 90.9606 72.0861 601.8593 8 7.8038 78.5734 635.6317 9 5.5350 89.8870 539.6831 10 71.8414 90.5842 692.3373 Let's create the residual image:: >>> resid = psfphot.make_residual_image(data) 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 (CircularGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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 = CircularGaussianPRF(flux=1, fwhm=2.7) 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) fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15, 5)) norm = simple_norm(data, 'sqrt', percent=99) ax[0].imshow(data, norm=norm, origin='lower') ax[1].imshow(data - resid, norm=norm, origin='lower') im = ax[2].imshow(resid, norm=norm, origin='lower') ax[0].set_title('Data') ax[1].set_title('Model') ax[2].set_title('Residual Image') fig.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):: >>> psfphot.finder_results['x_centroid'].info.format = '.4f' # optional format >>> psfphot.finder_results['y_centroid'].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' >>> psfphot.finder_results['daofind_mag'].info.format = '.4f' >>> print(psfphot.finder_results) # doctest: +FLOAT_CMP id x_centroid y_centroid sharpness ... peak flux mag daofind_mag --- ---------- ---------- --------- ... ------- -------- ------- ----------- 1 54.5299 7.7460 0.6006 ... 53.5953 476.3221 -6.6948 -2.1093 2 29.0927 25.5992 0.5955 ... 57.1982 499.4443 -6.7462 -2.1958 3 79.6185 28.7515 0.5957 ... 65.7175 574.1382 -6.8975 -2.3401 4 63.2485 48.6134 0.5802 ... 58.3985 521.4656 -6.7931 -2.2209 5 88.8820 54.1311 0.5948 ... 69.1869 576.2842 -6.9016 -2.4379 6 79.8727 61.1208 0.6216 ... 74.0935 612.8353 -6.9684 -2.4799 7 90.9621 72.0803 0.6167 ... 68.4157 561.7163 -6.8738 -2.4035 8 7.7962 78.5465 0.5979 ... 66.2173 595.6881 -6.9375 -2.3167 9 5.5858 89.8664 0.5741 ... 54.3786 505.6093 -6.7595 -2.1188 10 71.8303 90.5624 0.6038 ... 73.5747 639.9299 -7.0153 -2.4516 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 source at ``(x, y) = (63, 49)``. 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:: >>> 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:: >>> 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 source 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 (CircularGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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 = CircularGaussianPRF(flux=1, fwhm=2.7) 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) 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, norm=norm, origin='lower') ax[1].imshow(data - resid, norm=norm, origin='lower') im = ax[2].imshow(resid, norm=norm, 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') fig.tight_layout() .. _psf-forced-photometry: Forced Photometry (Fixed Model Parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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:: >>> psf_model2 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model2.x_0.fixed = True >>> psf_model2.y_0.fixed = True >>> psf_model2.fixed {'flux': False, 'x_0': True, 'y_0': True, 'fwhm': 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``):: >>> 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:: >>> 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.5067 63.0 49.0 500.2997 .. _psf-bounded-parameters: Bounded Model Parameters ^^^^^^^^^^^^^^^^^^^^^^^^ The astropy modeling and fitting framework also allows for bounding the parameter values during the fitting process. However, not all astropy "Fitter" classes support parameter bounds. Please see `Fitting Model to Data `_ for more details. The model parameter bounds apply to all sources in the image, thus this mechanism cannot be used to bound the x and y positions of individual sources. However, the x and y positions can be bounded for individual sources during the fitting by using the ``xy_bounds`` keyword in `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. This keyword accepts a tuple of floats representing the maximum distance in pixels that a fitted source can be from its initial (x, y) position. For example, you may want to constrain the flux of a source to be between certain values or ensure that it is a non-negative value. This can be done by setting the ``bounds`` attribute on the input PSF model parameters. Here we constrain the flux to be greater than or equal to 0:: >>> psf_model3 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model3.flux.bounds = (0, None) >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} The model parameter ``bounds`` can also be set using the ``min`` and/or ``max`` attributes. Here we set the minimum flux to be 0:: >>> psf_model3.flux.min = 0 >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} For this example, let's constrain the flux value to be between 400 and 600:: >>> psf_model3 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model3.flux.bounds = (400, 600) >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (400.0, 600.0), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} 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 sources that are within 20 pixels of each other:: >>> 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 seven groups were identified. The sources in each group were simultaneously fit:: >>> 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 source groups. Simultaneously fitting very large source groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds a threshold defined by the ``group_warning_threshold`` keyword. Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To subtract a local background from each source, define a `~photutils.background.LocalBackground` instance and input it via the ``local_bkg_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):: >>> from photutils.background import LocalBackground, MMMBackground >>> bkgstat = MMMBackground() >>> local_bkg_estimator = LocalBackground(5, 10, bkg_estimator=bkgstat) >>> finder = DAOStarFinder(10.0, 2.0) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... grouper=grouper, aperture_radius=4, ... local_bkg_estimator=local_bkg_estimator) >>> phot = psfphot(data, error=error) The local background values are output in the table:: >>> phot['local_bkg'].info.format = '.4f' # optional format >>> print(phot[('id', 'local_bkg')]) # doctest: +FLOAT_CMP id local_bkg --- --------- 1 -0.0839 2 0.1784 3 0.2593 4 -0.0574 5 0.2492 6 -0.0818 7 -0.1130 8 -0.2166 9 0.0102 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 sources in the image. This class is useful for crowded fields where faint sources are very close to bright sources. The faint sources may not be detected until after the bright sources are subtracted. For this simple example, let's input a table of three sources for the first fit iteration. Subsequent iterations will use the ``finder`` to find additional sources:: >>> from photutils.background import LocalBackground, MMMBackground >>> from photutils.psf import IterativePSFPhotometry >>> fit_shape = (5, 5) >>> finder = DAOStarFinder(10.0, 2.0) >>> bkgstat = MMMBackground() >>> local_bkg_estimator = LocalBackground(5, 10, bkg_estimator=bkgstat) >>> init_params = QTable() >>> init_params['x'] = [54, 29, 80] >>> init_params['y'] = [8, 26, 29] >>> psfphot2 = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, ... local_bkg_estimator=local_bkg_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:: >>> 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.2650 2 1 29.0883 25.6092 534.0850 3 1 79.6273 28.7480 613.0496 4 2 63.2340 48.6415 564.1528 5 2 88.8856 54.1203 615.4907 6 2 79.8765 61.1359 649.9589 7 2 90.9631 72.0880 603.7433 8 2 7.8203 78.5821 641.8223 9 2 5.5350 89.8870 539.5237 10 2 71.8485 90.5830 687.4573 Estimating the FWHM of sources ------------------------------ The `photutils.psf` package also provides a convenience function called `~photutils.psf.fit_fwhm` to estimate the full width at half maximum (FWHM) of one or more sources in an image. This function fits the source(s) with a circular 2D Gaussian PRF model (`~photutils.psf.CircularGaussianPRF`) using the `~photutils.psf.PSFPhotometry` class. If your sources are not circular or non-Gaussian, you can fit your sources using the `~photutils.psf.PSFPhotometry` class using a different PSF model. For example, let's estimate the FWHM of the sources in our example image defined above:: >>> from photutils.psf import fit_fwhm >>> finder = DAOStarFinder(6.0, 2.0) >>> finder_tbl = finder(data) >>> xypos = list(zip(finder_tbl['x_centroid'], finder_tbl['y_centroid'])) >>> fwhm = fit_fwhm(data, xypos=xypos, error=error, fit_shape=(5, 5), fwhm=2) >>> fwhm # doctest: +FLOAT_CMP array([2.69735154, 2.70371211, 2.68917219, 2.69310558, 2.68931721, 2.69804194, 2.69651045, 2.70423936, 2.71458867, 2.70285813]) Convenience Gaussian Fitting Function ------------------------------------- The `photutils.psf` package also provides a convenience function called :func:`~photutils.psf.fit_2dgaussian` for fitting one or more sources with a 2D Gaussian PRF model (`~photutils.psf.CircularGaussianPRF`) using the `~photutils.psf.PSFPhotometry` class. See the function documentation for more details and examples. API Reference ------------- :doc:`../reference/psf_api` astropy-photutils-3322558/docs/user_guide/psf_matching.rst000066400000000000000000000715171517052111400237300ustar00rootroot00000000000000.. _psf_matching: PSF Matching (`photutils.psf_matching`) ======================================= Introduction ------------ The `photutils.psf_matching` subpackage contains tools to generate kernels for matching point spread functions (PSFs). It provides two functions for computing PSF-matching kernels in the Fourier domain: * :func:`~photutils.psf_matching.make_kernel` — Uses the ratio of Fourier transforms with a hard amplitude threshold to regularize the division (see e.g., `Gordon et al. 2008`_; `Aniano et al. 2011`_). * :func:`~photutils.psf_matching.make_wiener_kernel` — Uses Wiener regularization, which smoothly suppresses noise amplification at spatial frequencies where the source response is weak. Both functions take a source PSF and a target PSF and return a matching kernel that, when convolved with the source PSF, produces the target PSF. They both support an optional ``window`` function to further suppress high-frequency noise. How It Works ^^^^^^^^^^^^ The key idea behind both methods is that convolution in the image domain corresponds to multiplication in the Fourier domain. If the source PSF has Fourier transform :math:`S` and the target PSF has Fourier transform :math:`T`, then the matching kernel :math:`K` satisfies :math:`T = S \cdot K`, so :math:`K = T / S`. The Fourier transform of a PSF is called the Optical Transfer Function (OTF). It describes how different spatial frequencies are transmitted through the optical system. Low frequencies (coarse image features) are typically strong in the OTF, while high frequencies (fine details) are weaker. In practice, dividing by near-zero OTF values amplifies noise. The two functions handle this differently: `~photutils.psf_matching.make_kernel` sets the Fourier ratio to zero at frequencies where the source OTF amplitude is below a fraction of the peak (controlled by the ``regularization`` parameter, default ``1e-4``): .. math:: R = \begin{cases} T / S & \text{if } |S| > \lambda \cdot \max(|S|) \\ 0 & \text{otherwise} \end{cases} .. math:: K = \mathcal{F}^{-1}[W \cdot R] `~photutils.psf_matching.make_wiener_kernel` instead adds a regularization term to the denominator, providing continuous, smooth regularization. Wiener regularization smoothly down-weights frequencies where the source response is weak, rather than zeroing them out with a hard threshold. This typically produces matching kernels with less ringing, especially for PSFs that have near-zero power at high spatial frequencies. By default, `~photutils.psf_matching.make_wiener_kernel` uses a frequency-independent scalar Tikhonov regularization term expressed as a fraction of the peak power in the source OTF: .. math:: K = \mathcal{F}^{-1} \left[ W \cdot \frac{T \cdot S^{*}} {|S|^{2} + \lambda \cdot \max(|S|^{2})} \right] where :math:`\mathcal{F}^{-1}` is the inverse Fourier transform, :math:`S^{*}` is the complex conjugate of :math:`S`, :math:`\lambda` is the ``regularization`` parameter (default ``1e-4``), and :math:`W` is the optional ``window`` function (defaulting to 1 if not provided). When a ``penalty`` operator is provided (e.g., ``penalty='laplacian'``), the regularization becomes frequency-dependent: .. math:: K = \mathcal{F}^{-1} \left[ W \cdot \frac{T \cdot S^{*}} {|S|^{2} + \lambda \cdot |P|^{2}} \right] where :math:`P` is the OTF of the penalty operator. A Laplacian penalty operator suppresses high spatial frequencies more heavily, which is particularly effective at suppressing noise amplification. Setting ``penalty='laplacian'`` reproduces the regularization approach used by the ``pypher`` package (`Boucaud et al. 2016`_). For additional control, an optional ``window`` function can be applied to both methods to further suppress high-frequency noise in the Fourier ratios. This is especially useful for real-world PSFs that may contain noise, diffraction artifacts, or other features that can amplify through the division. A window is generally less critical for `~photutils.psf_matching.make_wiener_kernel` because the regularization itself suppresses high-frequency noise. For more information about window functions, please see :ref:`psf_matching_window_functions`. Choosing a Method ^^^^^^^^^^^^^^^^^ Use `~photutils.psf_matching.make_kernel` when: - The traditional Fourier-ratio approach with a window function is preferred (e.g., see `Aniano et al. 2011`_). - You want fine-grained control over the spatial frequency-space filtering via a window function. Use `~photutils.psf_matching.make_wiener_kernel` when: - Working with PSFs that have near-zero power at high spatial frequencies (e.g., diffraction-limited PSFs). - You want to avoid ringing artifacts without needing to carefully tune a window function. - A single regularization parameter is preferred over choosing an OTF amplitude threshold plus a window function. - You want frequency-dependent regularization using a penalty operator (e.g., ``penalty='laplacian'`` for ``pypher``-style regularization). PSF Requirements and Preparation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The input source and target PSFs must satisfy these requirements: * **Same shape and pixel scale**: Both PSFs must be 2D arrays with identical shapes and pixel scales. If your PSFs have different shapes or pixel scales, use the :func:`~photutils.psf_matching.resize_psf` function to resample one PSF to match the other. This function uses spline interpolation and preserves the total flux. * **Odd dimensions**: PSF arrays should have odd dimensions in both axes to ensure a well-defined center point. * **Normalized**: PSF arrays should be normalized so that the sum of all pixels equals 1. * **Centered** (recommended but not required): The peak of the PSF should be at the center of the array. Noiseless Gaussian Example --------------------------- 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 photutils.psf import CircularGaussianSigmaPRF >>> yy, xx = np.mgrid[0:51, 0:51] >>> gm1 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=3) >>> gm2 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=5) >>> psf1 = gm1(xx, yy) >>> psf2 = gm2(xx, yy) For these 2D Gaussians, the matching kernel should be a 2D Gaussian with :math:`\sigma=4` (:math:`\sqrt{5^2 - 3^2}`). Let's create the matching kernel using both methods. Using ``make_kernel``:: >>> from photutils.psf_matching import make_kernel >>> kernel1 = make_kernel(psf1, psf2) Using ``make_wiener_kernel``:: >>> from photutils.psf_matching import make_wiener_kernel >>> kernel2 = make_wiener_kernel(psf1, psf2) Both output kernels are 2D arrays representing the matching kernel that, when convolved with the source PSF, produces the target PSF. The output matching kernels are normalized such that they sum to 1:: >>> print(kernel1.sum()) # doctest: +FLOAT_CMP 1.0 >>> print(kernel2.sum()) # doctest: +FLOAT_CMP 1.0 Let's plot both results side by side: .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianSigmaPRF from photutils.psf_matching import make_kernel, make_wiener_kernel yy, xx = np.mgrid[0:51, 0:51] gm1 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=3) gm2 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=5) psf1 = gm1(xx, yy) psf2 = gm2(xx, yy) kernel1 = make_kernel(psf1, psf2) kernel2 = make_wiener_kernel(psf1, psf2) fig, ax = plt.subplots(1, 2, figsize=(10, 4)) axim1 = ax[0].imshow(kernel1, origin='lower') fig.colorbar(axim1, ax=ax[0]) ax[0].set_title('make_kernel') axim2 = ax[1].imshow(kernel2, origin='lower') fig.colorbar(axim2, ax=ax[1]) ax[1].set_title('make_wiener_kernel') fig.tight_layout() As expected, both results are 2D Gaussians with :math:`\sigma=4`. Here we show 1D cuts across the center of the kernel images to confirm: .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianSigmaPRF from photutils.psf_matching import make_kernel, make_wiener_kernel yy, xx = np.mgrid[0:51, 0:51] gm1 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=3) gm2 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=5) gm3 = CircularGaussianSigmaPRF(flux=1, x_0=25, y_0=25, sigma=4) psf1 = gm1(xx, yy) psf2 = gm2(xx, yy) psf3 = gm3(xx, yy) kernel1 = make_kernel(psf1, psf2) kernel2 = make_wiener_kernel(psf1, psf2) fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(kernel1[25, :], label='make_kernel', lw=4) ax.plot(kernel2[25, :], label='make_wiener_kernel', lw=2, ls='--') ax.plot(psf3[25, :], label='$\\sigma=4$ Gaussian', ls=':') ax.set_xlabel('x') ax.set_ylabel('Flux along y=25') ax.legend() ax.set_ylim((0.0, 0.011)) For these noiseless Gaussians, both methods produce nearly identical results. The key differences emerge when working with real-world PSFs that have significant structure in their power spectra. .. _psf_matching_window_functions: Window Functions ---------------- When working with real-world PSFs (e.g., from observations or optical models), the Fourier ratio can still contain residual high-frequency spatial noise even after regularization. An optional `window function `_ (also called a taper function) can be applied to further suppress these artifacts. Both :func:`~photutils.psf_matching.make_kernel` and :func:`~photutils.psf_matching.make_wiener_kernel` accept an optional ``window`` parameter. A window function multiplies the Fourier ratio by a smooth, radially-symmetric 2D filter that equals 1.0 in the central low-frequency region and falls to 0.0 at the edges. This filters out high spatial frequencies where the signal-to-noise ratio is poorest. The trade-off is that tapering removes some real information along with the noise, so the choice of window involves balancing artifact suppression against fidelity. A window is generally less critical for `~photutils.psf_matching.make_wiener_kernel` because the regularization itself suppresses high-frequency noise. ``photutils.psf_matching`` provides five built-in window classes. They are all subclasses of `~photutils.psf_matching.SplitCosineBellWindow`, which is parameterized by two values: * ``alpha``: the fraction of the array radius over which the taper occurs (the cosine transition region). * ``beta``: the fraction of the array radius that remains at 1.0 (the flat inner region). The different window classes set these parameters in specific ways, offering different levels of convenience and control. `~photutils.psf_matching.SplitCosineBellWindow` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The split cosine bell is the most general window, taking both ``alpha`` and ``beta`` as independent parameters. The window equals 1.0 for radii less than ``beta`` times the maximum radius, tapers over the next ``alpha`` fraction, and is zero beyond. Use this when you need fine-grained control over both the preserved region and the taper width. `~photutils.psf_matching.TukeyWindow` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `Tukey window `_ (``beta = 1 - alpha``) features a flat central plateau at 1.0 surrounded by a cosine taper. The ``alpha`` parameter controls the fraction of the array that is tapered: smaller ``alpha`` preserves more data but provides less artifact suppression, while larger ``alpha`` tapers more aggressively. When ``alpha=0`` it becomes a `~photutils.psf_matching.TopHatWindow`; when ``alpha=1`` it becomes a `~photutils.psf_matching.HanningWindow`. This window provides a good balance and is a solid general-purpose choice. `~photutils.psf_matching.HanningWindow` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `Hann window `_ (``alpha=1.0``, ``beta=0.0``) is a raised cosine that equals 1.0 only at the exact center and smoothly tapers to zero at the edges. The entire array is tapered. This provides the strongest sidelobe suppression in Fourier space, at the cost of attenuating most of the data. Use this when edge artifacts and ringing are a primary concern. `~photutils.psf_matching.CosineBellWindow` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The cosine bell window (``alpha=alpha``, ``beta=0.0``) equals 1.0 at the center and begins tapering immediately outward using a cosine function over a fraction ``alpha`` of the array radius. Beyond the taper region the window is zero. When ``alpha=1``, this is equivalent to a `~photutils.psf_matching.HanningWindow`. Compared to a `~photutils.psf_matching.TukeyWindow` with the same ``alpha``, the cosine bell has no flat plateau, so the taper starts closer to the center. `~photutils.psf_matching.TopHatWindow` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The top hat window (``alpha=0.0``, ``beta=beta``) equals 1.0 inside a circular region and drops sharply to 0.0 outside with no smooth transition. This preserves all data within the cutoff radius but the sharp edge creates strong ringing artifacts in Fourier space. For most PSF matching applications, `~photutils.psf_matching.TukeyWindow` is generally preferred over this window. Custom Window Functions ^^^^^^^^^^^^^^^^^^^^^^^ Users may also define their own custom window function and pass it to :func:`~photutils.psf_matching.make_kernel` or :func:`~photutils.psf_matching.make_wiener_kernel`. The window function should be a callable that takes a single ``shape`` argument (a tuple defining the 2D array shape) and returns a 2D array of the same shape containing the window values. The window values should range from 0.0 to 1.0, where 1.0 indicates full preservation of that spatial frequency and 0.0 indicates complete suppression. The window should be radially symmetric and centered on the array. Example Window Function Plots ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Here are plots of 1D cuts across the center of each 2D window function defined above: .. plot:: import matplotlib.pyplot as plt from photutils.psf_matching import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) w1 = SplitCosineBellWindow(alpha=0.4, beta=0.3) w2 = TukeyWindow(alpha=0.5) w3 = HanningWindow() w4 = CosineBellWindow(alpha=0.5) w5 = TopHatWindow(beta=0.4) shape = (101, 101) y0 = (shape[0] - 1) // 2 # Initialize figure fig = plt.figure(figsize=(10, 7)) # Create a 2-row, 6-column grid gs = fig.add_gridspec(2, 6) # First row: 3 plots, each spanning 2 columns ax1 = fig.add_subplot(gs[0, 0:2]) ax2 = fig.add_subplot(gs[0, 2:4]) ax3 = fig.add_subplot(gs[0, 4:6]) # Second row: 2 plots, centered (occupying columns 1-2 and 3-4) ax4 = fig.add_subplot(gs[1, 1:3]) ax5 = fig.add_subplot(gs[1, 3:5]) axes = [ax1, ax2, ax3, ax4, ax5] windows = [w1, w2, w3, w4, w5] titles = [ 'Split Cosine Bell\n(alpha=0.4, beta=0.3)', 'Tukey\n(alpha=0.5)', 'Hanning', 'Cosine Bell\n(alpha=0.5)', 'Top Hat\n(beta=0.4)' ] # Plot using the OO interface for ax, window, title in zip(axes, windows, titles): ax.plot(window(shape)[y0, :]) ax.set_title(title) ax.set_xlabel('x') ax.set_ylim((0, 1.1)) fig.tight_layout() Matching Spitzer 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_psf = ch1_hdu.data # doctest: +REMOTE_DATA >>> ch4_psf = ch4_hdu.data # doctest: +REMOTE_DATA Let's display the images: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import SimpleNorm from photutils.datasets import load_irac_psf ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1_psf = ch1_hdu.data ch4_psf = ch4_hdu.data fig, ax = plt.subplots(ncols=2, figsize=(9, 4)) snorm = SimpleNorm('log', log_a=1000) snorm.imshow(ch1_psf, ax=ax[0], origin='lower') snorm.imshow(ch4_psf, ax=ax[1], origin='lower') ax[0].set_title('IRAC channel 1 PSF') ax[1].set_title('IRAC channel 4 PSF') fig.tight_layout() 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. For real-world PSFs like these, applying a window function is recommended for :func:`~photutils.psf_matching.make_kernel` to suppress residual high-frequency artifacts. Here we use the :class:`~photutils.psf_matching.SplitCosineBellWindow`: .. doctest-skip:: >>> from photutils.psf_matching import (SplitCosineBellWindow, ... make_kernel) >>> window = SplitCosineBellWindow(alpha=0.15, beta=0.3) >>> kernel1 = make_kernel(ch1_psf, ch4_psf, window=window, ... regularization=0.0001) With :func:`~photutils.psf_matching.make_wiener_kernel`, the Wiener regularization itself suppresses high-frequency noise, so a window function is generally not needed: .. doctest-skip:: >>> from photutils.psf_matching import make_wiener_kernel >>> kernel2 = make_wiener_kernel(ch1_psf, ch4_psf, ... regularization=0.0001) For frequency-dependent regularization using a Laplacian or biharmonic penalty operator: .. doctest-skip:: >>> kernel3 = make_wiener_kernel(ch1_psf, ch4_psf, ... regularization=0.0001, ... penalty='laplacian') >>> kernel4 = make_wiener_kernel(ch1_psf, ch4_psf, ... regularization=0.0001, ... penalty='biharmonic') Let's display the matching kernel results from all methods: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import SimpleNorm from photutils.datasets import load_irac_psf from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1_psf = ch1_hdu.data ch4_psf = ch4_hdu.data window = SplitCosineBellWindow(alpha=0.15, beta=0.3) regularization = 0.0001 kernel1 = make_kernel(ch1_psf, ch4_psf, window=window, regularization=regularization) kernel2 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization) kernel3 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='laplacian') kernel4 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='biharmonic') snorm = SimpleNorm('log', log_a=10) kernels = [kernel1, kernel2, kernel3, kernel4] titles = ['make_kernel', 'make_wiener_kernel', 'make_wiener_kernel\n(Laplacian penalty)', 'make_wiener_kernel\n(biharmonic penalty)'] fig, axes = plt.subplots(2, 2, figsize=(8, 7)) for ax, kernel, title in zip(axes.ravel(), kernels, titles): axim = snorm.imshow(kernel, ax=ax, origin='lower') fig.colorbar(axim, ax=ax) ax.set_title(title) fig.tight_layout() Let's now convolve the channel 1 PSF with each matching kernel using `scipy.signal.fftconvolve` and compare the PSF-matched results with the channel 4 PSF: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import SimpleNorm from scipy.signal import fftconvolve from photutils.datasets import load_irac_psf from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1_psf = ch1_hdu.data ch4_psf = ch4_hdu.data window = SplitCosineBellWindow(alpha=0.15, beta=0.3) regularization = 0.0001 kernel1 = make_kernel(ch1_psf, ch4_psf, window=window, regularization=regularization) kernel2 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization) kernel3 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='laplacian') kernel4 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='biharmonic') matched1 = fftconvolve(ch1_psf, kernel1, mode='same') matched2 = fftconvolve(ch1_psf, kernel2, mode='same') matched3 = fftconvolve(ch1_psf, kernel3, mode='same') matched4 = fftconvolve(ch1_psf, kernel4, mode='same') snorm = SimpleNorm('log', log_a=1000) titles = ['Channel 4 PSF', 'make_kernel', 'make_wiener_kernel', 'make_wiener_kernel\n(Laplacian penalty)', 'make_wiener_kernel\n(biharmonic penalty)'] images = [ch4_psf, matched1, matched2, matched3, matched4] fig = plt.figure(figsize=(14, 8)) gs = fig.add_gridspec(2, 3, width_ratios=[1, 1, 1]) # Column 1 spans both rows ax_main = fig.add_subplot(gs[:, 0]) # Columns 2 & 3: 2x2 grid ax_top_left = fig.add_subplot(gs[0, 1]) ax_top_right = fig.add_subplot(gs[0, 2]) ax_bot_left = fig.add_subplot(gs[1, 1]) ax_bot_right = fig.add_subplot(gs[1, 2]) axes = [ax_main, ax_top_left, ax_top_right, ax_bot_left, ax_bot_right] for ax, img, title in zip(axes, images, titles): axim = snorm.imshow(img, ax=ax, origin='lower') fig.colorbar(axim, ax=ax, fraction=0.046, pad=0.04) ax.set_title(title) fig.suptitle('Channel 1 PSF-matched to Channel 4', x=0.666, y=0.98, ha='center', fontsize=16) fig.tight_layout(rect=[0, 0, 1, 0.98]) Now let's examine the residuals between the PSF-matched results and the channel 4 PSF target: .. plot:: import matplotlib.pyplot as plt import numpy as np from scipy.signal import fftconvolve from photutils.datasets import load_irac_psf from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1_psf = ch1_hdu.data ch4_psf = ch4_hdu.data window = SplitCosineBellWindow(alpha=0.15, beta=0.3) regularization = 0.0001 kernel1 = make_kernel(ch1_psf, ch4_psf, window=window, regularization=regularization) kernel2 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization) kernel3 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='laplacian') kernel4 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='biharmonic') matched1 = fftconvolve(ch1_psf, kernel1, mode='same') matched2 = fftconvolve(ch1_psf, kernel2, mode='same') matched3 = fftconvolve(ch1_psf, kernel3, mode='same') matched4 = fftconvolve(ch1_psf, kernel4, mode='same') resid1 = matched1 - ch4_psf resid2 = matched2 - ch4_psf resid3 = matched3 - ch4_psf resid4 = matched4 - ch4_psf vmax = np.abs( np.array([resid1, resid2, resid3, resid4])).max() titles = ['make_kernel', 'make_wiener_kernel', 'make_wiener_kernel\n(Laplacian penalty)', 'make_wiener_kernel\n(biharmonic penalty)'] residuals = [resid1, resid2, resid3, resid4] fig, axes = plt.subplots(2, 2, figsize=(9, 7)) for ax, resid, title in zip(axes.ravel(), residuals, titles): axim = ax.imshow(resid, origin='lower', cmap='RdBu_r', vmin=-vmax, vmax=vmax) fig.colorbar(axim, ax=ax) ax.set_title(title) fig.suptitle('Residuals: PSF-matched minus channel 4 PSF') fig.tight_layout() The residuals are small relative to the peak PSF values, confirming that all four methods produce good PSF matches. Finally, let's compare the encircled energies of the PSF-matched results with the channel 1 and channel 4 PSFs using the :class:`~photutils.profiles.CurveOfGrowth` class. A residual subpanel immediately below the main panel shows how well each PSF-matched curve agrees with the channel 4 target: .. plot:: import matplotlib.pyplot as plt import numpy as np from scipy.signal import fftconvolve from photutils.datasets import load_irac_psf from photutils.profiles import CurveOfGrowth from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1_psf = ch1_hdu.data ch4_psf = ch4_hdu.data window = SplitCosineBellWindow(alpha=0.15, beta=0.3) regularization = 0.0001 kernel1 = make_kernel(ch1_psf, ch4_psf, window=window, regularization=regularization) kernel2 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization) kernel3 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='laplacian') kernel4 = make_wiener_kernel(ch1_psf, ch4_psf, regularization=regularization, penalty='biharmonic') matched1 = fftconvolve(ch1_psf, kernel1, mode='same') matched2 = fftconvolve(ch1_psf, kernel2, mode='same') matched3 = fftconvolve(ch1_psf, kernel3, mode='same') matched4 = fftconvolve(ch1_psf, kernel4, mode='same') xycen = (40.0, 40.0) radii = np.arange(1, 40) cog_ch1 = CurveOfGrowth(ch1_psf, xycen, radii) cog_ch4 = CurveOfGrowth(ch4_psf, xycen, radii) cog_m1 = CurveOfGrowth(matched1, xycen, radii) cog_m2 = CurveOfGrowth(matched2, xycen, radii) cog_m3 = CurveOfGrowth(matched3, xycen, radii) cog_m4 = CurveOfGrowth(matched4, xycen, radii) for cog in [cog_ch1, cog_ch4, cog_m1, cog_m2, cog_m3, cog_m4]: cog.normalize() labels = [ 'make_kernel', 'make_wiener_kernel', 'make_wiener_kernel (Laplacian)', 'make_wiener_kernel (biharmonic)', ] cogs_matched = [cog_m1, cog_m2, cog_m3, cog_m4] ls_list = ['--', '-.', ':', (0, (3, 1, 1, 1))] fig, (ax_top, ax_bot) = plt.subplots( 2, 1, figsize=(8, 8), gridspec_kw={'height_ratios': [3, 1]}, sharex=True, ) # Main panel cog_ch1.plot(ax=ax_top, label='Channel 1 PSF', lw=2, color='C0', ls='-') cog_ch4.plot(ax=ax_top, label='Channel 4 PSF', lw=3, color='k') for cog, label, ls in zip(cogs_matched, labels, ls_list): cog.plot(ax=ax_top, label=label, lw=2, ls=ls) ax_top.set_ylabel('Normalized Encircled Energy') ax_top.set_title( 'Encircled Energy: Channel 1 & 4 PSFs vs. PSF-matched results') ax_top.legend(fontsize=9) ax_top.set_xlabel('') # Residual subpanel (matched - ch4) for cog, label, ls in zip(cogs_matched, labels, ls_list): resid = cog.profile - cog_ch4.profile ax_bot.plot(cog.radius, resid, lw=2, ls=ls, label=label) ax_bot.axhline(0, color='k', lw=1, ls='-') ax_bot.set_xlabel('Radius (pixels)') ax_bot.set_ylabel('Residual') ax_bot.set_title('Matched $-$ Channel 4') fig.tight_layout() The encircled energy curves for the PSF-matched results closely track the channel 4 PSF, confirming that the PSF matching has been performed successfully across all four methods. The residual subpanel quantifies the small remaining differences between each PSF-matched curve and the channel 4 target. API Reference ------------- :doc:`../reference/psf_matching_api` .. _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 .. _Boucaud et al. 2016: https://ui.adsabs.harvard.edu/abs/2016A%26A...596A..63B/abstract astropy-photutils-3322558/docs/user_guide/radial_profiles.rst000066400000000000000000000264071517052111400244230ustar00rootroot00000000000000.. _radial_profiles: Radial Profiles (`photutils.profiles`) ====================================== Introduction ------------ `photutils.profiles` provides tools to calculate radial profiles (mean flux in concentric circular annular bins) and curves of growth (cumulative flux within concentric circular, square, or elliptical apertures). This page covers radial profiles. See :ref:`curves_of_growth` for curves of growth. 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) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig .. 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig fig, ax = plt.subplots(figsize=(5, 5)) norm = simple_norm(data, 'sqrt') ax.imshow(data, norm=norm, origin='lower') Creating a Radial Profile ------------------------- First, we'll use the `~photutils.centroids.centroid_2dg` function to find the source centroid from the simulated image defined above:: >>> from photutils.centroids import centroid_2dg >>> xycen = centroid_2dg(data) >>> print(xycen) # doctest: +FLOAT_CMP [47.76934534 52.3884076 ] 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 inputting 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) The output `~photutils.profiles.RadialProfile.radius` attribute values are defined as the arithmetic means of the input radial-bins edges (``radii``). Note that this is different from the input ``radii``, which are the radial bin edges rather than centers:: >>> 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.30187860e+01 4.02502046e+01 3.57758011e+01 3.16071235e+01 2.61511082e+01 2.10539746e+01 1.63701300e+01 1.16674718e+01 8.12828014e+00 5.78962699e+00 3.59342666e+00 2.35353336e+00 1.20355937e+00 7.67093923e-01 4.24650784e-01 8.67989701e-02 5.11484374e-02 -9.82041768e-02 2.37482124e-02 -3.66602855e-02 6.84802299e-02 1.72239596e-01 -3.86056497e-02 7.30423743e-02] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.18479813 0.68404352 0.52985783 0.4478116 0.39493271 0.35723008 0.32860388 0.30591356 0.28735575 0.27181133 0.25854415 0.24704749 0.23695963 0.22801451 0.22001149 0.21279603 0.20624688 0.20026744 0.19477961 0.18971954 0.18503438 0.18068002 0.17661928 0.17282057] Raw Data Profile ^^^^^^^^^^^^^^^^ The `~photutils.profiles.RadialProfile` class also includes :attr:`~photutils.profiles.RadialProfile.data_radius` and :attr:`~photutils.profiles.RadialProfile.data_profile` attributes that can be used to plot the raw data profile. These attributes provide the radii and values of the unmasked data points within the maximum radius defined by the input radii. Let's plot the raw data profile along with the radial profile and its error bars: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, color='C0') rp.plot_error(ax=ax) ax.scatter(rp.data_radius, rp.data_profile, s=1, color='C1') Normalization ^^^^^^^^^^^^^ 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() Plotting ^^^^^^^^ 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_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, label='Radial Profile') rp.plot_error(ax=ax) ax.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_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') rp.apertures[5].plot(ax=ax, color='C0', lw=2) rp.apertures[10].plot(ax=ax, color='C1', lw=2) rp.apertures[15].plot(ax=ax, color='C3', lw=2) Fitting the profile with a 1D Gaussian or Moffat Model ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The radial profile can be fitted with either a 1D Gaussian (`~astropy.modeling.functional_models.Gaussian1D`) or a 1D Moffat (`~astropy.modeling.functional_models.Moffat1D`) model. The fitted models are accessible via the `~photutils.profiles.RadialProfile.gaussian_fit` and `~photutils.profiles.RadialProfile.moffat_fit` attributes, respectively:: >>> rp.gaussian_fit # doctest: +FLOAT_CMP >>> rp.moffat_fit # doctest: +ELLIPSIS The FWHM of each fitted model is stored in the `~photutils.profiles.RadialProfile.gaussian_fwhm` and `~photutils.profiles.RadialProfile.moffat_fwhm` attributes:: >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.009084813327846 >>> print(rp.moffat_fwhm) # doctest: +FLOAT_CMP 10.868426520785151 The fitted models evaluated at the profile radius values are stored in the `~photutils.profiles.RadialProfile.gaussian_profile` and `~photutils.profiles.RadialProfile.moffat_profile` attributes. Moffat profiles have broader wings than Gaussians and are often a better representation of astronomical point-spread functions. Let's plot both fitted models on the radial profile: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile with Gaussian and Moffat fits fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, label='Radial Profile') rp.plot_error(ax=ax) ax.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') ax.plot(rp.radius, rp.moffat_profile, label='Moffat Fit') ax.legend() API Reference ------------- :doc:`../reference/profiles_api` astropy-photutils-3322558/docs/user_guide/segmentation.rst000066400000000000000000001013401517052111400237470ustar00rootroot00000000000000.. _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:: >>> 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:: >>> 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``:: >>> from photutils.segmentation import detect_sources >>> segment_map = detect_sources(convolved_data, threshold, n_pixels=10) >>> print(segment_map) shape: (300, 500) n_labels: 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 simple_norm >>> norm = simple_norm(data, 'sqrt', percent=99.5) >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, norm=norm, origin='lower') >>> ax1.set_title('Background-subtracted Data') >>> segment_map.imshow(ax=ax2) >>> ax2.set_title('Segmentation Image') .. 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 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, n_pixels=10) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) norm = simple_norm(data, 'sqrt', percent=99.5) ax1.imshow(data, norm=norm, origin='lower') ax1.set_title('Background-subtracted Data') segment_map.imshow(ax=ax2) ax2.set_title('Segmentation Image') fig.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 using a combination of multi-thresholding and `watershed segmentation `_. Note that in order to deblend sources, they must be separated enough that ere this a saddle point between them. The amount of deblending can be controlled with the two :func:`~photutils.segmentation.deblend_sources` keywords ``n_levels`` and ``contrast``. ``n_levels`` 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:: skimage >>> from photutils.segmentation import deblend_sources >>> segment_map2 = deblend_sources(convolved_data, segment_map, ... n_pixels=10, n_levels=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 ``n_pixels`` 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 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) n_pixels = 10 segment_map = detect_sources(convolved_data, threshold, n_pixels=n_pixels) deblended_segment_map = deblend_sources(convolved_data, segment_map, n_pixels=n_pixels, progress_bar=False) fig, ax = plt.subplots(1, 1, figsize=(10, 6.5)) deblended_segment_map.imshow(ax=ax) ax.set_title('Deblended Segmentation Image') fig.tight_layout() Let's plot one of the deblended sources: .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve 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) n_pixels = 10 segment_map = detect_sources(convolved_data, threshold, n_pixels=n_pixels) deblended_segment_map = deblend_sources(convolved_data, segment_map, n_pixels=n_pixels, progress_bar=False) 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') segm_cutout = segment_map[slc] segm_cutout.imshow(ax=ax2, cmap=segment_map.cmap) ax2.set_title('Original Segment') deblended_segm_cutout = deblended_segment_map[slc] deblended_segm_cutout.imshow(ax=ax3, cmap=deblended_segment_map.cmap) ax3.set_title('Deblended Segments') fig.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:: skimage >>> from photutils.segmentation import SourceFinder >>> finder = SourceFinder(n_pixels=10, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> print(segment_map) shape: (300, 500) n_labels: 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.relabel_consecutive`: Reassign the label numbers consecutively, such that there are no missing label numbers. * :meth:`~photutils.segmentation.SegmentationImage.reassign_labels`: Reassign one or more 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. Here's a simple example of removing border labels and relabeling the result: .. doctest-requires:: skimage >>> segment_map3 = segment_map.copy() >>> segment_map3.remove_border_labels(border_width=10, relabel=True) >>> print(segment_map3) shape: (300, 500) n_labels: 79 labels: [ 1 2 3 4 5 ... 75 76 77 78 79] Source Masks ------------ The :meth:`~photutils.segmentation.SegmentationImage.make_source_mask` method can be used to create a boolean source mask from a segmentation image. The source mask can be used, for example, to mask sources when estimating the background level. The source mask can optionally be dilated using the ``size`` or ``footprint`` keyword to mask a larger area around each source. Dilating the source mask is useful for excluding the faint wings of sources when estimating the background: .. doctest-requires:: skimage >>> mask = segment_map.make_source_mask() >>> dilated_mask = segment_map.make_source_mask(size=11) A circular footprint can also be used to dilate the source mask: .. doctest-requires:: skimage >>> from photutils.utils import circular_footprint >>> footprint = circular_footprint(radius=5) >>> dilated_mask2 = segment_map.make_source_mask(footprint=footprint) Note that using a square footprint (via the ``size`` keyword) is much faster than using other shapes (e.g., a circular footprint). Polygons and Regions -------------------- The :class:`~photutils.segmentation.SegmentationImage` class provides several methods for converting source segments into polygon representations and `regions`_ objects. These are useful for visualization and for exporting source segments to other tools. Note that these methods require the `rasterio`_, `shapely`_, and/or `regions`_ optional packages. The :attr:`~photutils.segmentation.SegmentationImage.polygons` property returns a list of `Shapely`_ polygon objects representing each source segment: .. doctest-requires:: rasterio, shapely >>> polygons = segment_map.polygons The :meth:`~photutils.segmentation.SegmentationImage.to_patches` method returns a list of `~matplotlib.patches.PathPatch` objects for the source segments, which can be overlaid on plots: .. doctest-requires:: matplotlib, rasterio, shapely >>> patches = segment_map.to_patches(edgecolor='white', lw=1.5) For convenience, the :meth:`~photutils.segmentation.SegmentationImage.plot_patches` method will plot these patches directly on an existing matplotlib axes: .. doctest-skip:: >>> patches = segment_map.plot_patches(edgecolor='white', lw=1.5) For working with individual labels, the :meth:`~photutils.segmentation.SegmentationImage.get_polygon`, :meth:`~photutils.segmentation.SegmentationImage.get_polygons`, :meth:`~photutils.segmentation.SegmentationImage.get_patch`, :meth:`~photutils.segmentation.SegmentationImage.get_patches`, :meth:`~photutils.segmentation.SegmentationImage.get_region`, and :meth:`~photutils.segmentation.SegmentationImage.get_regions` methods are significantly faster than the bulk properties when only a subset of labels is needed: .. doctest-requires:: matplotlib, rasterio, regions, shapely >>> polygon = segment_map.get_polygon(1) >>> patch = segment_map.get_patch(1, edgecolor='red', lw=2) >>> region = segment_map.get_region(1) Here's an example showing the source polygons overlaid on both the segmentation image and the science image: .. 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 (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 threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) finder = SourceFinder(n_pixels=10, progress_bar=False) segment_map = finder(convolved_data, threshold) fig, (ax1, ax2) = plt.subplots(ncols=1, nrows=2, figsize=(10, 12.5)) segment_map.imshow(ax=ax1) ax1.set_title('Segmentation Image') segment_map.plot_patches(ax=ax1, edgecolor='white', lw=1.5) norm = simple_norm(data, 'sqrt', percent=99.5) ax2.imshow(data, norm=norm, origin='lower') ax2.set_title('Background-subtracted Data') segment_map.plot_patches(ax=ax2, edgecolor='white', lw=1.5) fig.tight_layout() To convert the source segments to `regions`_ `~regions.PolygonPixelRegion` objects, use the :meth:`~photutils.segmentation.SegmentationImage.to_regions` method: .. doctest-requires:: rasterio, regions, shapely >>> regions = segment_map.to_regions() .. _rasterio: https://rasterio.readthedocs.io/en/stable/ .. _shapely: https://shapely.readthedocs.io/en/stable/ .. _regions: https://astropy-regions.readthedocs.io/en/stable/ Segment Objects --------------- The :class:`~photutils.segmentation.SegmentationImage` class provides :class:`~photutils.segmentation.Segment` objects that encapsulate individual labeled regions. Each `~photutils.segmentation.Segment` contains the label number, bounding-box slices, bounding box, area, and (optionally) the Shapely polygon outline. The :attr:`~photutils.segmentation.SegmentationImage.segments` property returns a list of `~photutils.segmentation.Segment` objects for all labels:: >>> segments = segment_map.segments >>> segments[0] label: 1 slices: (slice(0, 5, None), slice(230, 242, None)) area: 47 For working with individual labels, the :meth:`~photutils.segmentation.SegmentationImage.get_segment` and :meth:`~photutils.segmentation.SegmentationImage.get_segments` methods are significantly faster than the bulk ``segments`` property when only a subset of labels is needed:: >>> segment = segment_map.get_segment(1) >>> print(segment.label, segment.area) 1 47 >>> segments = segment_map.get_segments([1, 5, 10]) >>> [segment.label for segment in segments] [np.int32(1), np.int32(5), np.int32(10)] A `~photutils.segmentation.Segment` can provide cutout arrays of the segment data and of arbitrary data arrays via its :attr:`~photutils.segmentation.Segment.data` property and :meth:`~photutils.segmentation.Segment.make_cutout` method:: >>> segment = segment_map.get_segment(1) >>> segment_cutout = segment.data # labeled region, others set to 0 >>> data_cutout = segment.make_cutout(data) # science data cutout 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:: skimage >>> from photutils.segmentation import SourceCatalog >>> cat = SourceCatalog(data, segment_map, 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:: skimage >>> tbl = cat.to_table() >>> tbl['x_centroid'].info.format = '.2f' # optional format >>> tbl['y_centroid'].info.format = '.2f' >>> tbl['kron_flux'].info.format = '.2f' >>> print(tbl) label x_centroid y_centroid ... segment_flux_err kron_flux kron_flux_err ... ----- ---------- ---------- ... ---------------- --------- ------------- 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', percent=99.5) >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, norm=norm, origin='lower') >>> ax1.set_title('Data') >>> segment_map.imshow(ax=ax2) >>> 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) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels, 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', percent=99.5) ax1.imshow(data, norm=norm, origin='lower') ax1.set_title('Data with Kron apertures') segment_map.imshow(ax=ax2) 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) fig.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:: skimage >>> cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.select_labels(labels) >>> tbl2 = cat_subset.to_table() >>> tbl2['x_centroid'].info.format = '.2f' # optional format >>> tbl2['y_centroid'].info.format = '.2f' >>> tbl2['kron_flux'].info.format = '.2f' >>> print(tbl2) label x_centroid y_centroid ... segment_flux_err kron_flux kron_flux_err ... ----- ---------- ---------- ... ---------------- --------- ------------- 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:: skimage >>> cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.select_labels(labels) >>> columns = ['label', 'x_centroid', 'y_centroid', 'area', 'segment_flux'] >>> tbl3 = cat_subset.to_table(columns=columns) >>> tbl3['x_centroid'].info.format = '.4f' # optional format >>> tbl3['y_centroid'].info.format = '.4f' >>> tbl3['segment_flux'].info.format = '.4f' >>> print(tbl3) label x_centroid y_centroid 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 >= 1.8 .. doctest-requires:: skimage >>> cat = SourceCatalog(data, segment_map, background=bkg.background) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.select_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.1950691156 5.1952758684 244.1779658169 5 5.2065578767 5.2065437428 437.3496743914 20 5.2185224938 5.2182859243 537.4834502022 50 5.2278578177 5.2277566101 261.3878305059 75 5.2200812077 5.2203644550 344.5440540277 80 5.2177773524 5.2174773951 453.9205333733 .. _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_flux_err` and `~photutils.segmentation.SourceCatalog.kron_flux_err` properties are calculated. `~photutils.segmentation.SourceCatalog.segment_flux` and `~photutils.segmentation.SourceCatalog.segment_flux_err` are the instrumental flux and propagated flux error within the source segments: .. doctest-requires:: scipy >= 1.8 .. doctest-requires:: 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, segment_map, error=error) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.select_labels(labels) # select a subset of objects >>> columns = ['label', 'x_centroid', 'y_centroid', 'segment_flux', ... 'segment_flux_err'] >>> tbl5 = cat_subset.to_table(columns=columns) >>> tbl5['x_centroid'].info.format = '{:.4f}' # optional format >>> tbl5['y_centroid'].info.format = '{:.4f}' >>> tbl5['segment_flux'].info.format = '{:.4f}' >>> tbl5['segment_flux_err'].info.format = '{:.4f}' >>> for col in tbl5.colnames: ... tbl5[col].info.format = '%.8g' # for consistent table output >>> print(tbl5) label x_centroid y_centroid segment_flux segment_flux_err ----- --------- --------- ------------ ---------------- 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.6014 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``. If ``convolved_data`` is `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``. Dual-Image Mode (Detection Catalog) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In many astronomical workflows, source detection and deblending are performed on one image (e.g., a deep detection image or coadd) while photometry is measured on a different image (e.g., a single-band image). The ``detection_catalog`` keyword of :class:`~photutils.segmentation.SourceCatalog` enables this dual-image mode. When ``detection_catalog`` is input, the source centroids and morphological/shape properties are taken from the detection catalog, while photometry is measured on the input ``data``. For circular-aperture and Kron photometry, the aperture centers are based on the centroids from the detection catalog. For Kron photometry, the Kron apertures are based on the shape properties from the detection catalog. The ``wcs``, ``aperture_mask_method``, and ``kron_params`` keywords are inherited from the ``detection_catalog`` and are therefore ignored when ``detection_catalog`` is input. Note that the segmentation image used to create the detection catalog must be the same one input to the measurement catalog: .. doctest-requires:: skimage >>> det_cat = SourceCatalog(data, segment_map, ... convolved_data=convolved_data) >>> measurement_cat = SourceCatalog(data, segment_map, ... detection_catalog=det_cat) In this example, ``measurement_cat`` uses the centroids and shape properties (and Kron apertures) from ``det_cat`` while measuring photometry on ``data``. API Reference ------------- :doc:`../reference/segmentation_api` .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ astropy-photutils-3322558/docs/user_guide/utils.rst000066400000000000000000000023611517052111400224150ustar00rootroot00000000000000Utility Functions (`photutils.utils`) ===================================== Introduction ------------ The `photutils.utils` package contains general-purpose utility functions that do not fit into any of the other subpackages. Some functions and classes of note include: * :class:`~photutils.utils.CutoutImage`: Class to create a cutout object from a 2D array. * :class:`~photutils.utils.ImageDepth`: Class to calculate the limiting flux and magnitude of an image by placing random circular apertures on blank regions. * :class:`~photutils.utils.ShepardIDWInterpolator`: Class to perform inverse distance weighted (IDW) interpolation. * :func:`~photutils.utils.calc_total_error`: Function to calculate the total error in an image by combining a background-only error array with the source Poisson error. * :func:`~photutils.utils.circular_footprint`: Function to create a circular footprint array. * :func:`~photutils.utils.make_random_cmap`: Function to create a colormap consisting of random muted colors. This type of colormap is useful for plotting segmentation images. * :class:`~photutils.utils.NoDetectionsWarning`: Warning class to indicate no sources were detected. API Reference ------------- :doc:`../reference/utils_api` astropy-photutils-3322558/docs/whats_new/000077500000000000000000000000001517052111400203655ustar00rootroot00000000000000astropy-photutils-3322558/docs/whats_new/1.1.rst000066400000000000000000000047021517052111400214210ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.1? **************************** .. contents:: :local: :depth: 2 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: * ``circular_photometry`` * ``fluxfrac_radius`` * ``get_label`` * ``get_labels`` and new attributes: * ``fwhm`` (full width at half maximum) * ``segment`` * ``segment_ma`` astropy-photutils-3322558/docs/whats_new/1.10.rst000066400000000000000000000031021517052111400214720ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 Reading WebbPSF Gridded PSF Models ---------------------------------- The `~photutils.psf.GriddedPSFModel` ``read`` method can now read FITS files containing ePSF grids that were generated by `STPSF (formerly 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. astropy-photutils-3322558/docs/whats_new/1.11.rst000066400000000000000000000040711517052111400215010ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 ``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``. astropy-photutils-3322558/docs/whats_new/1.12.rst000066400000000000000000000003551517052111400215030ustar00rootroot00000000000000.. doctest-skip-all ***************************** What's New in Photutils 1.12? ***************************** The Photutils 1.12 release was made to support NumPy 2.0. Please see the :ref:`changelog` for the complete list of changes. astropy-photutils-3322558/docs/whats_new/1.13.rst000066400000000000000000000123531517052111400215050ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 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 reorganization ================================== 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, fluxes, or other parameters 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 added to generate 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 to 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. astropy-photutils-3322558/docs/whats_new/1.2.rst000066400000000000000000000002571517052111400214230ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.2? **************************** Please see the :ref:`changelog` for the complete list of changes. astropy-photutils-3322558/docs/whats_new/1.3.rst000066400000000000000000000002571517052111400214240ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.3? **************************** Please see the :ref:`changelog` for the complete list of changes. astropy-photutils-3322558/docs/whats_new/1.4.rst000066400000000000000000000062631517052111400214300ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.4? **************************** .. contents:: :local: :depth: 2 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 ``photutils.aperture.ApertureStats.min``, ``photutils.aperture.ApertureStats.max``, ``photutils.aperture.ApertureStats.mean``, ``photutils.aperture.ApertureStats.median``, ``photutils.aperture.ApertureStats.std``, ``photutils.aperture.ApertureStats.sum_aper_area``, and ``photutils.aperture.ApertureStats.sum``. It also can be used to calculate morphological properties like ``photutils.aperture.ApertureStats.centroid``, ``photutils.aperture.ApertureStats.fwhm``, ``photutils.aperture.ApertureStats.semimajor_sigma``, ``photutils.aperture.ApertureStats.semiminor_sigma``, ``photutils.aperture.ApertureStats.orientation``, and ``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 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. astropy-photutils-3322558/docs/whats_new/1.5.rst000066400000000000000000000106051517052111400214240ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.5? **************************** .. contents:: :local: :depth: 2 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 efficiently 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. astropy-photutils-3322558/docs/whats_new/1.6.rst000066400000000000000000000062421517052111400214270ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 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: * ``photutils.segmentation.SourceCatalog.centroid_win`` * ``photutils.segmentation.SourceCatalog.cutout_centroid_win`` * ``photutils.segmentation.SourceCatalog.xcentroid_win`` * ``photutils.segmentation.SourceCatalog.ycentroid_win`` * ``photutils.segmentation.SourceCatalog.sky_centroid_win`` The "quadratic" centroids are calculated using `~photutils.centroids.centroid_quadratic`. The new quadratic centroid properties are: * ``photutils.segmentation.SourceCatalog.centroid_quad`` * ``photutils.segmentation.SourceCatalog.cutout_centroid_quad`` * ``photutils.segmentation.SourceCatalog.xcentroid_quad`` * ``photutils.segmentation.SourceCatalog.ycentroid_quad`` * ``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. astropy-photutils-3322558/docs/whats_new/1.7.rst000066400000000000000000000037071517052111400214330ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 New Profiles Subpackage ======================= A new `photutils.profiles` 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` .. _Rasterio: https://rasterio.readthedocs.io/en/stable/ .. _Shapely: https://shapely.readthedocs.io/en/stable/ astropy-photutils-3322558/docs/whats_new/1.8.rst000066400000000000000000000017471517052111400214360ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 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. astropy-photutils-3322558/docs/whats_new/1.9.rst000066400000000000000000000063761517052111400214420ustar00rootroot00000000000000.. 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`. .. contents:: :local: :depth: 2 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. astropy-photutils-3322558/docs/whats_new/2.0.rst000066400000000000000000000303371517052111400214240ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-2.0: **************************** What's New in Photutils 2.0? **************************** Photutils 2.0 is a major release that adds significant new functionality and improvements to the package. Here we highlight some of the new functionality of the 2.0 release. In addition to these changes, Photutils 2.0 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Imports ======= Importing tools from all subpackages now requires including the subpackage name. These deprecations were introduced in version 1.6.0 (2022-12-09). Also, PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf`` For example, this is no longer allowed: ``from photutils import CircularAperture``. Instead use this: ``from photutils.aperture import CircularAperture``. SciPy is now a required dependency ================================== `SciPy `_ is now a required dependency for Photutils, instead of an optional dependency. This change was made because most of the subpackages in Photutils require SciPy for functionality. Aperture photometry tools now accept Region objects =================================================== The `~photutils.aperture.aperture_photometry` and `~photutils.aperture.ApertureStats` tools now accept supported ``regions.Region`` objects from the `Astropy regions package `_, i.e., those corresponding to circular, elliptical, and rectangular apertures. A new `~photutils.aperture.region_to_aperture` convenience function also has been added to convert supported ``regions.Region`` objects to ``Aperture`` objects. With these changes, the `Astropy regions package `_ is now an optional dependency for Photutils. It will need to be installed to use the above functionality. Background2D improved performance and changes ============================================= The `~photutils.background.Background2D` class has been refactored to significantly reduce its memory usage. In some cases, it is also significantly faster. To reduce memory usage, ``Background2D`` no longer keeps a cached copy of the returned ``background`` and ``background_rms`` properties. Assign these properties to variables if you need to use them multiple times, otherwise they will need to be recomputed. The ``background``, ``background_rms``, ``background_mesh``, and ``background_rms_mesh`` properties now have the same ``dtype`` as the input data. Two new properties were also added to the ``Background2D`` class, ``npixels_mesh`` and ``npixels_map``, that give a 2D array of the number of pixels used to compute the statistics in the low-resolution grid and the resized image, respectively. Additionally, the ``background_mesh`` and ``background_rms_mesh`` properties will have units if the input data has units. As part of these changes, the ``edge_method`` keyword is now deprecated and will be removed in a future version. When removed, the ``edge_method`` will always be ``'pad'``. The ``'crop'`` option has been strongly discouraged for some time now. Its usage creates a undesirable scaling of the low-resolution maps that leads to incorrect results. The ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties are now deprecated and will be removed in a future version. The ``data``, ``mask``, ``total_mask``, ``nboxes``, ``box_npixels``, and ``nboxes_tot`` class attributes have been removed. Finally, the `~photutils.background.BkgZoomInterpolator` ``grid_mode`` keyword is now deprecated. When ``grid_mode`` is eventually removed, the `True` option will always be used. 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 was provided only for backwards-compatibility. GriddedPSFModel improved performance ==================================== The `~photutils.psf.GriddedPSFModel` class has been refactored to significantly improve its performance. In typical PSF photometry use cases, it is now about 4 times faster than previous versions. New PSF Model classes ====================== New models were added to the ``photutils.psf`` module. These include: - `~photutils.psf.ImagePSF`: a general class for image-based PSF models that allows for intensity scaling and translations. - `~photutils.psf.GaussianPSF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and full width at half maximum (FWHM) along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPSF`: a circular 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.GaussianPRF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.CircularGaussianSigmaPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and sigma (standard deviation). - `~photutils.psf.MoffatPSF`: a 2D Moffat PSF model parameterized in terms of the position, total flux, :math:`\alpha`, and :math:`\beta` parameters. - `~photutils.psf.AiryDiskPSF`: a 2D Airy disk PSF model parameterized in terms of the position, total flux, and radius of the first dark ring. Note there are two types of defined models, PSF and PRF models. The PSF models are evaluated by sampling the analytic function at the input (x, y) coordinates. The PRF models are evaluated by integrating the analytic function over the pixel areas. The existing ``IntegratedGaussianPRF`` model is now deprecated and will be removed in a future version. It has been replaced by the `~photutils.psf.CircularGaussianSigmaPRF` model. The existing ``FittableImageModel`` and ``EPSFModel`` classes are now deprecated and will be removed in a future version. They have been replaced by the new `~photutils.psf.ImagePSF` class. Legacy ``LevMarLSQFitter`` no longer used ========================================= The default Astropy fitter for ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the Levenberg-Marquardt algorithm via the SciPy legacy function ``scipy.optimize.leastsq``, which is no longer recommended. This fitter supports parameter bounds using an unsophisticated min/max condition where parameters that are out of bounds are simply reset to the min or max of the bounds during each step. This can cause parameters to stick to one of the bounds during the fitting process if the parameter gets close to the bound. If needed, this fitter can still be used by explicitly setting the fitter in the ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` classes. The fitter used in ``RadialProfile`` to fit the profile with a Gaussian was also changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. The fitter used in ``centroid_1dg`` and ``centroid_2dg`` was also changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. For more information about Astropy's non-linear fitters, see :ref:`astropy:modeling-getting-started-nonlinear-notes`. Breaking API Change for PSF Photometry residual/model images ============================================================ The ``sub_shape`` keyword in `~photutils.psf.IterativePSFPhotometry` now defaults to using the model bounding box to define the shape. This is a change from the previous behavior where the default shape was set to ``fit_shape``. In general, one should want the subtraction shape to cover a large portion of the model image, which the bounding box does. If one wants to use a different shape, then the ``sub_shape`` keyword can be explicitly set. If the PSF model does not have a bounding box attribute, then the ``sub_shape`` keyword must be set to define the subtraction shape. Similarly, ``psf_shape`` is now an optional keyword in the ``make_model_image`` and ``make_residual_image`` methods of `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. The value defaults to using the model bounding box to define the shape and is required only if the PSF model does not have a bounding box attribute. In general, one should want the model and residual images to be constructed using a large portion of model image, which the bounding box does. If one wants to use a different shape, then the ``psf_shape`` keyword can be explicitly set. Bounding model fits in PSF Photometry ===================================== A new ``xy_bounds`` keyword was added to `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` to allow one to bound the x and y model parameters during the fitting. This can be used to prevent the fit values from wandering too far from the initial parameter guesses. New FWHM estimation tool ======================== A new `~photutils.psf.fit_fwhm` convenience function was added to estimate the FWHM of one or more sources in an image by fitting a circular 2D Gaussian PRF model using the PSF photometry tools. Similarly, a new `~photutils.psf.fit_2dgaussian` convenience function was added to fit a circular 2D Gaussian PRF to one or more sources in an image. Segmentation Image data type ============================ The `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions and `~photutils.segmentation.SourceFinder` class now return a ``SegmentationImage`` instance whose data dtype is ``np.int32`` instead of ``int`` (``int64``) unless more than (2**32 - 1) labels are needed. Also, the ``relabel_consecutive``, ``resassign_label(s)``, ``keep_label(s)``, ``remove_label(s)``, ``remove_border_labels``, and ``remove_masked_labels`` methods now keep the original dtype of the segmentation image instead of always changing it to ``int`` (``int64``). Improved performance for source deblending ========================================== Performance improvements and significant reductions in memory usage were made for source deblending, especially for large sources and/or large ``nlevels`` values. The memory usage is now mostly independent of the number of ``nlevels``, and the memory usage will be significantly reduced for large sources. This affects the `~photutils.segmentation.deblend_sources` function and the `~photutils.segmentation.SourceFinder` class. Additionally, the accuracy of the deblending progress bar is now improved when using multiprocessing. The progress bar now also displays the ID label number of either the current source being deblended (serial) or the last source that was deblended (multiprocessing). DAOStarFinder flux and mag changes ================================== The `~photutils.detection.DAOStarFinder` ``flux`` and ``mag`` columns were changed to give sensible values. Previously, the ``flux`` value was defined by the original DAOFIND algorithm as a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. Over the years, this has led to a lot of (understandable) confusion. The new ``flux`` column now gives the sum of data values within the kernel footprint. A ``daofind_mag`` column was added for comparison to the original IRAF DAOFIND algorithm. DAOStarFinder and IRAFStarFinder sky keyword removed ==================================================== The deprecated ``sky`` keyword in `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` has been removed. Also, there will no longer be a ``sky`` column in the `~photutils.detection.DAOStarFinder` output table. As documented, the input data is assumed to be background-subtracted. Quantity arrays in Centroids ============================ ``Quantity`` arrays can now be input to `~photutils.centroids.centroid_1dg` and `~photutils.centroids.centroid_2dg`. New Sphinx Theme ================ The documentation now uses the `PyData Sphinx `_ theme, which is a modern, responsive theme that is easy to read and navigate. astropy-photutils-3322558/docs/whats_new/2.1.rst000066400000000000000000000060271517052111400214240ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-2.1: **************************** What's New in Photutils 2.1? **************************** Here we highlight some of the new functionality of the 2.1 release. In addition to these changes, Photutils 2.1 includes several smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Aperture Photometry Output Table -------------------------------- The ``aperture_photometry`` output table will now include a ``sky_center`` column if ``wcs`` is input, even if the input aperture is not a sky aperture. Also, the ``xcenter`` and ``ycenter`` columns in the table returned by ``aperture_photometry`` no longer have (pixel) units for consistency with other tools in Photutils. Deblended Labels Mapping in Segmentation Image ---------------------------------------------- The ``SegmentationImage`` class now includes properties to identify and map any deblended labels. The ``deblended_labels`` property returns a list of deblended labels, the ``deblended_labels_map`` property returns a dictionary mapping the deblended labels to the parent labels, and the ``deblended_labels_inverse_map`` property returns a dictionary mapping the parent labels to the deblended labels. Star Finder Limits API Change ----------------------------- Detected sources that match interval ends for sharpness, roundness, and maximum peak values (``sharplo``, ``sharphi``, ``roundlo``, ``roundhi``, and ``peakmax``) are now included in the returned table of detected sources by ``DAOStarFinder`` and ``IRAFStarFinder``. Similarly, detected sources that match the maximum peak value (``peakmax``) are now included in the returned table of detected sources by ``StarFinder``. Find Peaks Border Width ----------------------- The ``find_peaks`` ``border_width`` keyword can now accept two values, indicating the border width along the y and x edges, respectively. Border Exclusion in DAOStarFinder and StarFinder ------------------------------------------------ When ``exclude_border`` is set to ``True`` in the ``DAOStarFinder`` and ``StarFinder`` classes, the excluded border region can now be different along the x and y edges if the kernel shape is rectangular. Gini Coefficient Mask --------------------- An optional ``mask`` keyword was added to the ``gini`` function to allow for the exclusion of certain pixels from the calculation of the Gini coefficient. Also, the ``gini`` function now returns zero instead of NaN if the (unmasked) data values sum to zero. New params_map keyword in make_model_image ------------------------------------------ An optional ``params_map`` keyword was added to ``make_model_image`` to allow a custom mapping between model parameter names and column names in the parameter table. Improved GriddedPSFModel Plots ------------------------------ The ``'viridis'`` color map is now the default in the ``GriddedPSFModel`` ``plot_grid`` method when ``deltas=True``. Also, the ``GriddedPSFModel`` ``plot_grid`` color bar now matches the height of the displayed image. astropy-photutils-3322558/docs/whats_new/2.2.rst000066400000000000000000000041111517052111400214150ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-2.2: **************************** What's New in Photutils 2.2? **************************** Here we highlight some of the new functionality of the 2.2 release. In addition to these changes, Photutils 2.2 includes several smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Converting Aperture Objects to Region Objects --------------------------------------------- A new `~photutils.aperture.aperture_to_region` function was added to convert an `~photutils.aperture.Aperture` object to a `regions.Region` or `regions.Regions` object. Because a `regions.Region` object can only have one position, a `regions.Regions` object will be returned if the input aperture has more than one position. Otherwise, a `regions.Region` object will be returned. The :meth:`regions.Region.write` and :meth:`regions.Regions.write` methods can be used to write the region(s) to a file. Segmentation Image Outlines as Regions Objects ---------------------------------------------- A new :meth:`~photutils.segmentation.SegmentationImage.to_regions` method was added to convert the outlines of the source segments to a `regions.Regions` object. The `regions.Regions` object contains a list of `regions.PolygonPixelRegion` objects, one for each source segment. The `regions.Regions` object can be written to a file using the :meth:`regions.Region.write` method. Raw Radial Profile ------------------ New ``data_radius`` and ``data_profile`` attributes were added to the `~photutils.profiles.RadialProfile` class for calculating the raw radial profile. These attributes return the radii and values of the data points within the maximum radius defined by the input radii. Pixel-based Aperture ``theta`` Units ------------------------------------ The ``theta`` attribute of `~photutils.aperture.EllipticalAperture`, `~photutils.aperture.EllipticalAnnulus`, `~photutils.aperture.RectangularAperture`, and `~photutils.aperture.RectangularAnnulus` apertures is now always returned as an angular `~astropy.units.Quantity` object. astropy-photutils-3322558/docs/whats_new/2.3.rst000066400000000000000000000117161517052111400214270ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-2.3: **************************** What's New in Photutils 2.3? **************************** Here we highlight some of the new functionality of the 2.3 release. In addition to these changes, Photutils 2.3 includes several smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Dependency Version Updates ========================== Photutils 2.3 bumps the minimum required versions of several key dependencies to provide users with access to the latest features and performance improvements: - NumPy minimum version is now 1.25 - SciPy minimum version is now 1.11.1 - Matplotlib minimum version is now 3.8 - scikit-image minimum version is now 0.21 PSF Photometry Improvements =========================== Enhanced Error Handling and Validation --------------------------------------- The PSF photometry classes have been significantly improved with better error handling and validation: - These classes no longer fail for invalid sources (those with no overlap with the input data, completely masked sources, or sources with too few unmasked pixels for fitting). Instead, they assign specific flag values (64, 128, 256) to identify these conditions. - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise an error if the input ``error`` array contains non-finite or zero values, preventing silent failures during fitting. - A new ``group_warning_threshold`` keyword has been added to control when warnings are issued about source grouping. Improved Performance and Memory Usage ------------------------------------- - When using Astropy 7.0+, the fitter object in ``PSFPhotometry`` and ``IterativePSFPhotometry`` now modifies the PSF model in place instead of creating copies, resulting in improved performance and significantly reduced memory usage. - For source groups, the PSF photometry classes now use a dynamically generated "flat model" approach instead of deeply nested compound models. This eliminates recursion limits that could cause failures with groups containing hundreds of sources, while providing dramatic performance improvements (up to 10x faster model creation and 50x less memory usage for large groups). New Reduced Chi-squared Fit Statistic ------------------------------------- The PSF photometry classes now return a reduced chi-squared statistic (``reduced_chi2`` column) in the results table. New Methods ----------- The PSF photometry classes now include new methods for flexible output formatting: - ``results_to_init_params``: Converts fit results to initialization parameter format - ``results_to_model_params``: Converts fit results to model parameter format Enhanced Flexibility --------------------- - ``GriddedPSFModel`` can now be used with a single input ePSF model, making it equivalent to ``ImagePSF`` for simpler use cases. - The ``finder`` callable input to ``PSFPhotometry`` and ``IterativePSFPhotometry`` now can return more flexible column names beyond the previously required ``'xcentroid'`` and ``'ycentroid'``. New Utility Functions --------------------- - ``decode_psf_flags``: A utility function for decoding PSF photometry bit flags. - ``PSF_FLAGS`` object: Provides readable, named constants for each PSF photometry bit flag and includes helper utilities for decoding. Isophote Package Improvements ============================= - The ``build_ellipse_model`` function in the isophote module has been Cythonized, making it considerably faster. - The ``build_ellipse_model`` function now includes an optional ``sma_interval`` keyword argument that was previously hardcoded. Segmentation Enhancements ========================= ``SegmentationImage`` Improvements ----------------------------------- - The ``to_regions`` method now supports a ``group`` keyword for better control over region grouping. - The ``polygons`` attribute can now handle non-contiguous segments, returning either Shapely ``Polygon`` or ``MultiPolygon`` objects as appropriate. - The ``to_patches`` and ``plot_patches`` methods now return ``matplotlib.patches.PathPatch`` objects for improved rendering. - The ``to_regions`` method now stores segment labels in the region object's ``meta`` dictionary for easy identification. ``SourceCatalog`` Features --------------------------- - The ``make_cutouts`` method now includes an optional ``array`` keyword for more flexible cutout generation. API Changes =========== ``Background2D`` now raises an explicit ``ValueError`` if the input data contains all non-finite values. Breaking Changes ---------------- The ``GriddedPSFModel`` ``data`` and ``grid_xypos`` attributes are now read-only to prevent accidental modification. Deprecations and Removals ------------------------- - The ``PSFPhotometry`` ``fit_param`` attribute is now deprecated. Use the new ``results_to_init_params`` method instead. - The deprecated ``PSFPhotometry`` ``fit_results`` attribute has been removed. astropy-photutils-3322558/docs/whats_new/3.0.rst000066400000000000000000001067601517052111400214310ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-3.0: **************************** What's New in Photutils 3.0? **************************** Photutils 3.0 is a major release that includes a small number of :ref:`breaking changes ` alongside several :ref:`new deprecations `. While these deprecations will now trigger warning messages, they will not break your code. We are intentionally providing a long deprecation period to give users ample time to update their code before these features are completely removed in version 4.0. Please review the :ref:`API Changes ` section before upgrading. Here we highlight some of the new functionality of the 3.0 release. In addition to these changes, Photutils 3.0 includes several smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Dependency Version Updates ========================== Photutils 3.0 bumps the minimum required versions of several key dependencies to provide users with access to the latest features and performance improvements: - NumPy minimum version is now 2.0 - SciPy minimum version is now 1.13 - Matplotlib minimum version is now 3.9 - scikit-image minimum version is now 0.23 Refactored ePSF Building ========================= The ePSF building tools have been significantly refactored for improved robustness, better diagnostics, and a cleaner API. New EPSFBuildResult class ------------------------- :class:`~photutils.psf.EPSFBuilder` now returns an :class:`~photutils.psf.EPSFBuildResult` dataclass instead of a plain tuple. The result object provides structured access to detailed build diagnostics:: >>> from photutils.psf import EPSFBuilder >>> builder = EPSFBuilder(oversampling=4) >>> result = builder(stars) >>> result.epsf # the constructed ePSF (ImagePSF) >>> result.fitted_stars # stars with updated centers/fluxes >>> result.iterations # number of iterations performed >>> result.converged # whether the build converged >>> result.final_center_accuracy # max center shift in last iteration Backward compatibility is maintained. Existing code using tuple unpacking will continue to work:: >>> epsf, stars = builder(stars) Improved star exclusion handling -------------------------------- Stars that repeatedly fail fitting are now automatically excluded from subsequent iterations, with informative warnings indicating the reason for exclusion (e.g., fit region extends beyond the cutout, or the fit did not converge). The number of excluded stars and their indices are reported in the :class:`~photutils.psf.EPSFBuildResult`. Improved PSF Matching Tools =========================== The PSF matching module has been significantly improved with better validation, regularization, and documentation. New regularization parameter ----------------------------- The :func:`~photutils.psf_matching.make_kernel` function now includes a ``regularization`` parameter to regularize division by near-zero values in the Fourier domain. This prevents numerical instabilities when the source PSF has very small Fourier coefficients:: >>> from photutils.psf_matching import make_kernel >>> kernel = make_kernel(source_psf, target_psf, regularization=1e-4) The default value of ``1e-4`` provides good regularization for most cases, but can be adjusted based on the noise characteristics of your PSFs. Improved validation ------------------- Both :func:`~photutils.psf_matching.make_kernel` and :func:`~photutils.psf_matching.resize_psf` now validate their inputs: * PSFs must be 2D arrays with odd dimensions * Window functions must return valid 2D arrays with values between [0, 1] * Pixel scales must be positive New PSF Matching Function with Wiener Regularization ---------------------------------------------------- A new :func:`~photutils.psf_matching.make_wiener_kernel` function has been added to the ``psf_matching`` subpackage. This function computes a Wiener-regularized PSF-matching kernel in the Fourier domain. The denominator includes a regularization term that stabilizes inversion of the source OTF (Optical Transfer Function, the Fourier transform of the PSF) by preventing division by small values, thereby suppressing noise amplification at spatial frequencies where the source response is weak. This method is particularly useful when working with PSFs that have near-zero power at high spatial frequencies (e.g., diffraction-limited PSFs), where the hard amplitude thresholding used by :func:`~photutils.psf_matching.make_kernel` can introduce discontinuities in Fourier space. Wiener regularization instead smoothly down-weights those frequencies, producing matching kernels with less ringing and often eliminating the need to carefully tune a window function. When no ``penalty`` is provided (default), the regularization is a frequency-independent (zero-order scalar) Tikhonov term expressed as a fraction of the peak power in the source OTF:: >>> from photutils.psf_matching import make_wiener_kernel >>> kernel = make_wiener_kernel(source_psf, target_psf) The function also supports a ``penalty`` parameter for frequency-dependent regularization. Setting ``penalty='laplacian'`` uses a discrete Laplacian operator that penalizes high spatial frequencies more heavily, reproducing the regularization approach used by the ``pypher`` package (`Boucaud et al. 2016`_):: >>> kernel = make_wiener_kernel(source_psf, target_psf, ... penalty='laplacian') .. _Boucaud et al. 2016: https://ui.adsabs.harvard.edu/abs/2016A%26A...596A..63B/abstract Improved Profile Tools ====================== New Curve of Growth Classes --------------------------- Two new curve-of-growth classes have been added to the `~photutils.profiles` subpackage: * :class:`~photutils.profiles.EnsquaredCurveOfGrowth`: Computes a curve of growth using concentric square apertures. The profile is measured as a function of half the square's side length. * :class:`~photutils.profiles.EllipticalCurveOfGrowth`: Computes a curve of growth using concentric elliptical apertures with a user-defined fixed axis ratio and orientation. The profile is measured as a function of the semimajor-axis length. These complement the existing :class:`~photutils.profiles.CurveOfGrowth` class, which uses concentric circular apertures. :class:`~photutils.profiles.EnsquaredCurveOfGrowth` example:: >>> from photutils.profiles import EnsquaredCurveOfGrowth >>> half_sizes = np.arange(1, 26) # half-sizes of the square apertures >>> ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error, ... mask=None) >>> ecog.normalize() >>> ee_vals = ecog.calc_ee_at_half_size(np.array([3, 6, 9])) :class:`~photutils.profiles.EllipticalCurveOfGrowth` example:: >>> from photutils.profiles import EllipticalCurveOfGrowth >>> radii = np.arange(1, 26) # semimajor-axis lengths >>> ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, ... theta=0.0, error=error, mask=None) >>> ecog.normalize() >>> ee_vals = ecog.calc_ee_at_radius(np.array([5, 10, 15])) Moffat Fitting for Radial Profiles ---------------------------------- :class:`~photutils.profiles.RadialProfile` now supports fitting a 1D Moffat model (using `~astropy.modeling.functional_models.Moffat1D`) to the radial profile, complementing the existing Gaussian fitting capability. Three new properties are available: * ``moffat_fit``: The fitted `~astropy.modeling.functional_models.Moffat1D` model. * ``moffat_profile``: The fitted 1D Moffat evaluated at the profile radii. * ``moffat_fwhm``: The FWHM from the fitted Moffat model. Moffat profiles have broader wings than Gaussians and are often a better representation of astronomical point-spread functions:: >>> rp = RadialProfile(data, xycen, edge_radii, error=error) >>> rp.moffat_fit >>> print(rp.moffat_fwhm) # doctest: +SKIP 11.2 Plot the fitted Moffat on the radial profile:: >>> fig, ax = plt.subplots() >>> rp.plot(ax=ax, label='Radial Profile') >>> ax.plot(rp.radius, rp.moffat_profile, label='Moffat Fit') >>> ax.legend() Improved PSF Photometry Tools ============================== New SourceGroups class ---------------------- A new :class:`~photutils.psf.SourceGroups` class provides a convenient and feature-rich interface for working with source grouping results. It includes: * Direct access to group IDs via the ``groups`` attribute * Properties for ``n_sources``, ``n_groups``, ``sizes``, and ``group_centers`` * The ``get_group_sources(group_id)`` method to retrieve sources in a specific group * A ``plot()`` method to visualize grouping results with color-coded apertures A :class:`~photutils.psf.SourceGroups` object can be returned by the :class:`~photutils.psf.SourceGrouper` class when called with the ``return_source_groups=True`` keyword argument:: >>> from photutils.psf import SourceGrouper >>> grouper = SourceGrouper(min_separation=10) >>> groups = grouper(x, y, return_source_groups=True) >>> print(groups.n_groups) # number of groups It can also be instantiated directly from source positions and group IDs:: >>> from photutils.psf import SourceGroups >>> groups = SourceGroups(x, y, group_ids) Improved Handling of Non-Finite Local Background Values in PSF Photometry ------------------------------------------------------------------------- :class:`~photutils.psf.PSFPhotometry` and :class:`~photutils.psf.IterativePSFPhotometry` now gracefully handle non-finite (NaN or inf) local background values instead of raising an error. This situation can occur when the local background estimator encounters fully masked regions within the background annulus. When a non-finite local background value is encountered: 1. The actual non-finite value is preserved and reported in the output table's ``local_bkg`` column 2. The value is not subtracted from the data before PSF fitting 3. A new flag (bit 2048, ``NON_FINITE_LOCALBKG``) is set in the ``flags`` column to indicate the issue This allows users to identify and handle problematic sources appropriately while still obtaining PSF fitting results where possible. Enhanced PSF Photometry Result Conversion Methods ------------------------------------------------- The :meth:`~photutils.psf.PSFPhotometry.results_to_init_params` and :meth:`~photutils.psf.PSFPhotometry.results_to_model_params` methods (and their :class:`~photutils.psf.IterativePSFPhotometry` equivalents) now support two new optional keyword arguments: * ``remove_invalid``: When ``True``, removes sources with non-finite fitted parameters (positions or flux) or invalid flags from the output table. This is useful for iterative PSF photometry workflows where you want to exclude failed fits from subsequent iterations. * ``reset_ids``: When ``True``, renumbers the source IDs sequentially starting from 1. This is particularly useful in combination with ``remove_invalid=True`` to maintain consecutive IDs after removing sources. These options provide more flexibility when converting PSF photometry results for use in subsequent fitting iterations or other analyses. New PSF decode_flags convenience method --------------------------------------- The :class:`~photutils.psf.PSFPhotometry` and :class:`~photutils.psf.IterativePSFPhotometry` classes now have a convenient ``decode_flags()`` method that decodes the bitwise flags from the ``'flags'`` column in the results table. The method returns a list of lists, where each inner list contains the active flag names for the corresponding source. This makes it easy to interpret which conditions were encountered during PSF fitting for each source. New ImagePSF shape property --------------------------- :class:`~photutils.psf.ImagePSF` now has a ``shape`` property that returns the shape of the (oversampled) PSF data array. Detection Improvements ====================== Spatially varying detection thresholds -------------------------------------- The ``threshold`` parameter in :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` now accepts a 2D array in addition to a scalar value, allowing for spatially varying detection thresholds across the image. This is useful, for example, when the noise level varies across the field or when combining the threshold with a 2D background RMS map. Unscaled threshold option for DAOStarFinder ------------------------------------------- :class:`~photutils.detection.DAOStarFinder` now accepts a ``scale_threshold`` keyword (default `True`). When set to `False`, the input ``threshold`` is applied directly to the convolved data without the default DAOFIND kernel-based scaling factor. New ``min_separation`` parameter for ``find_peaks`` --------------------------------------------------- :func:`~photutils.detection.find_peaks` now accepts a ``min_separation`` keyword argument that enforces a minimum peak separation without requiring an explicit circular footprint. When set, each detected peak must be the maximum value (or equal to the maximum) within a circle of the given radius, which is equivalent to passing a circular ``footprint`` of that radius to :func:`~scipy.ndimage.maximum_filter`:: >>> from photutils.detection import find_peaks >>> tbl = find_peaks(data, threshold=5.0, min_separation=12.5) This approach is approximately 10–400x faster than using an explicit circular footprint with :func:`~scipy.ndimage.maximum_filter` (depending on the radius). The result is identical to the slow circular-footprint method. This parameter is used internally by :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` when ``min_separation > 0`` is set. Performance improvements ------------------------ Source detection with :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` is now significantly faster. Cutout extraction and image moment computation have been fully vectorized. Public StarFinderCatalogBase class ---------------------------------- A new public :class:`~photutils.detection.StarFinderCatalogBase` class provides a common base for the source catalogs produced by :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder`. Users building custom star finders can subclass ``StarFinderCatalogBase`` to leverage all the built-in catalog infrastructure (table conversion, filtering, slicing, etc.). Segmentation Improvements ========================= Protection against large Kron radii ------------------------------------ :class:`~photutils.segmentation.SourceCatalog` now protects against unrealistically large ``kron_radius`` values that could cause out-of-memory errors. These can occur when noise or outlier pixels cause near-cancellation in the denominator of the Kron radius formula. ``kron_radius`` values exceeding the measurement aperture scale factor (6.0) are now set to NaN, and aperture masks that would exceed the input data size are rejected. This prevents pathologically large Kron apertures from allocating excessive memory during photometry. Improved performance of source properties ----------------------------------------- The following source properties in :class:`~photutils.segmentation.SourceCatalog` have been optimized for significantly improved performance by inlining the underlying computations and eliminating per-source function-call overhead: * ``centroid_quad`` * ``centroid_win`` (~3.5x speedup) * ``fluxfrac_radius`` (~6x speedup) * ``kron_flux`` * ``kron_fluxerr`` * ``kron_radius`` (~2x speedup) * ``moments`` * ``moments_central`` * ``perimeter`` New CentroidQuadratic class ============================ A new :class:`~photutils.centroids.CentroidQuadratic` class provides a callable object interface to the :func:`~photutils.centroids.centroid_quadratic` function. This class allows users to create a centroid function with specific fit parameters that can be reused multiple times. This is particularly useful when passing customized centroid functions to :func:`~photutils.centroids.centroid_sources` or other functions that accept a centroid function as an argument. The class is initialized with the desired ``fit_boxsize`` parameter, and then can be called with ``data`` and optional ``mask`` parameter:: >>> from photutils.centroids import CentroidQuadratic >>> centroid_func = CentroidQuadratic(fit_boxsize=7) >>> x, y = centroid_func(data, mask=mask) This is especially beneficial when using :func:`~photutils.centroids.centroid_sources` with fine-tuned centroid parameters:: >>> from photutils.centroids import centroid_sources >>> centroid_func = CentroidQuadratic(fit_boxsize=11) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_func) Improved Aperture Pixel-to-Sky Conversions ========================================== The :meth:`~photutils.aperture.PixelAperture.to_sky` and :meth:`~photutils.aperture.SkyAperture.to_pixel` methods for all aperture types have been significantly improved. They now use the local WCS Jacobian to accurately compute pixel scale factors and rotation angles, correctly handling WCS with distortions (e.g., SIP polynomial corrections) and non-square pixels. For elliptical and rectangular apertures specifically, the conversion now uses singular value decomposition (SVD) of the local Jacobian to compute independent scale factors along the width and height axes. This is more accurate than applying a single mean pixel scale to all linear dimensions, particularly near the edge of a distorted WCS or when the pixel scale differs significantly along the two axes. For all aperture types, the WCS properties (pixel scale, rotation angle) are evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position can be used. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate shape conversions for positions far from the first position. This behavior is now clearly documented in the :ref:`photutils-aperture` user guide and in the ``Notes`` section of each ``to_sky`` and ``to_pixel`` method docstring. .. _whatsnew-3.0-api-changes: API Changes =========== .. _whatsnew-3.0-deprecations: New Deprecations ---------------- Keyword-only Arguments ^^^^^^^^^^^^^^^^^^^^^^ Passing **optional** arguments positionally to all functions, classes, and methods in ``photutils`` is now deprecated. In the 4.0 release, all optional arguments must be passed as keyword arguments, e.g.:: # Old (deprecated) from photutils.centroids import centroid_2dg xc, yc = centroid_2dg(data, error, mask) # New from photutils.centroids import centroid_2dg xc, yc = centroid_2dg(data, error=error, mask=mask) This completes the transition to keyword-only arguments for all optional parameters in photutils, which was initiated in version 2.0. This change improves code readability and reduces the likelihood of errors from accidentally passing arguments in the wrong order. Deprecated Table Column Names ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Many photutils output tables previously used concatenated column names (e.g., ``xcentroid``, ``ycentroid``, ``segment_fluxerr``, ``kron_fluxerr``). In 3.0 these names are deprecated in favour of underscore-separated names (e.g., ``x_centroid``, ``y_centroid``, ``segment_flux_err``, ``kron_flux_err``) that are consistent with Python naming conventions and the rest of the photutils API. The following table column names are deprecated with the corresponding new names: - ``[name]_fluxerr`` -> ``[name]_flux_err`` - ``covar_sigx2`` -> ``covariance_xx`` - ``covar_sigxy`` -> ``covariance_xy`` - ``covar_sigy2`` -> ``covariance_yy`` - ``cxx`` -> ``ellipse_cxx`` - ``cxy`` -> ``ellipse_cxy`` - ``cyy`` -> ``ellipse_cyy`` - ``grad_error`` -> ``gradient_err`` - ``grad_rerror`` -> ``gradient_rel_err`` - ``kron_fluxerr`` -> ``kron_flux_err`` - ``npix`` -> ``n_pixels`` - ``npixfit`` -> ``n_pixels_fit`` - ``pa`` -> ``orientation`` - ``segment_fluxerr`` -> ``segment_flux_err`` - ``semimajor_sigma`` -> ``semimajor_axis`` - ``semiminor_sigma`` -> ``semiminor_axis`` - ``xcenter`` -> ``x_center`` - ``xcentroid`` -> ``x_centroid`` - ``xcentroid_quad`` -> ``x_centroid_quad`` - ``xcentroid_win`` -> ``x_centroid_win`` - ``ycenter`` -> ``y_center`` - ``ycentroid`` -> ``y_centroid`` - ``ycentroid_quad`` -> ``y_centroid_quad`` - ``ycentroid_win`` -> ``y_centroid_win`` To ease the transition, functions and classes that return output tables now return a ``DeprecatedColumnQTable`` instance, which is a thin wrapper to `~astropy.table.QTable`. These tables store data under the **new** column names internally, but transparently intercept accesses using the old names and emit an `~astropy.utils.exceptions.AstropyDeprecationWarning`:: >>> from photutils.segmentation import SourceCatalog >>> cat = SourceCatalog(data, segmentation_image) >>> tbl = cat.to_table() >>> tbl.colnames # new names are stored ['label', 'x_centroid', 'y_centroid', ...] >>> tbl['xcentroid'] # deprecated name still works, with a warning ... # AstropyDeprecationWarning: 'xcentroid' is deprecated; using 'x_centroid' All other table operations (slicing, sorting, copying, etc.) continue to work with either the old or new name, with the same warning for deprecated names. Opting into 4.0 Behavior Early """""""""""""""""""""""""""""" Users who have updated their code to use the new column names can opt in to the 4.0 behavior immediately by setting the ``photutils.future_column_names`` flag to `True`:: >>> import photutils >>> photutils.future_column_names = True When this flag is set, all photutils functions return plain `~astropy.table.QTable` instances with only the new column names. Accessing old column names will raise a `KeyError` instead of emitting a warning. This lets users verify their updated code is fully compatible with 4.0 before the release. .. note:: The ``future_column_names`` flag is a temporary migration aid. It will be **removed** in 4.0, at which point plain tables with the new column names will always be returned. Scoped Override for Libraries """"""""""""""""""""""""""""" Libraries that build on photutils can use the ``photutils.use_future_column_names`` context manager to opt into 4.0 behavior for a limited scope without changing the global flag. This is thread-safe and async-safe:: >>> from photutils import use_future_column_names >>> with use_future_column_names(): ... table = cat.to_table() # returns a plain QTable >>> # outside the block, behavior is unchanged The context-local override takes precedence over the global ``photutils.future_column_names`` flag and nests correctly:: >>> with use_future_column_names(): ... # new names only ... with use_future_column_names(enabled=False): ... # deprecated mapping is active again ... pass ... # back to new names only Aperture Package ^^^^^^^^^^^^^^^^ The following `~photutils.aperture.ApertureStats` properties have been renamed for consistency with the rest of the photutils API: * ``covar_sigx2`` -> ``covariance_xx`` * ``covar_sigxy`` -> ``covariance_xy`` * ``covar_sigy2`` -> ``covariance_yy`` * ``cxx`` -> ``ellipse_cxx`` * ``cxy`` -> ``ellipse_cxy`` * ``cyy`` -> ``ellipse_cyy`` * ``data_sumcutout`` -> ``data_sum_cutout`` * ``error_sumcutout`` -> ``error_sum_cutout`` * ``semimajor_sigma`` -> ``semimajor_axis`` * ``semiminor_sigma`` -> ``semiminor_axis`` * ``xcentroid`` -> ``x_centroid`` * ``ycentroid`` -> ``y_centroid`` The old property names are deprecated and will be removed in version 4.0. The `~photutils.aperture.ApertureStats` ``get_id`` and ``get_ids`` methods have been renamed to ``select_id`` and ``select_ids``, respectively (consistent with ``select_label`` and ``select_labels`` in `~photutils.segmentation.SourceCatalog`). The old method names are deprecated and will be removed in version 4.0. The ``xcenter`` and ``ycenter`` column names in the table returned by `~photutils.aperture.aperture_photometry` have been renamed to ``x_center`` and ``y_center``, respectively. The old names are deprecated and will be removed in version 4.0. The ``CircularMaskMixin``, ``EllipticalMaskMixin``, and ``RectangularMaskMixin`` classes are now deprecated and will be removed in a future version. The mask-generation logic is now handled internally by `~photutils.aperture.PixelAperture`. These mixin classes were implementation details used to share mask-generation code between aperture and annulus classes. No user action is required unless you were explicitly subclassing one of these mixin classes. Background Package ^^^^^^^^^^^^^^^^^^ The ``interpolator`` keyword argument for ``Background2D`` is now deprecated. When ``interpolator`` is eventually removed, the ``scipy.ndimage.zoom`` cubic spline interpolator will always be used to resize the low-resolution arrays. The behavior will be identical to the current ``BkgZoomInterpolator`` default. Relatedly, the ``BkgIDWInterpolator`` and ``BkgZoomInterpolator`` classes are now deprecated. The ``BkgIDWInterpolator`` is not well-suited for resizing images on a regular grid to larger sizes. It is also significantly slower than the default interpolator based on ``scipy.ndimage.zoom``. The ``BkgZoomInterpolator`` functionality will be preserved in the default resizing behavior of ``Background2D``. The ``Background2D`` ``npixels_mesh`` and ``npixels_map`` properties have been renamed to ``n_pixels_mesh`` and ``n_pixels_map``, respectively. The old names are deprecated. The ``Background2D`` ``bkgrms_estimator`` keyword argument is now deprecated. Use ``bkg_rms_estimator`` instead. Centroids Package ^^^^^^^^^^^^^^^^^ The ``xpeak``, ``ypeak``, and ``search_boxsize`` keyword arguments for the :func:`~photutils.centroids.centroid_quadratic` function are now deprecated. Use the :func:`~photutils.centroids.centroid_sources` function to centroid sources at specific positions. Datasets Package ^^^^^^^^^^^^^^^^ The ``get_path``, ``load_spitzer_image``, ``load_spitzer_catalog``, and ``load_star_image`` functions are now deprecated and will be removed in a future version. Detection Package ^^^^^^^^^^^^^^^^^ The ``sharplo`` and ``sharphi`` keyword arguments for :class:`~photutils.detection.DAOStarFinder` and :class:`~photutils.detection.IRAFStarFinder` are now deprecated. Use the ``sharpness_range=(lower, upper)`` tuple keyword instead. The ``roundlo`` and ``roundhi`` keyword arguments for :class:`~photutils.detection.DAOStarFinder` and :class:`~photutils.detection.IRAFStarFinder` are now deprecated. Use the ``roundness_range=(lower, upper)`` tuple keyword instead. The ``peakmax`` keyword argument for :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` is now deprecated. Use ``peak_max`` instead. The ``brightest`` keyword argument for :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` is now deprecated. Use ``n_brightest`` instead. The ``npeaks`` keyword argument for ``find_peaks`` is now deprecated. Use ``n_peaks`` instead. The ``minsep_fwhm`` keyword argument for ``IRAFStarFinder`` is now deprecated. Use ``min_separation`` instead. Isophote Package ^^^^^^^^^^^^^^^^ The ``nclip`` keyword argument for :meth:`~photutils.isophote.Ellipse.fit_image`, :meth:`~photutils.isophote.Ellipse.fit_isophote`, and :class:`~photutils.isophote.EllipseSample` is now deprecated. Use ``n_clip`` instead. The following :class:`~photutils.isophote.Isophote` and :class:`~photutils.isophote.IsophoteList` attributes have been renamed: * ``ndata`` -> ``n_data`` * ``nflag`` -> ``n_flag`` * ``niter`` -> ``n_iter`` The old attribute names are deprecated. PSF Package ^^^^^^^^^^^ The ``grid_from_epsfs`` helper function is now deprecated. This function creates a ``GriddedPSFModel`` from a list of ePSFs. Instead, use the ``GriddedPSFModel`` class directly. The :class:`~photutils.psf.EPSFFitter` class is now deprecated. To customize the ePSF fitting process, use the ``fitter``, ``fit_shape``, and ``fitter_maxiters`` parameters of :class:`~photutils.psf.EPSFBuilder` directly instead of creating an ``EPSFFitter`` instance. The ``npixfit_partial`` flag name and ``NPIXFIT_PARTIAL`` constant on :class:`~photutils.psf.PSFPhotometry` flags are now deprecated. Use ``n_pixels_fit_partial`` and ``N_PIXELS_FIT_PARTIAL`` instead. The ``localbkg_estimator`` keyword argument for :class:`~photutils.psf.PSFPhotometry` and :class:`~photutils.psf.IterativePSFPhotometry` is now deprecated. Use ``local_bkg_estimator`` instead. The ``include_localbkg`` keyword argument for the ``make_model_image`` and ``make_residual_image`` methods of :class:`~photutils.psf.PSFPhotometry` and :class:`~photutils.psf.IterativePSFPhotometry` is now deprecated. Use ``include_local_bkg`` instead. PSF Matching Package ^^^^^^^^^^^^^^^^^^^^ The ``photutils.psf.matching`` subpackage has been moved to be a top-level package at ``photutils.psf_matching``. This change improves consistency with photutils' other top-level subpackages (``aperture``, ``background``, ``centroids``, ``detection``, etc.), as PSF matching is a functionally independent tool for generating convolution kernels rather than part of the core PSF photometry workflow. Importing from the old location (``photutils.psf.matching``) is deprecated and will be removed in a future version. Update your imports:: # Old (deprecated) from photutils.psf.matching import CosineBellWindow # New from photutils.psf_matching import CosineBellWindow The PSF matching function ``create_matching_kernel`` has been renamed to ``make_kernel`` for brevity and consistency with Python naming conventions. The old function name is deprecated and will be removed in a future version. Update your code to use the new name:: # Old (deprecated) from photutils.psf.matching import create_matching_kernel from photutils.psf_matching import create_matching_kernel # New from photutils.psf_matching import make_kernel Segmentation Package ^^^^^^^^^^^^^^^^^^^^ The following ``SegmentationImage`` and ``SourceCatalog`` properties and methods have been renamed for consistency with the rest of the photutils API: * ``add_extra_property`` -> ``add_property`` * ``background`` -> ``background_cutout`` * ``background_ma`` -> ``background_cutout_masked`` * ``convdata`` -> ``conv_data_cutout`` * ``convdata_ma`` -> ``conv_data_cutout_masked`` * ``cutout_maxval_index`` -> ``cutout_max_value_index`` * ``cutout_minval_index`` -> ``cutout_min_value_index`` * ``data`` -> ``data_cutout`` * ``data_ma`` -> ``data_cutout_masked`` * ``deblended_labels_inverse_map`` -> ``parent_to_deblended_labels`` * ``deblended_labels_map`` -> ``deblended_label_to_parent`` * ``error`` -> ``error_cutout`` * ``error_ma`` -> ``error_cutout_masked`` * ``extra_properties`` -> ``custom_properties`` * ``fluxfrac_radius`` -> ``flux_radius`` * ``get_label`` -> ``select_label`` * ``get_labels`` -> ``select_labels`` * ``maxval_index`` -> ``max_value_index`` * ``maxval_xindex`` -> ``max_value_xindex`` * ``maxval_yindex`` -> ``max_value_yindex`` * ``minval_index`` -> ``min_value_index`` * ``minval_xindex`` -> ``min_value_xindex`` * ``minval_yindex`` -> ``min_value_yindex`` * ``nlabels`` -> ``n_labels`` * ``remove_extra_properties`` -> ``remove_properties`` * ``remove_extra_property`` -> ``remove_property`` * ``rename_extra_property`` -> ``rename_property`` * ``segment`` -> ``segment_cutout`` * ``segment_ma`` -> ``segment_cutout_masked`` The old property and method names are deprecated and will be removed in version 4.0. The following arguments used in various segmentation functions and classes have been renamed for consistency: * ``apermask_method`` -> ``aperture_mask_method`` * ``detection_cat`` -> ``detection_catalog`` * ``localbkg_width`` -> ``local_bkg_width`` * ``nlevels`` -> ``n_levels`` * ``npixels`` -> ``n_pixels`` * ``nproc`` -> ``n_processes`` * ``nsigma`` -> ``n_sigma`` * ``segment_img`` -> ``segmentation_image`` The old argument names are deprecated and will be removed in version 4.0. Utils Package ^^^^^^^^^^^^^ The ``nsigma``, ``napers``, and ``niters`` keyword arguments for :class:`~photutils.utils.ImageDepth` are now deprecated. Use ``n_sigma``, ``n_apertures``, and ``n_iters`` instead. The ``napers_used`` attribute on :class:`~photutils.utils.ImageDepth` is now deprecated. Use ``n_apertures_used`` instead. The ``ncolors`` parameter for :func:`~photutils.utils.make_random_cmap` has been renamed to ``n_colors``. The old name is deprecated. The ``reg`` parameter for :class:`~photutils.utils.ShepardIDWInterpolator` ``__call__`` has been renamed to ``regularization``. The old name is deprecated. Removed Deprecations -------------------- The following previously deprecated features from the ``background`` package have been removed: * The ``Background2D`` ``edge_method`` keyword argument. * The ``Background2D`` ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties. * The ``BkgZoomInterpolator`` ``grid_mode`` keyword argument. For the ``psf`` package, the previously deprecated ``FittableImageModel`` and ``EPSFModel`` classes have been removed. Use :class:`~photutils.psf.ImagePSF` instead. .. _whatsnew-3.0-breaking-changes: Breaking Changes ---------------- Consistent Default ``min_separation`` for Star Finders ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The default ``min_separation`` for :class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder` has been unified so that all three star finders derive a sensible non-zero default from the PSF scale. The default ``min_separation`` is now `None` for all three classes, which triggers an automatic default based on the PSF FWHM or kernel size: * :class:`~photutils.detection.DAOStarFinder`: changed from ``0`` (no separation) to ``2.5 * fwhm``. To recover the previous behavior, set ``min_separation=0``. * :class:`~photutils.detection.IRAFStarFinder`: changed from ``max(2, int(fwhm * 2.5 + 0.5))`` to ``2.5 * fwhm``. To recover the previous behavior, set ``min_separation=max(2, int(fwhm * 2.5 + 0.5))``. * :class:`~photutils.detection.StarFinder`: changed from ``5`` to ``2.5 * (min(kernel.shape) // 2)``. To recover the previous behavior, set ``min_separation=5``. Passing an explicit float continues to work as before. Set ``min_separation=0`` to disable the minimum separation requirement and allow sources to be detected arbitrarily close together. SourceCatalog ``orientation`` wrapped to [0, 360) degrees ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``orientation`` property of :class:`~photutils.segmentation.SourceCatalog` now always returns values in the range [0, 360) degrees. Star Finder ``pa`` Column Units and Range ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``orientation`` (was ``pa``) column in the output tables from :class:`~photutils.detection.IRAFStarFinder` and :class:`~photutils.detection.StarFinder` is now a ``Quantity`` array in the range of [0, 360) degrees. EPSFBuilder ``norm_radius`` removed ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``norm_radius`` keyword has been removed from :class:`~photutils.psf.EPSFBuilder`. This keyword is no longer relevant because the ePSF is now built directly as an :class:`~photutils.psf.ImagePSF`, which does not use a normalization radius. astropy-photutils-3322558/photutils/000077500000000000000000000000001517052111400174715ustar00rootroot00000000000000astropy-photutils-3322558/photutils/__init__.py000066400000000000000000000020511517052111400216000ustar00rootroot00000000000000# 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. """ try: from .version import version as __version__ except ImportError: __version__ = '' from .utils._deprecation import use_future_column_names # noqa: F401 future_column_names = False """ If `True`, all photutils functions return standard `~astropy.table.QTable` (or `~astropy.table.Table`) instances with the new column names instead of deprecated-column subclasses. Set this to `True` after updating your code to use the new column names to verify compatibility with the 4.0 behavior. For a scoped override that does not affect the global flag, use `photutils.use_future_column_names` as a context manager:: with photutils.use_future_column_names(): table = cat.to_table() # returns a plain QTable """ astropy-photutils-3322558/photutils/aperture/000077500000000000000000000000001517052111400213205ustar00rootroot00000000000000astropy-photutils-3322558/photutils/aperture/__init__.py000066400000000000000000000010201517052111400234220ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for performing aperture photometry. """ from .bounding_box import * # noqa: F401, F403 from .circle import * # noqa: F401, F403 from .converters 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 astropy-photutils-3322558/photutils/aperture/attributes.py000066400000000000000000000151341517052111400240640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Descriptor classes for aperture attribute validation. """ import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord __all__ = [ 'ApertureAttribute', 'PixelPositions', 'PositiveScalar', 'ScalarAngle', 'ScalarAngleOrValue', 'SkyCoordPositions', ] 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 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] def _validate(self, value): """ Validate the attribute value. An exception is raised if the value is invalid. """ 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): # Needed for zip positions (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 as exc: # Value is a zip object containing Quantity objects msg = f'{self.name!r} must not be a Quantity' raise TypeError(msg) from exc if isinstance(value, u.Quantity): msg = f'{self.name!r} must not be a Quantity' raise TypeError(msg) if np.any(~np.isfinite(value)): msg = (f'{self.name!r} must not contain any non-finite ' '(e.g., NaN or inf) positions') raise ValueError(msg) value_2d = np.atleast_2d(value) if value_2d.ndim > 2 or value_2d.shape[1] != 2: msg = (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)]') raise ValueError(msg) return value class SkyCoordPositions(ApertureAttribute): """ Check that value is a `~astropy.coordinates.SkyCoord`. """ def _validate(self, value): if not isinstance(value, SkyCoord): msg = f'{self.name!r} must be a SkyCoord instance' raise TypeError(msg) class PositiveScalar(ApertureAttribute): """ Check that value is a strictly positive (> 0) scalar. """ def _validate(self, value): if not np.isscalar(value) or value <= 0: msg = f'{self.name!r} must be a positive scalar' raise ValueError(msg) 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: msg = f'{self.name!r} must be a scalar' raise ValueError(msg) if value.unit.physical_type != 'angle': msg = f'{self.name!r} must have angular units' raise ValueError(msg) else: msg = f'{self.name!r} must be a scalar angle' raise TypeError(msg) 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 isinstance(value, u.Quantity): if not value.isscalar: msg = f'{self.name!r} must be a scalar' raise ValueError(msg) if value.unit.physical_type != 'angle': msg = f'{self.name!r} must have angular units' raise ValueError(msg) else: msg = f'{self.name!r} must be a scalar angle' raise TypeError(msg) if value <= 0: msg = f'{self.name!r} must be greater than zero' raise ValueError(msg) 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. The value is always output as a `~astropy.units.Quantity` with angular units. If the value is not a `~astropy.units.Quantity`, it is assumed to be in radians. """ 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) # If theta is not a Quantity, it is assumed to be in radians if not isinstance(value, u.Quantity): value <<= u.radian instance.__dict__[self.name] = value def _validate(self, value): if isinstance(value, u.Quantity): if not value.isscalar: msg = f'{self.name!r} must be a scalar' raise ValueError(msg) if value.unit.physical_type != 'angle': msg = f'{self.name!r} must have angular units' raise ValueError(msg) elif not np.isscalar(value): msg = (f'If not an angle Quantity, {self.name!r} must be a ' 'scalar float in radians') raise ValueError(msg) astropy-photutils-3322558/photutils/aperture/bounding_box.py000066400000000000000000000274661517052111400243660ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Class for a rectangular bounding box. """ import math import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __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)): msg = 'ixmin, ixmax, iymin, and iymax must all be integers' raise TypeError(msg) if ixmin > ixmax: msg = 'ixmin must be <= ixmax' raise ValueError(msg) if iymin > iymax: msg = 'iymin must be <= iymax' raise ValueError(msg) 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 The floating-point 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): msg = 'Can compare BoundingBox only to another BoundingBox.' raise TypeError(msg) 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 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 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: msg = 'input shape must have 2 elements' raise ValueError(msg) 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. The extent 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, optional 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)), origin='lower') ax.add_patch(bbox.as_artist(facecolor='none', edgecolor='white', lw=2.0)) """ 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): """ Convert the bounding box to a `~photutils.aperture.RectangularAperture`. Returns ------- aperture : `~photutils.aperture.RectangularAperture` A rectangular aperture. """ # 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) @deprecated_positional_kwargs(since='3.0', until='4.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, optional 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): msg = 'BoundingBox can be joined only with another BoundingBox.' raise TypeError(msg) 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): msg = ('BoundingBox can be intersected only with another ' 'BoundingBox.') raise TypeError(msg) 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) astropy-photutils-3322558/photutils/aperture/circle.py000066400000000000000000000517021517052111400231400ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Circular and circular-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from astropy.coordinates import Angle 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 from photutils.utils._deprecation import deprecated from photutils.utils._wcs_helpers import (pixel_to_sky_mean_scale, sky_to_pixel_mean_scale) __all__ = [ 'CircularAnnulus', 'CircularAperture', 'CircularMaskMixin', 'SkyCircularAnnulus', 'SkyCircularAperture', ] @deprecated('3.0') class CircularMaskMixin: # pragma: no cover """ Mixin class to create masks for circular and circular-annulus aperture objects. .. deprecated:: 3.0 """ 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_method(method, subpixels) if hasattr(self, 'r'): radius = self.r elif hasattr(self, 'r_out'): # annulus radius = self.r_out else: msg = 'Cannot determine the aperture radius' raise ValueError(msg) masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): 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] return masks class CircularAperture(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): """ The exact geometric area of the aperture shape. """ 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, optional 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 = [mpatches.Circle(xy_position, self.r, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ return circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r, use_exact, 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. Notes ----- The aperture shape parameters are converted using the local WCS pixel scale evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] _, mean_scale = pixel_to_sky_mean_scale( (float(first_pos[0]), float(first_pos[1])), wcs) r = Angle(self.r * mean_scale, 'arcsec') return SkyCircularAperture(positions=positions, r=r) class CircularAnnulus(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: msg = "'r_out' must be greater than 'r_in'" raise ValueError(msg) 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): """ The exact geometric area of the aperture shape. """ 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, optional 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] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ overlap = circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r_out, use_exact, subpixels) overlap -= circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r_in, use_exact, subpixels) return overlap 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. Notes ----- The aperture shape parameters are converted using the local WCS pixel scale evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] _, mean_scale = pixel_to_sky_mean_scale( (float(first_pos[0]), float(first_pos[1])), wcs) r_in = Angle(self.r_in * mean_scale, 'arcsec') r_out = Angle(self.r_out * mean_scale, 'arcsec') return SkyCircularAnnulus(positions=positions, r_in=r_in, r_out=r_out) 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. Notes ----- The aperture shape parameters are converted using the local WCS pixel scale evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] _, mean_scale = sky_to_pixel_mean_scale(skypos, wcs) r = self.r.to(u.arcsec).value * mean_scale return CircularAperture(positions=positions, r=r) 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 not r_out > r_in: msg = "'r_out' must be greater than 'r_in'" raise ValueError(msg) 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. Notes ----- The aperture shape parameters are converted using the local WCS pixel scale evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] _, mean_scale = sky_to_pixel_mean_scale(skypos, wcs) r_in = self.r_in.to(u.arcsec).value * mean_scale r_out = self.r_out.to(u.arcsec).value * mean_scale return CircularAnnulus(positions=positions, r_in=r_in, r_out=r_out) astropy-photutils-3322558/photutils/aperture/converters.py000066400000000000000000000423451517052111400240740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for converting between `regions.Region` and Aperture objects and between `shapely.Polygon` and `regions.PolygonRegion` objects. """ import astropy.units as u import numpy as np from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.core import Aperture from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) __all__ = ['aperture_to_region', 'region_to_aperture'] __doctest_requires__ = {'region_to_aperture': ['regions >= 0.12.dev'], 'aperture_to_region': ['regions >= 0.12.dev'], '_scalar_aperture_to_region': ['regions >= 0.12.dev'], '_shapely_polygon_to_region': ['regions >= 0.12.dev', 'shapely']} def region_to_aperture(region): """ Convert a given `regions.Region` object to an `~photutils.aperture.Aperture` object. Parameters ---------- region : `regions.Region` A supported `regions.Region` object. Returns ------- aperture : `~photutils.aperture.Aperture` An equivalent ``photutils`` aperture. Raises ------ `TypeError` The given `regions.Region` object is not supported. Notes ----- The ellipse ``width`` and ``height`` region parameters represent the full extent of the shapes and thus are divided by 2 when converting to elliptical aperture objects, which are defined using the semi-major (``a``) and semi-minor (``b``) axes. The ``width`` and ``height`` parameters are mapped to the semi-major (``a``) and semi-minor (``b``) axes parameters, respectively, of the elliptical apertures. The region ``angle`` for sky-based regions is defined as the angle of the ``width`` axis relative to WCS longitude axis (PA=90). However, the sky-based apertures define the ``theta`` as the position angle of the semimajor axis relative to the North celestial pole (PA=0). Therefore, for sky-based regions the region ``angle`` is converted to the aperture ``theta`` parameter by subtracting 90 degrees. .. |rarr| unicode:: U+0279E .. RIGHTWARDS ARROW The following `regions.Region` objects are supported, shown with their equivalent `~photutils.aperture.Aperture` object: * `~regions.CirclePixelRegion` |rarr| `~photutils.aperture.CircularAperture` * `~regions.CircleSkyRegion` |rarr| `~photutils.aperture.SkyCircularAperture` * `~regions.EllipsePixelRegion` |rarr| `~photutils.aperture.EllipticalAperture` * `~regions.EllipseSkyRegion` |rarr| `~photutils.aperture.SkyEllipticalAperture` * `~regions.RectanglePixelRegion` |rarr| `~photutils.aperture.RectangularAperture` * `~regions.RectangleSkyRegion` |rarr| `~photutils.aperture.SkyRectangularAperture` * `~regions.CircleAnnulusPixelRegion` |rarr| `~photutils.aperture.CircularAnnulus` * `~regions.CircleAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyCircularAnnulus` * `~regions.EllipseAnnulusPixelRegion` |rarr| `~photutils.aperture.EllipticalAnnulus` * `~regions.EllipseAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyEllipticalAnnulus` * `~regions.RectangleAnnulusPixelRegion` |rarr| `~photutils.aperture.RectangularAnnulus` * `~regions.RectangleAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyRectangularAnnulus` Examples -------- >>> from regions import CirclePixelRegion, PixCoord >>> from photutils.aperture import region_to_aperture >>> region = CirclePixelRegion(center=PixCoord(x=10, y=20), radius=5) >>> aperture = region_to_aperture(region) >>> aperture """ from regions import (CircleAnnulusPixelRegion, CircleAnnulusSkyRegion, CirclePixelRegion, CircleSkyRegion, EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion, EllipsePixelRegion, EllipseSkyRegion, RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion, RectanglePixelRegion, RectangleSkyRegion, Region) if not isinstance(region, Region): msg = 'Input region must be a Region object' raise TypeError(msg) if isinstance(region, CirclePixelRegion): aperture = CircularAperture(region.center.xy, region.radius) elif isinstance(region, CircleSkyRegion): aperture = SkyCircularAperture(region.center, region.radius) elif isinstance(region, EllipsePixelRegion): aperture = EllipticalAperture( region.center.xy, region.width * 0.5, region.height * 0.5, theta=region.angle) elif isinstance(region, EllipseSkyRegion): aperture = SkyEllipticalAperture( region.center, region.width * 0.5, region.height * 0.5, theta=(region.angle - (90 * u.deg))) elif isinstance(region, RectanglePixelRegion): aperture = RectangularAperture( region.center.xy, region.width, region.height, theta=region.angle) elif isinstance(region, RectangleSkyRegion): aperture = SkyRectangularAperture( region.center, region.width, region.height, theta=(region.angle - (90 * u.deg))) elif isinstance(region, CircleAnnulusPixelRegion): aperture = CircularAnnulus( region.center.xy, region.inner_radius, region.outer_radius) elif isinstance(region, CircleAnnulusSkyRegion): aperture = SkyCircularAnnulus( region.center, region.inner_radius, region.outer_radius) elif isinstance(region, EllipseAnnulusPixelRegion): aperture = EllipticalAnnulus( region.center.xy, region.inner_width * 0.5, region.outer_width * 0.5, region.outer_height * 0.5, b_in=region.inner_height * 0.5, theta=region.angle) elif isinstance(region, EllipseAnnulusSkyRegion): aperture = SkyEllipticalAnnulus( region.center, region.inner_width * 0.5, region.outer_width * 0.5, region.outer_height * 0.5, b_in=region.inner_height * 0.5, theta=(region.angle - (90 * u.deg))) elif isinstance(region, RectangleAnnulusPixelRegion): aperture = RectangularAnnulus( region.center.xy, region.inner_width, region.outer_width, region.outer_height, h_in=region.inner_height, theta=region.angle) elif isinstance(region, RectangleAnnulusSkyRegion): aperture = SkyRectangularAnnulus( region.center, region.inner_width, region.outer_width, region.outer_height, h_in=region.inner_height, theta=(region.angle - (90 * u.deg))) else: msg = (f'Cannot convert {region.__class__.__name__!r} to an ' 'Aperture object') raise TypeError(msg) return aperture def aperture_to_region(aperture): """ Convert a given `~photutils.aperture.Aperture` object to a `regions.Region` or `regions.Regions` object. Because a `regions.Region` object can only have one position, a `regions.Regions` object will be returned if the input ``aperture`` has more than one position. Otherwise, a `regions.Region` object will be returned. Parameters ---------- aperture : `~photutils.aperture.Aperture` An `~photutils.aperture.Aperture` object to convert. Returns ------- region : `regions.Region` or `regions.Regions` An equivalent `regions.Region` object. If the input ``aperture`` has more than one position then a `regions.Regions` will be returned. Notes ----- The elliptical aperture ``a`` and ``b`` parameters represent the semi-major and semi-minor axes, respectively. The ``a`` and ``b`` parameters are mapped to the ellipse ``width`` and ``height`` region parameters, respectively, by multiplying by 2 because they represent the full extent of the ellipse. The region ``angle`` for sky-based regions is defined as the angle of the ``width`` axis relative to WCS longitude axis (PA=90). However, the sky-based apertures define the ``theta`` as the position angle of the semimajor axis relative to the North celestial pole (PA=0). Therefore, for sky-based apertures the ``theta`` parameter is converted to the region ``angle`` by adding 90 degrees. .. |rarr| unicode:: U+0279E .. RIGHTWARDS ARROW The following `~photutils.aperture.Aperture` objects are supported, shown with their equivalent `regions.Region` object: * `~photutils.aperture.CircularAperture` |rarr| `~regions.CirclePixelRegion` * `~photutils.aperture.SkyCircularAperture` |rarr| `~regions.CircleSkyRegion` * `~photutils.aperture.EllipticalAperture` |rarr| `~regions.EllipsePixelRegion` * `~photutils.aperture.SkyEllipticalAperture` |rarr| `~regions.EllipseSkyRegion` * `~photutils.aperture.RectangularAperture` |rarr| `~regions.RectanglePixelRegion` * `~photutils.aperture.SkyRectangularAperture` |rarr| `~regions.RectangleSkyRegion` * `~photutils.aperture.CircularAnnulus` |rarr| `~regions.CircleAnnulusPixelRegion` * `~photutils.aperture.SkyCircularAnnulus` |rarr| `~regions.CircleAnnulusSkyRegion` * `~photutils.aperture.EllipticalAnnulus` |rarr| `~regions.EllipseAnnulusPixelRegion` * `~photutils.aperture.SkyEllipticalAnnulus` |rarr| `~regions.EllipseAnnulusSkyRegion` * `~photutils.aperture.RectangularAnnulus` |rarr| `~regions.RectangleAnnulusPixelRegion` * `~photutils.aperture.SkyRectangularAnnulus` |rarr| `~regions.RectangleAnnulusSkyRegion` Examples -------- >>> from photutils.aperture import CircularAperture, aperture_to_region >>> aperture = CircularAperture((10, 20), r=5) >>> region = aperture_to_region(aperture) >>> region >>> aperture = CircularAperture(((10, 20), (30, 40)), r=5) >>> region = aperture_to_region(aperture) >>> region , ])> """ from regions import Regions if not isinstance(aperture, Aperture): msg = 'Input aperture must be an Aperture object' raise TypeError(msg) if aperture.shape == (): return _scalar_aperture_to_region(aperture) # Multiple aperture positions return a Regions object regs = [_scalar_aperture_to_region(aper) for aper in aperture] return Regions(regs) def _scalar_aperture_to_region(aperture): """ Convert a given scalar `~photutils.aperture.Aperture` object to a `regions.Region` object. Parameters ---------- aperture : `~photutils.aperture.Aperture` An `~photutils.aperture.Aperture` object to convert. The ``aperture`` must have a single position (scalar). Returns ------- region : `regions.Region` or `regions.Regions` An equivalent `regions.Region` object. """ from regions import (CircleAnnulusPixelRegion, CircleAnnulusSkyRegion, CirclePixelRegion, CircleSkyRegion, EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion, EllipsePixelRegion, EllipseSkyRegion, PixCoord, RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion, RectanglePixelRegion, RectangleSkyRegion) if aperture.shape != (): msg = 'Only scalar (single-position) apertures are supported' raise ValueError(msg) if isinstance(aperture, CircularAperture): region = CirclePixelRegion(PixCoord(*aperture.positions), aperture.r) elif isinstance(aperture, SkyCircularAperture): region = CircleSkyRegion(aperture.positions, aperture.r) elif isinstance(aperture, EllipticalAperture): region = EllipsePixelRegion( PixCoord(*aperture.positions), aperture.a * 2, aperture.b * 2, angle=aperture.theta) elif isinstance(aperture, SkyEllipticalAperture): region = EllipseSkyRegion( aperture.positions, aperture.a * 2, aperture.b * 2, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, RectangularAperture): region = RectanglePixelRegion( PixCoord(*aperture.positions), aperture.w, aperture.h, angle=aperture.theta) elif isinstance(aperture, SkyRectangularAperture): region = RectangleSkyRegion( aperture.positions, aperture.w, aperture.h, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, CircularAnnulus): region = CircleAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.r_in, aperture.r_out) elif isinstance(aperture, SkyCircularAnnulus): region = CircleAnnulusSkyRegion( aperture.positions, aperture.r_in, aperture.r_out) elif isinstance(aperture, EllipticalAnnulus): region = EllipseAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.a_in * 2, aperture.a_out * 2, aperture.b_in * 2, aperture.b_out * 2, angle=aperture.theta) elif isinstance(aperture, SkyEllipticalAnnulus): region = EllipseAnnulusSkyRegion( aperture.positions, aperture.a_in * 2, aperture.a_out * 2, aperture.b_in * 2, aperture.b_out * 2, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, RectangularAnnulus): region = RectangleAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.w_in, aperture.w_out, aperture.h_in, aperture.h_out, angle=aperture.theta) elif isinstance(aperture, SkyRectangularAnnulus): region = RectangleAnnulusSkyRegion( aperture.positions, aperture.w_in, aperture.w_out, aperture.h_in, aperture.h_out, angle=(aperture.theta + (90 * u.deg))) else: msg = 'Cannot convert input aperture to a Region object' raise TypeError(msg) return region def _shapely_polygon_to_region(polygon, *, label=None, visual_kwargs=None): """ Convert a `shapely.Polygon` object to a `regions.PolygonPixelRegion` object. Parameters ---------- polygon : `shapely.Polygon` or `shapely.MultiPolygon` A Shapely Polygon or MultiPolygon object. label : str or `None`, optional A label for the region. If provided, it will be stored in the meta attribute of the returned `regions.PolygonPixelRegion` objects. visual_kwargs : dict or `None`, optional A dictionary of visual keyword arguments to pass to `regions.RegionVisual`. If provided, the visual attributes will be set on the returned region(s). Returns ------- result : list of `regions.PolygonPixelRegion` or `regions.Regions` If the polygon is a `shapely.Polygon`, then a `regions.PolygonPixelRegion` object is returned. If the polygon is a `shapely.MultiPolygon`, then a `regions.Regions` object is returned containing one or more `regions.PolygonPixelRegion` objects. Notes ----- The `regions.PolygonPixelRegion` object does not include the last Shapely vertex, which is the same as the first vertex. The `regions.PolygonPixelRegion` does not need to include the last vertex to close the polygon. Examples -------- >>> from shapely import Polygon >>> from photutils.aperture.converters import _shapely_polygon_to_region >>> polygon = Polygon([(1, 1), (3, 1), (2, 4), (1, 2)]) >>> region = _shapely_polygon_to_region(polygon) >>> region """ from regions import PixCoord, PolygonPixelRegion, Regions, RegionVisual from shapely import MultiPolygon, Polygon meta = {'label': label} if label is not None else None visual = RegionVisual(visual_kwargs) if visual_kwargs else None if isinstance(polygon, Polygon): x, y = np.transpose(polygon.exterior.coords[:-1]) return PolygonPixelRegion(vertices=PixCoord(x=x, y=y), meta=meta, visual=visual) if isinstance(polygon, MultiPolygon): geoms = [] for poly in polygon.geoms: x, y = np.transpose(poly.exterior.coords[:-1]) geoms.append(PolygonPixelRegion(vertices=PixCoord(x=x, y=y), meta=meta, visual=visual)) return Regions(geoms) msg = 'Input must be a Polygon or MultiPolygon object' raise TypeError(msg) astropy-photutils-3322558/photutils/aperture/core.py000066400000000000000000000701211517052111400226230ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Base aperture classes. """ import abc import inspect import warnings from copy import deepcopy import numpy as np from astropy.coordinates import SkyCoord from astropy.utils import lazyproperty from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.mask import ApertureMask from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['Aperture', 'PixelAperture', 'SkyAperture'] class Aperture(metaclass=abc.ABCMeta): """ Abstract base class for all apertures. """ _params = () def __len__(self): if self.isscalar: msg = f'A scalar {self.__class__.__name__!r} object has no len()' raise TypeError(msg) return self.shape[0] def __getitem__(self, index): if self.isscalar: msg = (f'A scalar {self.__class__.__name__!r} object cannot be ' 'indexed') raise TypeError(msg) 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) if isinstance(self, SkyAperture): return repr(self.positions) msg = 'Aperture must be a subclass of PixelAperture or SkyAperture' raise TypeError(msg) 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=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=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 a deep copy of this object. Returns ------- result : `Aperture` A deep copy of the Aperture object. """ 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): """ The aperture positions, as an array of (x, y) coordinates or a `~astropy.coordinates.SkyCoord`. """ @lazyproperty def shape(self): """ The shape of the instance. """ if isinstance(self.positions, SkyCoord): return self.positions.shape 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_method(method, subpixels, *, rectangle=False): """ Translate the mask method and subpixels parameters to the values used by the low-level `photutils.geometry` functions. Parameters ---------- method : {'exact', 'center', 'subpixel'} The mask method. subpixels : int The number of subpixels for subpixel method. rectangle : bool, optional Whether the aperture is a rectangular aperture. This is used to approximate the "exact" method for rectangular apertures, which is not currently supported by the low-level `photutils.geometry` functions. Returns ------- use_exact : int Whether to use exact method (1) or not (0). subpixels : int The number of subpixels for subpixel method. """ if method not in ('center', 'subpixel', 'exact'): msg = f'Invalid mask method: {method}' raise ValueError(msg) # Remove when rectangular apertures support "exact" method if rectangle and method == 'exact': method = 'subpixel' subpixels = 32 if ((method == 'subpixel') and (not isinstance(subpixels, int) or subpixels <= 0)): msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) if method == 'center': use_exact = 0 subpixels = 1 elif method == 'subpixel': use_exact = 0 elif method == 'exact': use_exact = 1 subpixels = 1 return use_exact, subpixels @property @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. """ @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, strict=True)] @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] 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, strict=True): 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 @property @abc.abstractmethod def area(self): """ The exact geometric 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 """ 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: msg = 'mask and data must have the same shape' raise ValueError(msg) 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] return areas @deprecated_positional_kwargs(since='3.0', until='4.0') 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_method( method, subpixels, rectangle=getattr(self, '_is_rectangle', False)) masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape overlap = self._compute_overlap( edges, nx, ny, use_exact, subpixels) masks.append(ApertureMask(overlap, bbox)) if self.isscalar: return masks[0] return masks @abc.abstractmethod def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture for a single position. Parameters ---------- edges : tuple of float The ``(xmin, xmax, ymin, ymax)`` pixel edges centered at the origin. nx, ny : int The number of pixels in x and y. use_exact : int Whether to use exact method (1) or not (0). subpixels : int The number of subpixels for subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap array. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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 sum within each aperture. aperture_sum_errs : `~numpy.ndarray` or `~astropy.units.Quantity` The errors on the sum 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 recommended to set ``method='subpixel'`` with a larger ``subpixels`` size. """ data = np.asanyarray(data) if data.ndim != 2: msg = 'data must be a 2D array' raise ValueError(msg) if error is not None: error = np.asanyarray(error) if error.shape != data.shape: msg = 'error and data must have the same shape' raise ValueError(msg) # Check Quantity inputs unit = {getattr(arr, 'unit', None) for arr in (data, error) if arr is not None} if len(unit) > 1: msg = ('If data or error has units, then they both must have ' 'the same units') raise ValueError(msg) # 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 BÊzier 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, optional 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, optional 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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, optional 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 @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. """ class SkyAperture(Aperture): """ Abstract base class for all apertures defined in celestial coordinates. """ @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. """ def _aperture_metadata(aperture, *, index=''): """ Return a dictionary of aperture metadata. Parameters ---------- aperture : `Aperture` An aperture object. index : str, optional A string that will be prepended to each metadata key. Returns ------- meta : dict A dictionary of aperture metadata """ params = aperture._params meta = {} meta[f'aperture{index}'] = aperture.__class__.__name__ for param in params: if param != 'positions': meta[f'aperture{index}_{param}'] = getattr(aperture, param) return meta astropy-photutils-3322558/photutils/aperture/ellipse.py000066400000000000000000000722221517052111400233340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Elliptical and elliptical-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from astropy.coordinates import Angle from astropy.utils import lazyproperty 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 from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) from photutils.utils._wcs_helpers import (pixel_ellipse_to_sky_svd, sky_ellipse_to_pixel_svd) __all__ = [ 'EllipticalAnnulus', 'EllipticalAperture', 'EllipticalMaskMixin', 'SkyEllipticalAnnulus', 'SkyEllipticalAperture', ] @deprecated('3.0', until='4.0') class EllipticalMaskMixin: # pragma: no cover """ Mixin class to create masks for elliptical and elliptical-annulus aperture objects. .. deprecated:: 3.0 """ 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_method(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: msg = 'Cannot determine the aperture shape' raise ValueError(msg) masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape theta_rad = self.theta.to(u.radian).value mask = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, a, b, theta_rad, 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, theta_rad, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks @staticmethod def _calc_extents(semimajor_axis, semiminor_axis, theta): """ Calculate half of the bounding box extents of an ellipse. """ return _calc_ellipse_extents(semimajor_axis, semiminor_axis, theta) def _calc_ellipse_extents(semimajor_axis, semiminor_axis, theta): """ Calculate half of the bounding box extents of an ellipse. """ theta_rad = theta.to(u.radian).value cos_theta = np.cos(theta_rad) sin_theta = np.sin(theta_rad) 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(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.') @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, a, b, theta=0.0): self.positions = positions self.a = a self.b = b self.theta = theta @lazyproperty def _xy_extents(self): """ The half of the bounding box extents of the ellipse in the x and y directions. """ return _calc_ellipse_extents(self.a, self.b, self.theta) @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ 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, optional 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) angle = self.theta.to(u.deg).value patches = [mpatches.Ellipse(xy_position, 2.0 * self.a, 2.0 * self.b, angle=angle, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ theta_rad = self.theta.to(u.radian).value return elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a, self.b, theta_rad, use_exact, 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] pixcoord = (float(first_pos[0]), float(first_pos[1])) _, sky_width, sky_height, sky_angle = pixel_ellipse_to_sky_svd( pixcoord, wcs, 2 * self.a, 2 * self.b, self.theta.to(u.rad).value) a = Angle(sky_width / 2, 'arcsec') b = Angle(sky_height / 2, 'arcsec') return SkyEllipticalAperture(positions=positions, a=a, b=b, theta=sky_angle) class EllipticalAnnulus(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.') @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0): if not a_out > a_in: msg = "'a_out' must be greater than 'a_in'" raise ValueError(msg) 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 elif not b_out > b_in: msg = "'b_out' must be greater than 'b_in'" raise ValueError(msg) self.b_in = b_in self.theta = theta @lazyproperty def _xy_extents(self): """ The half of the bounding box extents of the outer ellipse in the x and y directions. """ return _calc_ellipse_extents(self.a_out, self.b_out, self.theta) @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ 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, optional 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 = [] angle = self.theta.to(u.deg).value for xy_position in xy_positions: patch_inner = mpatches.Ellipse(xy_position, 2.0 * self.a_in, 2.0 * self.b_in, angle=angle) patch_outer = mpatches.Ellipse(xy_position, 2.0 * self.a_out, 2.0 * self.b_out, angle=angle) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ theta_rad = self.theta.to(u.radian).value overlap = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a_out, self.b_out, theta_rad, use_exact, subpixels) overlap -= elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a_in, self.b_in, theta_rad, use_exact, subpixels) return overlap 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] pixcoord = (float(first_pos[0]), float(first_pos[1])) theta_rad = self.theta.to(u.rad).value _, sky_w_out, sky_h_out, sky_angle = pixel_ellipse_to_sky_svd( pixcoord, wcs, 2 * self.a_out, 2 * self.b_out, theta_rad) _, sky_w_in, sky_h_in, _ = pixel_ellipse_to_sky_svd( pixcoord, wcs, 2 * self.a_in, 2 * self.b_in, theta_rad) a_out = Angle(sky_w_out / 2, 'arcsec') b_out = Angle(sky_h_out / 2, 'arcsec') a_in = Angle(sky_w_in / 2, 'arcsec') b_in = Angle(sky_h_in / 2, 'arcsec') return SkyEllipticalAnnulus(positions=positions, a_in=a_in, a_out=a_out, b_out=b_out, b_in=b_in, theta=sky_angle) 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.') @deprecated_positional_kwargs(since='3.0', until='4.0') 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] sky_angle_rad = self.theta.to(u.rad).value _, pix_width, pix_height, pix_angle = sky_ellipse_to_pixel_svd( skypos, wcs, 2 * self.a.to(u.arcsec).value, 2 * self.b.to(u.arcsec).value, sky_angle_rad) a = pix_width / 2 b = pix_height / 2 return EllipticalAperture(positions=positions, a=a, b=b, theta=pix_angle) 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.') @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0 * u.deg): if not a_out > a_in: msg = "'a_out' must be greater than 'a_in'" raise ValueError(msg) 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 elif not b_out > b_in: msg = "'b_out' must be greater than 'b_in'" raise ValueError(msg) 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] sky_angle_rad = self.theta.to(u.rad).value _, pix_w_out, pix_h_out, pix_angle = sky_ellipse_to_pixel_svd( skypos, wcs, 2 * self.a_out.to(u.arcsec).value, 2 * self.b_out.to(u.arcsec).value, sky_angle_rad) _, pix_w_in, pix_h_in, _ = sky_ellipse_to_pixel_svd( skypos, wcs, 2 * self.a_in.to(u.arcsec).value, 2 * self.b_in.to(u.arcsec).value, sky_angle_rad) a_out = pix_w_out / 2 b_out = pix_h_out / 2 a_in = pix_w_in / 2 b_in = pix_h_in / 2 return EllipticalAnnulus(positions=positions, a_in=a_in, a_out=a_out, b_out=b_out, b_in=b_in, theta=pix_angle) astropy-photutils-3322558/photutils/aperture/mask.py000066400000000000000000000266711517052111400226410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Class for aperture masks. """ import warnings import astropy.units as u import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['ApertureMask'] 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: msg = 'mask data and bounding box must have the same shape' raise ValueError(msg) self.bbox = bbox self._mask = (self.data == 0) # NumPy calls `obj.__array__(dtype)` positionally with # `np.asarray(obj, dtype=int)`, so dtype must remain a positional # argument. def __array__(self, dtype=None, *, copy=None): """ 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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" method, otherwise the fractional mask weights will be altered. An integer data type may be used if the `ApertureMask` was created with the "center" method. Returns ------- result : `~numpy.ndarray` A 2D array of the mask. """ if len(shape) != 2: msg = 'input shape must have 2 elements' raise ValueError(msg) # 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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 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: msg = 'data must be a 2D array' raise ValueError(msg) # 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 dtype = float if ~np.isfinite(fill_value) else 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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 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 # Ignore multiplication with non-finite data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) weighted_cutout = cutout * self.data # Fill values outside 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 and mask.shape != shape: msg = 'mask and data must have the same shape' raise ValueError(msg) 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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] astropy-photutils-3322558/photutils/aperture/photometry.py000066400000000000000000000254331517052111400241130ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing aperture photometry. """ import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture.converters import region_to_aperture from photutils.aperture.core import Aperture, SkyAperture, _aperture_metadata from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated_positional_kwargs) from photutils.utils._misc import _get_meta __all__ = ['aperture_photometry'] # Remove in 4.0 _DEPRECATED_COLUMNS: dict = { 'xcenter': 'x_center', 'ycenter': 'y_center', } @deprecated_positional_kwargs(since='3.0', until='4.0') 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`, supported `regions.Region`, \ list of `~photutils.aperture.Aperture` or `regions.Region` The aperture(s) to use for the photometry. If ``apertures`` is a list of `~photutils.aperture.Aperture` or `regions.Region`, then they all must have the same position(s). If ``apertures`` contains a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object, then a WCS must be input using the ``wcs`` keyword. Region objects are converted to aperture objects. 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`). If provided, the output table will include a ``'sky_center'`` column with the sky coordinates of the input aperture center(s). This keyword is required if the input ``apertures`` contains a `SkyAperture` or `~regions.SkyRegion`. Returns ------- table : `~astropy.table.QTable` A table of the photometry with the following columns: * ``'id'``: The source ID. * ``'x_center'``, ``'y_center'``: The ``x`` and ``y`` pixel coordinates of the input aperture center(s). * ``'sky_center'``: The sky coordinates of the input aperture center(s). Returned if a ``wcs`` is input. * ``'aperture_sum'``: The sum of the values within the aperture(s). * ``'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 ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. `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 recommended 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: msg = (f'The {key!r} keyword is ignored. Its value ' 'is obtained from the input NDData object.') warnings.warn(msg, 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 not isinstance(apertures, (list, tuple, np.ndarray)): single_aperture = True apertures = (apertures,) # Create table metadata using the input apertures, not the converted # ones aper_meta = {} for i, aperture in enumerate(apertures): i = '' if single_aperture else i aper_meta.update(_aperture_metadata(aperture, index=i)) # Convert regions to apertures if necessary apertures = [region_to_aperture(aper) if not isinstance(aper, Aperture) else aper for aper in apertures] # Convert sky to pixel apertures skyaper = False if isinstance(apertures[0], SkyAperture): if wcs is None: msg = ('A WCS transform must be defined by the input data or ' 'the wcs keyword when using a SkyAperture object.') raise ValueError(msg) # 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): msg = 'Input apertures must all have identical positions' raise ValueError(msg) # Define output table meta data meta = _get_meta() calling_args = f"method='{method}', subpixels={subpixels}" meta['aperture_photometry_args'] = calling_args meta.update(aper_meta) # Replace with QTable in 4.0 tbl = create_empty_deprecated_qtable( _DEPRECATED_COLUMNS, since='3.0', until='4.0') 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) tbl['x_center'] = xypos_pixel[0] tbl['y_center'] = 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 if wcs is not None and not skyaper: tbl['sky_center'] = wcs.pixel_to_world(*np.transpose(positions)) 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 astropy-photutils-3322558/photutils/aperture/rectangle.py000066400000000000000000000765431517052111400236550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Rectangular and rectangular-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from astropy.coordinates import Angle from astropy.utils import lazyproperty 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 from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) from photutils.utils._wcs_helpers import (pixel_to_sky_scales, sky_to_pixel_scales) __all__ = [ 'RectangularAnnulus', 'RectangularAperture', 'RectangularMaskMixin', 'SkyRectangularAnnulus', 'SkyRectangularAperture', ] @deprecated('3.0', until='4.0') class RectangularMaskMixin: # pragma: no cover """ Mixin class to create masks for rectangular or rectangular-annulus aperture objects. .. deprecated:: 3.0 """ 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_method(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: msg = 'Cannot determine the aperture radius' raise ValueError(msg) masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape theta_rad = self.theta.to(u.radian).value mask = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, w, h, theta_rad, 0, subpixels) # Subtract the inner rectangle 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, theta_rad, 0, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks @staticmethod def _calc_extents(width, height, theta): """ Calculate half of the bounding box extents of a rectangle. """ return _calc_rectangle_extents(width, height, theta) @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. """ return _calc_lower_left_positions(positions, width, height, theta) def _calc_rectangle_extents(width, height, theta): """ Calculate half of the bounding box extents of a rectangle. """ theta_rad = theta.to(u.radian).value half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta_rad) cos_theta = math.cos(theta_rad) 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 def _calc_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. """ theta_rad = theta.to(u.radian).value half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta_rad) cos_theta = math.cos(theta_rad) 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(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 a value in radians from ' 'the positive x axis.') _is_rectangle = True # remove when rectangles support "exact" method @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, w, h, theta=0.0): self.positions = positions self.w = w self.h = h self.theta = theta @lazyproperty def _xy_extents(self): """ The half-width and half-height of the bounding box of the rectangle. """ return _calc_rectangle_extents(self.w, self.h, self.theta) @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ 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, optional 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 = _calc_lower_left_positions(xy_positions, self.w, self.h, self.theta) angle = self.theta.to(u.deg).value patches = [mpatches.Rectangle(xy_position, self.w, self.h, angle=angle, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ theta_rad = self.theta.to(u.radian).value return rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w, self.h, theta_rad, use_exact, 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] pixcoord = (float(first_pos[0]), float(first_pos[1])) _, scale_w, scale_h, sky_angle = pixel_to_sky_scales( pixcoord, wcs, self.theta.to(u.rad).value) w = Angle(self.w * scale_w, 'arcsec') h = Angle(self.h * scale_h, 'arcsec') return SkyRectangularAperture(positions=positions, w=w, h=h, theta=sky_angle) class RectangularAnnulus(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 a value in radians from ' 'the positive x axis.') _is_rectangle = True # remove when rectangles support "exact" method @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0): if not w_out > w_in: msg = "'w_out' must be greater than 'w_in'" raise ValueError(msg) 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 elif not h_out > h_in: msg = "'h_out' must be greater than 'h_in'" raise ValueError(msg) self.h_in = h_in self.theta = theta @lazyproperty def _xy_extents(self): """ The half-width and half-height of the bounding box of the rectangle. """ return _calc_rectangle_extents(self.w_out, self.h_out, self.theta) @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ 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, optional 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 = _calc_lower_left_positions(xy_positions, self.w_in, self.h_in, self.theta) outer_xy_positions = _calc_lower_left_positions(xy_positions, self.w_out, self.h_out, self.theta) patches = [] angle = self.theta.to(u.deg).value for xy_in, xy_out in zip(inner_xy_positions, outer_xy_positions, strict=True): patch_inner = mpatches.Rectangle(xy_in, self.w_in, self.h_in, angle=angle) patch_outer = mpatches.Rectangle(xy_out, self.w_out, self.h_out, angle=angle) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] return patches def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. Parameters ---------- edges : list of 4 1D `~numpy.ndarray` The edges of the pixel grid in the form of ``[x_edges, y_edges, x_centers, y_centers]``. nx, ny : int The number of pixels in the x and y directions. use_exact : bool Whether to use the exact method for calculating the overlap. subpixels : int The number of subpixels to use in each dimension for the subpixel method. Returns ------- overlap : 2D `~numpy.ndarray` The overlap of the aperture on the pixel grid. The values will be between 0 and 1, where 0 means no overlap and 1 means full overlap. """ theta_rad = self.theta.to(u.radian).value overlap = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w_out, self.h_out, theta_rad, use_exact, subpixels) overlap -= rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w_in, self.h_in, theta_rad, use_exact, subpixels) return overlap 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] pixcoord = (float(first_pos[0]), float(first_pos[1])) _, scale_w, scale_h, sky_angle = pixel_to_sky_scales( pixcoord, wcs, self.theta.to(u.rad).value) w_in = Angle(self.w_in * scale_w, 'arcsec') w_out = Angle(self.w_out * scale_w, 'arcsec') h_in = Angle(self.h_in * scale_h, 'arcsec') h_out = Angle(self.h_out * scale_h, 'arcsec') return SkyRectangularAnnulus(positions=positions, w_in=w_in, w_out=w_out, h_out=h_out, h_in=h_in, theta=sky_angle) 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.') @deprecated_positional_kwargs(since='3.0', until='4.0') 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] sky_angle_rad = self.theta.to(u.rad).value _, scale_w, scale_h, pixel_angle = sky_to_pixel_scales( skypos, wcs, sky_angle_rad) w = self.w.to(u.arcsec).value * scale_w h = self.h.to(u.arcsec).value * scale_h return RectangularAperture(positions=positions, w=w, h=h, theta=pixel_angle) 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 inner 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.') @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0 * u.deg): if not w_out > w_in: msg = "'w_out' must be greater than 'w_in'" raise ValueError(msg) 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 elif not h_out > h_in: msg = "'h_out' must be greater than 'h_in'" raise ValueError(msg) 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. Notes ----- The aperture shape parameters are converted using the local WCS properties (pixel scale, rotation angle) evaluated at the first aperture position. Because aperture objects require scalar shape parameters, only a single reference position is used for the conversion. For apertures with multiple positions used with a WCS that has spatially-varying distortions, this may produce inaccurate results for positions far from the first position. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] sky_angle_rad = self.theta.to(u.rad).value _, scale_w, scale_h, pixel_angle = sky_to_pixel_scales( skypos, wcs, sky_angle_rad) w_in = self.w_in.to(u.arcsec).value * scale_w w_out = self.w_out.to(u.arcsec).value * scale_w h_in = self.h_in.to(u.arcsec).value * scale_h h_out = self.h_out.to(u.arcsec).value * scale_h return RectangularAnnulus(positions=positions, w_in=w_in, w_out=w_out, h_out=h_out, h_in=h_in, theta=pixel_angle) astropy-photutils-3322558/photutils/aperture/stats.py000066400000000000000000001704031517052111400230350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating 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.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import Aperture, SkyAperture, region_to_aperture from photutils.aperture.core import _aperture_metadata from photutils.morphology import gini as gini_func from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated_getattr, deprecated_positional_kwargs) from photutils.utils._misc import _get_meta from photutils.utils._moments import _image_moments from photutils.utils._quantity_helpers import process_quantities __all__ = ['ApertureStats'] # Default table columns for `to_table()` output DEFAULT_COLUMNS = ['id', 'x_centroid', 'y_centroid', '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_axis', 'semiminor_axis', 'orientation', 'eccentricity'] # Remove in 4.0 _DEPRECATED_ATTRIBUTES: dict = { 'covar_sigx2': 'covariance_xx', 'covar_sigxy': 'covariance_xy', 'covar_sigy2': 'covariance_yy', 'cxx': 'ellipse_cxx', 'cxy': 'ellipse_cxy', 'cyy': 'ellipse_cyy', 'data_sumcutout': 'data_sum_cutout', 'error_sumcutout': 'error_sum_cutout', 'get_id': 'select_id', 'get_ids': 'select_ids', 'semimajor_sigma': 'semimajor_axis', 'semiminor_sigma': 'semiminor_axis', 'xcentroid': 'x_centroid', 'ycentroid': 'y_centroid', } def as_scalar(method): """ Return a decorated method where it will always return a scalar value (instead of a length-1 tuple/list/array) if the class is scalar. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @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` or supported `~regions.Region` The aperture or region to apply to the data. The aperture or region object may contain more than one position. If the input ``aperture`` is a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object, then a WCS must be input using the ``wcs`` keyword. Region objects are converted to aperture objects. 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` or `~regions.SkyRegion` object. 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_sum_cutout``, and ``error_sum_cutout`` 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. `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. The returned statistics are measured for the pixels within the input aperture at its input position. This class does not change the position of the input aperture. This class returns the centroid value of the pixels within the input aperture, but the input aperture is not recentered at the measured centroid position when making the measurements. If desired, you can create a new `Aperture` object using the measured centroid and then re-run `~photutils.aperture.ApertureStats`. 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_sum_cutout``, and ``error_sum_cutout``. 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.x_centroid) # doctest: +FLOAT_CMP 149.99080259251238 >>> print(aperstats.y_centroid) # doctest: +FLOAT_CMP 24.97484633000507 >>> print(aperstats.centroid) # doctest: +FLOAT_CMP [149.99080259 24.97484633] >>> print(aperstats.mean, aperstats.median) # doctest: +FLOAT_CMP 47.76300955780609 31.913789514433084 >>> print(aperstats.std) # doctest: +FLOAT_CMP 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.x_centroid) # 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._input_aperture = self._validate_aperture(aperture) aperture_meta = _aperture_metadata(aperture) # use input aperture if isinstance(aperture, SkyAperture) and wcs is None: msg = 'A wcs is required when using a SkyAperture' raise ValueError(msg) # Convert region to aperture if necessary if not isinstance(aperture, Aperture): aperture = region_to_aperture(aperture) self.aperture = aperture 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): msg = 'sigma_clip must be a SigmaClip instance' raise TypeError(msg) 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: msg = 'local_bkg must be a 1D array' raise ValueError(msg) n_local_bkg = len(local_bkg) if n_local_bkg not in (1, self.n_apertures): msg = ('local_bkg must be scalar or have the same length ' 'as the input aperture') raise ValueError(msg) local_bkg = np.broadcast_to(local_bkg, self.n_apertures) if np.any(~np.isfinite(local_bkg)): msg = ('local_bkg must not contain any non-finite ' '(e.g., inf or NaN) values') raise ValueError(msg) 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() self.meta.update(aperture_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: msg = (f'The {key!r} keyword will be ignored. Its value ' 'is obtained from the input NDData object.') warnings.warn(msg, 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): try: from regions import Region aper_types = (Aperture, Region) except ImportError: aper_types = Aperture if not isinstance(aperture, aper_types): msg = 'aperture must be an Aperture or Region object' raise TypeError(msg) 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: msg = f'{name} must be a {ndim}D array' raise ValueError(msg) if shape and array.shape != self._data.shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_lazyproperties' # Subclasses get their own lazyproperty list if attr not in cls.__dict__: def islazyproperty(obj): return isinstance(obj, lazyproperty) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=islazyproperty)]) return getattr(cls, attr) @property def properties(self): """ A sorted list of the 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: msg = (f'A scalar {self.__class__.__name__!r} object cannot ' 'be indexed') raise TypeError(msg) 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: msg = f'Scalar {self.__class__.__name__!r} object has no len()' raise TypeError(msg) return self.n_apertures def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') @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. Returns ------- result : `ApertureStats` 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 select_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.select_ids(id_num) def select_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: msg = f'{id_num} is not a valid source ID number' raise ValueError(msg) sorter = np.argsort(self.id) indices = sorter[np.searchsorted(self.id, id_nums, sorter=sorter)] return self[indices] @deprecated_positional_kwargs(since='3.0', until='4.0') 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 elif isinstance(columns, str): table_columns = [columns] else: table_columns = columns # Replace with QTable in 4.0 tbl = create_empty_deprecated_qtable( _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') 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 for 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 an iterable. """ cutouts = [] for (slices, local_bkg) in zip(self._overlap_slices, self._local_bkg, strict=True): 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, strict=True): 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, strict=True)) @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, strict=True))[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, strict=True))[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, strict=True)] 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, strict=True)] @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, strict=True))[0]) @lazyproperty @as_scalar def data_sum_cutout(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, strict=True))[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, strict=True))[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, strict=True))[1]) @lazyproperty @as_scalar def error_sum_cutout(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 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, strict=True))[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 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, strict=True))[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, strict=True))[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([_image_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([_image_moments(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutout, cutout_centroid[:, 0], cutout_centroid[:, 1], strict=True)]) @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) y_centroid = moments[:, 1, 0] / moments[:, 0, 0] x_centroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((x_centroid, y_centroid)) @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 _x_centroid(self): """ The ``x`` coordinate of the centroid, always as an iterable. """ x_centroid = np.transpose(self.centroid)[0] if self.isscalar: x_centroid = (x_centroid,) return x_centroid @lazyproperty @as_scalar def x_centroid(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._x_centroid @lazyproperty def _y_centroid(self): """ The ``y`` coordinate of the centroid, always as an iterable. """ y_centroid = np.transpose(self.centroid)[1] if self.isscalar: y_centroid = (y_centroid,) return y_centroid @lazyproperty @as_scalar def y_centroid(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._y_centroid @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.x_centroid, self.y_centroid) @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" method. """ return self._bbox @lazyproperty @as_scalar def _bbox_bounds(self): """ The bounding box x and 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_sum_cutout) 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_err`, :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.eigvalsh requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvalsh(self._covariance[idx]) # Check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) eigvals[idx2] = (np.nan, np.nan) # Sort each eigenvalue pair in descending order # (eigvalsh returns values in ascending order) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @as_scalar def semimajor_axis(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, :] return np.sqrt(eigvals[:, 0]) @lazyproperty @as_scalar def semiminor_axis(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, :] 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_axis`) and semiminor (`semiminor_axis`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_axis**2 + self.semiminor_axis**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 np.rad2deg(orient_radians) * 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_axis / self.semiminor_axis @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_axis / self.semimajor_axis) @lazyproperty @as_scalar def covariance_xx(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 covariance_yy(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 covariance_xy(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 ellipse_cxx(self): r""" Coefficient for ``x**2`` in the generalized ellipse equation 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_axis)**2 + (np.sin(self.orientation) / self.semiminor_axis)**2) @lazyproperty @as_scalar def ellipse_cyy(self): r""" Coefficient for ``y**2`` in the generalized ellipse equation 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_axis)**2 + (np.cos(self.orientation) / self.semiminor_axis)**2) @lazyproperty @as_scalar def ellipse_cxy(self): r""" Coefficient for ``x * y`` in the generalized ellipse equation 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_axis**2) - (1.0 / self.semiminor_axis**2))) @lazyproperty @as_scalar def gini(self): r""" The `Gini coefficient `_ of the unmasked pixel values within the aperture. The Gini coefficient of the distribution of absolute flux values is calculated using the prescription from `Lotz et al. 2004 `_ (Eq. 6) as: .. math:: G = \frac{1}{\overline{|x|} \, n \, (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\overline{|x|}` is the mean of the absolute value of all pixel values :math:`x_i`. If the sum of all pixel values is zero, the Gini coefficient is zero. Negative pixel values are used via their absolute value. Invalid values (NaN and inf) in the input are automatically excluded from the calculation. If only a single finite pixel remains after filtering, the Gini coefficient is 0.0. """ return np.array([gini_func(arr) for arr in self._data_values_center]) astropy-photutils-3322558/photutils/aperture/tests/000077500000000000000000000000001517052111400224625ustar00rootroot00000000000000astropy-photutils-3322558/photutils/aperture/tests/__init__.py000066400000000000000000000000001517052111400245610ustar00rootroot00000000000000astropy-photutils-3322558/photutils/aperture/tests/test_aperture_common.py000066400000000000000000000040351517052111400272740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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)) astropy-photutils-3322558/photutils/aperture/tests/test_attributes.py000066400000000000000000000354771517052111400263010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the attributes module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from photutils.aperture.attributes import (ApertureAttribute, PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) class MockBase: """ Minimal class using ApertureAttribute as a descriptor. """ attr = ApertureAttribute(doc='a test attribute') class MockWithLazy: """ Minimal class with _lazyproperties for reset testing. """ attr = ApertureAttribute() _lazyproperties = ['cached'] # noqa: RUF012 class MockPixelPos: """ Minimal class using PixelPositions as a descriptor. """ positions = PixelPositions() class MockSkyCoord: """ Minimal class using SkyCoordPositions as a descriptor. """ positions = SkyCoordPositions() class MockPositiveScalar: """ Minimal class using PositiveScalar as a descriptor. """ r = PositiveScalar() class MockScalarAngle: """ Minimal class using ScalarAngle as a descriptor. """ angle = ScalarAngle() class MockPositiveScalarAngle: """ Minimal class using PositiveScalarAngle as a descriptor. """ angle = PositiveScalarAngle() class MockScalarAngleOrValue: """ Minimal class using ScalarAngleOrValue as a descriptor. """ theta = ScalarAngleOrValue() class TestApertureAttribute: """ Tests for the ApertureAttribute base descriptor class. """ def test_class_access(self): """ Test that __get__ with instance=None returns the descriptor. """ desc = MockBase.attr assert isinstance(desc, ApertureAttribute) def test_doc(self): """ Test that the descriptor docstring is set correctly. """ desc = MockBase.__dict__['attr'] assert desc.__doc__ == 'a test attribute' def test_set_name(self): """ Test that __set_name__ assigns the attribute name correctly. """ desc = MockBase.__dict__['attr'] assert desc.name == 'attr' def test_set_and_get(self): """ Test that setting and getting a value round-trips correctly. """ obj = MockBase() obj.attr = 5.0 assert obj.attr == 5.0 def test_converts_to_float(self): """ Test that non-Quantity scalars are converted to float. """ obj = MockBase() obj.attr = 3 assert isinstance(obj.attr, float) assert obj.attr == 3.0 def test_quantity_not_converted(self): """ Test that Quantity values are stored without conversion. """ obj = MockBase() val = 5.0 * u.m obj.attr = val assert isinstance(obj.attr, u.Quantity) def test_skycoord_not_converted(self): """ Test that SkyCoord values are stored without conversion. """ obj = MockBase() val = SkyCoord(ra=10.0, dec=20.0, unit='deg') obj.attr = val assert isinstance(obj.attr, SkyCoord) def test_reset_lazyproperties(self): """ Test that lazyproperties are cleared when the attribute is updated. """ obj = MockWithLazy() obj.attr = 1.0 obj.cached = 42 assert 'cached' in obj.__dict__ obj.attr = 2.0 # second assignment triggers _reset_lazyproperties assert 'cached' not in obj.__dict__ def test_reset_no_lazyproperties(self): """ Test that AttributeError is silently caught when _lazyproperties is absent. """ obj = MockBase() obj.attr = 1.0 # Triggers _reset_lazyproperties; obj has no _lazyproperties obj.attr = 2.0 assert obj.attr == 2.0 def test_delete(self): """ Test that deleting the attribute removes it from the instance. """ obj = MockBase() obj.attr = 5.0 del obj.attr assert 'attr' not in obj.__dict__ def test_validate_noop(self): """ Test that the base _validate method is a no-op. """ obj = MockBase() obj.attr = 99.0 # calls _validate internally without error assert obj.attr == 99.0 class TestPixelPositions: """ Tests for the PixelPositions descriptor class. """ def test_single_tuple(self): """ Test that a single (x, y) tuple is accepted. """ obj = MockPixelPos() obj.positions = (10, 20) # _validate returns the original (non-atleast_2d) array assert obj.positions.shape == (2,) assert obj.positions[0] == 10.0 assert obj.positions[1] == 20.0 def test_list_of_tuples(self): """ Test that a list of (x, y) tuples is accepted. """ obj = MockPixelPos() obj.positions = [(1, 2), (3, 4)] assert obj.positions.shape == (2, 2) def test_ndarray(self): """ Test that a 2D numpy array of positions is accepted. """ obj = MockPixelPos() pos = np.array([(5.0, 6.0), (7.0, 8.0)]) obj.positions = pos assert obj.positions.shape == (2, 2) def test_zip_input(self): """ Test that a zip of x and y arrays is accepted. """ obj = MockPixelPos() obj.positions = zip([1.0, 2.0], [3.0, 4.0], strict=False) assert obj.positions.shape == (2, 2) def test_reset_lazyproperties(self): """ Test that setting positions twice clears cached lazyproperties. """ obj = MockPixelPos() obj.positions = [(1, 2)] obj._lazyproperties = ['cached'] obj.cached = 99 obj.positions = [(3, 4)] # triggers reset assert 'cached' not in obj.__dict__ def test_quantity_error(self): """ Test that a Quantity array raises TypeError. """ obj = MockPixelPos() match = "'positions' must not be a Quantity" with pytest.raises(TypeError, match=match): obj.positions = np.array([1.0, 2.0]) * u.m def test_bad_type_error(self): """ Test the TypeError path via np.asanyarray(...).astype(float). """ desc = MockPixelPos.__dict__['positions'] match = "'positions' must not be a Quantity" with pytest.raises(TypeError, match=match): # Sets cannot be converted to float, triggering except TypeError desc._validate([({1, 2}, {3, 4})]) def test_zip_quantity_error(self): """ Test that TypeError is raised when zip contains Quantity objects. """ obj = MockPixelPos() match = "'positions' must not be a Quantity" with pytest.raises(TypeError, match=match): obj.positions = zip( [1.0 * u.m, 2.0 * u.m], [3.0 * u.m, 4.0 * u.m], strict=False, ) def test_nonfinite_nan_error(self): """ Test that NaN positions raise ValueError. """ obj = MockPixelPos() match = "'positions' must not contain any non-finite" with pytest.raises(ValueError, match=match): obj.positions = [(np.nan, 2.0)] def test_nonfinite_inf_error(self): """ Test that infinite positions raise ValueError. """ obj = MockPixelPos() match = "'positions' must not contain any non-finite" with pytest.raises(ValueError, match=match): obj.positions = [(1.0, np.inf)] def test_3d_error(self): """ Test that a 3D array raises ValueError. """ obj = MockPixelPos() match = "'positions' must be a" with pytest.raises(ValueError, match=match): obj.positions = np.ones((2, 2, 2)) def test_wrong_ncols_error(self): """ Test that positions with wrong column count raise ValueError. """ obj = MockPixelPos() match = "'positions' must be a" with pytest.raises(ValueError, match=match): obj.positions = [(1.0, 2.0, 3.0), (4.0, 5.0, 6.0)] class TestSkyCoordPositions: """ Tests for the SkyCoordPositions descriptor class. """ def test_valid(self): """ Test that a SkyCoord value is accepted. """ obj = MockSkyCoord() sky = SkyCoord(ra=10.0, dec=20.0, unit='deg') obj.positions = sky assert isinstance(obj.positions, SkyCoord) def test_invalid_type_error(self): """ Test that a non-SkyCoord value raises TypeError. """ obj = MockSkyCoord() match = "'positions' must be a SkyCoord instance" with pytest.raises(TypeError, match=match): obj.positions = (10.0, 20.0) class TestPositiveScalar: """ Tests for the PositiveScalar descriptor class. """ def test_valid(self): """ Test that a positive scalar value is accepted. """ obj = MockPositiveScalar() obj.r = 3.5 assert obj.r == 3.5 def test_zero_error(self): """ Test that zero raises ValueError. """ obj = MockPositiveScalar() match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): obj.r = 0.0 def test_negative_error(self): """ Test that a negative value raises ValueError. """ obj = MockPositiveScalar() match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): obj.r = -1.0 def test_array_error(self): """ Test that a non-scalar array raises ValueError. """ obj = MockPositiveScalar() match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): obj.r = np.array([1.0, 2.0]) class TestScalarAngle: """ Tests for the ScalarAngle descriptor class. """ def test_valid_rad(self): """ Test that a scalar radian Quantity is accepted. """ obj = MockScalarAngle() obj.angle = 0.5 * u.rad assert obj.angle == 0.5 * u.rad def test_valid_arcsec(self): """ Test that a scalar arcsec Quantity is accepted. """ obj = MockScalarAngle() obj.angle = 30.0 * u.arcsec assert obj.angle.unit == u.arcsec def test_nonscalar_error(self): """ Test that a non-scalar Quantity raises ValueError. """ obj = MockScalarAngle() match = "'angle' must be a scalar" with pytest.raises(ValueError, match=match): obj.angle = np.array([1.0, 2.0]) * u.arcsec def test_non_angle_units_error(self): """ Test that a Quantity with non-angular units raises ValueError. """ obj = MockScalarAngle() match = "'angle' must have angular units" with pytest.raises(ValueError, match=match): obj.angle = 5.0 * u.m def test_non_quantity_error(self): """ Test that a plain float raises TypeError. """ obj = MockScalarAngle() match = "'angle' must be a scalar angle" with pytest.raises(TypeError, match=match): obj.angle = 1.0 class TestPositiveScalarAngle: """ Tests for the PositiveScalarAngle descriptor class. """ def test_valid(self): """ Test that a positive scalar angle Quantity is accepted. """ obj = MockPositiveScalarAngle() obj.angle = 5.0 * u.arcsec assert obj.angle == 5.0 * u.arcsec def test_negative_error(self): """ Test that a negative angle raises ValueError. """ obj = MockPositiveScalarAngle() match = "'angle' must be greater than zero" with pytest.raises(ValueError, match=match): obj.angle = -1.0 * u.arcsec def test_nonscalar_error(self): """ Test that a non-scalar angle array raises ValueError. """ obj = MockPositiveScalarAngle() match = "'angle' must be a scalar" with pytest.raises(ValueError, match=match): # Single-element array passes > 0 check but fails isscalar obj.angle = np.array([5.0]) * u.arcsec def test_non_angle_units_error(self): """ Test that a Quantity with non-angular units raises ValueError. """ obj = MockPositiveScalarAngle() match = "'angle' must have angular units" with pytest.raises(ValueError, match=match): obj.angle = 5.0 * u.m def test_non_quantity_error(self): """ Test that a plain float raises TypeError. """ obj = MockPositiveScalarAngle() match = "'angle' must be a scalar angle" with pytest.raises(TypeError, match=match): obj.angle = 5.0 class TestScalarAngleOrValue: """ Tests for the ScalarAngleOrValue descriptor class. """ def test_valid_quantity(self): """ Test that a scalar angle Quantity is accepted. """ obj = MockScalarAngleOrValue() obj.theta = 30.0 * u.deg assert obj.theta == 30.0 * u.deg def test_float_converted_to_radian(self): """ Test that a plain float is stored as a radian Quantity. """ obj = MockScalarAngleOrValue() obj.theta = 1.5 assert isinstance(obj.theta, u.Quantity) assert obj.theta.unit == u.radian assert obj.theta.value == 1.5 def test_reset_lazyproperties(self): """ Test that setting theta twice clears cached lazyproperties. """ obj = MockScalarAngleOrValue() obj.theta = 1.0 obj._lazyproperties = ['cached'] obj.cached = 42 obj.theta = 2.0 # triggers reset assert 'cached' not in obj.__dict__ def test_nonscalar_quantity_error(self): """ Test that a non-scalar angle Quantity raises ValueError. """ obj = MockScalarAngleOrValue() match = "'theta' must be a scalar" with pytest.raises(ValueError, match=match): obj.theta = np.array([1.0, 2.0]) * u.arcsec def test_non_angle_units_error(self): """ Test that a Quantity with non-angular units raises ValueError. """ obj = MockScalarAngleOrValue() match = "'theta' must have angular units" with pytest.raises(ValueError, match=match): obj.theta = 5.0 * u.m def test_nonscalar_float_error(self): """ Test that a non-scalar float array raises ValueError. """ obj = MockScalarAngleOrValue() match = 'If not an angle Quantity' with pytest.raises(ValueError, match=match): obj.theta = np.array([1.0, 2.0]) astropy-photutils-3322558/photutils/aperture/tests/test_bounding_box.py000066400000000000000000000130661517052111400265560ustar00rootroot00000000000000# 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(): match = 'ixmin must be <= ixmax' with pytest.raises(ValueError, match=match): BoundingBox(100, 1, 1, 100) match = 'iymin must be <= iymax' with pytest.raises(ValueError, match=match): BoundingBox(1, 100, 100, 1) def test_bounding_box_inputs(): match = 'ixmin, ixmax, iymin, and iymax must all be integers' with pytest.raises(TypeError, match=match): BoundingBox([1], [10], [2], [9]) with pytest.raises(TypeError, match=match): BoundingBox([1, 2], 10, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1.0, 10.0, 2.0, 9.0) with pytest.raises(TypeError, match=match): BoundingBox(1.3, 10, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10.3, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10, 2.3, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10, 2, 9.3) def test_bounding_box_from_float(): 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) match = 'Can compare BoundingBox only to another BoundingBox' with pytest.raises(TypeError, match=match): 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_get_overlap_slices_invalid_shape(): """ Test that get_overlap_slices raises ValueError when shape does not have exactly 2 elements. """ bbox = BoundingBox(1, 10, 2, 20) match = 'input shape must have 2 elements' with pytest.raises(ValueError, match=match): bbox.get_overlap_slices((50,)) 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 match = 'BoundingBox can be joined only with another BoundingBox' with pytest.raises(TypeError, match=match): 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 match = 'BoundingBox can be intersected only with another BoundingBox' with pytest.raises(TypeError, match=match): bbox1.intersection((5, 21, 7, 32)) assert bbox1.intersection(BoundingBox(30, 40, 50, 60)) is None astropy-photutils-3322558/photutils/aperture/tests/test_circle.py000066400000000000000000000175401517052111400253430ustar00rootroot00000000000000# 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): match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): 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) # Test creating a legend with these patches labels = list(range(len(my_patches))) _, ax = plt.subplots() ax.legend(my_patches, labels) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'r_in' must be a positive scalar" with pytest.raises(ValueError, match=match): CircularAnnulus(POSITIONS, r_in=radius, r_out=7.0) match = "'r_out' must be greater than 'r_in'" with pytest.raises(ValueError, match=match): 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): match = "'r' must be greater than zero" with pytest.raises(ValueError, match=match): 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): match = "'r_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=radius * UNIT, r_out=7.0 * UNIT) match = "'r_out' must be greater than 'r_in'" with pytest.raises(ValueError, match=match): 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 @staticmethod def test_r_out_less_than_r_in(): """ Test that a ValueError is raised when r_out <= r_in. """ match = "'r_out' must be greater than 'r_in'" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=7.0 * UNIT, r_out=3.0 * UNIT) @staticmethod def test_non_angle_quantity(): """ Test that a ValueError is raised when r_in has non-angular units. """ match = "'r_in' must have angular units" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=0.5 * u.pix, r_out=7.0 * u.pix) 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 match = "A scalar 'CircularAperture' object has no len" with pytest.raises(TypeError, match=match): len(aper3) match = "A scalar 'CircularAperture' object cannot be indexed" with pytest.raises(TypeError, match=match): _ = 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) mask = np.zeros((3, 3), dtype=bool) match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): aper.area_overlap(data, mask=mask) def test_invalid_positions(): match = r"'positions' must be a \(x, y\) pixel position or a list" with pytest.raises(ValueError, match=match): _ = CircularAperture([], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([1], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([[1]], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([1, 2, 3], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([[1, 2, 3]], r=3) x = np.arange(3) y = np.arange(3) xypos = np.transpose((x, y)) * u.pix match = "'positions' must not be a Quantity" with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) x = np.arange(3) * u.pix y = np.arange(3) xypos = zip(x, y, strict=True) with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) x = np.arange(3) * u.pix y = np.arange(3) * u.pix xypos = zip(x, y, strict=True) with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) astropy-photutils-3322558/photutils/aperture/tests/test_converters.py000066400000000000000000000526251517052111400262770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the converters module. """ import numpy as np import pytest from astropy import units as u from astropy.coordinates import Angle, SkyCoord from astropy.tests.helper import assert_quantity_allclose from astropy.wcs import WCS from numpy.testing import assert_allclose from photutils.aperture import (CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, RectangularAnnulus, RectangularAperture, SkyCircularAnnulus, SkyCircularAperture, SkyEllipticalAnnulus, SkyEllipticalAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.converters import (_scalar_aperture_to_region, _shapely_polygon_to_region, aperture_to_region, region_to_aperture) from photutils.utils._optional_deps import HAS_REGIONS, HAS_SHAPELY @pytest.fixture def image_2d_wcs(): return WCS( { 'CTYPE1': 'RA---TAN', 'CUNIT1': 'deg', 'CDELT1': -0.0002777777778, 'CRPIX1': 1, 'CRVAL1': 337.5202808, 'CTYPE2': 'DEC--TAN', 'CUNIT2': 'deg', 'CDELT2': 0.0002777777778, 'CRPIX2': 1, 'CRVAL2': -20.833333059999998, }, ) def compare_region_shapes(reg1, reg2): from regions import PixCoord assert reg1.__class__ == reg2.__class__ for param in reg1._params: par1 = getattr(reg1, param) par2 = getattr(reg2, param) if isinstance(par1, PixCoord): assert_allclose(par1.xy, par2.xy) elif isinstance(par1, SkyCoord): assert par1 == par2 elif isinstance(par1, u.Quantity): assert_quantity_allclose(par1, par2) else: assert_allclose(par1, par2) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_circle(image_2d_wcs): from regions import CirclePixelRegion, PixCoord region_shape = CirclePixelRegion(center=PixCoord(x=42, y=43), radius=4.2) aperture = region_to_aperture(region_shape) assert isinstance(aperture, CircularAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.r, region_shape.radius) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyCircularAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.r, region_sky.radius) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): CirclePixelRegion(center=PixCoord(x=[0, 42], y=[1, 43]), radius=4.2) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): CirclePixelRegion(center=PixCoord(x=42, y=43), radius=[1, 4.2]) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_ellipse(image_2d_wcs): from regions import EllipsePixelRegion, PixCoord region_shape = EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, EllipticalAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.a * 2, region_shape.width) assert_allclose(aperture.b * 2, region_shape.height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyEllipticalAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.a * 2, region_sky.width) assert_quantity_allclose(aperture_sky.b * 2, region_sky.height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): EllipsePixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), width=16, height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=[1, 16], height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=[1, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_rectangle(image_2d_wcs): from regions import PixCoord, RectanglePixelRegion region_shape = RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, RectangularAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.w, region_shape.width) assert_allclose(aperture.h, region_shape.height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyRectangularAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.w, region_sky.width) assert_quantity_allclose(aperture_sky.h, region_sky.height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), width=16, height=10, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=[1, 16], height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=[1, 10], angle=Angle(30, 'deg'), ) match = 'must be a scalar' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_circle_annulus(image_2d_wcs): from regions import CircleAnnulusPixelRegion, PixCoord region_shape = CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=5, outer_radius=8, ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, CircularAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.r_in, region_shape.inner_radius) assert_allclose(aperture.r_out, region_shape.outer_radius) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyCircularAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.r_in, region_sky.inner_radius) assert_quantity_allclose(aperture_sky.r_out, region_sky.outer_radius) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): CircleAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_radius=5, outer_radius=8, ) with pytest.raises(ValueError, match=r'must be .* scalar'): CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=[1, 5], outer_radius=8, ) with pytest.raises(ValueError, match=r'must be .* scalar'): CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=5, outer_radius=[8, 10], ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_ellipse_annulus(image_2d_wcs): from regions import EllipseAnnulusPixelRegion, PixCoord region_shape = EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, EllipticalAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.a_in * 2, region_shape.inner_width) assert_allclose(aperture.a_out * 2, region_shape.outer_width) assert_allclose(aperture.b_in * 2, region_shape.inner_height) assert_allclose(aperture.b_out * 2, region_shape.outer_height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyEllipticalAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.a_in * 2, region_sky.inner_width) assert_quantity_allclose(aperture_sky.a_out * 2, region_sky.outer_width) assert_quantity_allclose(aperture_sky.b_in * 2, region_sky.inner_height) assert_quantity_allclose(aperture_sky.b_out * 2, region_sky.outer_height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=[1, 5.5], inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=[1, 3.5], outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=[8.5, 10], outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=[6.5, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_rectangle_annulus(image_2d_wcs): from regions import PixCoord, RectangleAnnulusPixelRegion region_shape = RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, RectangularAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.w_in, region_shape.inner_width) assert_allclose(aperture.w_out, region_shape.outer_width) assert_allclose(aperture.h_in, region_shape.inner_height) assert_allclose(aperture.h_out, region_shape.outer_height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyRectangularAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.w_in, region_sky.inner_width) assert_quantity_allclose(aperture_sky.w_out, region_sky.outer_width) assert_quantity_allclose(aperture_sky.h_in, region_sky.inner_height) assert_quantity_allclose(aperture_sky.h_out, region_sky.outer_height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=[1, 5.5], inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=[1, 3.5], outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=[8.5, 10], outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=[6.5, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_polygon(): from regions import PixCoord, PolygonPixelRegion region_shape = PolygonPixelRegion(vertices=PixCoord(x=[1, 2, 2], y=[1, 1, 2])) match = r'Cannot convert .* to an Aperture object' with pytest.raises(TypeError, match=match): region_to_aperture(region_shape) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_aperture_to_region(): from regions import Region, Regions xypos = [(10, 20), (30, 40), (50, 60), (70, 80)] ra, dec = np.transpose(xypos) skycoord = SkyCoord(ra=ra, dec=dec, unit='deg') unit = u.arcsec apertures = [CircularAperture(xypos, r=3.0), CircularAnnulus(xypos, r_in=3.0, r_out=7.0), SkyCircularAperture(skycoord, r=3.0 * unit), SkyCircularAnnulus(skycoord, r_in=3.0 * unit, r_out=7.0 * unit), EllipticalAperture(xypos, a=10.0, b=5.0, theta=np.pi / 2.0), EllipticalAnnulus(xypos, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3), SkyEllipticalAperture(skycoord, a=10.0 * unit, b=5.0 * unit, theta=30 * u.deg), SkyEllipticalAnnulus(skycoord, a_in=10.0 * unit, a_out=20.0 * unit, b_out=17.0 * unit, theta=60 * u.deg), RectangularAperture(xypos, w=10.0, h=5.0, theta=np.pi / 2.0), RectangularAnnulus(xypos, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3), SkyRectangularAperture(skycoord, w=10.0 * unit, h=5.0 * unit, theta=30 * u.deg), SkyRectangularAnnulus(skycoord, w_in=10.0 * unit, w_out=20.0 * unit, h_out=17.0 * unit, theta=60 * u.deg)] for aperture in apertures: region0 = aperture_to_region(aperture[0]) region = aperture_to_region(aperture) assert isinstance(region0, Region) assert isinstance(region, Regions) assert len(region) == len(aperture) aper0 = region_to_aperture(region0) assert aper0 == aperture[0] @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_invalid_inputs(): from regions import CirclePixelRegion, PixCoord aperture = CircularAperture((10, 12), r=4.2) region = CirclePixelRegion(center=PixCoord(x=10, y=12), radius=4.2) match = 'Input region must be a Region object' with pytest.raises(TypeError, match=match): region_to_aperture(aperture) match = 'Input aperture must be an Aperture object' with pytest.raises(TypeError, match=match): aperture_to_region(region) aperture = CircularAperture(((10, 12), (21, 7)), r=4.2) match = r'Only scalar .* apertures are supported' with pytest.raises(ValueError, match=match): _scalar_aperture_to_region(aperture) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_shapely_polygon_to_region(): from regions import PixCoord, PolygonPixelRegion from shapely import Polygon ref_region = PolygonPixelRegion(vertices=PixCoord(x=[1, 3, 2, 1], y=[1, 1, 4, 2])) polygon = Polygon([(1, 1), (3, 1), (2, 4), (1, 2)]) region = _shapely_polygon_to_region(polygon) assert region == ref_region match = 'Input must be a Polygon or MultiPolygon object' with pytest.raises(TypeError, match=match): _shapely_polygon_to_region('foo') @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_shapely_multipolygon_to_region(): """ Test that _shapely_polygon_to_region handles MultiPolygon inputs by returning a Regions object containing one PolygonPixelRegion per polygon. """ from regions import Regions from shapely import MultiPolygon, Polygon poly1 = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) poly2 = Polygon([(2, 2), (3, 2), (3, 3), (2, 3)]) multi = MultiPolygon([poly1, poly2]) result = _shapely_polygon_to_region(multi) assert isinstance(result, Regions) assert len(result) == 2 @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_scalar_aperture_to_region_unknown_type(): """ Test that _scalar_aperture_to_region raises TypeError for an aperture type that is not one of the 12 supported classes. """ class _FakeAperture: """ Minimal fake aperture with shape=() that passes the scalar check but has no matching isinstance branch. """ shape = () match = 'Cannot convert input aperture to a Region object' with pytest.raises(TypeError, match=match): _scalar_aperture_to_region(_FakeAperture()) astropy-photutils-3322558/photutils/aperture/tests/test_core.py000066400000000000000000000227031517052111400250270ustar00rootroot00000000000000# 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.coordinates import SkyCoord from numpy.testing import assert_allclose from photutils.aperture import (Aperture, CircularAperture, EllipticalAperture, SkyCircularAperture) from photutils.aperture.core import _aperture_metadata POSITIONS = [(5, 5), (10, 10), (15, 15)] SCALAR_POS = (5, 5) class MinimalAperture(Aperture): """ Minimal concrete Aperture subclass that is neither PixelAperture nor SkyAperture. Used to exercise bare-Aperture code paths. """ _params = ('positions',) @property def positions(self): """ Return a fixed single position. """ return np.array([[5.0, 5.0]]) class RaisesOnCompare: """ Helper object that raises TypeError on any != comparison, used to exercise the except-TypeError branch in Aperture.__eq__. """ def __ne__(self, other): """ Raise TypeError unconditionally. """ msg = 'incompatible types' raise TypeError(msg) class TestAperture: """ Tests for branches of the Aperture base class not covered elsewhere. """ def test_positions_str_raises_for_unknown_type(self): """ Test that _positions_str raises TypeError when the aperture is not a PixelAperture or SkyAperture subclass. """ aper = MinimalAperture() match = 'Aperture must be a subclass of PixelAperture or SkyAperture' with pytest.raises(TypeError, match=match): aper._positions_str() def test_eq_different_class(self): """ Test that __eq__ returns False when compared to a different class (exercises the isinstance early-return branch). """ aper1 = CircularAperture(SCALAR_POS, r=3) aper2 = EllipticalAperture(SCALAR_POS, a=3, b=2, theta=0) assert aper1 != aper2 def test_eq_different_params(self): """ Test that __eq__ returns False when the two instances have different _params tuples (exercises the params-mismatch branch). """ aper1 = CircularAperture(SCALAR_POS, r=3) aper2 = CircularAperture(SCALAR_POS, r=3) # Inject an extended _params tuple at the instance level so that # isinstance passes (both CircularAperture instances, same type so # Python does not invoke subclass reflection) while the params # check diverges, hitting the mismatch return at line 104. aper2.__dict__['_params'] = (*CircularAperture._params, 'extra') assert aper1 != aper2 def test_eq_comparison_type_error(self): """ Test that __eq__ returns False (rather than propagating the exception) when the position comparison raises TypeError. """ aper1 = CircularAperture(SCALAR_POS, r=3) aper2 = CircularAperture(SCALAR_POS, r=3) # Bypass the descriptor and inject a position object that raises # TypeError on != comparison, mimicking incompatible SkyCoords. aper1.__dict__['positions'] = RaisesOnCompare() aper2.__dict__['positions'] = RaisesOnCompare() assert aper1 != aper2 class TestPixelAperture: """ Tests for branches of the PixelAperture class not covered elsewhere. """ def test_to_mask_invalid_method(self): """ Test that to_mask raises ValueError for an unrecognised method string (exercises the invalid-method branch in _translate_mask_method). """ aper = CircularAperture(SCALAR_POS, r=3) match = 'Invalid mask method' with pytest.raises(ValueError, match=match): aper.to_mask(method='invalid') def test_bbox_multi_position(self): """ Test that the bbox property returns a list for a multi-position aperture (exercises the non-scalar branch). """ aper = CircularAperture(POSITIONS, r=3) bbox = aper.bbox assert isinstance(bbox, list) assert len(bbox) == len(POSITIONS) class TestPixelApertureDoPhotometry: """ Tests for error-handling branches of PixelAperture.do_photometry. """ def setup_method(self): """ Set up a simple scalar aperture and matching data array. """ self.aper = CircularAperture(SCALAR_POS, r=3) self.data = np.ones((20, 20)) def test_do_photometry_1d_data_error(self): """ Test that do_photometry raises ValueError when data is not a 2D array. """ match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): self.aper.do_photometry(np.ones(20)) def test_do_photometry_error_shape_mismatch(self): """ Test that do_photometry raises ValueError when the error array does not match the data shape. """ match = 'error and data must have the same shape' with pytest.raises(ValueError, match=match): self.aper.do_photometry(self.data, error=np.ones((5, 5))) def test_do_photometry_unit_mismatch_error(self): """ Test that do_photometry raises ValueError when data and error have different units. """ import astropy.units as u match = 'they both must have the same units' with pytest.raises(ValueError, match=match): self.aper.do_photometry( self.data * u.Jy, error=self.data * u.ct, ) def test_do_photometry_basic(self): """ Test that do_photometry returns the expected aperture sum for a uniform data array with no error input. """ sums, errs = self.aper.do_photometry(self.data) assert_allclose(sums[0], np.pi * 9, rtol=1e-3) assert len(errs) == 0 class TestApertureReprStr: """ Tests for __repr__ and __str__ of various aperture types. """ def test_repr_scalar(self): """ Test __repr__ for a scalar CircularAperture. """ aper = CircularAperture(SCALAR_POS, r=3) result = repr(aper) assert 'CircularAperture' in result assert 'r=3.0' in result def test_repr_multi(self): """ Test __repr__ for a multi-position CircularAperture. """ aper = CircularAperture(POSITIONS, r=3) result = repr(aper) assert 'CircularAperture' in result def test_str_scalar(self): """ Test __str__ for a scalar CircularAperture. """ aper = CircularAperture(SCALAR_POS, r=3) result = str(aper) assert 'Aperture: CircularAperture' in result assert 'r: 3.0' in result def test_str_multi(self): """ Test __str__ for a multi-position CircularAperture. """ aper = CircularAperture(POSITIONS, r=3) result = str(aper) assert 'Aperture: CircularAperture' in result def test_repr_sky(self): """ Test __repr__ for a SkyCircularAperture. """ pos = SkyCoord(ra=10, dec=20, unit='deg') aper = SkyCircularAperture(pos, r=1.0 * u.arcsec) result = repr(aper) assert 'SkyCircularAperture' in result class TestApertureIteration: """ Tests for __iter__ and __len__ of Aperture objects. """ def test_iter(self): """ Test that iterating over a multi-position aperture yields scalar apertures. """ aper = CircularAperture(POSITIONS, r=3) items = list(aper) assert len(items) == len(POSITIONS) for item in items: assert item.isscalar def test_copy(self): """ Test that copy creates a deep copy with independent data. """ aper = CircularAperture(POSITIONS, r=3) aper_copy = aper.copy() assert aper == aper_copy aper_copy.r = 5.0 assert aper != aper_copy assert aper.r == 3.0 def test_copy_sky(self): """ Test that copy works for SkyAperture objects. """ pos = SkyCoord(ra=[10, 20], dec=[30, 40], unit='deg') aper = SkyCircularAperture(pos, r=1.0 * u.arcsec) aper_copy = aper.copy() assert aper == aper_copy class TestApertureMetadata: """ Tests for the _aperture_metadata helper function. """ def test_metadata_keys(self): """ Test that _aperture_metadata returns the expected keys. """ aper = CircularAperture(SCALAR_POS, r=3) meta = _aperture_metadata(aper) assert 'aperture' in meta assert meta['aperture'] == 'CircularAperture' assert 'aperture_r' in meta assert meta['aperture_r'] == 3.0 def test_metadata_with_index(self): """ Test that _aperture_metadata uses the index in keys. """ aper = CircularAperture(SCALAR_POS, r=3) meta = _aperture_metadata(aper, index='_0') assert 'aperture_0' in meta assert meta['aperture_0'] == 'CircularAperture' assert 'aperture_0_r' in meta def test_metadata_class_name_not_repeated(self): """ Test that aperture class name key is set exactly once. """ aper = EllipticalAperture(SCALAR_POS, a=5, b=3, theta=0) meta = _aperture_metadata(aper) assert meta['aperture'] == 'EllipticalAperture' assert 'aperture_a' in meta assert 'aperture_b' in meta assert 'aperture_theta' in meta astropy-photutils-3322558/photutils/aperture/tests/test_ellipse.py000066400000000000000000000177471517052111400255500ustar00rootroot00000000000000# 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 astropy.tests.helper import assert_quantity_allclose from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) 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 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): match = "'a' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAperture(POSITIONS, a=radius, b=5.0, theta=np.pi / 2.0) match = "'b' must be a positive scalar" with pytest.raises(ValueError, match=match): 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 def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_nonscalar(self): """ Test that _to_patch returns a list for non-scalar apertures. """ patches = self.aperture._to_patch() assert isinstance(patches, list) 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): match = "'a_in' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=radius, a_out=20.0, b_out=17.0, theta=np.pi / 3) match = "'a_out' must be greater than 'a_in'" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=radius, b_out=17.0, theta=np.pi / 3) match = "'b_out' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=radius, theta=np.pi / 3) match = "'b_in' must be a positive scalar" with pytest.raises(ValueError, match=match): 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 def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad def test_b_in_greater_than_b_out(self): """ Test that a ValueError is raised when b_in >= b_out. """ match = "'b_out' must be greater than 'b_in'" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=5.0, b_in=8.0, theta=np.pi / 3) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_nonscalar(self): """ Test that _to_patch returns a list for non-scalar apertures. """ patches = self.aperture._to_patch() assert isinstance(patches, list) 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): match = "'a' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAperture(SKYCOORD, a=radius * UNIT, b=5.0 * UNIT, theta=30 * u.deg) match = "'b' must be greater than zero" with pytest.raises(ValueError, match=match): 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): match = "'a_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=radius * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) match = "'a_out' must be greater than 'a_in'" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=radius * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) match = "'b_out' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=radius * UNIT, theta=60 * u.deg) match = "'b_in' must be greater than zero" with pytest.raises(ValueError, match=match): 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_b_in_greater_than_b_out(self): """ Test that a ValueError is raised when b_in >= b_out. """ match = "'b_out' must be greater than 'b_in'" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=5.0 * UNIT, b_in=8.0 * UNIT, theta=60 * u.deg) 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_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) 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_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) astropy-photutils-3322558/photutils/aperture/tests/test_mask.py000066400000000000000000000173021517052111400250310ustar00rootroot00000000000000# 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 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 POSITIONS = [(-20, -20), (-20, 20), (20, -20), (60, 60)] def test_mask_input_shapes(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 10, 5, 10) match = 'mask data and bounding box must have the same shape' with pytest.raises(ValueError, match=match): 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=None) 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) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): mask.cutout(np.arange(10)) match = 'input shape must have 2 elements' with pytest.raises(ValueError, match=match): 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_cutout_partial_overlap_quantity(): """ Test that cutout with a Quantity array and partial overlap applies the data unit to the output cutout (covers the `cutout <<= data.unit` branch). """ aper = CircularAperture((-20, -20), r=30.0) mask = aper.to_mask() data = np.ones((50, 50)) * u.adu cutout = mask.cutout(data) assert isinstance(cutout, u.Quantity) assert cutout.unit == u.adu 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 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() match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): 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 astropy-photutils-3322558/photutils/aperture/tests/test_photometry.py000066400000000000000000001167101517052111400263130ustar00rootroot00000000000000# 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.nddata import NDData, StdDevUncertainty from astropy.utils.exceptions import AstropyUserWarning 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 make_4gaussians_image, make_gwcs, make_wcs from photutils.utils._optional_deps import (HAS_GWCS, HAS_MATPLOTLIB, HAS_REGIONS) APERTURE_CL = [CircularAperture, CircularAnnulus, EllipticalAperture, EllipticalAnnulus, RectangularAperture, RectangularAnnulus] TEST_APERTURES = list(zip(APERTURE_CL, ({'r': 3.0}, {'r_in': 3.0, 'r_out': 5.0}, {'a': 3.0, 'b': 5.0, 'theta': 1.0}, {'a_in': 3.0, 'a_out': 5.0, 'b_out': 4.0, 'b_in': 12.0 / 5.0, 'theta': 1.0}, {'w': 5, 'h': 8, 'theta': np.pi / 4}, {'w_in': 8, 'w_out': 12, 'h_out': 8, 'h_in': 16.0 / 3.0, 'theta': np.pi / 8}), strict=True)) @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. 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['x_center'].value == np.transpose(self.position)[0]) assert np.all(table['y_center'].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 def test_input_wcs(): data = make_4gaussians_image() wcs = make_wcs(data.shape) # Hard wired positions in make_4gaussian_image xypos = np.transpose(([160.0, 25.0, 150.0, 90.0], [70.0, 40.0, 25.0, 60.0])) aper = CircularAperture(xypos, 3.0) aper3 = [CircularAperture((160.0, 70.0), r) for r in (3, 4, 5)] phot1 = aperture_photometry(data, aper[0], wcs=wcs) phot2 = aperture_photometry(data, aper, wcs=wcs) phot3 = aperture_photometry(data, aper3, wcs=wcs) assert 'sky_center' in phot1.colnames assert 'sky_center' in phot2.colnames assert 'sky_center' in phot3.colnames 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, theta=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, theta=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, theta=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)) match = 'then they both must have the same units' with pytest.raises(ValueError, match=match): # data has unit, but error does not aperture_photometry(data2, aper, error=error1) error2 = u.Quantity(error1 * u.Jy) with pytest.raises(ValueError, match=match): # 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), a=r, b=r, theta=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=np.deg2rad(90.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=np.deg2rad(90.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=np.deg2rad(90.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=np.deg2rad(90.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=np.deg2rad(103.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=np.deg2rad(103.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=np.deg2rad(103.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=np.deg2rad(103.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 a length-1 aperture list appends a "_0" to 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', 'x_center', 'y_center', 'aperture_sum', 'aperture_sum_err']) colnames2 = aperture_photometry(data, [ap], error=data).colnames assert (colnames2 == ['id', 'x_center', 'y_center', 'aperture_sum_0', 'aperture_sum_err_0']) colnames3 = aperture_photometry(data, [ap, ap], error=data).colnames assert (colnames3 == ['id', 'x_center', 'y_center', '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 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) @pytest.mark.parametrize('units', [False, True]) def test_nddata_input(units): data = np.arange(400).reshape((20, 20)) error = np.sqrt(data) mask = np.zeros((20, 20), dtype=bool) mask[8:13, 8:13] = True if units: unit = u.Jy data = data * unit error = error * unit else: unit = None wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) aper = SkyCircularAperture(skycoord, r=0.7 * u.arcsec) tbl1 = aperture_photometry(data, aper, error=error, 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]) match = 'keyword is ignored. Its value is obtained from the input' with pytest.warns(AstropyUserWarning, match=match): aperture_photometry(nddata, aper, error=error) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class BaseTestRegionPhotometry: def test_region_matches_aperture(self): data = np.ones((40, 40), dtype=float) error = np.ones(data.shape, dtype=float) region_tables = [ aperture_photometry(data, self.region, method='center', error=error), aperture_photometry(data, self.region, method='subpixel', subpixels=12, error=error), aperture_photometry(data, self.region, method='exact', error=error), ] aperture_tables = [ aperture_photometry(data, self.aperture, method='center', error=error), aperture_photometry(data, self.aperture, method='subpixel', subpixels=12, error=error), aperture_photometry(data, self.aperture, method='exact', error=error), ] for reg_table, ap_table in zip(region_tables, aperture_tables, strict=True): assert_allclose(reg_table['aperture_sum'], ap_table['aperture_sum']) if isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): for reg_table, ap_table in zip(region_tables, aperture_tables, strict=True): assert_allclose(reg_table['aperture_sum_err'], ap_table['aperture_sum_err']) def test_invalid_inputs(): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) wcs = make_wcs(data.shape) sky_aper = aper.to_sky(wcs=wcs) match = 'A WCS transform must be defined' with pytest.raises(ValueError, match=match): aperture_photometry(data, sky_aper) aper2 = CircularAperture((7, 7), r=3) sky_aper2 = aper2.to_sky(wcs=wcs) apers = [aper, aper2] sky_apers = [sky_aper, sky_aper2] match = 'Input apertures must all have identical positions' with pytest.raises(ValueError, match=match): aperture_photometry(data, apers) with pytest.raises(ValueError, match=match): aperture_photometry(data, sky_apers, wcs=wcs) data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = 'subpixels must be a strictly positive integer' with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, method='subpixel', subpixels=0) with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, method='subpixel', subpixels=-1) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import CirclePixelRegion, PixCoord position = (20.0, 20.0) r = 10.0 self.region = CirclePixelRegion(PixCoord(*position), r) self.aperture = CircularAperture(position, r) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import CircleAnnulusPixelRegion, PixCoord position = (20.0, 20.0) r_in = 8.0 r_out = 10.0 self.region = CircleAnnulusPixelRegion(PixCoord(*position), r_in, r_out) self.aperture = CircularAnnulus(position, r_in, r_out) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import EllipsePixelRegion, PixCoord position = (20.0, 20.0) a = 10.0 b = 5.0 theta = (-np.pi / 4.0) * u.rad self.region = EllipsePixelRegion(PixCoord(*position), a * 2, b * 2, theta) self.aperture = EllipticalAperture(position, a, b, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import EllipseAnnulusPixelRegion, PixCoord position = (20.0, 20.0) a_in = 5.0 a_out = 8.0 b_in = 3.0 b_out = 5.0 theta = (-np.pi / 4.0) * u.rad self.region = EllipseAnnulusPixelRegion(PixCoord(*position), a_in * 2, a_out * 2, b_in * 2, b_out * 2, theta) self.aperture = EllipticalAnnulus(position, a_in, a_out, b_out, b_in=b_in, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import PixCoord, RectanglePixelRegion position = (20.0, 20.0) h = 5.0 w = 8.0 theta = (np.pi / 4.0) * u.rad self.region = RectanglePixelRegion(PixCoord(*position), w, h, theta) self.aperture = RectangularAperture(position, w, h, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import PixCoord, RectangleAnnulusPixelRegion 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) * u.rad self.region = RectangleAnnulusPixelRegion(PixCoord(*position), w_in, w_out, h_in, h_out, theta) self.aperture = RectangularAnnulus(position, w_in, w_out, h_out, h_in=h_in, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_unsupported_region_input(): from regions import PixCoord, PolygonPixelRegion region = PolygonPixelRegion(vertices=PixCoord(x=[1, 2, 3], y=[1, 1, 2])) data = np.ones((10, 10)) match = r'Cannot convert .* to an Aperture object' with pytest.raises(TypeError, match=match): aperture_photometry(data, region) def test_aperture_metadata(): x = [10, 20, 3] y = [3, 5, 10] xypos = list(zip(x, y, strict=False)) a1 = CircularAperture(xypos, r=3) a2 = CircularAperture(xypos, r=4) a3 = CircularAnnulus(xypos, 5, 10) a4 = EllipticalAperture(xypos, 10, 5, theta=10 * u.deg) a5 = EllipticalAnnulus(xypos, a_in=5, a_out=10, b_in=3, b_out=5, theta=20 * u.deg) a6 = RectangularAperture(xypos, 10, 5, theta=30 * u.deg) a7 = RectangularAnnulus(xypos, w_in=5, w_out=10, h_in=3, h_out=5, theta=40 * u.deg) apers = (a1, a2, a3, a4, a5, a6, a7) data = np.ones((50, 50)) tbl = aperture_photometry(data, apers) for i, aper in enumerate(apers): assert tbl.meta[f'aperture{i}'] == aper.__class__.__name__ params = aper._params for param in params: if param != 'positions': assert tbl.meta[f'aperture{i}_{param}'] == getattr(aper, param) wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) unit = u.arcsec saper = SkyEllipticalAnnulus(skycoord, a_in=0.1 * unit, a_out=0.2 * unit, b_in=0.05 * unit, b_out=0.1 * unit, theta=10 * u.deg) tbl = aperture_photometry(data, saper, wcs=wcs) assert tbl.meta['aperture'] == saper.__class__.__name__ assert tbl.meta['aperture_a_in'] == saper.a_in assert tbl.meta['aperture_a_out'] == saper.a_out assert tbl.meta['aperture_b_out'] == saper.b_out assert tbl.meta['aperture_theta'] == saper.theta astropy-photutils-3322558/photutils/aperture/tests/test_positional_kwargs.py000066400000000000000000000226051517052111400276370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deprecation of positional optional arguments in the aperture package. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.core import SkyAperture 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.utils._optional_deps import HAS_MATPLOTLIB DATA = np.ones((101, 101)) POSITION = (50, 50) SKY_POSITION = SkyCoord(ra=50.0, dec=50.0, unit='deg') PIXEL_APERTURE_CL = [CircularAperture, CircularAnnulus, EllipticalAperture, EllipticalAnnulus, RectangularAperture, RectangularAnnulus] SKY_APERTURE_CL = [SkyCircularAperture, SkyCircularAnnulus, SkyEllipticalAperture, SkyEllipticalAnnulus, SkyRectangularAperture, SkyRectangularAnnulus] APERTURE_CL = PIXEL_APERTURE_CL + SKY_APERTURE_CL PIXEL_TEST_APERTURES = list(zip(PIXEL_APERTURE_CL, ({'r': 3.0}, {'r_in': 3.0, 'r_out': 5.0}, {'a': 3.0, 'b': 5.0, 'theta': 1.0}, {'a_in': 3.0, 'a_out': 5.0, 'b_out': 4.0, 'b_in': 12.0 / 5.0, 'theta': 1.0}, {'w': 5, 'h': 8, 'theta': np.pi / 4}, {'w_in': 8, 'w_out': 12, 'h_out': 8, 'h_in': 16.0 / 3.0, 'theta': np.pi / 8}), strict=True)) SKY_TEST_APERTURES = list(zip(SKY_APERTURE_CL, ({'r': 3.0 * u.arcsec}, {'r_in': 3.0 * u.arcsec, 'r_out': 5.0 * u.arcsec}, {'a': 3.0 * u.arcsec, 'b': 5.0 * u.arcsec, 'theta': 1.0 * u.deg}, {'a_in': 3.0 * u.arcsec, 'a_out': 5.0 * u.arcsec, 'b_out': 4.0 * u.arcsec, 'b_in': 2.4 * u.arcsec, 'theta': 1.0 * u.deg}, {'w': 5.0 * u.arcsec, 'h': 8.0 * u.arcsec, 'theta': 45.0 * u.deg}, {'w_in': 8.0 * u.arcsec, 'w_out': 12.0 * u.arcsec, 'h_out': 8.0 * u.arcsec, 'h_in': 16.0 / 3.0 * u.arcsec, 'theta': 22.5 * u.deg}), strict=True)) TEST_APERTURES = PIXEL_TEST_APERTURES + SKY_TEST_APERTURES # Apertures with @deprecated_positional_kwargs on __init__ # (excludes circular apertures which have no optional kwargs) INIT_WARN_APERTURES = [ (cls, POSITION, params) for cls, params in PIXEL_TEST_APERTURES if cls not in (CircularAperture, CircularAnnulus) ] + [ (cls, SKY_POSITION, params) for cls, params in SKY_TEST_APERTURES if cls not in (SkyCircularAperture, SkyCircularAnnulus) ] class TestCircularMaskMixinPositionalKwargs: def test_to_mask_no_warning(self): aper = CircularAperture(POSITION, r=5) aper.to_mask(method='exact', subpixels=5) def test_to_mask_positional_warning(self): aper = CircularAperture(POSITION, r=5) match = 'to_mask' with pytest.warns(AstropyDeprecationWarning, match=match): aper.to_mask('exact') class TestApertureMethodsPositionalKwargs: @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_plot_no_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) aper.plot(ax=None) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_plot_positional_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) match = 'plot' with pytest.warns(AstropyDeprecationWarning, match=match): aper.plot(None) @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_to_mask_no_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) aper.to_mask(method='exact', subpixels=5) @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_to_mask_positional_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) match = 'to_mask' with pytest.warns(AstropyDeprecationWarning, match=match): aper.to_mask('exact') @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_do_photometry_no_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) aper.do_photometry(DATA, error=None, mask=None) @pytest.mark.parametrize(('aperture_class', 'params'), PIXEL_TEST_APERTURES) def test_do_photometry_positional_warning(self, aperture_class, params): aper = aperture_class(POSITION, **params) error = np.ones_like(DATA) match = 'do_photometry' with pytest.warns(AstropyDeprecationWarning, match=match): aper.do_photometry(DATA, error) class TestApertureInitPositionalKwargs: @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_init_no_warning(self, aperture_class, params): position = (SKY_POSITION if issubclass(aperture_class, SkyAperture) else POSITION) aperture_class(position, **params) @pytest.mark.parametrize(('aperture_class', 'position', 'params'), INIT_WARN_APERTURES) def test_init_positional_warning(self, aperture_class, position, params): match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): aperture_class(position, *params.values()) class TestBoundingBoxPositionalKwargs: @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_no_warning(self): bbox = BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) # Keyword use should not warn bbox.plot(ax=None, origin=(0, 0)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_positional_warning(self): bbox = BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) match = 'plot' with pytest.warns(AstropyDeprecationWarning, match=match): bbox.plot(None) class TestApertureMaskPositionalKwargs: def setup_method(self): aper = CircularAperture(POSITION, r=5) self.mask = aper.to_mask(method='exact') def test_to_image_no_warning(self): self.mask.to_image(shape=DATA.shape, dtype=float) def test_to_image_positional_warning(self): match = 'to_image' with pytest.warns(AstropyDeprecationWarning, match=match): self.mask.to_image(DATA.shape, int) def test_cutout_no_warning(self): self.mask.cutout(DATA, fill_value=0.0, copy=False) def test_cutout_positional_warning(self): match = 'cutout' with pytest.warns(AstropyDeprecationWarning, match=match): self.mask.cutout(DATA, 0.0) def test_multiply_no_warning(self): self.mask.multiply(DATA, fill_value=0.0) def test_multiply_positional_warning(self): match = 'multiply' with pytest.warns(AstropyDeprecationWarning, match=match): self.mask.multiply(DATA, 0.0) def test_get_values_no_warning(self): self.mask.get_values(DATA, mask=None) def test_get_values_positional_warning(self): match = 'get_values' with pytest.warns(AstropyDeprecationWarning, match=match): self.mask.get_values(DATA, None) class TestAperturePhotometryPositionalKwargs: def test_no_warning(self): aper = CircularAperture(POSITION, r=5) aperture_photometry(DATA, aper, error=None, mask=None) def test_positional_warning(self): aper = CircularAperture(POSITION, r=5) error = np.ones_like(DATA) match = 'aperture_photometry' with pytest.warns(AstropyDeprecationWarning, match=match): aperture_photometry(DATA, aper, error) astropy-photutils-3322558/photutils/aperture/tests/test_rectangle.py000066400000000000000000000201511517052111400260360ustar00rootroot00000000000000# 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 astropy.tests.helper import assert_quantity_allclose from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) 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 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): match = "'w' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAperture(POSITIONS, w=radius, h=5.0, theta=np.pi / 2.0) match = "'h' must be a positive scalar" with pytest.raises(ValueError, match=match): 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 def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_nonscalar(self): """ Test that _to_patch returns a list for non-scalar apertures. """ patches = self.aperture._to_patch() assert isinstance(patches, list) 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): match = "'w_in' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=radius, w_out=20.0, h_out=17, theta=np.pi / 3) match = "'w_out' must be greater than 'w_in'" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=radius, h_out=17, theta=np.pi / 3) match = "'h_out' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=radius, theta=np.pi / 3) match = "'h_in' must be a positive scalar" with pytest.raises(ValueError, match=match): 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 def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad def test_h_in_greater_than_h_out(self): """ Test that a ValueError is raised when h_in >= h_out. """ match = "'h_out' must be greater than 'h_in'" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=5.0, h_in=8.0, theta=np.pi / 3) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_nonscalar(self): """ Test that _to_patch returns a list for non-scalar apertures. """ patches = self.aperture._to_patch() assert isinstance(patches, list) 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): match = "'w' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAperture(SKYCOORD, w=radius * UNIT, h=5.0 * UNIT, theta=30 * u.deg) match = "'h' must be greater than zero" with pytest.raises(ValueError, match=match): 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): match = "'w_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=radius * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) match = "'w_out' must be greater than 'w_in'" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=radius * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) match = "'h_out' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=radius * UNIT, theta=60 * u.deg) match = "'h_in' must be greater than zero" with pytest.raises(ValueError, match=match): 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_h_in_greater_than_h_out(self): """ Test that a ValueError is raised when h_in >= h_out. """ match = "'h_out' must be greater than 'h_in'" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=5.0 * UNIT, h_in=8.0 * UNIT, theta=60 * u.deg) 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_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) 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_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) astropy-photutils-3322558/photutils/aperture/tests/test_stats.py000066400000000000000000000460321517052111400252360ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the stats module. """ import sys from unittest.mock import patch import astropy.units as u import numpy as np import pytest from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import SigmaClip from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose, assert_equal from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus) from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture) from photutils.aperture.stats import ApertureStats from photutils.datasets import make_100gaussians_image, make_wcs from photutils.utils._optional_deps import HAS_REGIONS 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()] index = [1, 3] if with_sigmaclip else [0, 2] index = index[1] if with_units else 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_sum_cutout', '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) match = 'A wcs is required when using a SkyAperture' with pytest.raises(ValueError, match=match): _ = ApertureStats(self.data, skyaper) def test_lazyproperties_class_cache(self): """ Test that _lazyproperties is cached on the class and shared across instances. """ apstats2 = ApertureStats(self.data, self.aperture) result1 = self.apstats1._lazyproperties result2 = apstats2._lazyproperties assert result1 is result2 def test_minimal_inputs(self): apstats = ApertureStats(self.data, self.aperture) props = ('sky_centroid', 'sky_centroid_icrs', 'error_sum_cutout') 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, error=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) match = 'local_bkg must be scalar or have the same length as the' with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture, local_bkg=(10, 20)) match = 'local_bkg must not contain any non-finite' with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(10, np.nan)) with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(-np.inf, 10)) match = 'local_bkg must be a 1D array' with pytest.raises(ValueError, match=match): _ = 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 tbl = self.apstats1.to_table(columns='sum') assert tbl.colnames == ['sum'] 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.select_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.select_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[1:] apstat1 = apstats.select_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[[2, 1, 0]] apstat1 = apstats.select_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 select_ids when ids are not sorted apstat0 = apstats[[2, 1, 0]] apstat1 = apstat0.select_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) match = "Scalar 'ApertureStats' object has no len" with pytest.raises(TypeError, match=match): _ = len(apstats[0]) apstat0 = apstats[0] match = "A scalar 'ApertureStats' object cannot be indexed" with pytest.raises(TypeError, match=match): apstat1 = apstat0[0] apstat0 = apstats[0] with pytest.raises(TypeError, match=match): apstat1 = apstat0[0] # can't slice scalar object match = '-1 is not a valid source ID number' with pytest.raises(ValueError, match=match): apstat0 = apstats.select_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_deprecated_attributes(self): """ Test that deprecated attributes are still available and give the same value as the new attributes, but raise an AstropyDeprecationWarning. """ apstats = ApertureStats(self.data, self.aperture, error=self.error) match = 'attribute was deprecated' deprecated_map = { 'covar_sigx2': 'covariance_xx', 'covar_sigxy': 'covariance_xy', 'covar_sigy2': 'covariance_yy', 'cxx': 'ellipse_cxx', 'cxy': 'ellipse_cxy', 'cyy': 'ellipse_cyy', 'data_sumcutout': 'data_sum_cutout', 'error_sumcutout': 'error_sum_cutout', 'get_id': 'select_id', 'get_ids': 'select_ids', 'semimajor_sigma': 'semimajor_axis', 'semiminor_sigma': 'semiminor_axis', 'xcentroid': 'x_centroid', 'ycentroid': 'y_centroid', } for old_name, new_name in deprecated_map.items(): with pytest.warns(AstropyDeprecationWarning, match=match): old_val = getattr(apstats, old_name) new_val = getattr(apstats, new_name) assert_equal(old_val, new_val) def test_invalid_inputs(self): match = 'aperture must be an Aperture or Region object' with pytest.raises(TypeError, match=match): ApertureStats(self.data, 10.0) with (patch.dict(sys.modules, {'regions': None}), pytest.raises(TypeError, match=match)): ApertureStats(self.data, 10.0) match = 'sigma_clip must be a SigmaClip instance' with pytest.raises(TypeError, match=match): ApertureStats(self.data, self.aperture, sigma_clip=10) match = 'error must be a 2D array' with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, error=10.0) match = 'error must be a 2D array' with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, error=np.ones(3)) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): 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.x_centroid == 12.0 assert apstats.y_centroid == 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.x_centroid, apstats2.x_centroid) assert_allclose(apstats1.y_centroid, apstats2.y_centroid) assert_allclose(apstats1.sum, apstats2.sum) if with_units: assert apstats1.sum.unit == unit match = 'keyword will be ignored' nddata = NDData(self.data, uncertainty=uncertainty, mask=mask, wcs=self.wcs, unit=unit) with pytest.warns(AstropyUserWarning, match=match): ApertureStats(nddata, self.aperture, mask=mask) 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) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_aperture_stats_region(): from regions import CirclePixelRegion, PixCoord region = CirclePixelRegion(center=PixCoord(5, 5), radius=3) aperture = CircularAperture((5, 5), r=3) data = np.ones((10, 10)) apstats1 = ApertureStats(data, region) apstats2 = ApertureStats(data, aperture) tbl = apstats1.to_table() for colname in tbl.colnames: val1 = getattr(apstats1, colname) if val1 is not None: assert_allclose(val1, getattr(apstats2, colname)) def test_aperture_metadata(): x = [10, 20, 3] y = [3, 5, 10] xypos = list(zip(x, y, strict=False)) a1 = CircularAperture(xypos, r=3) a2 = CircularAperture(xypos, r=4) a3 = CircularAnnulus(xypos, 5, 10) a4 = EllipticalAperture(xypos, 10, 5, theta=10 * u.deg) a5 = EllipticalAnnulus(xypos, a_in=5, a_out=10, b_in=3, b_out=5, theta=20 * u.deg) a6 = RectangularAperture(xypos, 10, 5, theta=30 * u.deg) a7 = RectangularAnnulus(xypos, w_in=5, w_out=10, h_in=3, h_out=5, theta=40 * u.deg) apers = (a1, a2, a3, a4, a5, a6, a7) data = np.ones((50, 50)) for aper in apers: apstats = ApertureStats(data, aper) tbl = apstats.to_table() assert tbl.meta['aperture'] == aper.__class__.__name__ params = aper._params for param in params: if param != 'positions': assert tbl.meta[f'aperture_{param}'] == getattr(aper, param) wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) unit = u.arcsec saper = SkyEllipticalAnnulus(skycoord, a_in=0.1 * unit, a_out=0.2 * unit, b_in=0.05 * unit, b_out=0.1 * unit, theta=10 * u.deg) apstats = ApertureStats(data, saper, wcs=wcs) tbl = apstats.to_table() assert tbl.meta['aperture'] == saper.__class__.__name__ assert tbl.meta['aperture_a_in'] == saper.a_in assert tbl.meta['aperture_a_out'] == saper.a_out assert tbl.meta['aperture_b_out'] == saper.b_out assert tbl.meta['aperture_theta'] == saper.theta astropy-photutils-3322558/photutils/background/000077500000000000000000000000001517052111400216105ustar00rootroot00000000000000astropy-photutils-3322558/photutils/background/__init__.py000066400000000000000000000005411517052111400237210ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for estimating 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 astropy-photutils-3322558/photutils/background/background_2d.py000066400000000000000000001140371517052111400246740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for estimating the 2D background and background RMS in an image. """ import copy import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData, block_replicate, reshape_as_blocks from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import generic_filter from photutils.aperture import RectangularAperture from photutils.background.core import (SIGMA_CLIP, SExtractorBackground, StdBackgroundRMS) from photutils.background.interpolators import (BkgIDWInterpolator, _BkgZoomInterpolator) from photutils.utils import ShepardIDWInterpolator from photutils.utils._deprecation import (deprecated, deprecated_renamed_argument) from photutils.utils._parameters import as_pair, create_default_sigmaclip from photutils.utils._repr import make_repr from photutils.utils._stats import nanmedian, nanmin __all__ = ['Background2D'] __doctest_skip__ = ['Background2D'] 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. .. note:: Better performance will generally be obtained if you have the `Bottleneck `_ package installed. See :ref:`performance-tips` for details, including notes on array byte order (endianness) when loading FITS data. 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, the image will be padded along the top and/or right edges. 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. 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 background and background RMS calculations. ``mask`` is intended to mask sources or bad pixels, but a background and background RMS value will be calculated for them based on interpolation of the low-resolution background and background RMS maps. Use ``coverage_mask`` to mask blank areas of an image. ``coverage_mask`` pixels are assigned a value of ``fill_value`` (default = 0) in the output background and background RMS maps. 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 to mask sources or bad pixels (in that case use ``mask`` instead). ``coverage_mask`` pixels are assigned a value of ``fill_value`` (default = 0) in the output background and background RMS maps. 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 allowed in a box for it to be included in the low-resolution map. 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``, non-finite ``data`` values, any padded area at the data edges, and those resulting from any sigma clipping. Setting ``exclude_percentile=0`` will exclude boxes that have any that have any masked pixels. Note that completely masked boxes are always excluded. In general, ``exclude_percentile`` should be kept as low as possible to ensure there are a sufficient number of unmasked pixels in each box 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). sigma_clip : `astropy.stats.SigmaClip` or `None`, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. 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`. bkg_rms_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 ``bkg_rms_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. .. deprecated:: 3.0 This keyword argument is deprecated and will be removed in a future version. When removed, the `scipy.ndimage.zoom` cubic spline interpolator will always be used to resize the low-resolution background and background RMS arrays to the full image size. Notes ----- Integer input data produce background and background RMS outputs with ``np.float32`` dtype to preserve precision from interpolation while minimizing memory usage. Float input data produce background and background RMS outputs with the same dtype as the input data. 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. """ @deprecated_renamed_argument('bkgrms_estimator', 'bkg_rms_estimator', '3.0', until='4.0') @deprecated_renamed_argument('interpolator', None, '3.0', until='4.0') 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, sigma_clip=SIGMA_CLIP, bkg_estimator=None, bkg_rms_estimator=None, interpolator=None): if isinstance(data, (u.Quantity, NDData)): # includes CCDData self._unit = data.unit data = data.data else: self._unit = None # self._data is a temporary instance variable to store the input # data (the variable is deleted in self._calculate_stats) self._data = self._validate_array(data, 'data', shape=False) self._data_dtype = self._data.dtype self._data_shape = self._data.shape if np.all(~np.isfinite(self._data)): msg = ('Input data contains all non-finite (NaN or infinity) ' 'values. Cannot compute a background.') raise ValueError(msg) # self._mask is a temporary instance variable to store the input # mask array (deleted in self._calculate_stats); self._has_mask # records whether a mask was provided for use after deletion. self._mask = self._validate_array(mask, 'mask') self._has_mask = self._mask is not None self.coverage_mask = self._validate_array(coverage_mask, 'coverage_mask') # box_size cannot be larger than the data array size self.box_size = as_pair('box_size', box_size, lower_bound=(0, 0), upper_bound=data.shape) self.fill_value = fill_value if exclude_percentile < 0 or exclude_percentile > 100: msg = 'exclude_percentile must be between 0 and 100 (inclusive)' raise ValueError(msg) self.exclude_percentile = exclude_percentile self.filter_size = as_pair('filter_size', filter_size, lower_bound=(0, 0), check_odd=True) self.filter_threshold = filter_threshold if sigma_clip is SIGMA_CLIP: sigma_clip = create_default_sigmaclip(sigma=SIGMA_CLIP.sigma, maxiters=SIGMA_CLIP.maxiters) self.sigma_clip = sigma_clip if interpolator is None: interpolator = _BkgZoomInterpolator() self.interpolator = interpolator if bkg_estimator is None: bkg_estimator = SExtractorBackground(sigma_clip=None) if bkg_rms_estimator is None: bkg_rms_estimator = StdBackgroundRMS(sigma_clip=None) # We perform sigma clipping as a separate step to avoid calling # it twice for the background and background RMS. Shallow-copy # the estimators before mutating their sigma_clip attribute so # that any user-supplied estimator object is not modified in # place. bkg_estimator = copy.copy(bkg_estimator) bkg_rms_estimator = copy.copy(bkg_rms_estimator) if hasattr(bkg_estimator, 'sigma_clip'): bkg_estimator.sigma_clip = None if hasattr(bkg_rms_estimator, 'sigma_clip'): bkg_rms_estimator.sigma_clip = None self.bkg_estimator = bkg_estimator self.bkg_rms_estimator = bkg_rms_estimator self._box_npixels = None # Store the interpolator keyword arguments for later use # (before self._data is deleted in self._calculate_stats) interp_dtype = self._data.dtype if interp_dtype.kind != 'f': interp_dtype = np.float32 self._interp_kwargs = {'shape': self._data.shape, 'dtype': interp_dtype, 'box_size': self.box_size} # Perform the initial calculations to avoid storing large data # arrays and to keep the memory usage minimal (self._bkg_stats, self._bkgrms_stats, self._ngood) = self._calculate_stats() # This is used to selectively filter the low-resolution maps self._min_bkg_stats = nanmin(self._bkg_stats) # Store a mask of the excluded mesh values (NaNs) in the # low-resolution maps self._mesh_nan_mask = np.isnan(self._bkg_stats) # Add keyword arguments needed for BkgZoomInterpolator. # BkgIDWInterpolator upscales the mesh based only on the good # pixels in the low-resolution mesh. if isinstance(self.interpolator, BkgIDWInterpolator): self._interp_kwargs['mesh_yxcen'] = self._calculate_mesh_yxcen() self._interp_kwargs['mesh_nan_mask'] = self._mesh_nan_mask def _repr_str_params(self): params = ('data', 'box_size', 'mask', 'coverage_mask', 'fill_value', 'exclude_percentile', 'filter_size', 'filter_threshold', 'sigma_clip', 'bkg_estimator', 'bkg_rms_estimator', 'interpolator') data_repr = f'' mask_repr = None if not self._has_mask else data_repr if 'coverage_mask' in self.__dict__ and self.coverage_mask is None: coverage_mask_repr = None else: coverage_mask_repr = data_repr overrides = {'data': data_repr, 'mask': mask_repr, 'coverage_mask': coverage_mask_repr} return params, overrides def __repr__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides) def __str__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides, long=True) def _validate_array(self, array, name, *, shape=True): """ Validate the input data, mask, and coverage_mask arrays. """ 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: msg = f'{name} must be a 2D array' raise ValueError(msg) if shape and array.shape != self._data.shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array def _apply_units(self, data): """ Apply units to the data. The units are based on the units of the input ``data`` array. Parameters ---------- data : `~numpy.ndarray` The input data array. Returns ------- data : `~numpy.ndarray` The data array with units applied. """ if self._unit is not None: data <<= self._unit return data def _combine_input_masks(self): """ Combine the input mask and coverage_mask. """ if self._mask is None and self.coverage_mask is None: return None if self._mask is None: return self.coverage_mask if self.coverage_mask is None: return self._mask return np.logical_or(self._mask, self.coverage_mask) def _combine_all_masks(self, mask): """ Combine the input masks (mask and coverage_mask) with the mask of invalid data values. """ input_mask = self._combine_input_masks() msg = ('Input data contains non-finite (NaN or infinity) values, ' 'which were automatically masked.') if input_mask is None: if np.any(mask): warnings.warn(msg, AstropyUserWarning) total_mask = mask else: condition = np.logical_and(np.logical_not(input_mask), mask) if np.any(condition): warnings.warn(msg, AstropyUserWarning) total_mask = np.logical_or(input_mask, mask) if np.all(total_mask): msg = 'All input pixels are masked. Cannot compute a background.' raise ValueError(msg) return total_mask @lazyproperty def _good_npixels_threshold(self): """ The minimum number of required unmasked pixels in a box used for it to be included in the low-resolution map. 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. Boxes that are completely masked are always excluded. """ return (1 - (self.exclude_percentile / 100.0)) * self._box_npixels def _sigmaclip_boxes(self, data, axis): """ Sigma clip the boxes along the specified axis. This method sigma clips the boxes along the specified axis and returns the sigma-clipped data. The input ``data`` is typically a 4D array where the first two dimensions represent the y and x positions of the boxes and the last two dimensions represent the y and x positions within each box. We perform sigma clipping as a separate step to avoid performing sigma clipping for both the background and background RMS estimators. Parameters ---------- data : `~numpy.ndarray` The 4D array of box data. axis : int or tuple of int The axis or axes along which to sigma clip the data. Returns ------- data : `~numpy.ndarray` The sigma-clipped data. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', category=AstropyUserWarning) if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False, copy=False) return data def _compute_box_statistics(self, data, *, axis=None): """ Compute the background and background RMS statistics in each box. Parameters ---------- data : `~numpy.ndarray` The 4D array of box data. axis : int or tuple of int, optional The axis or axes along which to compute the statistics. Returns ------- bkg : 2D `~numpy.ndarray` or float The background statistics in each box. bkgrms : 2D `~numpy.ndarray` or float The background RMS statistics in each box. """ data = self._sigmaclip_boxes(data, axis=axis) # Make 2D arrays of the box statistics bkg = self.bkg_estimator(data, axis=axis) bkgrms = self.bkg_rms_estimator(data, axis=axis) # Mask boxes with too few unmasked pixels ngood = np.count_nonzero(~np.isnan(data), axis=axis) box_mask = ngood <= self._good_npixels_threshold if np.ndim(bkg) == 0: if box_mask: # single corner box # np.nan is float64; use np.float32 to prevent numpy from # promoting the output data dtype to float64 if the # input data is float32 bkg = np.float32(np.nan) bkgrms = np.float32(np.nan) else: bkg[box_mask] = np.nan bkgrms[box_mask] = np.nan return bkg, bkgrms, ngood def _calculate_stats(self): """ Calculate the background and background RMS statistics in each box. Returns ------- bkg : 2D `~numpy.ndarray` The background statistics in each box. bkgrms : 2D `~numpy.ndarray` The background RMS statistics in each box. ngood : 2D `~numpy.ndarray` The number of unmasked pixels in each box. """ # If needed, copy the data to a float32 array to insert NaNs if self._data.dtype.kind != 'f': self._data = self._data.astype(np.float32) # Automatically mask non-finite values that aren't already # masked and combine all masks mask = self._combine_all_masks(~np.isfinite(self._data)) self._box_npixels = np.prod(self.box_size) nboxes = self._data.shape // self.box_size y1, x1 = nboxes * self.box_size # Core boxes - the part of the data array that is an integer # multiple of the box size. # Combine the last two axes for performance. # Below we transform both the data and mask arrays to avoid # making multiple copies of the data (one to insert NaN and # another for the reshape). Only one copy of the data and mask # array is made (except for the extra corner). The boolean mask # copy is much smaller than the data array. # An explicit copy of the data array is needed to avoid # modifying the original data array if the shape of the data # array is (y1, x1) (i.e., box_size = data.shape). core = reshape_as_blocks(self._data[:y1, :x1].copy(), self.box_size) core_mask = reshape_as_blocks(mask[:y1, :x1], self.box_size) core = core.reshape((*nboxes, -1)) core_mask = core_mask.reshape((*nboxes, -1)) core[core_mask] = np.nan bkg, bkgrms, ngood = self._compute_box_statistics(core, axis=-1) extra_row = y1 < self._data.shape[0] extra_col = x1 < self._data.shape[1] if extra_row or extra_col: if extra_row: # Extra row of boxes. # Here we need to make a copy of the data array to avoid # modifying the original data array. # Move the axes and combine the last two for performance. row_data = self._data[y1:, :x1].copy() row_mask = mask[y1:, :x1] row_data[row_mask] = np.nan row_data = reshape_as_blocks(row_data, (1, self.box_size[1])) row_data = np.moveaxis(row_data, 0, -1) row_data = row_data.reshape((*row_data.shape[:-2], -1)) row_bkg, row_bkgrms, row_ngood = self._compute_box_statistics( row_data, axis=-1) if extra_col: # Extra column of boxes. # Here we need to make a copy of the data array to avoid # modifying the original data array. # Move the axes and combine the last two for performance. col_data = self._data[:y1, x1:].copy() col_mask = mask[:y1, x1:] col_data[col_mask] = np.nan col_data = reshape_as_blocks(col_data, (self.box_size[0], 1)) col_data = np.transpose(col_data, (0, 3, 1, 2)) col_data = col_data.reshape((*col_data.shape[:-2], -1)) col_bkg, col_bkgrms, col_ngood = self._compute_box_statistics( col_data, axis=-1) if extra_row and extra_col: # Extra corner box -- append to extra column. # Here we need to make a copy of the data array to avoid # modifying the original data array. corner_data = self._data[y1:, x1:].copy() corner_mask = mask[y1:, x1:] corner_data[corner_mask] = np.nan crn_bkg, crn_bkgrms, crn_ngood = self._compute_box_statistics( corner_data, axis=None) col_bkg = np.vstack((col_bkg, crn_bkg)) col_bkgrms = np.vstack((col_bkgrms, crn_bkgrms)) col_ngood = np.vstack((col_ngood, crn_ngood)) # Combine the core and extra boxes to construct the # complete 2D bkg and bkgrms arrays if extra_row: bkg = np.vstack([bkg, row_bkg[:, 0]]) bkgrms = np.vstack([bkgrms, row_bkgrms[:, 0]]) ngood = np.vstack([ngood, row_ngood[:, 0]]) if extra_col: bkg = np.hstack([bkg, col_bkg]) bkgrms = np.hstack([bkgrms, col_bkgrms]) ngood = np.hstack([ngood, col_ngood]) if np.all(np.isnan(bkg)): msg = (f'All boxes contain <= {self._good_npixels_threshold} ' f'unmasked or finite pixels ({self.box_size=}, ' f'{self.exclude_percentile=}). Please check your data ' 'or increase "exclude_percentile" to allow more boxes to ' 'be included.') raise ValueError(msg) # We no longer need the temporary input arrays del self._data del self._mask return bkg, bkgrms, ngood def _interpolate_grid(self, data, *, n_neighbors=10, eps=0.0, power=1.0, regularization=0.0): """ Fill in any NaN values in the low-resolution 2D mesh background and background RMS images using inverse distance weighting (IDW) interpolation. This method ensures that the low-resolution mesh contains no NaNs before applying a regular-grid interpolator to expand it to the full image size. If there are no NaNs, the input is returned (cast to the original dtype). Otherwise, NaN pixels are replaced by IDW interpolation using valid mesh values. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of the box statistics, possibly containing NaNs. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. eps : float, optional Approximation parameter for nearest neighbors (see `scipy.spatial.cKDTree.query`). power : float, optional The power of the inverse distance used for the interpolation weights. regularization : float, optional Regularization parameter to control the smoothness of the interpolator. Returns ------- result : 2D `~numpy.ndarray` The input array with NaNs replaced by interpolated values. """ if not np.any(np.isnan(data)): return data mask = ~np.isnan(data) idx = np.where(mask) yx = np.column_stack(idx) interp_func = ShepardIDWInterpolator(yx, data[mask]) # Interpolate the masked pixels where data is NaN idx = np.where(np.isnan(data)) yx_indices = np.column_stack(idx) interp_values = interp_func(yx_indices, n_neighbors=n_neighbors, power=power, eps=eps, regularization=regularization) interp_data = np.copy(data) # copy to avoid modifying the input data interp_data[idx] = interp_values return interp_data def _selective_filter(self, data): """ Filter only pixels above ``filter_threshold`` in a low- resolution 2D image. The pixels to be filtered are determined by applying the ``filter_threshold`` to the low-resolution 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 ------- result : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ bkg_stats_interp = self._interpolate_grid(self._bkg_stats) above_threshold = bkg_stats_interp > self.filter_threshold if not np.any(above_threshold): return data # Apply the median filter across the whole mesh in one call, # then blend: keep the filtered value only where the background # is above the threshold; use the original value everywhere # else. filtered = generic_filter(data, nanmedian, size=self.filter_size, mode='constant', cval=np.nan) return np.where(above_threshold, filtered, data) def _filter_grid(self, data): """ Apply a 2D median filter to a low-resolution 2D image. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of mesh values. Returns ------- result : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ if tuple(self.filter_size) == (1, 1): return data if (self.filter_threshold is None or self.filter_threshold < self._min_bkg_stats): # Filter the entire array 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 def _calculate_mesh_yxcen(self): """ Calculate the y and x positions of the centers of the low- resolution background and background RMS meshes with respect to the input data array. This is used by the IDW interpolator to expand the low- resolution mesh to the full-size image. It is also used to plot the mesh boxes on the input image. """ mesh_idx = np.where(~self._mesh_nan_mask) # good mesh indices box_cen = (self.box_size - 1) / 2.0 return (mesh_idx * self.box_size[:, None]) + box_cen[:, None] def _try_free_bkg_stats(self): """ Free ``_bkg_stats`` when it is safe to do so. ``_bkg_stats`` is always needed by ``background_mesh`` (via ``_interpolate_grid``). It is also needed by ``_selective_filter`` (called from ``_filter_grid``) when ``filter_threshold`` is not ``None``. It is therefore safe to free it only after ``background_mesh`` has been cached and either ``filter_threshold`` is ``None`` (so ``background_rms_mesh`` does not need it) or ``background_rms_mesh`` has also been cached. """ if 'background_mesh' not in self.__dict__: return if (self.filter_threshold is None or 'background_rms_mesh' in self.__dict__): self._bkg_stats = None # delete to save memory @lazyproperty def background_mesh(self): """ The low-resolution background image. This image is equivalent to the low-resolution "MINIBACK" background map check image in SourceExtractor. """ data = self._interpolate_grid(self._bkg_stats) result = self._apply_units(self._filter_grid(data)) self._try_free_bkg_stats() return result @lazyproperty def background_rms_mesh(self): """ The low-resolution background RMS image. This image is equivalent to the low-resolution "MINIBACK_RMS" background rms map check image in SourceExtractor. """ data = self._interpolate_grid(self._bkgrms_stats) self._bkgrms_stats = None # delete to save memory result = self._apply_units(self._filter_grid(data)) self._try_free_bkg_stats() return result @property @deprecated(since='3.0', alternative='n_pixels_mesh', until='4.0') def npixels_mesh(self): """ A 2D array of the number pixels used to compute the statistics in each mesh. .. deprecated:: 3.0 Use ``n_pixels_mesh`` instead. """ return self._ngood @property def n_pixels_mesh(self): """ A 2D array of the number pixels used to compute the statistics in each mesh. """ return self._ngood @property @deprecated(since='3.0', alternative='n_pixels_map', until='4.0') def npixels_map(self): """ A 2D map of the number of pixels used to compute the statistics in each mesh, resized to the shape of the input image. .. deprecated:: 3.0 Use ``n_pixels_map`` instead. .. note:: The returned image is (re)calculated each time this property is accessed. Store the result in a variable if you need to access it more than once. """ return self.n_pixels_map @property def n_pixels_map(self): """ A 2D map of the number of pixels used to compute the statistics in each mesh, resized to the shape of the input image. .. note:: The returned image is (re)calculated each time this property is accessed. Store the result in a variable if you need to access it more than once. """ n_pixels_map = block_replicate(self.n_pixels_mesh, self._interp_kwargs['box_size'], conserve_sum=False) return n_pixels_map[:self._interp_kwargs['shape'][0], :self._interp_kwargs['shape'][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: "). .. note:: This value is computed over the full ``background_mesh``, which includes IDW-interpolated values for any mesh boxes that were excluded from the statistics (e.g., due to masking or ``exclude_percentile``). It therefore represents the median of the final interpolated mesh, not solely the median of directly measured mesh values. """ return self._apply_units(np.median(self.background_mesh)) @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: "). .. note:: This value is computed over the full ``background_rms_mesh``, which includes IDW-interpolated values for any mesh boxes that were excluded from the statistics (e.g., due to masking or ``exclude_percentile``). It therefore represents the median of the final interpolated mesh, not solely the median of directly measured mesh values. """ return self._apply_units(np.median(self.background_rms_mesh)) def _calculate_image(self, data): """ Calculate the full-sized background or background rms image from the low-resolution mesh. """ data = self.interpolator(data, **self._interp_kwargs) if self.coverage_mask is not None: data[self.coverage_mask] = self.fill_value return self._apply_units(data) @property def background(self): """ A 2D `~numpy.ndarray` containing the background image. .. note:: The returned image is (re)calculated each time this property is accessed. Store the result in a variable if you need to access it more than once. """ return self._calculate_image(self.background_mesh) @property def background_rms(self): """ A 2D `~numpy.ndarray` containing the background RMS image. .. note:: The returned image is (re)calculated each time this property is accessed. Store the result in a variable if you need to access it more than once. """ return self._calculate_image(self.background_rms_mesh) 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, optional 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() mesh_xycen = np.flipud(self._calculate_mesh_yxcen()) ax.scatter(*mesh_xycen, s=markersize, marker=marker, color=color, alpha=alpha) if outlines: xycen = np.column_stack(mesh_xycen) apers = RectangularAperture(xycen, w=self.box_size[1], h=self.box_size[0], theta=0.0) apers.plot(ax=ax, alpha=alpha, **kwargs) astropy-photutils-3322558/photutils/background/core.py000066400000000000000000000553501517052111400231220ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for estimating 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._deprecation import deprecated_positional_kwargs from photutils.utils._parameters import (SigmaClipSentinelDefault, create_default_sigmaclip) from photutils.utils._repr import make_repr from photutils.utils._stats import nanmean, nanmedian, nanstd __all__ = [ 'BackgroundBase', 'BackgroundRMSBase', 'BiweightLocationBackground', 'BiweightScaleBackgroundRMS', 'MADStdBackgroundRMS', 'MMMBackground', 'MeanBackground', 'MedianBackground', 'ModeEstimatorBackground', 'SExtractorBackground', 'StdBackgroundRMS', ] SIGMA_CLIP = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) _SIGMA_CLIP_PARAM_DOC = ( 'sigma_clip : `astropy.stats.SigmaClip` or `None`, optional\n' ' A `~astropy.stats.SigmaClip` object that defines the sigma\n' ' clipping parameters. If `None` then no sigma clipping will be\n' ' performed.' ) def _insert_sigma_clip_doc(cls): """ Class decorator that replaces the ```` placeholder in a class docstring with the shared ``sigma_clip`` parameter description. """ cls.__doc__ = cls.__doc__.replace( '', _SIGMA_CLIP_PARAM_DOC, ) return cls def _validate_sigma_clip(sigma_clip): """ Validate and generate the ``sigma_clip`` parameter. If ``sigma_clip`` is the sentinel default, a fresh `~astropy.stats.SigmaClip` instance is created from its stored parameters. `None` is accepted (meaning no sigma clipping). Any other value must be a `~astropy.stats.SigmaClip` instance. Parameters ---------- sigma_clip : `~photutils.utils._parameters.SigmaClipSentinelDefault`,\ `~astropy.stats.SigmaClip`, or `None` The value supplied to a base-class ``__init__``. Returns ------- sigma_clip : `~astropy.stats.SigmaClip` or `None` A concrete `~astropy.stats.SigmaClip` instance, or `None`. """ if sigma_clip is SIGMA_CLIP: return create_default_sigmaclip(sigma=SIGMA_CLIP.sigma, maxiters=SIGMA_CLIP.maxiters) if not isinstance(sigma_clip, SigmaClip) and sigma_clip is not None: msg = 'sigma_clip must be an astropy SigmaClip instance or None' raise TypeError(msg) return sigma_clip def _prepare_data(sigma_clip, data, axis): """ Prepare input data for a background estimation step. Applies sigma clipping when a `~astropy.stats.SigmaClip` instance is provided, or fills masked-array fill values with NaN when the input is a `~numpy.ma.MaskedArray` and sigma clipping is disabled. Parameters ---------- sigma_clip : `~astropy.stats.SigmaClip` or `None` The sigma-clipping object to apply. If `None`, no clipping is performed. data : array_like or `~numpy.ma.MaskedArray` The input data array. axis : int, tuple of int, or `None` The axis along which sigma clipping is applied. Returns ------- data : `~numpy.ndarray` The prepared data array, with masked or clipped values replaced by NaN. """ if sigma_clip is not None: return sigma_clip(data, axis=axis, masked=False) if isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN return data.filled(np.nan) return data def _apply_masked(result, masked): """ Optionally wrap NaN values in a masked array. Parameters ---------- result : `~numpy.ndarray` or scalar The computed background or background RMS value(s). masked : bool If `True` and ``result`` is an `~numpy.ndarray`, return a `~numpy.ma.MaskedArray` with NaN values masked. Otherwise return ``result`` unchanged. Returns ------- result : `~numpy.ndarray`, `~numpy.ma.MaskedArray`, or scalar The result, optionally wrapped as a masked array. """ if masked and isinstance(result, np.ndarray): return np.ma.masked_where(np.isnan(result), result) return result class _BackgroundCommonBase: """ Internal mixin providing shared infrastructure for `BackgroundBase` and `BackgroundRMSBase`. This class is not part of the public API and should not be instantiated directly or subclassed outside of this module. """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, sigma_clip=SIGMA_CLIP): self.sigma_clip = _validate_sigma_clip(sigma_clip) def __repr__(self): return make_repr(self, ('sigma_clip',)) class BackgroundBase(_BackgroundCommonBase, abc.ABC): """ Base class for classes that estimate scalar background values. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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. """ class BackgroundRMSBase(_BackgroundCommonBase, abc.ABC): """ Base class for classes that estimate scalar background RMS values. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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. """ @_insert_sigma_clip_doc class MeanBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) mean. Parameters ---------- 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=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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # Ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmean(data, axis=axis) return _apply_masked(result, masked) @_insert_sigma_clip_doc class MedianBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) median. Parameters ---------- 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=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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # Ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmedian(data, axis=axis) return _apply_masked(result, masked) @_insert_sigma_clip_doc 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 median value. Defaults to 3. mean_factor : float, optional The multiplicative factor for the mean value. Defaults to 2. 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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, median_factor=3.0, mean_factor=2.0, sigma_clip=SIGMA_CLIP): super().__init__(sigma_clip=sigma_clip) 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) @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # 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))) return _apply_masked(result, masked) @_insert_sigma_clip_doc 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 ---------- 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 `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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, sigma_clip=SIGMA_CLIP): super().__init__(median_factor=3.0, mean_factor=2.0, sigma_clip=sigma_clip) @_insert_sigma_clip_doc 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 ---------- 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=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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # 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)) result = (2.5 * _median) - (1.5 * _mean) # Set the background to the mean where the std is zero mean_mask = _std == 0 result[mean_mask] = _mean[mean_mask] # Set the background to the median when the absolute # difference between the mean and median divided by the # standard deviation is greater than or equal to 0.3 med_mask = (np.abs(_mean - _median) / _std) >= 0.3 mask = np.logical_and(med_mask, np.logical_not(mean_mask)) result[mask] = _median[mask] # If result is a scalar, return it as a float if result.shape == (1,) and axis is None: result = result[0] return _apply_masked(result, masked) @_insert_sigma_clip_doc 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`. 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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, c=6.0, M=None, sigma_clip=SIGMA_CLIP): super().__init__(sigma_clip=sigma_clip) self.c = c self.M = M def __repr__(self): params = ('c', 'M', 'sigma_clip') return make_repr(self, params) @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # 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) return _apply_masked(result, masked) @_insert_sigma_clip_doc class StdBackgroundRMS(BackgroundRMSBase): """ Class to calculate the background RMS in an array as the (sigma- clipped) standard deviation. Parameters ---------- 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=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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background_rms(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # Ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanstd(data, axis=axis) return _apply_masked(result, masked) @_insert_sigma_clip_doc 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 ---------- 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=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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background_rms(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # Ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = mad_std(data, axis=axis, ignore_nan=True) return _apply_masked(result, masked) @_insert_sigma_clip_doc 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`. 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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, c=9.0, M=None, sigma_clip=SIGMA_CLIP): super().__init__(sigma_clip=sigma_clip) self.c = c self.M = M def __repr__(self): params = ('c', 'M', 'sigma_clip') return make_repr(self, params) @deprecated_positional_kwargs(since='3.0', until='4.0') def calc_background_rms(self, data, axis=None, masked=False): data = _prepare_data(self.sigma_clip, data, axis) # 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) return _apply_masked(result, masked) astropy-photutils-3322558/photutils/background/interpolators.py000066400000000000000000000206021517052111400250670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for upsampling images for Background2D using interpolation. """ import numpy as np from astropy.units import Quantity from astropy.utils.decorators import deprecated from scipy.ndimage import zoom from photutils.utils import ShepardIDWInterpolator from photutils.utils._repr import make_repr __all__ = ['BkgIDWInterpolator', 'BkgZoomInterpolator'] class _BkgZoomInterpolator: """ Class to generate a 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. 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. Notes ----- When resizing the mesh to the full image size, the samples are considered as the centers of regularly-spaced grid elements (i.e., `~scipy.ndimage.zoom` ``grid_mode`` is True). This makes zoom's behavior consistent with `scipy.ndimage.map_coordinates` and `skimage.transform.resize` """ def __init__(self, *, order=3, mode='reflect', cval=0.0, clip=True): self.order = order self.mode = mode self.cval = cval self.clip = clip def __repr__(self): params = ('order', 'mode', 'cval', 'clip') return make_repr(self, params) def __call__(self, data, **kwargs): """ Resize the 2D mesh array. Parameters ---------- data : 2D `~numpy.ndarray` The low-resolution 2D mesh array. **kwargs : dict Additional keyword arguments passed to the interpolator. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. Notes ----- If ``data`` is an `~astropy.units.Quantity`, units are stripped before interpolation. Unit re-assignment is the caller's responsibility. """ data = np.asanyarray(data) if isinstance(data, Quantity): data = data.value if np.ptp(data) == 0: return np.full(kwargs['shape'], np.min(data), dtype=kwargs['dtype']) # 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 = kwargs['box_size'] result = zoom(data, zoom_factor, order=self.order, mode=self.mode, cval=self.cval, grid_mode=True) result = result[0:kwargs['shape'][0], 0:kwargs['shape'][1]] if self.clip: minval = np.min(data) maxval = np.max(data) np.clip(result, minval, maxval, out=result) # clip in place return result @deprecated(since='3.0', message=('BkgZoomInterpolator is deprecated and will ' 'be removed in version 4.0.')) class BkgZoomInterpolator(_BkgZoomInterpolator): """ Class to generate a 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. 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. Notes ----- When resizing the mesh to the full image size, the samples are considered as the centers of regularly-spaced grid elements (i.e., `~scipy.ndimage.zoom` ``grid_mode`` is True). This makes zoom's behavior consistent with `scipy.ndimage.map_coordinates` and `skimage.transform.resize` """ def __init__(self, *, order=3, mode='reflect', cval=0.0, clip=True): super().__init__(order=order, mode=mode, cval=cval, clip=clip) @deprecated(since='3.0', message=('BkgIDWInterpolator is deprecated and will ' 'be removed in a version 4.0.')) class BkgIDWInterpolator: """ Class to generate a 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. regularization : 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, regularization=0.0): self.leafsize = leafsize self.n_neighbors = n_neighbors self.power = power self.regularization = regularization def __repr__(self): params = ('leafsize', 'n_neighbors', 'power', 'regularization') return make_repr(self, params) def __call__(self, data, **kwargs): """ Resize the 2D mesh array. Parameters ---------- data : 2D `~numpy.ndarray` The low-resolution 2D mesh array. **kwargs : dict Additional keyword arguments passed to the interpolator. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. Notes ----- If ``data`` is an `~astropy.units.Quantity`, units are stripped before interpolation. Unit re-assignment is the caller's responsibility. """ data = np.asanyarray(data) if isinstance(data, Quantity): data = data.value if np.ptp(data) == 0: return np.full(kwargs['shape'], np.min(data), dtype=kwargs['dtype']) # Create the interpolator from only the good mesh points yxcen = np.column_stack(kwargs['mesh_yxcen']) good_idx = np.where(~kwargs['mesh_nan_mask']) data = data[good_idx] interp_func = ShepardIDWInterpolator(yxcen, data, leafsize=self.leafsize) # Define the position coordinates used when calling the # interpolator yi, xi = np.mgrid[0:kwargs['shape'][0], 0:kwargs['shape'][1]] yx_indices = np.column_stack((yi.ravel(), xi.ravel())) data = interp_func(yx_indices, n_neighbors=self.n_neighbors, power=self.power, regularization=self.regularization) return data.reshape(kwargs['shape']) astropy-photutils-3322558/photutils/background/local_background.py000066400000000000000000000133711517052111400254600ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for estimating local background using a circular annulus aperture. """ import numpy as np from photutils.aperture import CircularAnnulus from photutils.background import MedianBackground from photutils.utils._deprecation import deprecated_positional_kwargs 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 1D `~numpy.ndarray` or `~numpy.ma.MaskedArray`. The default is an instance of `~photutils.background.MedianBackground` with sigma clipping (i.e., sigma-clipped median). Examples -------- >>> import numpy as np >>> from photutils.background import LocalBackground >>> data = np.ones((101, 101)) >>> local_bkg = LocalBackground(5, 10) >>> bkg = local_bkg(data, 50, 50) >>> print(bkg) # doctest: +FLOAT_CMP 1.0 >>> # Multiple positions >>> x = [30, 50, 70] >>> y = [30, 50, 70] >>> bkg = local_bkg(data, x, y) >>> print(bkg) # doctest: +FLOAT_CMP [1. 1. 1.] """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, inner_radius, outer_radius, bkg_estimator=None): if inner_radius <= 0: msg = 'inner_radius must be positive.' raise ValueError(msg) if outer_radius <= 0: msg = 'outer_radius must be positive.' raise ValueError(msg) if outer_radius <= inner_radius: msg = 'outer_radius must be greater than inner_radius.' raise ValueError(msg) self.inner_radius = inner_radius self.outer_radius = outer_radius if bkg_estimator is None: bkg_estimator = MedianBackground() self.bkg_estimator = bkg_estimator def __repr__(self): params = ('inner_radius', 'outer_radius', 'bkg_estimator') return make_repr(self, params) def to_aperture(self, x, y): """ Return a `~photutils.aperture.CircularAnnulus` instance representing the local background annulus at the given positions. Parameters ---------- x, y : float or 1D float `~numpy.ndarray` The aperture center (x, y) position(s) at which to create the annulus aperture. Returns ------- apertures : `~photutils.aperture.CircularAnnulus` instance The circular annulus aperture(s) at the given position(s). Examples -------- >>> from photutils.background import LocalBackground >>> local_bkg = LocalBackground(5, 10) >>> aperture = local_bkg.to_aperture(50, 50) >>> aperture # doctest: +FLOAT_CMP >>> # Multiple positions >>> aperture = local_bkg.to_aperture([30, 70], [40, 80]) >>> aperture # doctest: +FLOAT_CMP >>> print(len(aperture.positions)) 2 """ x = np.atleast_1d(x) y = np.atleast_1d(y) positions = np.array(list(zip(x, y, strict=True))) return CircularAnnulus(positions, self.inner_radius, self.outer_radius) @deprecated_positional_kwargs(since='3.0', until='4.0') 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. If all pixels in an annulus are masked or outside the data bounds, the corresponding value will be NaN. Examples -------- >>> import numpy as np >>> from photutils.background import LocalBackground >>> data = np.ones((101, 101)) >>> local_bkg = LocalBackground(5, 10) >>> bkg = local_bkg(data, 50, 50) >>> print(bkg) # doctest: +FLOAT_CMP 1.0 >>> # Multiple positions >>> bkg = local_bkg(data, [30, 50], [40, 60]) >>> print(bkg) # doctest: +FLOAT_CMP [1. 1.] >>> # Position outside data returns NaN >>> bkg = local_bkg(data, -50, -50) >>> print(np.isnan(bkg)) True """ apertures = self.to_aperture(x, y) apermasks = apertures.to_mask(method='center') n_apertures = len(apermasks) bkg = np.empty(n_apertures) for i, apermask in enumerate(apermasks): values = apermask.get_values(data, mask=mask) bkg[i] = self.bkg_estimator(values) if bkg.size == 1: bkg = bkg[0] return bkg astropy-photutils-3322558/photutils/background/tests/000077500000000000000000000000001517052111400227525ustar00rootroot00000000000000astropy-photutils-3322558/photutils/background/tests/__init__.py000066400000000000000000000000001517052111400250510ustar00rootroot00000000000000astropy-photutils-3322558/photutils/background/tests/test_background_2d.py000066400000000000000000001055431517052111400270770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the background_2d module. """ import astropy.units as u import numpy as np import pytest from astropy.nddata import CCDData, NDData from astropy.stats import SigmaClip from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose, assert_equal from photutils.background import (Background2D, BkgZoomInterpolator, MeanBackground, MedianBackground, SExtractorBackground) from photutils.utils._optional_deps import HAS_MATPLOTLIB @pytest.fixture def test_data(): """ Create test data for Background2D tests. """ return np.ones((100, 100)) @pytest.fixture def bkg_rms(test_data): """ Expected background RMS for test data. """ return np.zeros(test_data.shape) @pytest.fixture def bkg_mesh(): """ Expected background mesh for test data. """ return np.ones((4, 4)) @pytest.fixture def bkg_rms_mesh(): """ Expected background RMS mesh for test data. """ return np.zeros((4, 4)) @pytest.fixture(params=['quantity', 'nddata_with_unit', 'ccddata']) def nddata_variant(request, test_data): """ Create different variants of input data with units for testing. """ if request.param == 'quantity': return test_data << u.ct if request.param == 'nddata_with_unit': return NDData(test_data, unit=u.ct) return CCDData(test_data, unit=u.ct) @pytest.fixture def nddata_no_unit(test_data): """ Create NDData without units for testing. """ return NDData(test_data, unit=None) class TestBackground2D: """ Test the Background2D class. """ @pytest.mark.parametrize('filter_size', [(1, 1), (3, 3)]) def test_background(self, filter_size, test_data, bkg_rms, bkg_mesh, bkg_rms_mesh): """ Test with different filter sizes. """ bkg = Background2D(test_data, (25, 25), filter_size=filter_size) assert_allclose(bkg.background, test_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 assert bkg.n_pixels_mesh.shape == (4, 4) assert bkg.n_pixels_map.shape == test_data.shape @pytest.mark.parametrize('box_size', [(25, 25), (23, 22)]) @pytest.mark.parametrize('dtype', ['int', 'int32', 'float32']) def test_background_dtype(self, box_size, dtype, test_data, bkg_rms): """ Test that the output background and RMS have the same dtype as the input data, or are floating point if the input is integer. """ filter_size = 3 data2 = test_data.copy().astype(dtype) bkg = Background2D(data2, box_size, filter_size=filter_size) if data2.dtype.kind == 'f': assert bkg.background.dtype == data2.dtype assert bkg.background_rms.dtype == data2.dtype assert bkg.background_mesh.dtype == data2.dtype assert bkg.background_rms_mesh.dtype == data2.dtype else: assert np.issubdtype(bkg.background.dtype, np.floating) assert np.issubdtype(bkg.background_rms.dtype, np.floating) assert np.issubdtype(bkg.background_mesh.dtype, np.floating) assert np.issubdtype(bkg.background_rms_mesh.dtype, np.floating) assert bkg.n_pixels_map.dtype == int assert bkg.n_pixels_mesh.dtype == int assert_allclose(bkg.background, data2) assert_allclose(bkg.background_rms, bkg_rms) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 assert bkg.n_pixels_map.shape == test_data.shape def test_background_nddata(self, test_data, bkg_rms, bkg_mesh, bkg_rms_mesh, nddata_variant, nddata_no_unit): """ Test with NDData and CCDData, and also test units. """ bkg = Background2D(nddata_variant, (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(nddata_no_unit, (25, 25), filter_size=3) assert_allclose(bkg.background, test_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 def test_background_rect(self): """ 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) 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 def test_background_nonconstant_data(self, test_data, bkg_mesh): """ Test on non-constant data to ensure that the background mesh is computed correctly and that the background is properly interpolated. """ data = np.copy(test_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)) assert_allclose(bkg1.background_mesh, bkg_low_res) assert bkg1.background.shape == data.shape rng = np.random.default_rng(0) data = rng.normal(1.0, 0.1, (121, 289)) mask = np.zeros(data.shape, dtype=bool) mask[50:100, 50:100] = True bkg = Background2D(data, (25, 25), mask=mask) assert np.mean(bkg.background) < 1.0 assert np.mean(bkg.background_rms) < 1.0 assert bkg.background_median < 1.0 assert bkg.background_rms_median < 0.1 assert bkg.n_pixels_mesh.shape == (5, 12) assert bkg.n_pixels_map.shape == data.shape def test_bkg_estimator_not_mutated(self, test_data): """ Test that user-supplied estimator objects are not mutated. Background2D silences sigma clipping on the internal copy of the estimators. The original objects passed by the caller must be left unchanged. """ sigclip = SigmaClip(sigma=3.0) bkg_est = MeanBackground(sigma_clip=sigclip) bkgrms_est = MedianBackground(sigma_clip=sigclip) # Remember the sigma_clip values before the call assert bkg_est.sigma_clip is sigclip assert bkgrms_est.sigma_clip is sigclip Background2D(test_data, (25, 25), bkg_estimator=bkg_est, bkg_rms_estimator=bkgrms_est) # Check that original sigma_clip values are unchanged after the # call assert bkg_est.sigma_clip is sigclip assert bkgrms_est.sigma_clip is sigclip def test_filter_threshold_rms_mesh_before_mesh(self): """ Test that accessing background_rms_mesh before background_mesh does not crash when filter_threshold is set. Background2D._bkg_stats is used by _selective_filter, which is called when filter_threshold is not None. It must still be available when background_mesh is computed even if background_rms_mesh was computed first. """ data = np.ones((100, 100)) data[25:50, 50:75] = 10.0 bkg = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=9.0) # Access rms_mesh first, then the regular mesh rms_mesh = bkg.background_rms_mesh mesh = bkg.background_mesh assert rms_mesh.shape == (4, 4) assert mesh.shape == (4, 4) # Both should still give sensible results assert_allclose(mesh[1, 2], 1.0, atol=0.01) def test_rms_mesh_before_mesh_no_filter_threshold(self): """ Test that accessing background_rms_mesh before background_mesh does not crash when filter_threshold is None (the default). _try_free_bkg_stats must not free _bkg_stats before background_mesh has been computed, otherwise _interpolate_grid receives None and raises a TypeError on np.isnan. """ data = np.ones((101, 101)) coverage_mask = np.zeros(data.shape, dtype=bool) coverage_mask[50:, 50:] = True bkg = Background2D(data, 50, coverage_mask=coverage_mask) # Access rms_mesh first, then the regular mesh rms_mesh = bkg.background_rms_mesh mesh = bkg.background_mesh assert rms_mesh.shape == mesh.shape def test_no_sigma_clipping(self, test_data): """ Test bkg_estimator inputs without sigma clipping. """ data = np.copy(test_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] def test_function_estimators(self, test_data): """ Test with user-defined functions for bkg_estimator and bkg_rms_estimator. """ def bkg_func(data, *, axis=None): return np.nanmean(data, axis=axis) def bkgrms_func(data, *, axis=None): return np.nanstd(data, axis=axis) bkg = Background2D(test_data, (25, 25), filter_size=(1, 1), sigma_clip=None, bkg_estimator=bkg_func, bkg_rms_estimator=bkgrms_func) assert_allclose(bkg.background, test_data) assert_allclose(bkg.background_rms, np.zeros(test_data.shape)) def test_integer_input_background_not_truncated(self): """ Test that the background is not truncated when the input data is integer type. """ data = np.array([[1, 2], [1, 2]], dtype=int) bkg = Background2D(data, (2, 2), filter_size=(1, 1), sigma_clip=None, bkg_estimator=MeanBackground()) assert_allclose(bkg.background_mesh, [[1.5]]) assert_allclose(bkg.background, np.full(data.shape, 1.5)) assert np.issubdtype(bkg.background.dtype, np.floating) def test_resizing(self): """ Test that the background mesh is resized correctly when the input data dimensions are not integer multiples of the box size. """ 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_data, bkg_rms): """ Test with an input mask with different box sizes. Note that box_size=(23, 22) tests the resizing of the image and mask. """ data = np.copy(test_data) data[25:50, 25:50] = 100.0 mask = np.zeros(test_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, test_data, rtol=2.0e-5) assert_allclose(bkg.background_rms, bkg_rms) def test_mask(self, test_data): """ Test with an input mask. """ data = np.copy(test_data) data[25:50, 25:50] = 100.0 mask = np.zeros(test_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 np.all(bkg1.n_pixels_map == 625) assert np.all(bkg1.n_pixels_mesh == 625) assert bkg1.background.shape == data.shape assert_allclose(bkg1.background_mesh[0, 0], 1.0) assert_allclose(bkg1.background_mesh[1, 1], 100.0) assert np.all(bkg1.background_rms_mesh == 0.0) bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground()) ngoodpix = test_data.size - 625 assert np.count_nonzero(bkg2.n_pixels_map == 625) == ngoodpix assert np.count_nonzero(bkg2.n_pixels_mesh == 625) == 15 assert bkg2.background.shape == data.shape assert_allclose(bkg2.background_mesh, 1.0) assert np.all(bkg2.background_rms_mesh == 0.0) @pytest.mark.parametrize('fill_value', [0.0, np.nan, -1.0]) def test_coverage_mask(self, fill_value, test_data): """ Test with an input coverage mask. """ data = np.copy(test_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 that combined mask and coverage_mask gives the same # results mask = np.zeros(test_data.shape, dtype=bool) coverage_mask = np.zeros(test_data.shape, dtype=bool) mask[:50, :25] = True coverage_mask[:50, 25:50] = True match = r'Input data contains non-finite \(NaN or infinity\) values' with pytest.warns(AstropyUserWarning, match=match): bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, coverage_mask=mask, fill_value=0.0, bkg_estimator=MeanBackground()) assert_allclose(bkg1.background_mesh, bkg2.background_mesh) assert_allclose(bkg1.background_rms_mesh, bkg2.background_rms_mesh) def test_mask_nonfinite(self, test_data): """ Test that non-finite values in the input data are masked and a warning is issued. """ data = test_data.copy() data[0, 0:50] = np.nan match = r'Input data contains non-finite \(NaN or infinity\) values' with pytest.warns(AstropyUserWarning, match=match): bkg = Background2D(data, (25, 25), filter_size=(1, 1)) assert_allclose(bkg.background, test_data, rtol=1e-5) def test_mask_with_already_masked_nans(self, test_data): """ Test masked invalid values. These tests should not issue a warning. """ data = test_data.copy() data[50, 25:50] = np.nan mask = np.isnan(data) bkg = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask) assert_allclose(bkg.background, test_data, rtol=1e-5) bkg = Background2D(data, (25, 25), filter_size=(1, 1), coverage_mask=mask) assert bkg.background.shape == data.shape 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 bkg.background.shape == data.shape def test_masked_array(self, test_data): """ Test that masked arrays are handled correctly. """ data = test_data.copy() data[0, 0:50] = True mask = np.zeros(test_data.shape, dtype=bool) mask[0, 0:50] = True data_ma1 = np.ma.MaskedArray(test_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, test_data): """ Test that an error is raised if all pixels are masked. """ mask = np.ones(test_data.shape, dtype=bool) match = 'All input pixels are masked. Cannot compute a background.' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), mask=mask) with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), coverage_mask=mask) mask = np.zeros(test_data.shape, dtype=bool) coverage_mask = np.zeros(test_data.shape, dtype=bool) mask[:, 0:40] = True coverage_mask[:, 40:] = True with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), mask=mask, coverage_mask=coverage_mask) data = test_data.copy() data[:] = np.nan match = r'Input data contains all non-finite \(NaN or infinity\)' with pytest.raises(ValueError, match=match): Background2D(data, (25, 25)) def test_zero_padding(self, test_data, bkg_rms): """ Test case where padding is added only on one axis. """ bkg = Background2D(test_data, (25, 22), filter_size=(1, 1)) assert_allclose(bkg.background, test_data, rtol=1e-5) assert_allclose(bkg.background_rms, bkg_rms) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 bkg = Background2D(test_data, (22, 25), filter_size=(1, 1)) assert_allclose(bkg.background, test_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, test_data): """ Test that the exclude_percentile parameter excludes the correct pixels. """ data = np.copy(test_data) data[0:50, 0:50] = np.nan match = r'Input data contains non-finite \(NaN or infinity\) values' with pytest.warns(AstropyUserWarning, match=match): bkg = Background2D(data, (25, 25), filter_size=(1, 1), exclude_percentile=100.0) assert_equal(bkg.n_pixels_mesh[0:2, 0:2], np.zeros((2, 2))) assert bkg.n_pixels_mesh[-1, -1] == 625 data = np.ones((111, 121)) bkg = Background2D(data, box_size=10, exclude_percentile=100) assert_allclose(bkg.background_mesh, np.ones((12, 13))) data[:] = np.nan data[0, 0] = 1.0 match1 = r'Input data contains non-finite \(NaN or infinity\) values' match2 = r'All boxes contain .* unmasked or finite pixels' ctx1 = pytest.warns(AstropyUserWarning, match=match1) ctx2 = pytest.raises(ValueError, match=match2) with ctx1, ctx2: Background2D(data, (10, 10)) def test_filter_threshold(self, test_data, bkg_mesh): """ Test that the filter_threshold parameter filters the correct pixels. """ data = np.copy(test_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, test_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, test_data, bkg_mesh): """ Test that the filter_threshold parameter does not filter any pixels when it is set too high. """ data = np.copy(test_data) data[25:50, 50:75] = 10.0 ref_data = np.copy(bkg_mesh) ref_data[1, 2] = 10.0 bkg = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=100.0) assert_allclose(bkg.background_mesh, ref_data) def test_filter_threshold_nofilter(self, test_data, bkg_mesh): """ Test that the filter_threshold does not filter any pixels when the filter_size is (1, 1). """ data = np.copy(test_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, test_data): """ Test that scalar box_size and filter_size are correctly converted to tuples. """ bkg1 = Background2D(test_data, (25, 25), filter_size=(3, 3)) bkg2 = Background2D(test_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, test_data): """ Test that an error is raised if box_size has an invalid number of elements. """ match = 'box_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): Background2D(test_data, (5, 5, 3)) def test_invalid_filter_size(self, test_data): """ Test that an error is raised if filter_size has an invalid number of elements. """ match = 'filter_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): Background2D(test_data, (5, 5), filter_size=(3, 3, 3)) def test_invalid_exclude_percentile(self, test_data): """ Test that an error is raised if exclude_percentile is outside the range [0, 100]. """ match = 'exclude_percentile must be between 0 and 100' with pytest.raises(ValueError, match=match): Background2D(test_data, (5, 5), exclude_percentile=-1) with pytest.raises(ValueError, match=match): Background2D(test_data, (5, 5), exclude_percentile=101) def test_mask_nomask(self, test_data): """ Test that mask and coverage_mask can be set to np.ma.nomask and that the background is computed correctly. """ bkg = Background2D(test_data, (25, 25), filter_size=(1, 1), mask=np.ma.nomask) assert not bkg._has_mask bkg = Background2D(test_data, (25, 25), filter_size=(1, 1), coverage_mask=np.ma.nomask) assert bkg.coverage_mask is None def test_invalid_mask(self, test_data): """ Test that an error is raised if the mask has an invalid shape or number of dimensions. """ match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2))) match = 'mask must be a 2D array' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2, 2))) def test_invalid_coverage_mask(self, test_data): """ Test that an error is raised if the coverage_mask has an invalid shape or number of dimensions. """ match = 'data and coverage_mask must have the same shape' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2))) match = 'coverage_mask must be a 2D array' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2, 2))) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_meshes(self, test_data): """ Test the plot_meshes method. This test should run without any errors, but there is no return value. """ bkg = Background2D(test_data, (25, 25)) bkg.plot_meshes(outlines=True) def test_repr(self): """ Test the __repr__ method. """ data = np.ones((300, 500)) bkg = Background2D(data, (74, 99)) cls_repr = repr(bkg) assert cls_repr.startswith(f'{bkg.__class__.__name__}') mask = np.zeros(data.shape, dtype=bool) mask[0:10, 0:10] = True bkg = Background2D(data, (74, 99), mask=mask) cls_repr = repr(bkg) assert cls_repr.startswith(f'{bkg.__class__.__name__}') assert 'mask' in cls_repr bkg = Background2D(data, (74, 99), coverage_mask=mask) cls_repr = repr(bkg) assert cls_repr.startswith(f'{bkg.__class__.__name__}') assert 'coverage_mask' in cls_repr def test_str(self): """ Test the __str__ method. """ data = np.ones((300, 500)) bkg = Background2D(data, (74, 99)) cls_str = str(bkg) cls_name = bkg.__class__.__name__ cls_name = f'{bkg.__class__.__module__}.{cls_name}' assert cls_str.startswith(f'<{cls_name}>') def test_masks(self): """ Test that the input data is not modified when a mask is applied and that the same background is computed whether the non-finite values are masked or set to NaN. """ arr = np.arange(25.0).reshape(5, 5) arr_orig = arr.copy() mask = np.zeros(arr.shape, dtype=bool) mask[0, 0] = np.nan mask[-1, 0] = np.nan mask[-1, -1] = np.nan mask[0, -1] = np.nan box_size = (2, 2) exclude_percentile = 100 filter_size = 1 bkg_estimator = MeanBackground() bkg1 = Background2D(arr, box_size, mask=mask, exclude_percentile=exclude_percentile, filter_size=filter_size, bkg_estimator=bkg_estimator) bkgimg1 = bkg1.background assert_equal(arr, arr_orig) arr2 = arr.copy() arr2[mask] = np.nan arr3 = arr2.copy() match = r'Input data contains non-finite \(NaN or infinity\) values' with pytest.warns(AstropyUserWarning, match=match): bkg2 = Background2D(arr2, box_size, mask=None, exclude_percentile=exclude_percentile, filter_size=filter_size, bkg_estimator=bkg_estimator) bkgimg2 = bkg2.background assert_equal(arr2, arr3) assert_allclose(bkgimg1, bkgimg2) @pytest.mark.parametrize('bkg_est', [MeanBackground(), SExtractorBackground()]) def test_large_boxsize(self, bkg_est): """ Test that when boxsize is the same as the image size that the input data is unchanged and that the background mesh is a single value equal to the background estimator applied to the entire image. """ shape = (103, 107) data = np.ones(shape) data[50:55, 50:55] = 1000.0 data[20:25, 20:25] = 1000.0 box_size = data.shape filter_size = (3, 3) data_orig = data.copy() bkg = Background2D(data, box_size, filter_size=filter_size, bkg_estimator=bkg_est) bkgim = bkg.background assert bkgim.shape == shape assert_equal(data, data_orig) def test_interpolator_keyword_deprecation(self, test_data): """ Test that the interpolator keyword is deprecated. """ match = 'BkgZoomInterpolator is deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): interp = BkgZoomInterpolator() match = "'interpolator' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): bkg = Background2D(test_data, (25, 25), interpolator=interp) assert_allclose(bkg.background, test_data) bkg = Background2D(test_data, (25, 25)) # Should not raise assert_allclose(bkg.background, test_data) def test_background_box_size_one(self, test_data): """ Test that when box_size is (1, 1) the background is equal to the input data. """ bkg = Background2D(test_data, (1, 1), filter_size=(1, 1)) assert_allclose(bkg.background, test_data, rtol=1e-5) def test_background_prime_dimensions(self): """ Test with prime-number dimensions. """ data = np.ones((97, 101)) # Prime dimensions bkg = Background2D(data, (10, 10)) assert bkg.background.shape == data.shape assert_allclose(bkg.background, data, rtol=1e-5) def test_background_box_size_larger_than_image(self): """ Test when box_size exceeds image dimensions. """ data = np.ones((50, 60)) bkg = Background2D(data, (100, 100)) assert bkg.background.shape == data.shape # With box size larger than image, should get single mesh value assert bkg.background_mesh.shape == (1, 1) # Test with one box dimension larger than image bkg = Background2D(data, (100, 30)) assert bkg.background.shape == data.shape assert bkg.background_mesh.shape == (1, 2) bkg = Background2D(data, (25, 100)) assert bkg.background.shape == data.shape assert bkg.background_mesh.shape == (2, 1) def test_background_mesh_properties(self, test_data): """ Test that the background mesh properties are consistent with the input data and box size. """ bkg = Background2D(test_data, (25, 25)) assert bkg.background_mesh.shape[0] * 25 >= test_data.shape[0] assert bkg.background_mesh.shape[1] * 25 >= test_data.shape[1] assert_allclose(bkg.background_median, np.median(bkg.background_mesh)) assert_allclose(bkg.background_rms_median, np.median(bkg.background_rms_mesh)) def test_input_data_not_mutated(self, test_data): """ Test that the input data array is not modified by Background2D for various combinations of mask, coverage_mask, and box sizes that require padding. """ # Basic case: no mask, no coverage_mask data = test_data.copy() data_orig = data.copy() Background2D(data, (25, 25)) assert_equal(data, data_orig) # No mask, no coverage_mask, box_size same as image shape data = test_data.copy() data_orig = data.copy() Background2D(data, data.shape) assert_equal(data, data_orig) # With mask, box_size same as image shape data = test_data.copy() data_orig = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[10:20, 10:20] = True Background2D(data, data.shape, mask=mask) assert_equal(data, data_orig) # With coverage_mask, box_size same as image shape data = test_data.copy() data_orig = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[10:20, 10:20] = True Background2D(data, data.shape, coverage_mask=mask) assert_equal(data, data_orig) # With outliers in the data (exercises sigma-clipping path) data = test_data.copy() data[10, 10] = 1000.0 data_orig = data.copy() Background2D(data, (25, 25)) assert_equal(data, data_orig) # With mask data = test_data.copy() data_orig = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[10:20, 10:20] = True Background2D(data, (25, 25), mask=mask) assert_equal(data, data_orig) # With coverage_mask data = test_data.copy() data_orig = data.copy() coverage_mask = np.zeros(data.shape, dtype=bool) coverage_mask[50:, 50:] = True Background2D(data, (25, 25), coverage_mask=coverage_mask) assert_equal(data, data_orig) # With both mask and coverage_mask data = test_data.copy() data_orig = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[10:20, 10:20] = True coverage_mask = np.zeros(data.shape, dtype=bool) coverage_mask[50:, 50:] = True Background2D(data, (25, 25), mask=mask, coverage_mask=coverage_mask) assert_equal(data, data_orig) # With box size that requires padding (not an integer multiple) data = test_data.copy() data_orig = data.copy() Background2D(data, (23, 22)) # 100 / 23 and 100 / 22 are not integers assert_equal(data, data_orig) # Padding with mask data = test_data.copy() data_orig = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[5:15, 5:15] = True Background2D(data, (23, 22), mask=mask) assert_equal(data, data_orig) # Padding with coverage_mask data = test_data.copy() data_orig = data.copy() coverage_mask = np.zeros(data.shape, dtype=bool) coverage_mask[60:, 60:] = True Background2D(data, (23, 22), coverage_mask=coverage_mask) assert_equal(data, data_orig) def test_input_masked_array_not_mutated(self, test_data): """ Test that a masked-array input is not modified by Background2D. """ data_values = test_data.copy() mask = np.zeros(data_values.shape, dtype=bool) mask[10:20, 10:20] = True data_ma = np.ma.MaskedArray(data_values, mask=mask) data_values_orig = data_values.copy() mask_orig = mask.copy() Background2D(data_ma, (25, 25)) assert_equal(data_ma.data, data_values_orig) assert_equal(data_ma.mask, mask_orig) # Box size requiring padding data_ma2 = np.ma.MaskedArray(data_values.copy(), mask=mask.copy()) Background2D(data_ma2, (23, 22)) assert_equal(data_ma2.data, data_values_orig) assert_equal(data_ma2.mask, mask_orig) def test_deprecations(test_data): data = test_data.copy() bkg = Background2D(data, (25, 25)) match = 'was deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): assert bkg.npixels_mesh.shape == (4, 4) with pytest.warns(AstropyDeprecationWarning, match=match): assert bkg.npixels_map.shape == data.shape astropy-photutils-3322558/photutils/background/tests/test_core.py000066400000000000000000000557251517052111400253310ustar00rootroot00000000000000# 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, assert_equal from photutils.background.core import (BiweightLocationBackground, BiweightScaleBackgroundRMS, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) from photutils.datasets import make_noise_image from photutils.utils._stats import nanmean # Parametrize lists for background estimator classes BACKGROUND_CLASSES = [MeanBackground, MedianBackground, ModeEstimatorBackground, MMMBackground, SExtractorBackground, BiweightLocationBackground] # Parametrize lists for background RMS estimator classes BACKGROUND_RMS_CLASSES = [StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] @pytest.fixture def bkg_value(): """ Expected background value for test data. """ return 12.4 @pytest.fixture def std_value(): """ Expected standard deviation for test data. """ return 0.42 @pytest.fixture def test_data(bkg_value, std_value): """ Create test data with Gaussian noise. """ return make_noise_image((100, 100), distribution='gaussian', mean=bkg_value, stddev=std_value, seed=0) @pytest.fixture def sigma_clip(): """ Sigma clipping instance for tests. """ return SigmaClip(sigma=3.0) class TestBackgroundEstimators: """ Test suite for all background estimator classes. Tests common functionality shared by all background estimators including basic calculations, axis handling, masking, units, and error handling. """ @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_constant_background(self, bkg_class, sigma_clip): """ Test background calculation on constant data. """ 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)) # 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, 1.0) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_background_with_sigma_clip(self, bkg_class, sigma_clip, test_data, bkg_value): """ Test background calculation with sigma clipping. """ bkg = bkg_class(sigma_clip=sigma_clip) bkgval = bkg.calc_background(test_data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, bkg_value, atol=0.017) assert_allclose(bkg(test_data), bkg.calc_background(test_data)) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_background_without_sigma_clip(self, bkg_class, test_data, bkg_value): """ Test background calculation without sigma clipping. """ bkg = bkg_class(sigma_clip=None) bkgval = bkg.calc_background(test_data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, bkg_value, atol=0.017) assert_allclose(bkg(test_data), bkg.calc_background(test_data)) # Test with masked array mask = np.zeros(test_data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(test_data, mask=mask) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, bkg_value, atol=0.018) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_axis_parameter(self, bkg_class, sigma_clip, test_data): """ Test background calculation along specific axes. """ bkg = bkg_class(sigma_clip=sigma_clip) # Test axis=0 bkg_arr = bkg.calc_background(test_data, axis=0) bkgi = np.array([bkg.calc_background(test_data[:, i]) for i in range(100)]) assert_allclose(bkg_arr, bkgi) # Test axis=1 bkg_arr = bkg.calc_background(test_data, axis=1) bkgi = [] for i in range(100): bkgi.append(bkg.calc_background(test_data[i, :])) bkgi = np.array(bkgi) assert_allclose(bkg_arr, bkgi) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_axis_tuple(self, bkg_class, test_data): """ Test that axis=None and axis=(0,1) give same result. """ bkg = bkg_class(sigma_clip=None) bkg_val1 = bkg.calc_background(test_data, axis=None) bkg_val2 = bkg.calc_background(test_data, axis=(0, 1)) assert_allclose(bkg_val1, bkg_val2) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_multidimensional_arrays(self, bkg_class): """ Test background calculation with multidimensional arrays. """ data1 = np.ones((1, 100, 100)) data2 = np.ones((1, 100 * 100)) data3 = np.ones((1, 1, 100 * 100)) data4 = np.ones((1, 1, 1, 100 * 100)) bkg = bkg_class(sigma_clip=None) val = bkg(data1, axis=None) assert np.ndim(val) == 0 val = bkg(data1, axis=(1, 2)) assert val.shape == (1,) val = bkg(data1, axis=-1) assert val.shape == (1, 100) val = bkg(data2, axis=-1) assert val.shape == (1,) val = bkg(data3, axis=-1) assert val.shape == (1, 1) val = bkg(data4, axis=-1) assert val.shape == (1, 1, 1) val = bkg(data4, axis=(2, 3)) assert val.shape == (1, 1) val = bkg(data4, axis=(1, 2, 3)) assert val.shape == (1,) val = bkg(data4, axis=(0, 1, 2)) assert val.shape == (10000,) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_masked_arrays(self, bkg_class, test_data, bkg_value): """ Test masked array handling with masked parameter. """ bkg = bkg_class(sigma_clip=None) mask = np.zeros(test_data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(test_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_value, atol=0.004) # 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(nanmean(bkgval2), bkg_value, atol=0.004) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_units(self, bkg_class, sigma_clip): """ Test that units are properly propagated. """ 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('bkg_class', BACKGROUND_CLASSES) def test_repr(self, bkg_class): """ Test string representation. """ bkg = bkg_class() bkg_repr = repr(bkg) assert bkg_repr == str(bkg) assert bkg_repr.startswith(f'{bkg.__class__.__name__}') @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_invalid_sigma_clip(self, bkg_class): """ Test error handling for invalid sigma_clip parameter. """ match = 'sigma_clip must be an astropy SigmaClip instance or None' with pytest.raises(TypeError, match=match): bkg_class(sigma_clip=3) class TestBackgroundEdgeCases: """ Test edge cases for background estimators. Tests unusual data conditions like all-NaN arrays, various data types, 1D arrays, and completely masked arrays. """ @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_all_nan_data(self, bkg_class): """ Test behavior with all-NaN data. """ data = np.full((10, 10), np.nan) bkg = bkg_class(sigma_clip=None) result = bkg.calc_background(data) assert np.isnan(result) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) @pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64, np.float16, np.float32, np.float64]) def test_various_dtypes(self, bkg_class, dtype): """ Test with various numeric data types. """ data = np.ones((50, 50), dtype=dtype) bkg = bkg_class(sigma_clip=None) result = bkg.calc_background(data) assert_allclose(result, 1.0) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_1d_arrays(self, bkg_class): """ Test with 1D arrays. """ rng = np.random.default_rng(0) data = rng.standard_normal(1000) + 5.0 bkg = bkg_class(sigma_clip=None) result = bkg.calc_background(data) assert_allclose(result, 5.0, atol=0.2) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_completely_masked_array(self, bkg_class): """ Test with completely masked array. """ data = np.ma.array(np.ones((10, 10)), mask=True) bkg = bkg_class(sigma_clip=None) result = bkg.calc_background(data) assert np.isnan(result) def test_sigma_clipping_effect(self): """ Test that sigma clipping removes outliers for MeanBackground. """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000 # Add outliers bkg_no_clip = MeanBackground(sigma_clip=None) bkg_with_clip = MeanBackground(sigma_clip=SigmaClip(sigma=3.0)) result_no_clip = bkg_no_clip.calc_background(data) result_with_clip = bkg_with_clip.calc_background(data) # With clipping should be closer to 1.0 assert abs(result_with_clip - 1.0) < abs(result_no_clip - 1.0) assert result_no_clip > 1.0 assert_allclose(result_with_clip, 1.0, atol=0.01) class TestSpecificEstimators: """ Test class-specific behavior for individual estimators. Tests functionality unique to specific estimator implementations. """ def test_sextractor_zero_std(self): """ Test SExtractorBackground with zero standard deviation. """ data = np.ones((100, 100)) bkg = SExtractorBackground(sigma_clip=None) assert_allclose(bkg.calc_background(data), 1.0) def test_sextractor_skewed_data(self): """ Test SExtractorBackground with highly skewed data. """ 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(('median_factor', 'mean_factor'), [ (3.0, 2.0), # default (2.5, 1.5), # custom (1.0, 0.0), # edge case ]) def test_mode_estimator_parameters(self, median_factor, mean_factor): """ Test ModeEstimatorBackground with different parameters. """ rng = np.random.default_rng(0) data = rng.standard_normal((100, 100)) + 10 bkg = ModeEstimatorBackground(median_factor=median_factor, mean_factor=mean_factor, sigma_clip=None) result = bkg.calc_background(data) # Verify the formula is applied correctly expected = (median_factor * np.median(data) - mean_factor * np.mean(data)) assert_allclose(result, expected) def test_biweight_location_constant_data(self): """ Test BiweightLocationBackground with constant data. Regression test for https://github.com/astropy/astropy/pull/16964. """ data = np.full((5, 1, 10), 7.5) bkg = BiweightLocationBackground(sigma_clip=None) result = bkg.calc_background(data, axis=-1) assert result.shape == (5, 1) data = np.full((5, 1, 4, 10), 1.4) bkg = BiweightLocationBackground(sigma_clip=None) result = bkg.calc_background(data, axis=-1) assert result.shape == (5, 1, 4) data = np.full((5, 1, 10), 1.4) bkg = BiweightLocationBackground(sigma_clip=None) result1 = bkg.calc_background(data, axis=None) result2 = bkg.calc_background(data, axis=(0, 1, 2)) assert result1.shape == () assert result2.shape == () assert result1 == result2 def test_biweight_scale_constant_data(self): """ Test BiweightScaleBackgroundRMS with constant data. Regression test for https://github.com/astropy/astropy/pull/16964. """ data = np.full((5, 1, 10), 7.5) bkg = BiweightScaleBackgroundRMS(sigma_clip=None) result = bkg.calc_background_rms(data, axis=-1) assert result.shape == (5, 1) data = np.full((5, 1, 4, 10), 1.4) bkg = BiweightScaleBackgroundRMS(sigma_clip=None) result = bkg.calc_background_rms(data, axis=-1) assert result.shape == (5, 1, 4) data = np.full((5, 1, 10), 1.4) bkg = BiweightScaleBackgroundRMS(sigma_clip=None) result1 = bkg.calc_background_rms(data, axis=None) result2 = bkg.calc_background_rms(data, axis=(0, 1, 2)) assert result1.shape == () assert result2.shape == () assert result1 == result2 class TestBackgroundRMSEstimators: """ Test suite for all background RMS estimator classes. Tests common functionality shared by all RMS estimators. """ @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_background_rms_with_sigma_clip(self, rms_class, sigma_clip, test_data, std_value): """ Test RMS calculation with sigma clipping. """ bkgrms = rms_class(sigma_clip=sigma_clip) assert_allclose(bkgrms.calc_background_rms(test_data), std_value, atol=0.007) assert_allclose(bkgrms(test_data), bkgrms.calc_background_rms(test_data)) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_background_rms_without_sigma_clip(self, rms_class, test_data, std_value): """ Test RMS calculation without sigma clipping. """ bkgrms = rms_class(sigma_clip=None) assert_allclose(bkgrms.calc_background_rms(test_data), std_value, atol=0.004) assert_allclose(bkgrms(test_data), bkgrms.calc_background_rms(test_data)) # Test with masked array mask = np.zeros(test_data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(test_data, mask=mask) rms = bkgrms.calc_background_rms(data) assert not np.ma.isMaskedArray(bkgrms) assert_allclose(rms, std_value, atol=0.004) assert_allclose(bkgrms(data), bkgrms.calc_background_rms(data)) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_axis_parameter(self, rms_class, sigma_clip, test_data): """ Test RMS calculation along specific axes. """ bkgrms = rms_class(sigma_clip=sigma_clip) # Test axis=0 rms_arr = bkgrms.calc_background_rms(test_data, axis=0) rmsi = np.array([bkgrms.calc_background_rms(test_data[:, i]) for i in range(100)]) assert_allclose(rms_arr, rmsi) # Test axis=1 rms_arr = bkgrms.calc_background_rms(test_data, axis=1) rmsi = [] for i in range(100): rmsi.append(bkgrms.calc_background_rms(test_data[i, :])) rmsi = np.array(rmsi) assert_allclose(rms_arr, rmsi) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_multidimensional_arrays(self, rms_class): """ Test RMS calculation with multidimensional arrays. """ data1 = np.ones((1, 100, 100)) data2 = np.ones((1, 100 * 100)) data3 = np.ones((1, 1, 100 * 100)) data4 = np.ones((1, 1, 1, 100 * 100)) bkgrms = rms_class(sigma_clip=None) val = bkgrms(data1, axis=None) assert np.ndim(val) == 0 val = bkgrms(data1, axis=(1, 2)) assert val.shape == (1,) val = bkgrms(data1, axis=-1) assert val.shape == (1, 100) val = bkgrms(data2, axis=-1) assert val.shape == (1,) val = bkgrms(data3, axis=-1) assert val.shape == (1, 1) val = bkgrms(data4, axis=-1) assert val.shape == (1, 1, 1) val = bkgrms(data4, axis=(2, 3)) assert val.shape == (1, 1) val = bkgrms(data4, axis=(1, 2, 3)) assert val.shape == (1,) val = bkgrms(data4, axis=(0, 1, 2)) assert val.shape == (10000,) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_masked_arrays(self, rms_class, test_data, std_value): """ Test masked array handling with masked parameter. """ bkgrms = rms_class(sigma_clip=None) mask = np.zeros(test_data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(test_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_value, atol=0.04) # 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(nanmean(rms3), std_value, atol=0.04) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_units(self, rms_class, sigma_clip): """ Test that units are properly propagated. """ 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('rms_class', BACKGROUND_RMS_CLASSES) def test_repr(self, rms_class): """ Test string representation. """ bkgrms = rms_class() rms_repr = repr(bkgrms) assert rms_repr == str(bkgrms) assert rms_repr.startswith(f'{bkgrms.__class__.__name__}') @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_invalid_sigma_clip(self, rms_class): """ Test error handling for invalid sigma_clip parameter. """ match = 'sigma_clip must be an astropy SigmaClip instance or None' with pytest.raises(TypeError, match=match): rms_class(sigma_clip=3) class TestInputNotMutated: """ Test that input data is never modified by background estimator calls. """ @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_background_does_not_mutate_input(self, bkg_class): """ Test that calc_background does not modify a plain ndarray. """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 # outliers that trigger sigma clipping data_orig = data.copy() bkg = bkg_class(sigma_clip=SigmaClip(sigma=3.0)) bkg.calc_background(data) assert_equal(data, data_orig) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_background_does_not_mutate_input_no_clip(self, bkg_class): """ Test that calc_background does not modify input when sigma clipping is disabled. """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 data_orig = data.copy() bkg = bkg_class(sigma_clip=None) bkg.calc_background(data) assert_equal(data, data_orig) @pytest.mark.parametrize('bkg_class', BACKGROUND_CLASSES) def test_background_does_not_mutate_masked_input(self, bkg_class): """ Test that calc_background does not modify a masked array (data values or mask). """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 mask = np.zeros(data.shape, dtype=bool) mask[0:5, 0:5] = True data_ma = np.ma.MaskedArray(data.copy(), mask=mask) data_values_orig = data_ma.data.copy() mask_orig = mask.copy() bkg = bkg_class(sigma_clip=None) bkg.calc_background(data_ma) assert_equal(data_ma.data, data_values_orig) assert_equal(data_ma.mask, mask_orig) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_background_rms_does_not_mutate_input(self, rms_class): """ Test that calc_background_rms does not modify a plain ndarray. """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 data_orig = data.copy() bkgrms = rms_class(sigma_clip=SigmaClip(sigma=3.0)) bkgrms.calc_background_rms(data) assert_equal(data, data_orig) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_background_rms_does_not_mutate_input_no_clip(self, rms_class): """ Test that calc_background_rms does not modify input when sigma clipping is disabled. """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 data_orig = data.copy() bkgrms = rms_class(sigma_clip=None) bkgrms.calc_background_rms(data) assert_equal(data, data_orig) @pytest.mark.parametrize('rms_class', BACKGROUND_RMS_CLASSES) def test_background_rms_does_not_mutate_masked_input(self, rms_class): """ Test that calc_background_rms does not modify a masked array (data values or mask). """ data = np.ones((100, 100)) data[0:5, 0:5] = 1000.0 mask = np.zeros(data.shape, dtype=bool) mask[0:5, 0:5] = True data_ma = np.ma.MaskedArray(data.copy(), mask=mask) data_values_orig = data_ma.data.copy() mask_orig = mask.copy() bkgrms = rms_class(sigma_clip=None) bkgrms.calc_background_rms(data_ma) assert_equal(data_ma.data, data_values_orig) assert_equal(data_ma.mask, mask_orig) astropy-photutils-3322558/photutils/background/tests/test_interpolators.py000066400000000000000000000072371517052111400273010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolators module. """ import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.background.background_2d import Background2D from photutils.background.interpolators import (BkgIDWInterpolator, _BkgZoomInterpolator) @pytest.fixture def test_data(): """ Create test data for interpolator tests. """ return np.ones((300, 300)) @pytest.fixture def test_mesh(): """ Create test mesh for interpolator tests. """ return np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) def test_zoom_interp_constant_mesh(test_data): """ Test the zoom interpolator with a constant-valued mesh. When all mesh values are equal, the interpolator takes an early-exit path that fills the output with the constant value directly, bypassing `scipy.ndimage.zoom` entirely. This path must produce the correct fill value both for plain arrays and for Quantity inputs. """ bkg = Background2D(test_data, 100) interp = _BkgZoomInterpolator() constant_mesh = np.full((3, 3), 7.5) result = interp(constant_mesh, **bkg._interp_kwargs) assert result.shape == bkg._interp_kwargs['shape'] assert np.all(result == 7.5) # Also verify with a Quantity mesh unit = u.nJy bkg_q = Background2D(test_data << unit, 100) result_q = interp(constant_mesh << unit, **bkg_q._interp_kwargs) assert result_q.shape == bkg_q._interp_kwargs['shape'] assert np.all(result_q == 7.5) def test_zoom_interp(test_data, test_mesh): """ Test the zoom interpolator. """ bkg = Background2D(test_data, 100) interp = _BkgZoomInterpolator(clip=False) zoom = interp(test_mesh, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # Test with units unit = u.nJy bkg = Background2D(test_data << unit, 100) interp = _BkgZoomInterpolator(clip=False) zoom = interp(test_mesh << unit, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # Test repr cls_repr = repr(interp) assert cls_repr.startswith(f'{interp.__class__.__name__}') def test_zoom_interp_clip(test_data, test_mesh): """ Test the zoom interpolator with clipping. """ bkg = Background2D(test_data, 100) interp1 = _BkgZoomInterpolator(clip=False) zoom1 = interp1(test_mesh, **bkg._interp_kwargs) interp2 = _BkgZoomInterpolator(clip=True) zoom2 = interp2(test_mesh, **bkg._interp_kwargs) minval = np.min(test_mesh) maxval = np.max(test_mesh) assert np.min(zoom1) < minval assert np.max(zoom1) > maxval assert np.min(zoom2) == minval assert np.max(zoom2) == maxval def test_idw_interp(test_data, test_mesh): """ Test the IDW interpolator. """ with pytest.warns(AstropyDeprecationWarning): interp = BkgIDWInterpolator() with pytest.warns(AstropyDeprecationWarning): bkg = Background2D(test_data, 100, interpolator=interp) zoom = interp(test_mesh, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # Test constant mesh data zoom = interp(np.ones_like(test_mesh), **bkg._interp_kwargs) assert np.all(zoom == 1) # Test with units unit = u.nJy with pytest.warns(AstropyDeprecationWarning): bkg = Background2D(test_data << unit, 100, interpolator=interp) zoom = interp(test_mesh << unit, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # Test repr cls_repr = repr(interp) assert cls_repr.startswith(f'{interp.__class__.__name__}') astropy-photutils-3322558/photutils/background/tests/test_local_background.py000066400000000000000000000074751517052111400276710ustar00rootroot00000000000000# 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.aperture import CircularAnnulus from photutils.background import LocalBackground, MedianBackground def test_local_background_invalid_radii(): """ Test that LocalBackground raises errors for invalid radius values. """ # Test negative inner radius match = 'inner_radius must be positive' with pytest.raises(ValueError, match=match): LocalBackground(-5, 10) # Test zero inner radius with pytest.raises(ValueError, match=match): LocalBackground(0, 10) # Test negative outer radius match = 'outer_radius must be positive' with pytest.raises(ValueError, match=match): LocalBackground(5, -10) # Test zero outer radius with pytest.raises(ValueError, match=match): LocalBackground(5, 0) # Test outer_radius <= inner_radius match = 'outer_radius must be greater than inner_radius' with pytest.raises(ValueError, match=match): LocalBackground(10, 5) # Test equal radii with pytest.raises(ValueError, match=match): LocalBackground(10, 10) def test_local_background(): """ Test the basic functionality of LocalBackground with a simple constant data array. """ 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) match = "'positions' must not contain any non-finite" with pytest.raises(ValueError, match=match): _ = local_bkg(data, x[2], np.inf) cls_repr = repr(local_bkg) assert cls_repr.startswith(local_bkg.__class__.__name__) # Test default bkg_estimator local_bkg2 = LocalBackground(5, 10, bkg_estimator=None) bkg4 = local_bkg2(data, x, y) assert_allclose(bkg4, bkg) def test_local_background_estimator_1d(): """ Test that the bkg_estimator can be a 1D function that takes an array and returns a scalar. """ def estimator(data): assert data.ndim == 1 return np.nanmedian(data) data = np.ones((51, 51)) local_bkg = LocalBackground(3, 6, bkg_estimator=estimator) bkg = local_bkg(data, [10, 20], [10, 20]) assert_allclose(bkg, np.ones(2)) def test_to_aperture_scalar(): """ Test to_aperture method with scalar x and y positions. """ r_in = 5 r_out = 10 local_bkg = LocalBackground(r_in, r_out) # Test scalar positions x = 50.0 y = 50.0 aperture = local_bkg.to_aperture(x, y) # Check aperture type and properties assert isinstance(aperture, CircularAnnulus) assert_allclose(aperture.positions, [[x, y]]) assert_allclose(aperture.r_in, r_in) assert_allclose(aperture.r_out, r_out) def test_to_aperture_array(): """ Test to_aperture method with array x and y positions. """ r_in = 7.5 r_out = 15.2 local_bkg = LocalBackground(r_in, r_out) # Test array positions x = np.array([10.0, 20.1, 35.3]) y = np.array([14.4, 27.2, 33.4]) xypos = list(zip(x, y, strict=False)) aperture = local_bkg.to_aperture(x, y) # Check aperture type and properties assert isinstance(aperture, CircularAnnulus) assert_allclose(aperture.positions, xypos) assert_allclose(aperture.r_in, r_in) assert_allclose(aperture.r_out, r_out) # Test list positions x = list(x) y = list(y) aperture2 = local_bkg.to_aperture(x, y) assert aperture == aperture2 astropy-photutils-3322558/photutils/background/tests/test_positional_kwargs.py000066400000000000000000000066111517052111400301260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.background import (BiweightLocationBackground, BiweightScaleBackgroundRMS, LocalBackground, MADStdBackgroundRMS, MeanBackground, MedianBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) BKG_CLASSES = [MeanBackground, MedianBackground, ModeEstimatorBackground, SExtractorBackground, BiweightLocationBackground] BKGRMS_CLASSES = [StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] class TestBackgroundBasePositionalKwargs: """ Test that __call__ on BackgroundBase subclasses warns for positional optional args. """ def setup_method(self): self.data = np.arange(100, dtype=float) @pytest.mark.parametrize('cls', BKG_CLASSES) def test_call_positional_warns(self, cls): bkg = cls() match = '__call__' with pytest.warns(AstropyDeprecationWarning, match=match): bkg(self.data, None) match = 'calc_background' with pytest.warns(AstropyDeprecationWarning, match=match): bkg.calc_background(self.data, None) @pytest.mark.parametrize('cls', BKG_CLASSES) def test_call_keyword_no_warning(self, cls): bkg = cls() bkg(self.data, axis=None) class TestBackgroundRMSBasePositionalKwargs: """ Test that __call__ on BackgroundRMSBase subclasses warns for positional optional args. """ def setup_method(self): self.data = np.arange(100, dtype=float) @pytest.mark.parametrize('cls', BKGRMS_CLASSES) def test_call_positional_warns(self, cls): bkgrms = cls() match = '__call__' with pytest.warns(AstropyDeprecationWarning, match=match): bkgrms(self.data, None) match = 'calc_background_rms' with pytest.warns(AstropyDeprecationWarning, match=match): bkgrms.calc_background_rms(self.data, None) @pytest.mark.parametrize('cls', BKGRMS_CLASSES) def test_call_keyword_no_warning(self, cls): bkgrms = cls() bkgrms(self.data, axis=None) class TestLocalBackgroundPositionalKwargs: """ Test that LocalBackground warns for positional optional args. """ def setup_method(self): self.data = np.ones((101, 101)) def test_init_positional_warns(self): match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): LocalBackground(5, 10, MedianBackground()) def test_init_keyword_no_warning(self): LocalBackground(5, 10, bkg_estimator=MedianBackground()) def test_call_positional_warns(self): local_bkg = LocalBackground(5, 10) mask = np.zeros((101, 101), dtype=bool) match = '__call__' with pytest.warns(AstropyDeprecationWarning, match=match): local_bkg(self.data, 50, 50, mask) def test_call_keyword_no_warning(self): local_bkg = LocalBackground(5, 10) mask = np.zeros((101, 101), dtype=bool) local_bkg(self.data, 50, 50, mask=mask) astropy-photutils-3322558/photutils/centroids/000077500000000000000000000000001517052111400214635ustar00rootroot00000000000000astropy-photutils-3322558/photutils/centroids/__init__.py000066400000000000000000000003221517052111400235710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for centroiding sources. """ from .core import * # noqa: F401, F403 from .gaussian import * # noqa: F401, F403 astropy-photutils-3322558/photutils/centroids/_utils.py000066400000000000000000000200221517052111400233300ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Private utility functions for centroiding. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning def _validate_data(data, *, ndim=2): """ Validate and convert the input data array. Parameters ---------- data : array_like The input data array. ndim : int or `None`, optional The required number of dimensions. If `None`, no dimensionality check is performed. Returns ------- data : `~numpy.ndarray` The input data converted to a float array. """ data = np.asanyarray(data, dtype=float) if ndim is not None and data.ndim != ndim: msg = f'data must be a {ndim}D array' raise ValueError(msg) return data def _validate_mask_shape(data, mask): """ Validate that the data and mask have the same shape. Parameters ---------- data : `~numpy.ndarray` The input data array. mask : bool `~numpy.ndarray` or `None` The input mask array. """ if mask is not None and data.shape != mask.shape: msg = 'data and mask must have the same shape' raise ValueError(msg) def _process_data_mask(data, mask, *, ndim=2, fill_value=np.nan): """ Process the input data and mask. This function validates the input data and mask, handles non-finite values, and returns the processed data. The input ``mask`` is never mutated; a new array is created if the mask needs to be combined with a `~numpy.ma.MaskedArray` mask. Copies of ``data`` are made only when modifications are required. Parameters ---------- data : array_like The input data array. mask : bool `~numpy.ndarray` or `None` A boolean mask where `True` indicates a masked (invalid) pixel. ndim : int or `None`, optional The required number of dimensions for ``data``. If `None`, no dimensionality check is performed. fill_value : float, optional The value used to replace masked or non-finite data values. Returns ------- data : `~numpy.ndarray` Processed data with masked and non-finite values replaced by ``fill_value``. Always returned as a plain `~numpy.ndarray` (never a `~numpy.ma.MaskedArray`). """ data = _validate_data(data, ndim=ndim) is_copied = False is_masked_array = isinstance(data, np.ma.MaskedArray) _validate_mask_shape(data, mask) badmask = ~np.isfinite(data) if np.ma.is_masked(data): mask2 = data.mask mask = mask2 if mask is None else mask | mask2 if mask is not None: if np.any(mask): data = data.copy() is_copied = True data[mask] = fill_value badmask &= ~mask if np.any(badmask): msg = ('Input data contains non-finite values (e.g., NaN or inf) ' 'that were automatically masked.') warnings.warn(msg, AstropyUserWarning) if not is_copied: data = data.copy() data[badmask] = fill_value # If the input was a MaskedArray, return a plain ndarray; the mask # has already been applied to the data above. if is_masked_array: data = np.asarray(data) return data def _validate_gaussian_inputs(data, mask, error): """ Process and validate the data, mask, and optional error inputs for Gaussian centroid functions. The input ``mask`` and ``error`` arrays are not mutated; copies are made only when modifications are needed. Parameters ---------- data : 2D `~numpy.ndarray` The input data array. mask : 2D bool `~numpy.ndarray` or `None` A boolean mask where `True` indicates a masked (invalid) pixel. error : 2D `~numpy.ndarray` or `None` The 1-sigma error array. Returns ------- data : 2D `~numpy.ndarray` Processed data with all invalid pixels (from the mask, non-finite data, and non-finite error) set to zero. combined_mask : 2D bool `~numpy.ndarray` Boolean mask of all invalid pixels. error : 2D `~numpy.ndarray` or `None` Error array with all invalid pixels set to zero (copied only if a modification was required), or `None` if no error was provided. """ data = _process_data_mask(data, mask, fill_value=np.nan) combined_mask = ~np.isfinite(data) if error is not None: error = np.asanyarray(error, dtype=float) if data.shape != error.shape: msg = 'data and error must have the same shape' raise ValueError(msg) error_mask = ~np.isfinite(error) if np.any(error_mask): combined_mask |= error_mask # Zero error at all invalid pixel positions; copy only if needed if np.any(combined_mask): error = error.copy() error[combined_mask] = 0.0 # Apply the full combined mask to data once if np.any(combined_mask): # The data array may still be the original input object if # no modifications were needed above. We copy here to avoid # mutating the caller's original data. data = data.copy() data[combined_mask] = 0.0 return data, combined_mask, error 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. """ data = _process_data_mask(data, mask, ndim=1, fill_value=0.0) 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 _gaussian2d_moments(data): """ Estimate 2D Gaussian parameters from the moments of 2D data. This function can be useful for providing initial parameter values when fitting a 2D Gaussian to the ``data``. Parameters ---------- data : 2D `~numpy.ndarray` The 2D data array. Values at masked pixels must already be set to zero before calling this function. Returns ------- amplitude, x_mean, y_mean, x_stddev, y_stddev, theta : float The estimated amplitude (``amplitude``), centroid (``x_mean``, ``y_mean``), axis standard deviations (``x_stddev``, ``y_stddev``), and rotation angle in radians (``theta``) of a 2D Gaussian. """ y, x = np.indices(data.shape) amplitude = np.max(data) total = np.sum(data) # 1st-order moments (centroid) x_mean = np.sum(x * data) / total y_mean = np.sum(y * data) / total # 2nd-order central moments dx = x - x_mean dy = y - y_mean mu_20 = np.sum(dx**2 * data) / total mu_02 = np.sum(dy**2 * data) / total mu_11 = np.sum(dx * dy * data) / total # Covariance matrix covar = np.array([[mu_02, mu_11], [mu_11, mu_20]]) # Eigenvalues in descending order give semimajor/semiminor sigma^2 eigvals = np.linalg.eigvalsh(covar)[::-1] eigvals = np.clip(eigvals, 0, None) x_stddev = np.sqrt(eigvals[0]) y_stddev = np.sqrt(eigvals[1]) # Orientation angle (radians) between x-axis and the major axis. # When the distribution is nearly isotropic (mu_20 ~ mu_02, mu_11 ~ # 0), the angle is undefined; guard against floating-point noise by # returning theta = 0 in that case. anisotropy = np.sqrt((mu_20 - mu_02) ** 2 + (2.0 * mu_11) ** 2) if anisotropy < 1e-6 * (mu_20 + mu_02): theta = 0.0 else: theta = 0.5 * np.arctan2(2.0 * mu_11, mu_20 - mu_02) return amplitude, x_mean, y_mean, x_stddev, y_stddev, theta astropy-photutils-3322558/photutils/centroids/core.py000066400000000000000000000703741517052111400230000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for centroiding sources. """ import inspect import warnings import numpy as np from astropy.nddata import overlap_slices from astropy.utils.exceptions import AstropyUserWarning from photutils.centroids._utils import _process_data_mask from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._parameters import as_pair from photutils.utils._quantity_helpers import process_quantities from photutils.utils._repr import make_repr from photutils.utils._round import round_half_away __all__ = ['CentroidQuadratic', 'centroid_com', 'centroid_quadratic', 'centroid_sources'] @deprecated_positional_kwargs(since='3.0', until='4.0') def centroid_com(data, mask=None): """ Calculate the centroid of an array as the flux-weighted center of mass derived from `image moments `_. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for non-finite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The centroid is calculated using only the unmasked data values. Parameters ---------- data : array_like The input n-dimensional array. ``data`` can be a `~numpy.ma.MaskedArray`. The image should be a background-subtracted cutout image containing a single source. The source should be significantly stronger than the background noise. If the data contains nearly equal positive and negative values (i.e., the sum is close to zero), the centroid calculation will be numerically unstable and may produce undefined results that fall outside the array bounds. 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. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. Returns ------- centroid : `~numpy.ndarray` The coordinates of the centroid in pixel order (e.g., ``(x, y)`` or ``(x, y, z)``), not numpy axis order. If the sum of the (unmasked) data is zero, then a `~numpy.ndarray` of NaN values will be returned. If the sum is close to zero, the centroid may be poorly defined and fall outside the array bounds. Notes ----- The centroid is calculated as: .. math:: x_c = \\frac{\\sum x_i I_i}{\\sum I_i}, \\quad y_c = \\frac{\\sum y_i I_i}{\\sum I_i} where :math:`I_i` is the intensity at pixel :math:`(x_i, y_i)`. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_com >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_com(data) >>> print(np.array((x1, y1))) [19.9796724 20.00992593] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_com from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_com(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ (data,), _ = process_quantities((data,), ('data',)) data = _process_data_mask(data, mask, ndim=None, fill_value=0.0) total = np.sum(data) if abs(total) < 1.e-30: return np.full(data.ndim, np.nan) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] # Output array is reversed to give (x, y) order (e.g., for 2D data) return np.array([np.sum(indices[axis] * data) / total for axis in range(data.ndim)])[::-1] @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('xpeak', None, '3.0', until='4.0') @deprecated_renamed_argument('ypeak', None, '3.0', until='4.0') @deprecated_renamed_argument('search_boxsize', None, '3.0', until='4.0') def centroid_quadratic(data, mask=None, fit_boxsize=5, xpeak=None, ypeak=None, search_boxsize=None): """ Calculate the centroid of a 2D array by fitting a 2D quadratic polynomial. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for non-finite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The centroid is calculated using only the unmasked data values. 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 be 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 array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`. The image should be a background-subtracted cutout image containing a single source. 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 calculations. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. 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. 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. .. deprecated:: 3.0 The ``xpeak`` and ``ypeak`` keywords are deprecated and will be removed in a future version. Use `~photutils.centroids.centroid_sources` to centroid sources at specific positions. 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 ``search_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. .. deprecated:: 3.0 The ``search_boxsize`` keyword is deprecated and will be removed in a future version. Use `~photutils.centroids.centroid_sources` to centroid sources at specific positions. 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, "Do fast stellar centroiding methods saturate the CramÊr-Rao lower bound?", `arXiv:1610.05873 `_ Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_quadratic >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_quadratic(data) >>> print(np.array((x1, y1))) [19.94009505 20.06884997] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_quadratic from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_quadratic(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ (data,), _ = process_quantities((data,), ('data',)) if ((xpeak is None and ypeak is not None) or (xpeak is not None and ypeak is None)): msg = 'xpeak and ypeak must both be input or "None"' raise ValueError(msg) data = _process_data_mask(data, mask) ny, nx = data.shape fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(0, 0), upper_bound=data.shape, check_odd=True) if np.prod(fit_boxsize) < 6: msg = ('fit_boxsize is too small. 6 values are required to fit a ' '2D quadratic polynomial.') raise ValueError(msg) if xpeak is not None and ((xpeak < 0) or (xpeak > data.shape[1] - 1)): msg = 'xpeak is outside the input data' raise ValueError(msg) if ypeak is not None and ((ypeak < 0) or (ypeak > data.shape[0] - 1)): msg = 'ypeak is outside the input data' raise ValueError(msg) if xpeak is None or ypeak is None: yidx, xidx = np.unravel_index(np.nanargmax(data), data.shape) else: xidx = round_half_away(xpeak) yidx = round_half_away(ypeak) if search_boxsize is not None: search_boxsize = as_pair('search_boxsize', search_boxsize, lower_bound=(0, 0), 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 # Return the position of the maximum if it is at the edge of the # data if xidx in (0, nx - 1) or yidx in (0, ny - 1): msg = ('maximum value is at the edge of the data and its ' 'position was returned; no quadratic fit was performed') warnings.warn(msg, 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: msg = ('at least 6 unmasked data points are required to ' 'perform a 2D quadratic fit') warnings.warn(msg, 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() # Pre-allocate coefficient matrix for optimization coeff_matrix = np.empty((x.size, 6), dtype=float) coeff_matrix[:, 0] = 1 coeff_matrix[:, 1] = x coeff_matrix[:, 2] = y coeff_matrix[:, 3] = x * y coeff_matrix[:, 4] = x * x coeff_matrix[:, 5] = y * y # Include only finite values in the fit. finite_mask = np.isfinite(cutout) if not np.all(finite_mask): coeff_matrix = coeff_matrix[finite_mask] cutout = cutout[finite_mask] try: c = np.linalg.lstsq(coeff_matrix, cutout, rcond=None)[0] except np.linalg.LinAlgError: msg = 'quadratic fit failed' warnings.warn(msg, 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 the determinant is <= 0, the surface has a saddle point. If # the determinant is > 0, the surface has a minimum or maximum. The # curvature is negative (maximum) if c20 < 0 and c02 < 0. However, # if det > 0, then 4 * c20 * c02 > c11**2 >= 0, so c20 and c02 must # have the same sign. Therefore, we only need to check if c20 > 0 # (or c02 > 0) to determine if the surface has a minimum. if det <= 0 or c20 > 0: msg = 'quadratic fit does not have a maximum' warnings.warn(msg, 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: msg = 'quadratic polynomial maximum value falls outside of the image' warnings.warn(msg, AstropyUserWarning) return np.array((np.nan, np.nan)) return xycen class CentroidQuadratic: """ Class to calculate the centroid of a 2D array by fitting a 2D quadratic polynomial. This class provides a callable interface to the `~photutils.centroids.centroid_quadratic` function, allowing a centroid function with specific fit parameters to be defined and reused. This is useful, for example, when using a customized centroid function with `~photutils.centroids.centroid_sources`. Parameters ---------- 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. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import CentroidQuadratic >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> centroid_func = CentroidQuadratic(fit_boxsize=5) >>> x1, y1 = centroid_func(data) >>> print(np.array((x1, y1))) # doctest: +FLOAT_CMP [19.94009505 20.06884997] Using with `~photutils.centroids.centroid_sources`:: >>> from photutils.centroids import centroid_sources >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> centroid_func = CentroidQuadratic(fit_boxsize=3) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_func) """ def __init__(self, *, fit_boxsize=5): self.fit_boxsize = fit_boxsize def __repr__(self): return make_repr(self, ['fit_boxsize']) def __str__(self): return make_repr(self, ['fit_boxsize'], long=True) def __call__(self, data, *, mask=None): """ Calculate the centroid. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. The automatically masked values are combined (using bitwise OR) with the input ``mask``. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will also be combined (using bitwise OR) with the input ``mask``. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`. The image should be a background-subtracted cutout image containing a single source. 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. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. Masked data are excluded from calculations. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Notes ----- Unlike `~photutils.centroids.centroid_1dg` and `~photutils.centroids.centroid_2dg`, this method does not support an error array. """ kwargs = {'mask': mask, 'fit_boxsize': self.fit_boxsize, } return centroid_quadratic(data, **kwargs) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 in a 2D array using a specified centroid function. 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. Masks and non-finite values are handled by the input ``centroid_func``. When using a centroid function provided by Photutils, non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. The ``centroid_1dg`` and ``centroid_2dg`` functions also automatically mask any pixels with non-finite ``error`` array values. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask(s) for non-finite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The centroid is calculated using only the unmasked data values. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`. 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 will 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``. The same ``footprint`` is used for all sources. 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. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. 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 two scalar values representing the (x, y) centroid. The default is `~photutils.centroids.centroid_com`. **kwargs : dict, optional 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 -------- >>> import numpy as np >>> from photutils.centroids import centroid_2dg, centroid_sources >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_2dg) >>> print(x) # doctest: +FLOAT_CMP [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) # doctest: +FLOAT_CMP [40.03657613 60.01836631 24.96777946 69.80208702] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg, centroid_sources from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) x, y = centroid_sources(data, x_init, y_init, box_size=25, centroid_func=centroid_2dg) fig, ax = plt.subplots(figsize=(8, 4)) ax.imshow(data, origin='lower') ax.scatter(x, y, marker='+', s=80, color='red', label='Centroids') ax.legend() fig.tight_layout() """ if np.ndim(data) != 2: msg = 'data must be a 2D array' raise ValueError(msg) xpos = np.atleast_1d(xpos) ypos = np.atleast_1d(ypos) if xpos.ndim != 1: msg = 'xpos must be a 1D array' raise ValueError(msg) if ypos.ndim != 1: msg = 'ypos must be a 1D array' raise ValueError(msg) if len(xpos) != len(ypos): msg = 'xpos and ypos must have the same length' raise ValueError(msg) if (xpos.min() < 0 or ypos.min() < 0 or xpos.max() > data.shape[1] - 1 or ypos.max() > data.shape[0] - 1): msg = 'xpos, ypos values contain points outside the input data' raise ValueError(msg) if footprint is None: if box_size is None: msg = 'box_size or footprint must be defined' raise ValueError(msg) box_size = as_pair('box_size', box_size, lower_bound=(0, 0), check_odd=True) footprint = np.ones(box_size, dtype=bool) else: footprint = np.asanyarray(footprint, dtype=bool) if footprint.ndim != 2: msg = 'footprint must be a 2D array' raise ValueError(msg) if mask is not None and mask.shape != data.shape: msg = 'mask and data must have the same shape' raise ValueError(msg) spec = inspect.signature(centroid_func) if 'mask' not in spec.parameters: msg = "The input 'centroid_func' must have a 'mask' keyword." raise ValueError(msg) # Drop any **kwargs not supported by the centroid_func centroid_kwargs = {key: val for key, val in kwargs.items() if key in spec.parameters} # Save the original error array before the loop so that each # iteration independently slices the full-image array error_array = centroid_kwargs.get('error') # Extract xpeak/ypeak before the loop so the original absolute # coordinates are available for every source. The per-iteration # block below re-adds them with the correct cutout offset each time. # Remove this block once xpeak and ypeak are fully deprecated. xpeak_orig = centroid_kwargs.pop('xpeak', None) ypeak_orig = centroid_kwargs.pop('ypeak', None) n_sources = len(xpos) xcentroids = np.zeros(n_sources, dtype=float) ycentroids = np.zeros(n_sources, dtype=float) inverted_footprint = np.logical_not(footprint) for i, (xp, yp) in enumerate(zip(xpos, ypos, strict=True)): slices_large, slices_small = overlap_slices(data.shape, footprint.shape, (yp, xp)) data_cutout = data[slices_large] # Trim footprint mask if it has only partial overlap on the data footprint_mask = inverted_footprint[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): msg = (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.') raise ValueError(msg) centroid_kwargs.update({'mask': mask_cutout}) if error_array is not None: centroid_kwargs['error'] = error_array[slices_large] # Remove this block once xpeak and ypeak are fully deprecated. # Clear any xpeak/ypeak left by the previous iteration, then # re-add with the offset relative to this source's cutout. centroid_kwargs.pop('xpeak', None) centroid_kwargs.pop('ypeak', None) if xpeak_orig is not None and ypeak_orig is not None: centroid_kwargs['xpeak'] = xpeak_orig - slices_large[1].start centroid_kwargs['ypeak'] = ypeak_orig - slices_large[0].start try: xcen, ycen = centroid_func(data_cutout, **centroid_kwargs) except (ValueError, TypeError) as exc: msg = f'Centroid failed for source at ({xp}, {yp}): {exc}' warnings.warn(msg, AstropyUserWarning, stacklevel=2) xcen, ycen = np.nan, np.nan xcentroids[i] = xcen + slices_large[1].start ycentroids[i] = ycen + slices_large[0].start return xcentroids, ycentroids astropy-photutils-3322558/photutils/centroids/gaussian.py000066400000000000000000000200011517052111400236400ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for centroiding sources using Gaussians. """ import warnings import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from photutils.centroids._utils import (_gaussian1d_moments, _gaussian2d_moments, _validate_gaussian_inputs) from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._quantity_helpers import process_quantities __all__ = ['centroid_1dg', 'centroid_2dg'] @deprecated_positional_kwargs(since='3.0', until='4.0') 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. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for non-finite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The centroid is calculated using only the unmasked data values. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`. 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. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_1dg >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_1dg(data) >>> print(np.array((x1, y1))) [19.96553246 20.04952841] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_1dg from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_1dg(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ (data, error), _ = process_quantities((data, error), ('data', 'error')) data, mask, error = _validate_gaussian_inputs(data, mask, error) if error is not None: error_squared = error**2 xy_error = [np.sqrt(np.sum(error_squared, axis=i)) for i in (0, 1)] xy_weights = [1.0 / xy_err.clip(min=1.0e-30) for xy_err in xy_error] 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(mask): bad_idx = [np.all(mask, axis=i) for i in (0, 1)] for i in (0, 1): xy_weights[i][bad_idx[i]] = 0.0 xy_data = [np.sum(data, axis=i) for i in (0, 1)] # Gaussian1D stddev is bounded to be strictly positive fitter = TRFLSQFitter() centroid = [] for (data_i, weights_i) in zip(xy_data, xy_weights, strict=True): params_init = _gaussian1d_moments(data_i) g_init = Gaussian1D(*params_init) x = np.arange(data_i.size) g_fit = fitter(g_init, x, data_i, weights=weights_i) centroid.append(g_fit.mean.value) return np.array(centroid) @deprecated_positional_kwargs(since='3.0', until='4.0') def centroid_2dg(data, error=None, mask=None): """ Calculate the centroid of a 2D array by fitting a 2D Gaussian to the array. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for non-finite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The centroid is calculated using only the unmasked data values. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray`. 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. If ``data`` is a `~numpy.ma.MaskedArray`, its mask will be combined (using bitwise OR) with the input ``mask``. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_2dg >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_2dg(data) >>> print(np.array((x1, y1))) [19.9851944 20.01490157] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_2dg(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ (data, error), _ = process_quantities((data, error), ('data', 'error')) data, mask, error = _validate_gaussian_inputs(data, mask, error) if np.count_nonzero(~mask) < 6: msg = ('Input data must have a least 6 unmasked values to fit a ' '2D Gaussian.') raise ValueError(msg) # Subtract the minimum of the data to make the data values positive. # Moments from negative data values can yield undefined Gaussian # parameters, e.g., x_stddev and y_stddev. shifted = data - np.min(data) if np.sum(shifted) == 0: msg = ('Input data must have non-constant values to fit a ' '2D Gaussian.') raise ValueError(msg) if error is not None: weights = 1.0 / error.clip(min=1.0e-30) else: weights = np.ones(data.shape) # Assign zero weight to masked pixels if np.any(mask): weights[mask] = 0.0 amplitude, x_mean, y_mean, x_stddev, y_stddev, theta = _gaussian2d_moments( shifted) g_init = Gaussian2D(amplitude=amplitude, x_mean=x_mean, y_mean=y_mean, x_stddev=x_stddev, y_stddev=y_stddev, theta=theta) fitter = TRFLSQFitter() y, x = np.indices(data.shape) with warnings.catch_warnings(record=True) as fit_warnings: warnings.simplefilter('always', AstropyUserWarning) gfit = fitter(g_init, x, y, data, weights=weights) if any(issubclass(w.category, AstropyUserWarning) for w in fit_warnings): msg = 'The fit may not have converged. Please check your results.' warnings.warn(msg, AstropyUserWarning) return np.array([gfit.x_mean.value, gfit.y_mean.value]) astropy-photutils-3322558/photutils/centroids/tests/000077500000000000000000000000001517052111400226255ustar00rootroot00000000000000astropy-photutils-3322558/photutils/centroids/tests/__init__.py000066400000000000000000000000001517052111400247240ustar00rootroot00000000000000astropy-photutils-3322558/photutils/centroids/tests/test_core.py000066400000000000000000000752751517052111400252060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ from contextlib import nullcontext from unittest.mock import patch import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose, assert_array_equal from photutils.centroids.core import (CentroidQuadratic, 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 def _make_gaussian_source(shape, amplitude, xc, yc, xstd, ystd, theta): """ Make a 2D Gaussian source. """ yy, xx = np.mgrid[0:shape[0], 0:shape[1]] model = Gaussian2D(amplitude, xc, yc, xstd, ystd, theta) return model(xx, yy) @pytest.fixture(name='test_data') def fixture_test_data(): """ Create test data with multiple Gaussian sources. """ ysize = 50 xsize = 47 yy, xx = np.mgrid[0:ysize, 0:xsize] data = np.zeros((ysize, xsize)) xpos = (1, 25, 25, 35, 46) ypos = (1, 25, 12, 35, 49) for xc, yc in zip(xpos, ypos, strict=True): model = Gaussian2D(10.0, xc, yc, x_stddev=2, y_stddev=2, theta=0) data += model(xx, yy) return data, xpos, ypos @pytest.fixture(name='nan_data') def fixture_nan_data(): """ Create Gaussian test data with NaN values in row 20. """ xc_ref = 24.7 yc_ref = 25.2 data = _make_gaussian_source((50, 50), 2.4, xc_ref, yc_ref, 5.0, 5.0, 0) data[20, :] = np.nan return data, xc_ref, yc_ref @pytest.mark.parametrize('x_std', [3.2, 4.0]) @pytest.mark.parametrize('y_std', [5.7, 4.1]) @pytest.mark.parametrize('theta', np.deg2rad([30.0, 45.0])) @pytest.mark.parametrize('units', [True, False]) def test_centroid_com(x_std, y_std, theta, units): """ Test centroid_com with Gaussian data. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, x_std, y_std, theta) if units: data = data * u.nJy xc, yc = centroid_com(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) # Test with mask x0 = 11 y0 = 15 data[y0, x0] = 1.0e5 * u.nJy if units else 1.0e5 mask = np.zeros(data.shape, dtype=bool) mask[y0, x0] = True xc, yc = centroid_com(data, mask=mask) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) @pytest.mark.parametrize('use_mask', [True, False]) def test_centroid_com_nan_withmask(nan_data, use_mask): """ Test centroid_com with NaN values and optional mask. """ data, xc_ref, yc_ref = nan_data if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True ctx = nullcontext() else: mask = None match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) 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 not use_mask: assert len(warnlist) == 1 def test_centroid_com_allmask(): """ Test centroid_com when all data are masked or zero. """ xc_ref = 24.7 yc_ref = 25.2 data = _make_gaussian_source((50, 50), 2.4, xc_ref, yc_ref, 5.0, 5.0, 0) 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(): """ Test centroid_com with invalid inputs. """ data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_com(data, mask=mask) @pytest.mark.parametrize('ndim', [1, 2, 3, 4, 5]) def test_centroid_com_zero_sum(ndim): """ Test centroid_com when the sum of the data is zero, which should return NaN. """ data = np.zeros([10] * ndim) cen = centroid_com(data) assert cen.shape == (ndim,) for cen_ in cen: assert np.isnan(cen_) def test_centroid_com_masked_array(): """ Test centroid_com with a MaskedArray input. """ data = np.ma.array([[1.0, 1.0, 1.0], [1.0, 100.0, 1.0], [10.0, 1.0, 1.0]], mask=[[0, 0, 0], [0, 1, 0], [0, 0, 0]]) # If mask is respected, peak (1, 1) is ignored and centroid will be # pulled towards (0, 0). xc1, yc1 = centroid_com(data) # Compare with explicit mask xc2, yc2 = centroid_com(data.data, mask=data.mask) assert xc1 == xc2 assert yc1 == yc2 # Combined mask test mask_arg = np.zeros(data.shape, dtype=bool) mask_arg[0, 0] = True # Now both (1, 1) and (0, 0) are masked. xc3, yc3 = centroid_com(data, mask=mask_arg) full_mask = data.mask | mask_arg xc4, yc4 = centroid_com(data.data, mask=full_mask) assert xc3 == xc4 assert yc3 == yc4 def test_centroid_com_mutation(): """ Test that centroid_com does not mutate the input data or mask. """ data = np.ones((5, 5)) mask = np.zeros((5, 5), dtype=bool) mask[2, 2] = True data_orig = data.copy() mask_orig = mask.copy() centroid_com(data, mask=mask) assert_array_equal(data, data_orig) assert_array_equal(mask, mask_orig) @pytest.mark.parametrize('x_std', [3.2, 4.0]) @pytest.mark.parametrize('y_std', [5.7, 4.1]) @pytest.mark.parametrize('theta', np.deg2rad([30.0, 45.0])) @pytest.mark.parametrize('units', [True, False]) def test_centroid_quadratic(x_std, y_std, theta, units): """ Test centroid_quadratic with Gaussian data. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, x_std, y_std, theta) if units: data = data * u.nJy xc, yc = centroid_quadratic(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=0.015) # Test with mask x0 = 11 y0 = 15 data[y0, x0] = 1.0e5 * u.nJy if units else 1.0e5 mask = np.zeros(data.shape, dtype=bool) mask[y0, x0] = True data[y0, x0] = 1.0e5 * u.nJy if units else 1.0e5 xc, yc = centroid_quadratic(data, mask=mask) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=0.015) def test_centroid_quadratic_xypeak(): """ Test centroid_quadratic with xpeak and ypeak inputs. """ 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)) with pytest.warns(AstropyDeprecationWarning): xycen2 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen2, (5, 5)) with pytest.warns(AstropyDeprecationWarning): xycen3 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3, search_boxsize=5) assert_allclose(xycen3, (7, 7)) match = 'xpeak is outside the input data' with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=15, ypeak=5) with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=15, ypeak=15) match = 'ypeak is outside the input data' with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=5, ypeak=15) def test_centroid_quadratic_nan(): """ Test centroid_quadratic with NaN values. """ data = _make_gaussian_source((100, 100), 42.1, 47.8, 52.4, 4.7, 4.7, 0) 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, mask=mask) assert_allclose(xycen, [47.58324, 51.827182]) @pytest.mark.parametrize('use_mask', [True, False]) def test_centroid_quadratic_nan_withmask(nan_data, use_mask): """ Test centroid_quadratic with NaN values and optional mask. """ data, xc_ref, yc_ref = nan_data if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True ctx = nullcontext() else: mask = None match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) with ctx as warnlist: xc, yc = centroid_quadratic(data, mask=mask) assert_allclose(xc, xc_ref, rtol=0, atol=0.15) assert_allclose(yc, yc_ref, rtol=0, atol=0.15) if not use_mask: assert len(warnlist) == 1 def test_centroid_quadratic_nan_in_fitbox(): """ Test centroid_quadratic with a NaN inside the fit box. This tests that non-finite values are removed from the coefficient matrix and cutout before the least-squares fit. The NaN pixel is masked so that _process_data_mask fills it with NaN (fill_value), which is then filtered out by the ``finite_mask`` check inside the fit. """ data = _make_gaussian_source((11, 11), 100.0, 5.0, 5.0, 2.0, 2.0, 0) # Place a NaN adjacent to the peak; with fit_boxsize=5 centered at # (5, 5) the fit box covers rows/cols [3:8], so (row=5, col=4) is # inside the box and will trigger the ``if not np.all(finite_mask)`` # branch. data[5, 4] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[5, 4] = True # suppress the non-finite warning via explicit mask xycen = centroid_quadratic(data, mask=mask, fit_boxsize=5) assert_allclose(xycen, (5.0, 5.0), atol=0.01) def test_centroid_quadratic_npts(): """ Test centroid_quadratic with insufficient unmasked data points. """ data = np.zeros((3, 3)) data[1, 1] = 1 mask = np.zeros(data.shape, dtype=bool) mask[0, :] = True mask[2, :] = True match = 'at least 6 unmasked data points' with pytest.warns(AstropyUserWarning, match=match): centroid_quadratic(data, mask=mask) def test_centroid_quadratic_invalid_inputs(): """ Test centroid_quadratic with invalid inputs. """ data = np.zeros((4, 4, 4)) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): centroid_quadratic(data) data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'xpeak and ypeak must both be input or "None"' with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=3, ypeak=None) with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=None, ypeak=3) match = 'fit_boxsize must have 1 or 2 elements' with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(2, 2, 2)) match = 'fit_boxsize must have an odd value for both axes' with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(-2, 2)) with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(2, 2)) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_quadratic(data, mask=mask) def test_centroid_quadratic_edge(): """ Test centroid_quadratic when the maximum is at the edge. """ data = np.zeros((11, 11)) data[1, 1] = 100 data[9, 9] = 100 with pytest.warns(AstropyDeprecationWarning): xycen = centroid_quadratic(data, xpeak=1, ypeak=1, fit_boxsize=5) assert_allclose(xycen, (0.923077, 0.923077)) with pytest.warns(AstropyDeprecationWarning): 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 match = 'maximum value is at the edge' with pytest.warns(AstropyUserWarning, match=match): xycen = centroid_quadratic(data) assert_allclose(xycen, (0, 0)) def test_centroid_quadratic_mutation(): """ Test that centroid_quadratic does not mutate the input data or mask. """ data = np.ones((11, 11)) data[5, 5] = 10.0 mask = np.zeros((11, 11), dtype=bool) mask[0, 0] = True data_orig = data.copy() mask_orig = mask.copy() centroid_quadratic(data, mask=mask) assert_array_equal(data, data_orig) assert_array_equal(mask, mask_orig) def test_centroid_quadratic_units(): """ Test that centroid_quadratic strips Quantity units and returns the same result as a plain float array. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, 3.2, 5.7, 0) xc_plain, yc_plain = centroid_quadratic(data) xc_unit, yc_unit = centroid_quadratic(data * u.nJy) assert_allclose(xc_plain, xc_unit) assert_allclose(yc_plain, yc_unit) def test_centroid_quadratic_fit_failed(): """ Test centroid_quadratic when the quadratic fit fails. This tests the LinAlgError exception handling. Since lstsq is very robust (uses SVD internally), we use mocking to trigger this condition. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 data[4, 5] = 8.0 data[6, 5] = 8.0 data[5, 4] = 8.0 data[5, 6] = 8.0 with patch('numpy.linalg.lstsq', side_effect=np.linalg.LinAlgError): match = 'quadratic fit failed' with (pytest.warns(AstropyDeprecationWarning), pytest.warns(AstropyUserWarning, match=match)): xycen = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=5) assert np.isnan(xycen[0]) assert np.isnan(xycen[1]) def test_centroid_quadratic_no_maximum(): """ Test centroid_quadratic when the quadratic fit does not have a maximum. This tests the case where the fitted polynomial has a saddle point or minimum instead of a maximum (det <= 0 or positive curvature). """ # Create data with a saddle-like pattern that will result in a # quadratic fit without a proper maximum data = np.zeros((11, 11)) y, x = np.mgrid[0:11, 0:11] # Create a saddle: z = x^2 - y^2 (positive curvature in x, negative # in y) data = (x - 5.0)**2 - (y - 5.0)**2 + 10 # Add a peak so the fit box centers there data[5, 5] = 20.0 match = 'quadratic fit does not have a maximum' with (pytest.warns(AstropyDeprecationWarning), pytest.warns(AstropyUserWarning, match=match)): xycen = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=5) assert np.isnan(xycen[0]) assert np.isnan(xycen[1]) def test_centroid_quadratic_max_outside_image(): """ Test centroid_quadratic when the polynomial maximum falls outside the image. This tests the case where the quadratic fit has a valid maximum but it lies outside the image boundaries. """ # Create data where values increase toward the origin (0, 0) but # with a local peak at (3, 3). This causes the quadratic fit to # extrapolate the maximum outside the image boundaries. data = np.zeros((7, 7)) y, x = np.mgrid[0:7, 0:7] data = 10 - x.astype(float) - y.astype(float) data = np.maximum(data, 0.1) data[3, 3] = 6.0 # local peak to center the fit match = 'quadratic polynomial maximum value falls outside' with (pytest.warns(AstropyDeprecationWarning), pytest.warns(AstropyUserWarning, match=match)): xycen = centroid_quadratic(data, xpeak=3, ypeak=3, fit_boxsize=5) assert np.isnan(xycen[0]) assert np.isnan(xycen[1]) class TestCentroidSources: """ Test the centroid_sources function. """ @staticmethod def test_centroid_sources(): """ Test centroid_sources with Gaussian data. """ theta = np.pi / 6.0 data = _make_gaussian_source((50, 47), 2.4, 25.7, 26.2, 3.2, 5.7, theta) 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) match = 'xpos must be a 1D array' with pytest.raises(ValueError, match=match): centroid_sources(data, [[25]], 26, box_size=11) match = 'ypos must be a 1D array' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, [[26]], box_size=11) match = 'xpos and ypos must have the same length' with pytest.raises(ValueError, match=match): centroid_sources(data, [25, 26], [26], box_size=11) match = 'box_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, box_size=(1, 2, 3)) match = 'box_size or footprint must be defined' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, box_size=None, footprint=None) match = 'footprint must be a 2D array' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, footprint=np.ones((3, 3, 3))) def test_func(data): return data match = "The input 'centroid_func' must have a 'mask' keyword" with pytest.raises(ValueError, match=match): centroid_sources(data, [25], 26, centroid_func=test_func) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): centroid_sources(np.ones((3, 3, 3)), 1, 1, box_size=3) @pytest.mark.parametrize('centroid_func', [centroid_com, centroid_quadratic, centroid_1dg, centroid_2dg]) def test_xypos(self, test_data, centroid_func): """ Test centroid_sources with xpos/ypos outside data range. """ data = test_data[0] match = 'xpos, ypos values contain points outside the input data' with pytest.raises(ValueError, match=match): centroid_sources(data, 47, 50, box_size=5, centroid_func=centroid_func) def test_gaussian_fits_npts(self, test_data): """ Test centroid_sources with Gaussian fits with insufficient points. """ data, xpos, ypos = test_data xcen, ycen = centroid_sources(data, xpos, ypos, box_size=3, centroid_func=centroid_1dg) xres = np.copy(xpos).astype(float) yres = np.copy(ypos).astype(float) xres[-1] = 46.689208 yres[-1] = 49.689208 assert_allclose(xcen, xres, atol=1e-5) assert_allclose(ycen, yres, atol=1e-5) match = 'Centroid failed for source' with pytest.warns(AstropyUserWarning, match=match): xcen, ycen = centroid_sources(data, xpos, ypos, box_size=3, centroid_func=centroid_2dg) xres[-1] = np.nan yres[-1] = np.nan assert_allclose(xcen, xres) assert_allclose(ycen, yres) xcen, ycen = centroid_sources(data, xpos, ypos, box_size=5, centroid_func=centroid_1dg) assert_allclose(xcen, xpos) assert_allclose(ycen, ypos) match = 'Centroid failed for source' with pytest.warns(AstropyUserWarning, match=match): xcen, ycen = centroid_sources(data, xpos, ypos, box_size=3, centroid_func=centroid_quadratic) assert_allclose(xcen, xres) assert_allclose(ycen, yres) def test_centroid_quadratic_mask(self): """ Test centroid_sources with centroid_quadratic and a mask. The original data should not be altered when a mask is input. """ xc_ref = 24.7 yc_ref = 25.2 data = _make_gaussian_source((51, 51), 2.4, xc_ref, yc_ref, 5.0, 5.0, 0) 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, test_data): """ Test centroid_sources with mask input. """ data = test_data[0] xcen1, ycen1 = centroid_sources(data, 25, 23, box_size=(55, 55)) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[24, 24] = True mask[11, 24] = True xcen2, ycen2 = centroid_sources(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, test_data): """ Test centroid_sources with error=None for Gaussian centroids. """ data = test_data[0] xycen1 = centroid_sources(data, xpos=25, ypos=25, error=None, centroid_func=centroid_1dg) xycen2 = centroid_sources(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) @pytest.mark.filterwarnings(r'ignore:.*was deprecated') def test_xypeaks_none(self, test_data): """ Test centroid_sources with xpeak and ypeak as None for centroid_quadratic. """ data = test_data[0] xycen1 = centroid_sources(data, xpos=25, ypos=25, error=None, xpeak=None, ypeak=25, centroid_func=centroid_quadratic) xycen2 = centroid_sources(data, xpos=25, ypos=25, error=None, xpeak=25, ypeak=None, centroid_func=centroid_quadratic) xycen3 = centroid_sources(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_centroid_quadratic_kwargs(self): """ Test centroid_sources with centroid_quadratic and various keyword arguments. """ data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 with pytest.warns(AstropyDeprecationWarning): 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])) def test_mask_wrong_shape(self): """ Test centroid_sources raises ValueError when the mask shape does not match the data shape. """ data = np.ones((50, 50)) mask = np.zeros((30, 30), dtype=bool) match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 25, box_size=11, mask=mask) def test_xypeak_multiple_sources(self): """ Test that xpeak/ypeak are correctly offset for each source in a multi-source centroid_sources call. """ # Two isolated peaks close together so that xpeak=10 (absolute) # lies within both source cutouts (box_size=5): # source 1 cutout: x[8:13], start=8 -> relative xpeak = 10-8 = 2 # source 2 cutout: x[9:14], start=9 -> relative xpeak = 10-9 = 1 data = np.zeros((25, 25)) data[10, 10] = 100.0 data[10, 11] = 100.0 with pytest.warns(AstropyDeprecationWarning): xc_multi, yc_multi = centroid_sources( data, xpos=[10, 11], ypos=[10, 10], box_size=5, centroid_func=centroid_quadratic, xpeak=10, ypeak=10, fit_boxsize=3) # Compare with individual single-source calls using the same # xpeak/ypeak to get the reference values. with pytest.warns(AstropyDeprecationWarning): xc1, yc1 = centroid_sources( data, xpos=10, ypos=10, box_size=5, centroid_func=centroid_quadratic, xpeak=10, ypeak=10, fit_boxsize=3) with pytest.warns(AstropyDeprecationWarning): xc2, yc2 = centroid_sources( data, xpos=11, ypos=10, box_size=5, centroid_func=centroid_quadratic, xpeak=10, ypeak=10, fit_boxsize=3) assert_allclose(xc_multi[0], xc1[0]) assert_allclose(yc_multi[0], yc1[0]) assert_allclose(xc_multi[1], xc2[0]) assert_allclose(yc_multi[1], yc2[0]) def test_centroid_sources_error_multiple_sources(): """ Test that centroid_sources correctly applies an error array for multiple sources. """ xpos = [25.0, 75.0] ypos = [30.0, 70.0] data1 = _make_gaussian_source((100, 100), 10.0, xpos[0], ypos[0], 4.0, 4.0, 0) data2 = _make_gaussian_source((100, 100), 10.0, xpos[1], ypos[1], 4.0, 4.0, 0) data = data1 + data2 error = np.ones(data.shape, dtype=float) xc, yc = centroid_sources(data, xpos, ypos, box_size=21, centroid_func=centroid_1dg, error=error) assert_allclose(xc, xpos) assert_allclose(yc, ypos) xc, yc = centroid_sources(data, xpos, ypos, box_size=21, centroid_func=centroid_2dg, error=error) assert_allclose(xc, xpos) assert_allclose(yc, ypos) def test_centroid_sources_mutation(): """ Test that centroid_sources does not mutate the input data or mask. """ data = np.ones((50, 50)) mask = np.zeros((50, 50), dtype=bool) mask[10, 10] = True xpos = [25.0] ypos = [26.0] data_orig = data.copy() mask_orig = mask.copy() centroid_sources(data, xpos, ypos, box_size=11, mask=mask) assert_array_equal(data, data_orig) assert_array_equal(mask, mask_orig) 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) footprint = np.zeros((3, 3)) match = 'is completely masked' with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) footprint = np.zeros(data.shape, dtype=bool) with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) mask = np.ones(data.shape, dtype=bool) with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, box_size=11, mask=mask) class TestCentroidQuadraticClass: """ Test the CentroidQuadratic class. """ @pytest.mark.parametrize('x_std', [3.2, 4.0]) @pytest.mark.parametrize('y_std', [5.7, 4.1]) @pytest.mark.parametrize('theta', np.deg2rad([30.0, 45.0])) def test_basic(self, x_std, y_std, theta): """ Test basic CentroidQuadratic functionality. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, x_std, y_std, theta) # Test with default parameters centroid_func = CentroidQuadratic() xc, yc = centroid_func(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=0.015) def test_mask(self): """ Test CentroidQuadratic with mask input. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, 3.2, 5.7, 0) # Add an outlier x0 = 11 y0 = 15 data[y0, x0] = 1.0e5 mask = np.zeros(data.shape, dtype=bool) mask[y0, x0] = True centroid_func = CentroidQuadratic() xc, yc = centroid_func(data, mask=mask) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=0.015) def test_fit_boxsize(self): """ Test CentroidQuadratic with custom fit_boxsize. """ data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 centroid_func = CentroidQuadratic(fit_boxsize=3) xycen = centroid_func(data) assert_allclose(xycen, (9, 9)) def test_with_centroid_sources(self): """ Test CentroidQuadratic with centroid_sources function. """ data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 # Test with custom fit_boxsize centroid_func = CentroidQuadratic(fit_boxsize=3) xycen = centroid_sources(data, xpos=5, ypos=5, box_size=7, centroid_func=centroid_func) assert_allclose(xycen, ([7], [7])) def test_repr(self): """ Test CentroidQuadratic __repr__ method. """ centroid_func = CentroidQuadratic() cls_repr = repr(centroid_func) assert cls_repr == 'CentroidQuadratic(fit_boxsize=5)' centroid_func = CentroidQuadratic(fit_boxsize=3) cls_repr = repr(centroid_func) assert cls_repr == 'CentroidQuadratic(fit_boxsize=3)' centroid_func = CentroidQuadratic(fit_boxsize=(3, 5)) cls_repr = repr(centroid_func) assert cls_repr == 'CentroidQuadratic(fit_boxsize=(3, 5))' def test_str(self): """ Test CentroidQuadratic __str__ method. """ centroid_func = CentroidQuadratic() cls_str = str(centroid_func) cls_name = 'photutils.centroids.core.CentroidQuadratic' expected = f'<{cls_name}>\nfit_boxsize: 5' assert cls_str == expected centroid_func = CentroidQuadratic(fit_boxsize=3) cls_str = str(centroid_func) expected = f'<{cls_name}>\nfit_boxsize: 3' assert cls_str == expected astropy-photutils-3322558/photutils/centroids/tests/test_gaussian.py000066400000000000000000000205371517052111400260570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gaussian module. """ from contextlib import nullcontext 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_array_equal from photutils.centroids._utils import _gaussian1d_moments from photutils.centroids.gaussian import centroid_1dg, centroid_2dg def _make_gaussian_source(shape, amplitude, xc, yc, xstd, ystd, theta): """ Make a 2D Gaussian source. """ yy, xx = np.mgrid[0:shape[0], 0:shape[1]] model = Gaussian2D(amplitude, xc, yc, xstd, ystd, theta) return model(xx, yy) @pytest.mark.parametrize('x_std', [3.2, 4.0]) @pytest.mark.parametrize('y_std', [5.7, 4.1]) @pytest.mark.parametrize('theta', np.deg2rad([30.0, 45.0])) @pytest.mark.parametrize('units', [True, False]) def test_centroids(x_std, y_std, theta, units): """ Test the 1D and 2D Gaussian centroid functions on a simple 2D Gaussian model. """ xc_ref = 25.7 yc_ref = 26.2 data = _make_gaussian_source((50, 47), 2.4, xc_ref, yc_ref, x_std, y_std, theta) error = np.sqrt(np.abs(data)) value = 1.0e5 if units: unit = u.nJy data = data * unit error = error * unit value *= unit xc, yc = centroid_1dg(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) # Test with errors xc, yc = centroid_1dg(data, error=error) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, error=error) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) # Test with mask mask = np.zeros(data.shape, dtype=bool) data[10, 10] = value mask[10, 10] = True xc, yc = centroid_1dg(data, mask=mask) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, mask=mask) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=1.0e-3) @pytest.mark.parametrize('use_mask', [True, False]) def test_centroids_nan_withmask(use_mask): """ Test that the 1D and 2D Gaussian centroid functions can handle NaN values in the input data, both with and without a mask. """ xc_ref = 24.7 yc_ref = 25.2 data = _make_gaussian_source((50, 50), 2.4, xc_ref, yc_ref, 5.0, 5.0, 0.0) 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 match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) 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 def test_invalid_shapes(): """ Test that the 1D and 2D Gaussian centroid functions raise an error for invalid data, mask, or error shapes. """ data = np.zeros((4, 4, 4)) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): centroid_1dg(data) with pytest.raises(ValueError, match=match): centroid_2dg(data) data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_1dg(data, mask=mask) with pytest.raises(ValueError, match=match): centroid_2dg(data, mask=mask) data = np.zeros(4) mask = np.zeros(2, dtype=bool) with pytest.raises(ValueError, match=match): _gaussian1d_moments(data, mask=mask) def test_invalid_error_shape(): """ Test that the 1D and 2D Gaussian centroid functions raise an error for invalid error shapes. """ error = np.zeros((2, 2), dtype=bool) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): centroid_1dg(np.zeros((4, 4)), error=error) with pytest.raises(ValueError, match=match): centroid_2dg(np.zeros((4, 4)), error=error) def test_centroid_2dg_dof(): """ Test that the 2D Gaussian centroid function raises an error if there are not enough unmasked values to fit the model. """ data = np.ones((2, 2)) match = 'Input data must have a least 6 unmasked values to fit' with pytest.raises(ValueError, match=match): centroid_2dg(data) @pytest.mark.parametrize('value', [0.0, 1.0, -3.7]) def test_centroid_2dg_constant_data(value): """ Test that centroid_2dg raises a ValueError for constant (flat) input data. After subtracting the minimum, a constant array becomes all-zero, making the moment sum zero and the Gaussian parameters undefined. This previously produced silent NaN results; now it raises a clear error. """ data = np.full((10, 10), value) match = 'Input data must have non-constant values' with pytest.raises(ValueError, match=match): centroid_2dg(data) def test_gaussian1d_moments(): """ Test the _gaussian1d_moments function on a simple 1D Gaussian model. """ 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) # Test that masked NaNs do not raise a warning data[0] = np.nan 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) # Test that unmasked NaNs raise a warning data[0] = np.nan mask = np.zeros(data.shape).astype(bool) mask[0] = False match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): result = _gaussian1d_moments(data, mask=mask) assert_allclose(result, desired, rtol=0, atol=1.0e-6) def test_gaussian2d_warning(): """ Test that the 2D Gaussian centroid function raises a warning if the fit may not have converged. """ data = _make_gaussian_source((51, 51), 1.0, 24.17, 25.87, 1.7, 4.7, 0.0) match = 'The fit may not have converged' with pytest.warns(AstropyUserWarning, match=match): centroid_2dg(data + 100000) def test_no_input_mutation(): """ Test that input mask and error arrays are not mutated by centroid_1dg or centroid_2dg. """ data = _make_gaussian_source((50, 50), 2.4, 25.0, 25.0, 5.0, 5.0, 0.0) # Add a masked position and a NaN in error to exercise all # copy-on-write paths without triggering data-NaN warnings mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True error = np.sqrt(np.abs(data)) error[15, 15] = np.nan mask_orig = mask.copy() error_orig = error.copy() centroid_1dg(data, error=error, mask=mask) assert_array_equal(mask, mask_orig) assert_array_equal(error, error_orig) centroid_2dg(data, error=error, mask=mask) assert_array_equal(mask, mask_orig) assert_array_equal(error, error_orig) def test_masked_array_input(): """ Test that MaskedArray inputs to centroid_1dg and centroid_2dg give the same results as equivalent plain array and mask inputs. """ data = _make_gaussian_source((50, 50), 2.4, 25.0, 25.0, 5.0, 5.0, 0.0) mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True # Plain array with mask keyword xc1, yc1 = centroid_1dg(data, mask=mask) xc2, yc2 = centroid_2dg(data, mask=mask) # MaskedArray (no mask keyword) masked_data = np.ma.array(data, mask=mask) xc1_ma, yc1_ma = centroid_1dg(masked_data) xc2_ma, yc2_ma = centroid_2dg(masked_data) assert_allclose([xc1_ma, yc1_ma], [xc1, yc1]) assert_allclose([xc2_ma, yc2_ma], [xc2, yc2]) astropy-photutils-3322558/photutils/centroids/tests/test_utils.py000066400000000000000000000252001517052111400253750ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _utils 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_array_equal from photutils.centroids._utils import (_gaussian2d_moments, _process_data_mask, _validate_data, _validate_gaussian_inputs, _validate_mask_shape) class TestValidateData: """ Tests for _validate_data. """ def test_converts_to_float(self): data = np.array([[1, 2], [3, 4]], dtype=int) result = _validate_data(data) assert result.dtype == float def test_2d_default(self): data = np.ones((4, 4)) result = _validate_data(data) assert result.shape == (4, 4) def test_wrong_ndim_raises(self): data = np.ones((4, 4, 4)) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): _validate_data(data, ndim=2) def test_1d_valid(self): data = np.ones(10) result = _validate_data(data, ndim=1) assert result.ndim == 1 def test_ndim_none_accepts_any_shape(self): for shape in [(5,), (5, 5), (5, 5, 5)]: result = _validate_data(np.ones(shape), ndim=None) assert result.shape == shape class TestValidateMaskShape: """ Tests for _validate_mask_shape. """ def test_none_mask_passes(self): data = np.ones((4, 4)) _validate_mask_shape(data, None) def test_matching_shape_passes(self): data = np.ones((4, 4)) mask = np.zeros((4, 4), dtype=bool) _validate_mask_shape(data, mask) def test_mismatched_shape_raises(self): data = np.ones((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): _validate_mask_shape(data, mask) class TestProcessDataMask: """ Tests for _process_data_mask. """ def test_finite_data_unchanged(self): data = np.array([[1.0, 2.0], [3.0, 4.0]]) result = _process_data_mask(data, None) assert_array_equal(result, data) def test_nan_fills_and_warns(self): data = np.array([[1.0, np.nan], [3.0, 4.0]]) match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): result = _process_data_mask(data, None) assert np.isnan(result[0, 1]) def test_nan_fills_with_custom_fill_value(self): data = np.array([[1.0, np.nan], [3.0, 4.0]]) with pytest.warns(AstropyUserWarning): result = _process_data_mask(data, None, fill_value=0.0) assert result[0, 1] == 0.0 def test_mask_fills_fill_value(self): data = np.array([[1.0, 2.0], [3.0, 4.0]]) mask = np.array([[False, True], [False, False]]) result = _process_data_mask(data, mask, fill_value=0.0) assert result[0, 1] == 0.0 def test_masked_nan_no_warning(self): data = np.array([[1.0, np.nan], [3.0, 4.0]]) mask = np.array([[False, True], [False, False]]) # Masked NaN should not trigger a warning result = _process_data_mask(data, mask, fill_value=0.0) assert result[0, 1] == 0.0 def test_ndim_validation(self): data = np.ones((4, 4, 4)) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): _process_data_mask(data, None, ndim=2) def test_mask_shape_validation(self): data = np.ones((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): _process_data_mask(data, mask) def test_masked_array_no_mutation(self): """ Input mask must not be mutated when data is a MaskedArray. """ masked_data = np.ma.array([[1.0, 2.0], [3.0, 4.0]], mask=[[False, True], [False, False]]) input_mask = np.zeros((2, 2), dtype=bool) input_mask_orig = input_mask.copy() _process_data_mask(masked_data, input_mask) assert_array_equal(input_mask, input_mask_orig) def test_masked_array_returns_ndarray(self): """ MaskedArray input must return a plain ndarray, not MaskedArray. """ masked_data = np.ma.array([[1.0, 2.0], [3.0, 4.0]], mask=[[False, True], [False, False]]) result = _process_data_mask(masked_data, None) assert not isinstance(result, np.ma.MaskedArray) assert isinstance(result, np.ndarray) def test_masked_array_mask_combined(self): """ MaskedArray mask and keyword mask are combined correctly. """ masked_data = np.ma.array([[1.0, 2.0], [3.0, 4.0]], mask=[[False, True], [False, False]]) extra_mask = np.array([[True, False], [False, False]]) result = _process_data_mask(masked_data, extra_mask, fill_value=0.0) # Both (0,0) from extra_mask and (0,1) from MaskedArray should # be 0 assert result[0, 0] == 0.0 assert result[0, 1] == 0.0 class TestValidateGaussianInputs: """ Tests for _validate_gaussian_inputs. """ @pytest.fixture def gauss_data(self): rng = np.random.default_rng(0) return np.abs(rng.standard_normal((20, 20))) + 1.0 def test_no_error_no_mask(self, gauss_data): data, mask, error = _validate_gaussian_inputs(gauss_data, None, None) assert error is None assert data.shape == gauss_data.shape assert mask.shape == gauss_data.shape assert mask.dtype == bool def test_error_shape_mismatch_raises(self, gauss_data): error = np.ones((5, 5)) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): _validate_gaussian_inputs(gauss_data, None, error) def test_nan_in_error_sets_combined_mask(self, gauss_data): error = np.ones_like(gauss_data) error[5, 5] = np.nan data, combined_mask, _ = _validate_gaussian_inputs( gauss_data, None, error) assert combined_mask[5, 5] assert data[5, 5] == 0.0 def test_nan_in_data_sets_combined_mask(self, gauss_data): gauss_data = gauss_data.copy() gauss_data[3, 3] = np.nan match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): data, combined_mask, _ = _validate_gaussian_inputs( gauss_data, None, None) assert combined_mask[3, 3] assert data[3, 3] == 0.0 def test_input_mask_not_mutated(self, gauss_data): error = np.ones_like(gauss_data) error[5, 5] = np.nan mask = np.zeros(gauss_data.shape, dtype=bool) mask_orig = mask.copy() _validate_gaussian_inputs(gauss_data, mask, error) assert_array_equal(mask, mask_orig) def test_input_error_not_mutated(self, gauss_data): error = np.ones_like(gauss_data) error[5, 5] = np.nan error_orig = error.copy() _validate_gaussian_inputs(gauss_data, None, error) assert_array_equal(error, error_orig) def test_data_not_mutated_when_error_nan(self, gauss_data): """ data must not be mutated when NaN in error extends combined_mask beyond the positions already handled by _process_data_mask. When mask=None and data is clean, _process_data_mask returns the original object without copying. If error then contributes new NaN positions, the code must not write 0.0 directly into the caller's array. """ error = np.ones_like(gauss_data) error[5, 5] = np.nan data_orig = gauss_data.copy() _validate_gaussian_inputs(gauss_data, None, error) assert_array_equal(gauss_data, data_orig) def test_error_zeroed_at_combined_mask(self, gauss_data): mask = np.zeros(gauss_data.shape, dtype=bool) mask[2, 2] = True error = np.ones_like(gauss_data) error[5, 5] = np.nan _, _, out_error = _validate_gaussian_inputs(gauss_data, mask, error) assert out_error[2, 2] == 0.0 # masked by input mask assert out_error[5, 5] == 0.0 # masked by NaN in error def test_no_copy_when_no_invalids(self, gauss_data): """ Test that error values are unchanged when there are no NaNs in data or error, and mask is None. """ error = np.ones_like(gauss_data) _, _, out_error = _validate_gaussian_inputs(gauss_data, None, error) assert_array_equal(out_error, error) class TestGaussian2DMoments: """ Tests for _gaussian2d_moments. """ def test_symmetric_gaussian(self): """ Circular Gaussian: centroid and equal stddevs are recovered. """ xcen, ycen, std = 25.0, 25.0, 5.0 model = Gaussian2D(1.0, xcen, ycen, x_stddev=std, y_stddev=std) y, x = np.mgrid[0:50, 0:50] data = model(x, y) (amplitude, x_mean, y_mean, x_stddev, y_stddev, theta, ) = _gaussian2d_moments(data) assert_allclose(amplitude, 1.0, atol=0.01) assert_allclose(x_mean, xcen, atol=0.01) assert_allclose(y_mean, ycen, atol=0.01) assert_allclose(x_stddev, std, atol=0.1) assert_allclose(y_stddev, std, atol=0.1) assert_allclose(theta, 0, atol=0.05) @pytest.mark.parametrize('theta_in', np.deg2rad((0, 22, 37, 45, 60, 88, 90))) def test_asymmetric_gaussian_theta(self, theta_in): """ Axis-aligned elliptical Gaussian (theta=0): centroid and axis stddevs are recovered. The larger sigma maps to x_stddev. """ ampl = 3.5 xcen, ycen = 30.0, 20.0 x_std, y_std = 6.0, 3.0 model = Gaussian2D(ampl, xcen, ycen, x_stddev=x_std, y_stddev=y_std, theta=theta_in) y, x = np.mgrid[0:50, 0:50] data = model(x, y) (amplitude, x_mean, y_mean, x_stddev, y_stddev, theta, ) = _gaussian2d_moments(data) assert_allclose(amplitude, ampl, atol=0.01) assert_allclose(x_mean, xcen, atol=0.02) assert_allclose(y_mean, ycen, atol=0.02) assert_allclose(x_stddev, x_std, atol=0.1) assert_allclose(y_stddev, y_std, atol=0.1) assert_allclose(theta, theta_in, atol=0.05) astropy-photutils-3322558/photutils/conftest.py000066400000000000000000000022771517052111400217000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Configuration file for the pytest test suite. """ from importlib.metadata import packages_distributions, requires, version from packaging.requirements import Requirement from photutils.utils._optional_deps import _DIST_TO_IMPORT try: from pytest_astropy_header.display import (PYTEST_HEADER_MODULES, TESTED_VERSIONS) except ImportError: PYTEST_HEADER_MODULES = {} TESTED_VERSIONS = {} def pytest_configure(): """ Configure pytest settings. """ # Resolve the distribution name for this package import_name = __package__ dist_name = packages_distributions().get(import_name, [import_name])[0] # Collect all dependency import names (core + 'all' extra) dep_names = set() for req_str in (requires(dist_name) or []): req = Requirement(req_str) if not req.marker or req.marker.evaluate({'extra': 'all'}): dep_names.add(_DIST_TO_IMPORT.get(req.name, req.name)) PYTEST_HEADER_MODULES.clear() for dep in sorted(dep_names): PYTEST_HEADER_MODULES[dep] = dep TESTED_VERSIONS[dist_name] = version(dist_name) astropy-photutils-3322558/photutils/datasets/000077500000000000000000000000001517052111400213015ustar00rootroot00000000000000astropy-photutils-3322558/photutils/datasets/__init__.py000066400000000000000000000010751517052111400234150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for loading datasets or making simulated data. These tools are typically used in examples, tutorials, and tests, but can also be used for general data analysis or exploration. """ from .images import * # noqa: F401, F403 from .load import * # noqa: F401, F403 from .model_params import * # noqa: F401, F403 from .noise import * # noqa: F401, F403 from .wcs import * # noqa: F401, F403 # prevent circular imports # isort: off from .examples import * # noqa: F401, F403 astropy-photutils-3322558/photutils/datasets/data/000077500000000000000000000000001517052111400222125ustar00rootroot00000000000000astropy-photutils-3322558/photutils/datasets/data/100gaussians_params.ecsv000066400000000000000000000317021517052111400266600ustar00rootroot00000000000000# %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 astropy-photutils-3322558/photutils/datasets/data/4gaussians_params.ecsv000066400000000000000000000007151517052111400265230ustar00rootroot00000000000000# %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 astropy-photutils-3322558/photutils/datasets/examples.py000066400000000000000000000101541517052111400234720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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 from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['make_4gaussians_image', 'make_100gaussians_image'] _DATASETS_DATA_DIR = pathlib.Path(get_pkg_data_path('datasets', 'data', package='photutils')) def _make_gaussians_image(filename, shape, noise, noise_stddev, **kwargs): """ Make an image containing 2D Gaussians from a parameter file. Parameters ---------- filename : str The name of the ECSV file containing the Gaussian parameters. shape : 2-tuple of int The shape of the output image. noise : bool Whether to include noise in the output image. noise_stddev : float The standard deviation of the Gaussian noise. **kwargs Additional keyword arguments passed to `make_model_image`. Returns ------- image : 2D `~numpy.ndarray` Image containing 2D Gaussian sources. """ model = Gaussian2D() params = QTable.read(_DATASETS_DATA_DIR / filename, format='ascii.ecsv') data = make_model_image(shape, model, params, x_name='x_mean', y_name='y_mean', **kwargs) data += 5.0 # background if noise: rng = np.random.default_rng(seed=0) data += rng.normal(loc=0.0, scale=noise_stddev, size=shape) return data @deprecated_positional_kwargs(since='3.0', until='4.0') 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 astropy.visualization import simple_norm from photutils.datasets import make_4gaussians_image image = make_4gaussians_image() fig, ax = plt.subplots() norm = simple_norm(image, 'sqrt', percent=99.5) ax.imshow(image, norm=norm, origin='lower') """ return _make_gaussians_image('4gaussians_params.ecsv', (100, 200), noise, noise_stddev=5.0) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image image = make_100gaussians_image() fig, ax = plt.subplots() norm = simple_norm(image, 'sqrt', percent=99.5) ax.imshow(image, norm=norm, origin='lower') """ return _make_gaussians_image('100gaussians_params.ecsv', (300, 500), noise, noise_stddev=2.0, bbox_factor=6.0) astropy-photutils-3322558/photutils/datasets/images.py000066400000000000000000000411321517052111400231210ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for making simulated images for documentation examples and tests. """ import astropy.units as u import numpy as np from astropy.convolution import discretize_model from astropy.modeling import Model from astropy.nddata import NoOverlapError, overlap_slices from astropy.table import Table from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar __all__ = ['make_model_image'] def make_model_image(shape, model, params_table, *, model_shape=None, bbox_factor=None, x_name='x_0', y_name='y_0', params_map=None, 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. By default, the table is searched for column names that match model parameter names and the values specified by ``x_name`` and ``y_name``. However, the user can specify a different mapping between model parameter names and column names using the ``params_map`` keyword. 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 or ``params_map`` will be set to the ``model`` default value. To attach units to model parameters, ``params_table`` must be input as a `~astropy.table.QTable`. Rows that contain any non-finite model parameters will be skipped. 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 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 unless ``params_map`` is input. 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 if the model supports it). 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 the model ``bounding_box`` method does not accept a ``factor`` keyword, then this keyword is ignored. 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. Note that some Photutils PSF models have a ``bbox_factor`` keyword that is used to define the model bounding box. In that case, this keyword is ignored. x_name : str, optional The name of the ``model`` parameter that corresponds to the x position of the sources. If ``param_map`` is not input, then this value 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. If ``param_map`` is not input, then this value must also be a column name in ``params_table``. params_map : dict or None, optional A dictionary mapping the model parameter names to the column names in the input ``params_table``. The dictionary keys are the model parameter names and the values are the column names in the input ``params_table``. This can be used to map column names to model parameter names that are different. For example, if the input column name is 'flux_f200w' and the model parameter name is 'flux', then use ``params_map={'flux': 'flux_f200w'}``. This table may also be used if you want to map the model x and y parameters to different columns than ``x_name`` and ``y_name``, but the ``x_name`` and ``y_name`` keys must be included in the dictionary. 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. Returns ------- array : 2D `~numpy.ndarray` The rendered image containing the model sources. Notes ----- The local background value around each source is optionally included using the ``local_bkg`` column in the input ``params_table``. This local background is added to each source over its ``model_shape`` region. In regions where the ``model_shape`` of sources 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. 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) fig, ax = plt.subplots() ax.imshow(data, origin='lower') fig.tight_layout() .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np 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') fig, ax = plt.subplots() ax.imshow(data, origin='lower') fig.tight_layout() """ if not isinstance(shape, tuple) or len(shape) != 2: msg = 'shape must be a 2-tuple' raise ValueError(msg) if not isinstance(model, Model): msg = 'model must be a Model instance' raise TypeError(msg) if model.n_inputs != 2 or model.n_outputs != 1: msg = 'model must be a 2D model' raise ValueError(msg) if not isinstance(params_table, Table): msg = 'params_table must be an astropy Table' raise TypeError(msg) xypos_map = {x_name: x_name, y_name: y_name} # By default, use the model parameter names as the column names # if they are in the table params_to_set = set(params_table.colnames) & set(model.param_names) xypos_map.update({param: param for param in params_to_set}) if params_map is not None: # params_map takes precedence over x_name and y_name and # any matching column names in params_table xypos_map.update(params_map) params_map = xypos_map for key, value in params_map.items(): if key not in model.param_names: msg = f'key "{key}" not in model parameter names' raise ValueError(msg) if value not in params_table.colnames: msg = f'value "{value}" not in params_table column names' raise ValueError(msg) if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 0)) 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 as exc: msg = ('model_shape must be specified if the model does not ' 'have a bounding_box attribute') raise ValueError(msg) from exc if 'local_bkg' in params_table.colnames: local_bkg = params_table['local_bkg'] else: local_bkg = np.zeros(len(params_table)) # Copy the input model to leave it unchanged model = model.copy() if progress_bar: desc = 'Add model sources' params_table = add_progress_bar(params_table, desc=desc) image = np.zeros(shape, dtype=float) apply_units = True for i, source in enumerate(params_table): for key, param in params_map.items(): value = source[param] # Skip if any parameter value is not finite if not np.isfinite(value): break setattr(model, key, value) else: # All parameters are finite for the source # This assumes that if the user also uses params_table to # override the (x/y)_name mapping that the x_name and y_name # values are correct (i.e., the mapping keys include x_name # and y_name). There is no good way to check/enforce this. x0 = getattr(model, x_name).value y0 = getattr(model, y_name).value if variable_shape: mod_shape = model_shape[i] elif model_shape is None: # The bounding box size generally depends on model # parameters, so needs to be calculated for each source mod_shape = _model_shape_from_bbox(model, bbox_factor=bbox_factor) 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 the model is a Quantity, then the output image # should also be a Quantity with the same units; # but apply the units only once if apply_units and isinstance(subimg, u.Quantity): apply_units = False image <<= subimg.unit try: image[slc_lg] += subimg + local_bkg[i] except u.UnitConversionError as exc: msg = ('The local_bkg column must have the same flux ' 'units as the output image') raise ValueError(msg) from exc except NoOverlapError: # Evaluate the model to get the model output units result = model(0, 0) if isinstance(result, u.Quantity): image <<= result.unit continue return image @deprecated_positional_kwargs(since='3.0', until='4.0') def _model_shape_from_bbox(model, bbox_factor=None): """ Calculate the model shape from the model bounding box. Parameters ---------- model : 2D `astropy.modeling.Model` The 2D model to be used to render the sources. bbox_factor : `None` or float, optional The multiplicative factor to pass to the model ``bounding_box`` method to determine the model shape. If the model ``bounding_box`` method does not accept a ``factor`` keyword, then this keyword is ignored. If `None`, the default model bounding box will be used. Returns ------- model_shape : 2-tuple of int The shape around the (x, y) center of the model that will used to evaluate the model. Raises ------ ValueError If the model does not have a bounding_box attribute. """ try: _ = model.bounding_box except NotImplementedError as exc: msg = 'model does not have a bounding_box attribute' raise ValueError(msg) from exc if bbox_factor is not None: try: bbox = model.bounding_box(factor=bbox_factor) except NotImplementedError: bbox = model.bounding_box.bounding_box() else: bbox = model.bounding_box.bounding_box() return (int(np.ceil(bbox[0][1] - bbox[0][0])), int(np.ceil(bbox[1][1] - bbox[1][0]))) astropy-photutils-3322558/photutils/datasets/load.py000066400000000000000000000234141517052111400225760ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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, is_url_in_cache) from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) __all__ = [ 'get_path', 'load_irac_psf', 'load_simulated_hst_star_image', 'load_spitzer_catalog', 'load_spitzer_image', 'load_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': url = f'http://data.astropy.org/photometry/{filename}' # First check if the file is already in the local cache from the # primary URL, then check the backup URL. If the file is in the # cache, the download_file function will simply return the local # path to the cached file without trying to download it again. if is_url_in_cache(url): path = download_file(url, cache=True, show_progress=show_progress) elif is_url_in_cache(datasets_url): path = download_file(datasets_url, cache=True, show_progress=show_progress) else: # If the file is not in the local cache, then try to # download it from the respective URLs. try: 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': path = download_file(datasets_url, cache=cache, show_progress=show_progress) else: msg = f'Invalid location: {location}' raise ValueError(msg) return path @deprecated(since='3.0') 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. """ return _get_path(filename, location=location, cache=cache, show_progress=show_progress) def _load_fits_as_imagehdu(path): """ Load a FITS file and return its primary HDU data and header wrapped in an `~astropy.io.fits.ImageHDU`. Parameters ---------- path : str The local path to the FITS file. Returns ------- hdu : `~astropy.io.fits.ImageHDU` The primary HDU data and header as an ImageHDU. """ with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) @deprecated(since='3.0') def load_spitzer_image(show_progress=False): """ Load a 4.5 micron Spitzer image. The catalog for this image is returned by ``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. """ path = _get_path('spitzer_example_image.fits', location='remote', show_progress=show_progress) return _load_fits_as_imagehdu(path) @deprecated(since='3.0') def load_spitzer_catalog(show_progress=False): """ Load a 4.5 micron Spitzer catalog. The image from which this catalog was derived is returned by ``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. """ path = _get_path('spitzer_example_catalog.xml', location='remote', show_progress=show_progress) return Table.read(path) @deprecated_positional_kwargs(since='3.0', until='4.0') def load_irac_psf(channel, show_progress=False): """ 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 simple_norm 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 = simple_norm(hdu1.data, stretch='log') fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) ax1.imshow(hdu1.data, norm=norm, origin='lower') ax1.set_title('IRAC Ch1 PSF') ax2.imshow(hdu2.data, norm=norm, origin='lower') ax2.set_title('IRAC Ch2 PSF') ax3.imshow(hdu3.data, norm=norm, origin='lower') ax3.set_title('IRAC Ch3 PSF') ax4.imshow(hdu4.data, norm=norm, origin='lower') ax4.set_title('IRAC Ch4 PSF') fig.tight_layout() """ channel = int(channel) if channel < 1 or channel > 4: msg = 'channel must be 1, 2, 3, or 4' raise ValueError(msg) filepath = f'irac_ch{channel}_flight.fits' path = _get_path(filepath, location='remote', show_progress=show_progress) return _load_fits_as_imagehdu(path) @deprecated(since='3.0') def load_star_image(show_progress=False): """ 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. """ path = _get_path('M6707HH.fits', location='remote', show_progress=show_progress) return _load_fits_as_imagehdu(path) @deprecated_positional_kwargs(since='3.0', until='4.0') def load_simulated_hst_star_image(show_progress=False): """ 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 astropy.visualization import simple_norm from photutils.datasets import load_simulated_hst_star_image hdu = load_simulated_hst_star_image() fig, ax = plt.subplots() norm = simple_norm(hdu.data, 'sqrt', percent=99.5) ax.imshow(hdu.data, norm=norm, origin='lower') """ path = _get_path('hst_wfc3ir_f160w_simulated_starfield.fits', location='photutils-datasets', show_progress=show_progress) return _load_fits_as_imagehdu(path) astropy-photutils-3322558/photutils/datasets/model_params.py000066400000000000000000000261731517052111400243270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for making tables of model parameters or making models from a table of model parameters. """ import numpy as np from astropy.table import QTable from photutils.utils._coords import make_random_xycoords from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair __all__ = ['make_model_params', 'make_random_models_table', 'params_table_to_models'] 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, 0)) border_size = as_pair('border_size', border_size, lower_bound=(0, 1)) 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]: msg = 'border_size is too large for the given shape' raise ValueError(msg) 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: msg = f'{param} must be a 2-tuple' raise ValueError(msg) vals = rng.uniform(*prange, len(model_params)) model_params[param] = vals return model_params @deprecated_positional_kwargs(since='3.0', until='4.0') 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_image` to create an image containing the model sources. Parameters ---------- n_sources : int 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. 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 def params_table_to_models(params_table, model): """ Create a list of models from a table of model parameters. Parameters ---------- params_table : `~astropy.table.Table` A table containing the model parameters for each source. Each row of the table corresponds to a different model whose parameters are defined by the column names. 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`. A column named 'name' can also be included in the table to assign a name to each model. model : `astropy.modeling.Model` The model whose parameters will be updated. Returns ------- models : list of `astropy.modeling.Model` A list of models created from the input table of model parameters. Examples -------- >>> from astropy.table import QTable >>> from photutils.datasets import params_table_to_models >>> from photutils.psf import CircularGaussianPSF >>> tbl = QTable() >>> tbl['x_0'] = [1, 2, 3] >>> tbl['y_0'] = [4, 5, 6] >>> tbl['flux'] = [100, 200, 300] >>> model = CircularGaussianPSF() >>> models = params_table_to_models(tbl, model) >>> models [, , ] """ param_names = set(model.param_names) colnames = set(params_table.colnames) if param_names.isdisjoint(colnames): msg = 'No matching model parameter names found in params_table' raise ValueError(msg) param_names = [*list(param_names), 'name'] models = [] for row in params_table: new_model = model.copy() for param_name in param_names: if param_name not in colnames: continue setattr(new_model, param_name, row[param_name]) models.append(new_model) return models astropy-photutils-3322558/photutils/datasets/noise.py000066400000000000000000000115321517052111400227720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for including noise in simulated data. """ import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['apply_poisson_noise', 'make_noise_image'] @deprecated_positional_kwargs(since='3.0', until='4.0') 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. The output array will have the same dtype as the input ``data``. 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') ax1.set_title('Original image') ax2.imshow(data2, origin='lower') ax2.set_title('Original image with Poisson noise applied') """ data = np.asanyarray(data) if np.any(data < 0): msg = 'data must not contain any negative values' raise ValueError(msg) rng = np.random.default_rng(seed) return rng.poisson(data).astype(data.dtype) @deprecated_positional_kwargs(since='3.0', until='4.0') def make_noise_image(shape, distribution='gaussian', mean=None, stddev=None, seed=None): r""" Make a noise image containing Gaussian or Poisson noise. This function simply takes random samples from a Gaussian or Poisson distribution with the given parameters. If you want to apply Poisson noise to existing sources, see the `~photutils.datasets.apply_poisson_noise` function. 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') ax1.set_title(r'Gaussian noise ($\mu=0$, $\sigma=5.$)') ax2.imshow(image2, origin='lower') ax2.set_title(r'Poisson noise ($\mu=5$)') """ if mean is None: msg = "'mean' must be input" raise ValueError(msg) rng = np.random.default_rng(seed) if distribution == 'gaussian': if stddev is None: msg = "'stddev' must be input for Gaussian noise" raise ValueError(msg) image = rng.normal(loc=mean, scale=stddev, size=shape) elif distribution == 'poisson': image = rng.poisson(lam=mean, size=shape) else: msg = (f'Invalid distribution: {distribution}. Use either ' "'gaussian' or 'poisson'.") raise ValueError(msg) return image astropy-photutils-3322558/photutils/datasets/tests/000077500000000000000000000000001517052111400224435ustar00rootroot00000000000000astropy-photutils-3322558/photutils/datasets/tests/__init__.py000066400000000000000000000000001517052111400245420ustar00rootroot00000000000000astropy-photutils-3322558/photutils/datasets/tests/test_examples.py000066400000000000000000000023731517052111400256770ustar00rootroot00000000000000# 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(): """ Test the make_4gaussians_image function. """ 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_4gaussians_image_no_noise(): """ Test the make_4gaussians_image function with no noise. """ shape = (100, 200) image = make_4gaussians_image(noise=False) assert image.shape == shape assert image.min() >= 0 def test_make_100gaussians_image(): """ Test the make_100gaussians_image function. """ 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) def test_make_100gaussians_image_no_noise(): """ Test the make_100gaussians_image function with no noise. """ shape = (300, 500) image = make_100gaussians_image(noise=False) assert image.shape == shape assert image.min() >= 0 astropy-photutils-3322558/photutils/datasets/tests/test_images.py000066400000000000000000000237051517052111400253300ustar00rootroot00000000000000# 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, Polynomial2D from astropy.table import QTable from numpy.testing import assert_allclose from photutils.datasets import make_model_image from photutils.datasets.images import _model_shape_from_bbox from photutils.psf import (CircularGaussianPSF, CircularGaussianSigmaPRF, ImagePSF) def test_make_model_image(): """ Test the basic functionality of 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 def test_make_model_image_units(): """ Test that the model image is created with the correct units when the flux column has 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 = CircularGaussianSigmaPRF(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' params['local_bkg'] = [0.1, 0.2, 0.3] with pytest.raises(ValueError, match=match): make_model_image(shape, model, params, model_shape=model_shape) def test_make_model_image_units_no_overlap(): """ Test that the model image is created with the correct units when there is no overlap between the model and the image. """ unit = u.Jy params = QTable() params['x_0'] = [50, 70.5] params['y_0'] = [50, 50.5] params['flux'] = [2, 3] * unit model = CircularGaussianSigmaPRF(sigma=1.5) shape = (10, 12) image = make_model_image(shape, model, params) assert image.shape == shape assert isinstance(image, u.Quantity) assert image.unit == unit assert model.flux == 1.0 # Default flux (unchanged) params['flux'] = [2, 3] image = make_model_image(shape, model, params) assert image.shape == shape assert not isinstance(image, u.Quantity) assert model.flux == 1.0 # Default flux (unchanged) def test_make_model_image_discretize_method(): """ Test the model image when using different discretization methods. """ 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(): """ Test the model image when there is no overlap between the model and the image. """ 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(): """ Test that the appropriate exceptions are raised for invalid 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(TypeError, match=match): make_model_image((100, 100), None, QTable()) match = 'model must be a 2D model' model = Moffat2D() model.n_inputs = 1 with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable()) match = 'params_table must be an astropy Table' model = Moffat2D() with pytest.raises(TypeError, match=match): make_model_image((100, 100), model, None) match = 'not in model parameter names' model = Moffat2D() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable(), x_name='invalid') match = 'not in params_table column names' model = Moffat2D() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable(), y_name='invalid') model = Moffat2D() params = QTable() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, params) model = Moffat2D() params = QTable() params['x_0'] = [50, 70, 90] with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, params) match = 'model_shape must be specified if the model does not have' 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) with pytest.raises(ValueError, match=match): make_model_image(shape, model, params) def test_make_model_image_bbox(): """ Test the model image when using a PSF model that has a bounding box and the bbox_factor keyword to control the size of the bounding box. """ model1 = CircularGaussianPSF(x_0=50, y_0=50, fwhm=10) yy, xx = np.mgrid[:101, :101] model2 = ImagePSF(model1(xx, yy), x_0=50, y_0=50) params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] shape = (100, 151) image1 = make_model_image(shape, model2, params, bbox_factor=10) image2 = make_model_image(shape, model2, params, bbox_factor=None) assert_allclose(image1, image2) image3 = make_model_image(shape, model1, params, bbox_factor=10) image4 = make_model_image(shape, model1, params, bbox_factor=None) assert_allclose(image3, image4) model1.bbox_factor = 10 image5 = make_model_image(shape, model1, params) assert np.sum(image5) > np.sum(image4) assert_allclose(image3, image4) def test_make_model_image_params_map(): """ Test the model image when using a parameter mapping to map the model parameter names to different column names in the input table. """ 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) params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma2'] = [1.7, 2.32, 5.8] params['alpha4'] = [2.9, 5.7, 4.6] params_map = {'gamma': 'gamma2', 'alpha': 'alpha4'} model = Moffat2D(amplitude=1) shape = (300, 500) model_shape = (11, 11) image2 = make_model_image(shape, model, params, model_shape=model_shape, params_map=params_map) assert_allclose(image, image2) def test_make_model_image_nonfinite(): """ Test the model image when the input table contains non-finite values. """ params = QTable() params['x_0'] = [50, np.nan, 90, 100] params['y_0'] = [50, 50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8, np.inf] params['alpha'] = [2.9, 5.7, 4.6, 3.1] 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() < 33 assert image[50, 100] == 0 # All invalid sources params = QTable() params['x_0'] = [50, np.nan, 90, 100] params['y_0'] = [-np.inf, 50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8, np.inf] params['alpha'] = [2.9, 5.7, np.nan, 3.1] 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() == 0 def test_make_model_image_progress_bar(): """ Test the model image with progress_bar=True. """ pytest.importorskip('tqdm') 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, progress_bar=True) assert image.shape == shape assert image.sum() > 1 def test_model_shape_from_bbox_no_bbox(): """ Test that _model_shape_from_bbox raises an error when the model does not have a bounding_box attribute. """ model = Polynomial2D(degree=2) match = 'model does not have a bounding_box attribute' with pytest.raises(ValueError, match=match): _model_shape_from_bbox(model) astropy-photutils-3322558/photutils/datasets/tests/test_load.py000066400000000000000000000370621517052111400250030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the load module. """ from unittest.mock import patch from urllib.error import URLError import numpy as np import pytest from astropy.io import fits from astropy.table import Table from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.datasets import load from photutils.datasets.load import _get_path, _load_fits_as_imagehdu def test_get_path(): """ Test _get_path with a valid filename and location, and with an invalid location. """ fn = '4gaussians_params.ecsv' path = _get_path(fn, location='local') assert fn in path match = 'Invalid location:' with pytest.raises(ValueError, match=match): _get_path('filename', location='invalid') def test_get_path_photutils_datasets(): """ Test _get_path with location='photutils-datasets'. """ with patch('photutils.datasets.load.download_file') as mock_dl: mock_dl.return_value = '/path/to/file.fits' result = _get_path('file.fits', location='photutils-datasets', cache=False) assert result == '/path/to/file.fits' mock_dl.assert_called_once() call_args = mock_dl.call_args assert 'photutils-datasets' in call_args[0][0] assert call_args[1]['cache'] is False @pytest.fixture def url_paths(): """ Fixture providing URLs for cache tests. """ filename = 'test_file.fits' primary_url = f'http://data.astropy.org/photometry/{filename}' datasets_url = ( 'https://github.com/astropy/photutils-datasets/raw/' f'main/data/{filename}' ) return { 'filename': filename, 'primary_url': primary_url, 'datasets_url': datasets_url, } class TestGetPathCache: """ Tests for the caching behavior of _get_path. Tests that it correctly checks the cache for both the primary and fallback URLs, and that it falls back to downloading from the datasets URL if the primary URL is not cached and fails to download. """ def test_cache_hit_primary_url(self, url_paths): """ Test that _get_path uses the cached file when the primary URL is already in the cache, without trying the fallback URL. """ with ( patch('photutils.datasets.load.is_url_in_cache') as mock_cache, patch('photutils.datasets.load.download_file') as mock_dl, ): mock_cache.side_effect = ( lambda url: url == url_paths['primary_url'] ) mock_dl.return_value = '/cached/path/test_file.fits' result = _get_path(url_paths['filename'], location='remote') assert result == '/cached/path/test_file.fits' mock_dl.assert_called_once_with( url_paths['primary_url'], cache=True, show_progress=False, ) def test_cache_hit_datasets_url(self, url_paths): """ Test that _get_path uses the cached file when only the fallback datasets URL is in the cache. """ with ( patch('photutils.datasets.load.is_url_in_cache') as mock_cache, patch('photutils.datasets.load.download_file') as mock_dl, ): mock_cache.side_effect = ( lambda url: url == url_paths['datasets_url'] ) mock_dl.return_value = '/cached/path/test_file.fits' result = _get_path(url_paths['filename'], location='remote') assert result == '/cached/path/test_file.fits' mock_dl.assert_called_once_with( url_paths['datasets_url'], cache=True, show_progress=False, ) def test_no_cache_falls_through_to_download(self, url_paths): """ Test that _get_path tries the primary URL and falls back to the datasets URL when neither is cached and the primary fails. """ with ( patch('photutils.datasets.load.is_url_in_cache', return_value=False), patch('photutils.datasets.load.download_file') as mock_dl, ): mock_dl.side_effect = [ URLError('timeout'), '/downloaded/path/test_file.fits', ] result = _get_path(url_paths['filename'], location='remote') assert result == '/downloaded/path/test_file.fits' assert mock_dl.call_count == 2 def test_load_irac_psf_invalid_channel(): """ Test that load_irac_psf raises a ValueError when an invalid channel number is provided. """ match = 'channel must be 1, 2, 3, or 4' with pytest.raises(ValueError, match=match): load.load_irac_psf(0) with pytest.raises(ValueError, match=match): load.load_irac_psf(5) @pytest.mark.remote_data def test_load_star_image(): """ Test that load_star_image returns an HDU with the expected header and data shape. """ with pytest.warns(AstropyDeprecationWarning): hdu = load.load_star_image() assert len(hdu.header) == 106 assert hdu.data.shape == (1059, 1059) def test_load_fits_as_imagehdu(tmp_path): """ Test the _load_fits_as_imagehdu helper function. """ data = np.ones((10, 10)) header = fits.Header() header['SIMPLE'] = True header['BITPIX'] = -32 header['NAXIS'] = 2 header['NAXIS1'] = 10 header['NAXIS2'] = 10 header['EXTEND'] = True header['COMMENT'] = 'Test header' primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'test.fits' hdulist.writeto(filepath) result = _load_fits_as_imagehdu(str(filepath)) assert isinstance(result, fits.ImageHDU) assert np.array_equal(result.data, data) assert result.header['COMMENT'] == 'Test header' class TestLoadFunctionsMocked: """ Tests for the load functions using mocking to avoid remote data access. """ def test_load_spitzer_image(self, tmp_path): """ Test the load_spitzer_image function with mocked file download. """ data = np.random.default_rng(seed=0).random((100, 100)) header = fits.Header() header['TELESCOP'] = 'Spitzer' header['INSTRUME'] = 'IRAC' primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'spitzer_example_image.fits' hdulist.writeto(filepath) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath))): hdu = load.load_spitzer_image() assert isinstance(hdu, fits.ImageHDU) assert np.array_equal(hdu.data, data) assert hdu.header['TELESCOP'] == 'Spitzer' def test_load_spitzer_image_show_progress(self, tmp_path): """ Test the load_spitzer_image function with show_progress=True. """ data = np.random.default_rng(seed=0).random((100, 100)) header = fits.Header() primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'spitzer_example_image.fits' hdulist.writeto(filepath) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath)) as mock_get_path): hdu = load.load_spitzer_image(show_progress=True) assert isinstance(hdu, fits.ImageHDU) mock_get_path.assert_called_once_with( 'spitzer_example_image.fits', location='remote', show_progress=True, ) def test_load_spitzer_catalog(self, tmp_path): """ Test the load_spitzer_catalog function with mocked file download. """ catalog_data = Table() catalog_data['l'] = [18.23, 18.15, 18.30] catalog_data['b'] = [0.20, 0.22, 0.18] filepath = tmp_path / 'spitzer_example_catalog.xml' catalog_data.write(filepath, format='votable', overwrite=True) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath))): catalog = load.load_spitzer_catalog() assert isinstance(catalog, Table) assert 'l' in catalog.colnames assert 'b' in catalog.colnames assert len(catalog) == 3 def test_load_spitzer_catalog_show_progress(self, tmp_path): """ Test the load_spitzer_catalog function with show_progress=True. """ catalog_data = Table() catalog_data['l'] = [18.23] catalog_data['b'] = [0.20] filepath = tmp_path / 'spitzer_example_catalog.xml' catalog_data.write(filepath, format='votable', overwrite=True) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath)) as mock_get_path): catalog = load.load_spitzer_catalog(show_progress=True) assert isinstance(catalog, Table) mock_get_path.assert_called_once_with( 'spitzer_example_catalog.xml', location='remote', show_progress=True, ) def test_load_irac_psf(self, tmp_path): """ Test the load_irac_psf function with mocked file download. """ data = np.random.default_rng(seed=0).random((41, 41)) header = fits.Header() header['TELESCOP'] = 'Spitzer' header['INSTRUME'] = 'IRAC' header['CHNLNUM'] = 1 primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'irac_ch1_flight.fits' hdulist.writeto(filepath) with patch('photutils.datasets.load._get_path', return_value=str(filepath)): hdu = load.load_irac_psf(1) assert isinstance(hdu, fits.ImageHDU) assert np.array_equal(hdu.data, data) assert hdu.header['TELESCOP'] == 'Spitzer' def test_load_irac_psf_all_channels(self, tmp_path): """ Test the load_irac_psf function for all valid channels. """ for channel in range(1, 5): data = np.random.default_rng(seed=0).random((41, 41)) header = fits.Header() header['CHNLNUM'] = channel primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / f'irac_ch{channel}_flight.fits' hdulist.writeto(filepath, overwrite=True) with patch('photutils.datasets.load._get_path', return_value=str(filepath)): hdu = load.load_irac_psf(channel) assert isinstance(hdu, fits.ImageHDU) assert hdu.header['CHNLNUM'] == channel def test_load_irac_psf_show_progress(self, tmp_path): """ Test the load_irac_psf function with show_progress=True. """ data = np.random.default_rng(seed=0).random((41, 41)) header = fits.Header() primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'irac_ch2_flight.fits' hdulist.writeto(filepath) with ( patch('photutils.datasets.load._get_path', return_value=str(filepath)) as mock_get_path, ): hdu = load.load_irac_psf(2, show_progress=True) assert isinstance(hdu, fits.ImageHDU) mock_get_path.assert_called_once_with( 'irac_ch2_flight.fits', location='remote', show_progress=True, ) def test_load_star_image_mocked(self, tmp_path): """ Test the load_star_image function with mocked file download. """ data = np.random.default_rng(seed=0).random((200, 200)) header = fits.Header() header['OBJECT'] = 'M67' header['TELESCOP'] = 'Palomar' primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'M6707HH.fits' hdulist.writeto(filepath) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath))): hdu = load.load_star_image() assert isinstance(hdu, fits.ImageHDU) assert np.array_equal(hdu.data, data) assert hdu.header['OBJECT'] == 'M67' def test_load_star_image_show_progress(self, tmp_path): """ Test the load_star_image function with show_progress=True. """ data = np.random.default_rng(seed=0).random((200, 200)) header = fits.Header() primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'M6707HH.fits' hdulist.writeto(filepath) with (pytest.warns(AstropyDeprecationWarning), patch('photutils.datasets.load._get_path', return_value=str(filepath)) as mock_get_path): hdu = load.load_star_image(show_progress=True) assert isinstance(hdu, fits.ImageHDU) mock_get_path.assert_called_once_with( 'M6707HH.fits', location='remote', show_progress=True, ) def test_load_simulated_hst_star_image(self, tmp_path): """ Test the load_simulated_hst_star_image function with mocked file download. """ data = np.random.default_rng(seed=0).random((300, 300)) header = fits.Header() header['TELESCOP'] = 'HST' header['INSTRUME'] = 'WFC3' header['FILTER'] = 'F160W' primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'hst_wfc3ir_f160w_simulated_starfield.fits' hdulist.writeto(filepath) with patch('photutils.datasets.load._get_path', return_value=str(filepath)): hdu = load.load_simulated_hst_star_image() assert isinstance(hdu, fits.ImageHDU) assert np.array_equal(hdu.data, data) assert hdu.header['TELESCOP'] == 'HST' assert hdu.header['INSTRUME'] == 'WFC3' assert hdu.header['FILTER'] == 'F160W' def test_load_simulated_hst_star_image_show_progress(self, tmp_path): """ Test the load_simulated_hst_star_image function with show_progress=True. """ data = np.random.default_rng(seed=0).random((300, 300)) header = fits.Header() primary_hdu = fits.PrimaryHDU(data=data, header=header) hdulist = fits.HDUList([primary_hdu]) filepath = tmp_path / 'hst_wfc3ir_f160w_simulated_starfield.fits' hdulist.writeto(filepath) with ( patch('photutils.datasets.load._get_path', return_value=str(filepath)) as mock_get_path, ): hdu = load.load_simulated_hst_star_image(show_progress=True) assert isinstance(hdu, fits.ImageHDU) mock_get_path.assert_called_once_with( 'hst_wfc3ir_f160w_simulated_starfield.fits', location='photutils-datasets', show_progress=True, ) def test_deprecated_get_path(): """ Test that the deprecated get_path function raises a warning and returns the expected path. """ fn = '4gaussians_params.ecsv' with pytest.warns(AstropyDeprecationWarning): result = load.get_path(fn, location='local') assert fn in result astropy-photutils-3322558/photutils/datasets/tests/test_model_params.py000066400000000000000000000103131517052111400265150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model_params module. """ import numpy as np import pytest from astropy.table import QTable, Table from astropy.utils.exceptions import AstropyUserWarning from photutils.datasets import (make_model_params, make_random_models_table, params_table_to_models) from photutils.psf import CircularGaussianPSF def test_make_model_params(): """ Test the basic functionality of ``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)) def test_make_model_params_nsources(): """ Test case when the number of the possible sources is less than ``n_sources``. """ match = r'Unable to produce .* coordinates within the given shape' shape = (200, 500) n_sources = 100 with pytest.warns(AstropyUserWarning, match=match): 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 def test_make_model_params_border_size(): """ Test case when the border size is too large for the given shape. """ shape = (10, 10) n_sources = 10 flux = (100, 1000) match = 'border_size is too large for the given shape' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=flux, border_size=20) def test_make_random_models_table(): """ Test the basic functionality of ``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_params_table_to_models(): """ Test the basic functionality of ``params_table_to_models``. """ tbl = QTable() tbl['x_0'] = [1, 2, 3] tbl['y_0'] = [4, 5, 6] tbl['flux'] = [100, 200, 300] tbl['name'] = ['a', 'b', 'c'] model = CircularGaussianPSF() models = params_table_to_models(tbl, model) assert len(models) == 3 for i, model in enumerate(models): assert model.x_0 == tbl['x_0'][i] assert model.y_0 == tbl['y_0'][i] assert model.flux == tbl['flux'][i] assert model.name == tbl['name'][i] tbl = QTable() tbl['invalid1'] = [1, 2, 3] tbl['invalid2'] = [4, 5, 6] match = 'No matching model parameter names found in params_table' with pytest.raises(ValueError, match=match): params_table_to_models(tbl, model) astropy-photutils-3322558/photutils/datasets/tests/test_noise.py000066400000000000000000000056771517052111400252100ustar00rootroot00000000000000# 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(): """ Test if Poisson noise is applied correctly. """ 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_dtype(): """ Test if the output data type is the same as the input data type. """ data = np.ones((10, 10), dtype=float) * 5.0 result = apply_poisson_noise(data, seed=0) assert result.dtype == data.dtype def test_apply_poisson_noise_seed(): """ Test if Poisson noise is reproducible with a given seed. """ shape = (100, 100) data = np.ones(shape) * 10.0 result1 = apply_poisson_noise(data, seed=12345) result2 = apply_poisson_noise(data, seed=12345) assert_allclose(result1, result2) def test_apply_poisson_noise_negative(): """ Test if negative image values raises ValueError. """ shape = (100, 100) data = np.zeros(shape) - 1.0 match = 'data must not contain any negative values' with pytest.raises(ValueError, match=match): apply_poisson_noise(data) def test_make_noise_image(): """ Test if noise image is generated correctly. """ shape = (100, 100) image = make_noise_image(shape, distribution='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(): """ Test noise image with Poisson noise. """ shape = (100, 100) image = make_noise_image(shape, distribution='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 to make_noise_image. """ shape = (100, 100) match = 'Invalid distribution:' with pytest.raises(ValueError, match=match): make_noise_image(shape, distribution='invalid', mean=0, stddev=2.0) match = "'mean' must be input" with pytest.raises(ValueError, match=match): make_noise_image(shape, distribution='gaussian', stddev=2.0) match = "'stddev' must be input for Gaussian noise" with pytest.raises(ValueError, match=match): make_noise_image(shape, distribution='gaussian', mean=2.0) def test_make_noise_image_seed(): """ Test if noise image is reproducible with a given seed. """ shape = (100, 100) image1 = make_noise_image(shape, distribution='gaussian', mean=0.0, stddev=2.0, seed=12345) image2 = make_noise_image(shape, distribution='gaussian', mean=0.0, stddev=2.0, seed=12345) assert_allclose(image1, image2) astropy-photutils-3322558/photutils/datasets/tests/test_positional_kwargs.py000066400000000000000000000075161517052111400276240ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.datasets.images import _model_shape_from_bbox from photutils.datasets.load import load_irac_psf from photutils.datasets.model_params import make_random_models_table from photutils.datasets.noise import apply_poisson_noise, make_noise_image from photutils.datasets.wcs import make_gwcs, make_wcs from photutils.utils._optional_deps import HAS_GWCS class TestLoadIracPsfPositionalKwargs: """ Test that load_irac_psf warns for positional optional args. """ @pytest.mark.remote_data def test_positional_warns(self): match = 'load_irac_psf' with pytest.warns(AstropyDeprecationWarning, match=match): load_irac_psf(1, False) # noqa: FBT003 @pytest.mark.remote_data def test_keyword_no_warning(self): load_irac_psf(1, show_progress=False) class TestMakeRandomModelsTablePositionalKwargs: """ Test that make_random_models_table warns for positional optional args. """ def test_positional_warns(self): match = 'make_random_models_table' with pytest.warns(AstropyDeprecationWarning, match=match): make_random_models_table(5, {'x_mean': [0, 100]}, 0) def test_keyword_no_warning(self): make_random_models_table(5, {'x_mean': [0, 100]}, seed=0) class TestApplyPoissonNoisePositionalKwargs: """ Test that apply_poisson_noise warns for positional optional args. """ def test_positional_warns(self): data = np.ones((10, 10)) match = 'apply_poisson_noise' with pytest.warns(AstropyDeprecationWarning, match=match): apply_poisson_noise(data, 0) def test_keyword_no_warning(self): data = np.ones((10, 10)) apply_poisson_noise(data, seed=0) class TestMakeNoiseImagePositionalKwargs: """ Test that make_noise_image warns for positional optional args. """ def test_positional_warns(self): match = 'make_noise_image' with pytest.warns(AstropyDeprecationWarning, match=match): make_noise_image((10, 10), 'gaussian', mean=0.0, stddev=2.0) def test_keyword_no_warning(self): make_noise_image((10, 10), distribution='gaussian', mean=0.0, stddev=2.0) class TestMakeWcsPositionalKwargs: """ Test that make_wcs warns for positional optional args. """ def test_positional_warns(self): match = 'make_wcs' with pytest.warns(AstropyDeprecationWarning, match=match): make_wcs((100, 100), False) # noqa: FBT003 def test_keyword_no_warning(self): make_wcs((100, 100), galactic=False) class TestMakeGwcsPositionalKwargs: """ Test that make_gwcs warns for positional optional args. """ @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_positional_warns(self): match = 'make_gwcs' with pytest.warns(AstropyDeprecationWarning, match=match): make_gwcs((100, 100), False) # noqa: FBT003 @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_keyword_no_warning(self): make_gwcs((100, 100), galactic=False) class TestModelShapeFromBboxPositionalKwargs: """ Test that _model_shape_from_bbox warns for positional optional args. """ def test_positional_warns(self): model = Gaussian2D() match = '_model_shape_from_bbox' with pytest.warns(AstropyDeprecationWarning, match=match): _model_shape_from_bbox(model, 5.0) def test_keyword_no_warning(self): model = Gaussian2D() _model_shape_from_bbox(model, bbox_factor=5.0) astropy-photutils-3322558/photutils/datasets/tests/test_wcs.py000066400000000000000000000026471517052111400246610ustar00rootroot00000000000000# 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) astropy-photutils-3322558/photutils/datasets/wcs.py000066400000000000000000000120541517052111400224510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for making example WCS objects. """ import astropy.units as u import numpy as np from astropy import coordinates as coord from astropy.modeling import models from astropy.wcs import WCS from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['make_gwcs', 'make_wcs'] __doctest_requires__ = {'make_gwcs': ['gwcs']} @deprecated_positional_kwargs(since='3.0', until='4.0') 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 @deprecated_positional_kwargs(since='3.0', until='4.0') def make_gwcs(shape, galactic=False): """ Create a simple celestial gWCS object in either the ICRS or Galactic 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) astropy-photutils-3322558/photutils/detection/000077500000000000000000000000001517052111400214475ustar00rootroot00000000000000astropy-photutils-3322558/photutils/detection/__init__.py000066400000000000000000000006341517052111400235630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for detecting point-like (stellar) sources or local peaks 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 astropy-photutils-3322558/photutils/detection/core.py000066400000000000000000000771571517052111400227720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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 inspect import math import warnings import astropy.units as u import numpy as np from astropy.stats import gaussian_fwhm_to_sigma from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.detection.peakfinder import find_peaks from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._misc import _get_meta from photutils.utils._quantity_helpers import check_units from photutils.utils._repr import make_repr from photutils.utils.cutouts import _make_cutouts from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinderBase', 'StarFinderCatalogBase'] # Remove in 4.0 _DEPRECATED_ATTRIBUTES: dict = { 'xcentroid': 'x_centroid', 'ycentroid': 'y_centroid', 'cutout_xcentroid': 'cutout_x_centroid', 'cutout_ycentroid': 'cutout_y_centroid', 'pa': 'orientation', 'npix': 'n_pixels', } class StarFinderBase(metaclass=abc.ABCMeta): """ Abstract base class for star finders. """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __call__(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. """ 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. Should be NaN-free; any NaN values should be handled before calling this method. kernel : `_StarFinderKernel` or 2D `~numpy.ndarray` The convolution kernel. ``StarFinder`` inputs the kernel as a 2D array. threshold : float or 2D array_like The absolute image value above which to select sources. The exact value depends on the calling star finder class (e.g., `DAOStarFinder` multiplies the ``threshold`` by the kernel relative error, whereas `IRAFStarFinder` and `StarFinder` directly use the input ``threshold``). A 2D ``threshold`` must have the same shape as ``convolved_data``. 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 `_. Returns ------- result : Nx2 `~numpy.ndarray` or `None` An Nx2 array containing the (x, y) pixel coordinates. `None` is returned if no sources are found. """ # Define a local footprint for the peak finder find_peaks_kwargs = {} if min_separation == 0: # use kernel-shape footprint if isinstance(kernel, np.ndarray): footprint = np.ones(kernel.shape) else: footprint = kernel.mask.astype(bool) find_peaks_kwargs['footprint'] = footprint else: find_peaks_kwargs['min_separation'] = min_separation # Define the border exclusion region if exclude_border: if isinstance(kernel, np.ndarray): yborder = (kernel.shape[0] - 1) // 2 xborder = (kernel.shape[1] - 1) // 2 else: yborder = kernel.y_radius xborder = kernel.x_radius border_width = (yborder, xborder) else: border_width = None # 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, mask=mask, border_width=border_width, **find_peaks_kwargs) if tbl is None: return None return np.transpose((tbl['x_peak'], tbl['y_peak'])) @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. """ class StarFinderCatalogBase(metaclass=abc.ABCMeta): """ Abstract base class for star finder catalogs. This class provides common functionality for catalog classes that store and compute properties of detected sources. External packages may subclass it to create custom star finder catalogs. Subclasses **must** implement: * :attr:`x_centroid` property -- Object centroid in the x direction. * :attr:`y_centroid` property -- Object centroid in the y direction. * `apply_filters` method -- Filter the catalog using algorithm-specific criteria. * ``default_columns`` attribute -- A tuple of column names used by `to_table` when no explicit columns are given. This should be set in the subclass ``__init__``. Subclasses **may** override: * `_get_init_attributes` -- Return attribute names to copy during slicing. The override should include ``'default_columns'`` in the returned tuple. * `make_cutouts` -- Customize how cutout arrays are extracted. * `cutout_data` -- Customize the cutouts used for photometry (e.g., zeroing negative pixels). Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. xypos : Nx2 `~numpy.ndarray` An Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. kernel : 2D `~numpy.ndarray` A 2D array of the PSF kernel. Internally, the star finder classes may also pass a kernel object. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` is set to `None`, then no peak pixel value filtering will be performed. """ @deprecated_renamed_argument('brightest', 'n_brightest', '3.0', until='4.0') @deprecated_renamed_argument('peakmax', 'peak_max', '3.0', until='4.0') def __init__(self, data, xypos, kernel, *, n_brightest=None, peak_max=None): # Validate the units check_units((data, peak_max), ('data', 'peak_max')) self.data = data unit = data.unit if isinstance(data, u.Quantity) else None self.unit = unit self.kernel = kernel self.cutout_shape = kernel.shape self.xypos = np.atleast_2d(xypos) self.n_brightest = n_brightest self.peak_max = peak_max self.default_columns = () self.id = np.arange(len(self)) + 1 def __repr__(self): params = ('nsources',) overrides = {'nsources': len(self)} return make_repr(self, params, brackets=True, overrides=overrides) def __str__(self): params = ('nsources',) overrides = {'nsources': len(self)} return make_repr(self, params, overrides=overrides, long=True) def __len__(self): return len(self.xypos) def __getitem__(self, index): """ Index or slice the catalog. This method should be overridden in subclasses to handle class-specific attributes. """ # 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__) # Get attributes to copy from subclass init_attr = self._get_init_attributes() 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') 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 # Ensure 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 def _get_init_attributes(self): """ Return a tuple of attribute names to copy during slicing. This method should be overridden in subclasses. """ return ('data', 'unit', 'kernel', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_lazyproperties' # Subclasses get their own lazyproperty list if attr not in cls.__dict__: def islazyproperty(obj): return isinstance(obj, lazyproperty) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=islazyproperty)]) return getattr(cls, attr) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 def make_cutouts(self, data): """ Make cutouts from the data array. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image array. Returns ------- cutouts : 3D `~numpy.ndarray` The cutout arrays. """ data_arr = data.value if isinstance(data, u.Quantity) else data cutouts, _ = _make_cutouts(data_arr, self.xypos[:, 0], self.xypos[:, 1], self.cutout_shape) if self.unit is not None: cutouts <<= self.unit return cutouts @lazyproperty def cutout_data(self): """ The cutout data arrays. Subclasses may override this property to customize the cutouts used for moment-based photometry calculations (e.g., zeroing negative pixels or subtracting a local sky background). """ return self.make_cutouts(self.data) @lazyproperty def moments(self): """ The raw image moments. """ data = self.cutout_data if isinstance(data, u.Quantity): data = data.value ky, kx = data.shape[1], data.shape[2] y = np.arange(ky, dtype=float) x = np.arange(kx, dtype=float) ypowers = np.column_stack([np.ones(ky), y]) # (ky, 2) xpowers = np.column_stack([np.ones(kx), x]) # (kx, 2) # M[n, p, q] = sum_jk data[n,j,k] * y[j]^p * x[k]^q return ypowers.T @ data @ xpowers @lazyproperty def moments_central(self): """ The central image moments. """ data = self.cutout_data if isinstance(data, u.Quantity): data = data.value ky, kx = data.shape[1], data.shape[2] y = np.arange(ky, dtype=float) x = np.arange(kx, dtype=float) # Per-source shifted coordinates dy = y[np.newaxis, :] - self.cutout_y_centroid[:, np.newaxis] dx = x[np.newaxis, :] - self.cutout_x_centroid[:, np.newaxis] # Per-source power arrays: (n, ky, 3) and (n, kx, 3) ypowers = np.stack([np.ones_like(dy), dy, dy**2], axis=-1) xpowers = np.stack([np.ones_like(dx), dx, dx**2], axis=-1) # Batched matmul: ypowers^T @ data @ xpowers per source moments = (np.transpose(ypowers, (0, 2, 1)) @ data @ xpowers) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return moments / self.moments[:, 0, 0][:, np.newaxis, np.newaxis] @lazyproperty def cutout_centroid(self): """ The cutout centroids. """ moments = self.moments # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) y_centroid = moments[:, 1, 0] / moments[:, 0, 0] x_centroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((y_centroid, x_centroid)) @lazyproperty def cutout_x_centroid(self): """ The cutout x centroids. """ return np.transpose(self.cutout_centroid)[1] @lazyproperty def cutout_y_centroid(self): """ The cutout y centroids. """ return np.transpose(self.cutout_centroid)[0] @property @abc.abstractmethod def x_centroid(self): """ Object centroid in the x direction. This property must be implemented in subclasses. """ @property @abc.abstractmethod def y_centroid(self): """ Object centroid in the y direction. This property must be implemented in subclasses. """ # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') @lazyproperty def mu_sum(self): """ The sum of the central moments. """ return (self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0]) @lazyproperty def mu_diff(self): """ The difference of the central moments. """ return (self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0]) @lazyproperty def fwhm(self): """ The FWHM of the sources. """ return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @lazyproperty 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 and will be in the range [0, 360) degrees. """ angle = 0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff) return (np.rad2deg(angle) % 360) << u.deg @lazyproperty def roundness(self): """ The roundness of the sources. """ 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 peak(self): """ The peak pixel values. """ return np.max(self.cutout_data, axis=(1, 2)) @lazyproperty def flux(self): """ The instrumental fluxes. """ return np.sum(self.cutout_data, axis=(1, 2)) @lazyproperty def mag(self): """ The instrumental magnitudes. """ # Ignore RuntimeWarning if flux is <= 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.n_brightest is not None: idx = np.argsort(self.flux)[::-1][:self.n_brightest] newcat = self[idx] return newcat def _filter_finite(self, attrs, *, initial_mask=None, skip_attrs=()): """ Filter the catalog by removing sources with non-finite values. Parameters ---------- attrs : tuple of str Attribute names to check for finiteness. initial_mask : 1D `~numpy.ndarray` of bool or `None`, optional A pre-existing boolean mask to combine with. If `None`, starts with all `True`. skip_attrs : tuple of str, optional Attribute names to skip during finiteness checking. Returns ------- catalog : ``self.__class__`` or `None` The filtered catalog, or `None` if no sources remain. """ if initial_mask is None: mask = np.ones(len(self), dtype=bool) else: mask = initial_mask.copy() for attr in attrs: if attr in skip_attrs: continue mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: msg = 'No sources were found.' warnings.warn(msg, NoDetectionsWarning) return None return newcat def _filter_bounds(self, bounds, *, initial_mask=None, peakattr='peak'): """ Filter the catalog by sharpness, roundness, and peak_max bounds. Parameters ---------- bounds : list of tuple Each tuple is ``(attr_name, range)`` giving the attribute to check and the range of allowed values. The range is a tuple of the form ``(lower_bound, upper_bound)``, or `None` to skip filtering for that attribute. initial_mask : 1D `~numpy.ndarray` of bool or `None`, optional A pre-existing boolean mask to combine with. If `None`, starts with all `True`. peakattr : str, optional The attribute name for the peak value used for peak_max filtering. The default is ``'peak'``. Returns ------- catalog : ``self.__class__`` or `None` The filtered catalog, or `None` if no sources remain. """ if initial_mask is None: mask = np.ones(len(self), dtype=bool) else: mask = initial_mask.copy() for attr, range_val in bounds: if range_val is None: continue min_val, max_val = range_val values = getattr(self, attr) mask &= (values >= min_val) mask &= (values <= max_val) # peak_max filtering is applied separately from the bounds list # because it uses a different attribute (peakattr) and is always # a single upper bound, not a range. if self.peak_max is not None: mask &= (getattr(self, peakattr) <= self.peak_max) newcat = self[mask] if len(newcat) == 0: msg = 'Sources were found, but none pass the filtering criteria' warnings.warn(msg, NoDetectionsWarning) return None return newcat @abc.abstractmethod def apply_filters(self): """ Filter the catalog. This method must be implemented in subclasses to apply algorithm-specific filtering criteria. """ 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): """ Create a QTable of catalog properties. Parameters ---------- columns : list of str, optional List of column names to include in the table. If `None`, uses ``self.default_columns``. Returns ------- table : `~astropy.table.QTable` A table of the catalog properties. """ # Replace with QTable in 4.0 table = create_empty_deprecated_qtable( _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') table.meta.update(_get_meta()) # keep table.meta type if columns is None: if not self.default_columns: msg = ('default_columns attribute is not set; either ' 'pass explicit column names or set ' 'default_columns in the subclass __init__') raise AttributeError(msg) columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table 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: .. math:: f(x, y) = A \\exp\\bigl(-g(x, y)\\bigr) where .. math:: g(x, y) = a (x - x_0)^{2} + 2 b (x - x_0)(y - y_0) + c (y - y_0)^{2} Attributes ---------- data : 2D `~numpy.ndarray` The kernel data array, used for convolution. shape : tuple of int The ``(ny, nx)`` shape of ``data``. mask : 2D `~numpy.ndarray` of int Binary mask (1 inside the kernel footprint, 0 outside). Used for the peak-finding footprint, sharpness computation in `_DAOStarFinderCatalog`, and sky estimation in `_IRAFStarFinderCatalog`. rel_err : float The kernel relative error, used by `DAOStarFinder` to scale the detection threshold. gaussian_kernel_unmasked : 2D `~numpy.ndarray` The unmasked Gaussian kernel (peak normalized to 1), used by `_DAOStarFinderCatalog` for marginal fitting. x_sigma, y_sigma : float Standard deviations along the major and minor axes. x_radius, y_radius : int Half-widths of the kernel array in pixels. n_pixels : int Total number of pixels within the kernel ``mask``. 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 np.ndim(fwhm) != 0: msg = 'fwhm must be a scalar value' raise TypeError(msg) if fwhm <= 0: msg = 'fwhm must be positive' raise ValueError(msg) if ratio <= 0 or ratio > 1: msg = 'ratio must be > 0 and <= 1.0' raise ValueError(msg) if sigma_radius <= 0: msg = 'sigma_radius must be positive' raise ValueError(msg) self.fwhm = fwhm self.ratio = ratio self.theta = theta % 360.0 self.sigma_radius = sigma_radius self.x_sigma = self.fwhm * gaussian_fwhm_to_sigma self.y_sigma = self.x_sigma * self.ratio theta_radians = np.deg2rad(self.theta) cost = np.cos(theta_radians) sint = np.sin(theta_radians) x_sigma2 = self.x_sigma**2 y_sigma2 = self.y_sigma**2 a = (cost**2 / (2.0 * x_sigma2)) + (sint**2 / (2.0 * y_sigma2)) # Counterclockwise rotation b = 0.5 * cost * sint * ((1.0 / x_sigma2) - (1.0 / y_sigma2)) c = (sint**2 / (2.0 * x_sigma2)) + (cost**2 / (2.0 * y_sigma2)) # 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. f = self.sigma_radius**2 / 2.0 denom = (a * c) - b**2 # Ensure nx and ny are always odd. # The minimum kernel size is 5x5. nx = 2 * int(max(2, math.sqrt(c * f / denom))) + 1 ny = 2 * int(max(2, math.sqrt(a * f / denom))) + 1 self.x_radius = nx // 2 self.y_radius = ny // 2 # Define the kernel on a 2D grid xc = self.x_radius yc = self.y_radius yy, xx = np.mgrid[0:ny, 0:nx] circular_radius = np.sqrt((xx - xc)**2 + (yy - yc)**2) elliptical_radius = (a * (xx - xc)**2 + 2.0 * b * (xx - xc) * (yy - yc) + c * (yy - yc)**2) self.mask = np.where( (elliptical_radius <= f) | (circular_radius <= 2.0), 1, 0).astype(int) self.n_pixels = self.mask.sum() # Central (peak) pixel of gaussian_kernel has a value of 1.0 self.gaussian_kernel_unmasked = np.exp(-elliptical_radius) gaussian_kernel = self.gaussian_kernel_unmasked * self.mask # The denom represents (variance * n_pixels) denom = ((gaussian_kernel**2).sum() - (gaussian_kernel.sum()**2 / self.n_pixels)) self.rel_err = 1.0 / np.sqrt(denom) # Normalize the kernel to zero sum if normalize_zerosum: self.data = ((gaussian_kernel - (gaussian_kernel.sum() / self.n_pixels)) / denom) * self.mask else: self.data = gaussian_kernel self.shape = self.data.shape def __repr__(self): params = ('fwhm', 'ratio', 'theta', 'sigma_radius') return make_repr(self, params) def __str__(self): params = ('fwhm', 'ratio', 'theta', 'sigma_radius') return make_repr(self, params, long=True) def _validate_n_brightest(n_brightest): """ Validate the ``n_brightest`` parameter. It must be >0 and an integer. Parameters ---------- n_brightest : int, None, or bool The number of brightest sources to select. If `None`, all sources are selected. If a boolean is passed, a `TypeError` is raised. """ if n_brightest is not None: if isinstance(n_brightest, bool): msg = 'n_brightest must be an integer' raise TypeError(msg) if n_brightest <= 0: msg = 'n_brightest must be > 0' raise ValueError(msg) bright_int = int(n_brightest) if bright_int != n_brightest: msg = 'n_brightest must be an integer' raise ValueError(msg) n_brightest = bright_int return n_brightest def _handle_deprecated_range(old_lower, old_upper, new_range, old_name, new_name, default_range): """ Handle deprecated lower/upper bound parameters replaced by a single range parameter. Parameters ---------- old_lower : float or `_DeprecatedDefault` The deprecated lower-bound parameter value. old_upper : float or `_DeprecatedDefault` The deprecated upper-bound parameter value. new_range : tuple of 2 floats or `None` The new range parameter value. old_name : str The base name of the deprecated parameters (e.g., ``'sharp'`` for ``'sharplo'`` / ``'sharphi'``). new_name : str The name of the new range parameter (e.g., ``'sharpness_range'``). default_range : tuple of 2 floats The default range values when ``new_range`` is `None`. Returns ------- result : tuple of 2 floats or `None` The resolved range. """ if old_lower is not _DEPR_DEFAULT or old_upper is not _DEPR_DEFAULT: msg = (f"The '{old_name}lo' and '{old_name}hi' parameters are " 'deprecated and will be removed in a future version. ' f"Use '{new_name}=(lower, upper)' instead.") warnings.warn(msg, AstropyDeprecationWarning) _default = new_range if new_range is not None else default_range lower = (old_lower if old_lower is not _DEPR_DEFAULT else _default[0]) upper = (old_upper if old_upper is not _DEPR_DEFAULT else _default[1]) return (lower, upper) return new_range class _DeprecatedDefault: """ Sentinel default value for a deprecated parameter. """ def __repr__(self): return '' _DEPR_DEFAULT = _DeprecatedDefault() astropy-photutils-3322558/photutils/detection/daofinder.py000066400000000000000000001170671517052111400237700ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ DAOStarFinder class. """ import warnings import astropy.units as u import numpy as np from astropy.utils import lazyproperty from photutils.detection.core import (_DEPR_DEFAULT, StarFinderBase, StarFinderCatalogBase, _handle_deprecated_range, _StarFinderKernel, _validate_n_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._quantity_helpers import check_units, isscalar from photutils.utils._repr import make_repr from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['DAOStarFinder'] class DAOStarFinder(StarFinderBase): """ Detect stars in an image using the DAOFIND (`Stetson 1987 `_) algorithm. DAOFIND 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 ``roundness_range`` 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 or 2D `~numpy.ndarray` The absolute image value above which to select sources. If ``threshold`` is a 2D array, it must have the same shape as the input ``data``. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. By default, ``threshold`` is internally scaled by a factor derived from the Gaussian kernel, so the effective threshold applied to the convolved data may differ from the input value. Set ``scale_threshold=False`` to apply the value exactly as given. 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) (:math:`\\sigma = \\mbox{FWHM} / (2 \\sqrt{2 \\log(2)})`). sharplo : float, optional The lower bound on sharpness for object detection. .. deprecated:: 3.0 Use ``sharpness_range=(lower, upper)`` instead. sharphi : float, optional The upper bound on sharpness for object detection. .. deprecated:: 3.0 Use ``sharpness_range=(lower, upper)`` instead. roundlo : float, optional The lower bound on roundness for object detection. .. deprecated:: 3.0 Use ``roundness_range=(lower, upper)`` instead. roundhi : float, optional The upper bound on roundness for object detection. .. deprecated:: 3.0 Use ``roundness_range=(lower, upper)`` instead. 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. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` 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` (default) then the minimum separation is calculated as ``2.5 * fwhm``. Set to 0 to disable minimum separation. Note that large values may result in long run times. .. versionchanged:: 3.0 The default ``min_separation`` changed from 0 to ``2.5 * fwhm``. To recover the previous behavior, set ``min_separation=0``. scale_threshold : bool, optional If `True` (default), the input ``threshold`` is multiplied by the kernel relative error before being applied to the convolved data. This is the behavior of the original DAOFIND algorithm. If `False`, the input ``threshold`` is used directly without any scaling. sharpness_range : tuple of 2 floats or `None`, optional The ``(lower, upper)`` inclusive bounds on sharpness for object detection. Objects with sharpness outside this range will be rejected. If `None`, no sharpness filtering is performed. The default is ``(0.2, 1.0)``. roundness_range : tuple of 2 floats or `None`, optional The ``(lower, upper)`` inclusive bounds on roundness for object detection. Objects with roundness outside this range will be rejected. Both ``roundness1`` and ``roundness2`` are tested against this range. If `None`, no roundness filtering is performed. The default is ``(-1.0, 1.0)``. See Also -------- IRAFStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peak_max`` must 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) """ @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('brightest', 'n_brightest', '3.0', until='4.0') @deprecated_renamed_argument('peakmax', 'peak_max', '3.0', until='4.0') def __init__(self, threshold, fwhm, ratio=1.0, theta=0.0, sigma_radius=1.5, sharplo=_DEPR_DEFAULT, sharphi=_DEPR_DEFAULT, roundlo=_DEPR_DEFAULT, roundhi=_DEPR_DEFAULT, exclude_border=False, n_brightest=None, peak_max=None, xycoords=None, min_separation=None, scale_threshold=True, *, sharpness_range=(0.2, 1.0), roundness_range=(-1.0, 1.0)): # Validate the units, but do not strip them inputs = (threshold, peak_max) names = ('threshold', 'peak_max') check_units(inputs, names) if not isscalar(fwhm): msg = 'fwhm must be a scalar value' raise TypeError(msg) sharpness_range = _handle_deprecated_range( sharplo, sharphi, sharpness_range, 'sharp', 'sharpness_range', (0.2, 1.0)) roundness_range = _handle_deprecated_range( roundlo, roundhi, roundness_range, 'round', 'roundness_range', (-1.0, 1.0)) if sharpness_range is not None: if np.ndim(sharpness_range) != 1 or np.size(sharpness_range) != 2: msg = ('sharpness_range must be a 2-element (lower, upper) ' 'tuple or None') raise ValueError(msg) sharpness_range = tuple(sharpness_range) if roundness_range is not None: if np.ndim(roundness_range) != 1 or np.size(roundness_range) != 2: msg = ('roundness_range must be a 2-element (lower, upper) ' 'tuple or None') raise ValueError(msg) roundness_range = tuple(roundness_range) self.threshold = threshold self.fwhm = fwhm self.ratio = ratio self.theta = theta % 360.0 self.sigma_radius = sigma_radius self.sharpness_range = sharpness_range self.roundness_range = roundness_range self.exclude_border = exclude_border self.n_brightest = _validate_n_brightest(n_brightest) self.peak_max = peak_max if min_separation is not None: if min_separation < 0: msg = 'min_separation must be >= 0' raise ValueError(msg) self.min_separation = min_separation else: self.min_separation = 2.5 * self.fwhm if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: msg = 'xycoords must be shaped as an Nx2 array' raise ValueError(msg) self.xycoords = xycoords self.scale_threshold = scale_threshold self.kernel = _StarFinderKernel(self.fwhm, ratio=self.ratio, theta=self.theta, sigma_radius=self.sigma_radius) if self.scale_threshold: self.threshold_eff = self.threshold * self.kernel.rel_err else: self.threshold_eff = self.threshold def _repr_str_params(self): params = ('threshold', 'fwhm', 'ratio', 'theta', 'sigma_radius', 'sharpness_range', 'roundness_range', 'exclude_border', 'n_brightest', 'peak_max', 'xycoords', 'min_separation', 'scale_threshold') overrides = {} if not isscalar(self.threshold): overrides['threshold'] = ( f'') if self.xycoords is not None: overrides['xycoords'] = ( f'') return params, overrides def __repr__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides) def __str__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides, long=True) def _get_raw_catalog(self, data, *, mask=None): """ Get the raw catalog of sources from the input data. Parameters ---------- data : 2D `~numpy.ndarray` 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 ------- cat : `_DAOStarFinderCatalog` or `None` A catalog of sources found in the input data. `None` is returned if no sources are found. """ 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: msg = 'No sources were found.' warnings.warn(msg, NoDetectionsWarning) return None return _DAOStarFinderCatalog(data, convolved_data, xypos, self.threshold, self.kernel, sharpness_range=self.sharpness_range, roundness_range=self.roundness_range, n_brightest=self.n_brightest, peak_max=self.peak_max, scale_threshold=self.scale_threshold) @deprecated_positional_kwargs(since='3.0', until='4.0') 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. `None` is returned if no stars are found. The table contains the following parameters: * ``id``: unique object identification number. * ``x_centroid, y_centroid``: object centroid. * ``sharpness``: object sharpness. * ``roundness1``: object roundness based on symmetry. * ``roundness2``: object roundness based on marginal Gaussian fits. * ``n_pixels``: the total number of pixels in the Gaussian kernel array. * ``peak``: the peak pixel value of the object. * ``flux``: the object instrumental flux calculated as the sum of data values within the kernel footprint. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. * ``daofind_mag``: the "mag" parameter returned by the DAOFIND algorithm. It is a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. This parameter is reported only for comparison to the IRAF DAOFIND output. It should not be interpreted as a magnitude derived from an integrated flux. """ # Validate the units, but do not strip them inputs = (data, self.threshold, self.peak_max) names = ('data', 'threshold', 'peak_max') check_units(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(StarFinderCatalogBase): """ 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` An Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. threshold : float or 2D `~numpy.ndarray` The absolute image value above which sources were selected. If ``threshold`` is a 2D array, it must have the same shape as ``data``. 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``. sharpness_range : tuple of 2 floats, optional The ``(lower, upper)`` inclusive bounds on sharpness for object detection. Objects with sharpness outside this range will be rejected. The default is ``(0.2, 1.0)``. roundness_range : tuple of 2 floats, optional The ``(lower, upper)`` inclusive bounds on roundness for object detection. Objects with roundness outside this range will be rejected. Both ``roundness1`` and ``roundness2`` are tested against this range. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, convolved_data, xypos, threshold, kernel, *, sharpness_range=(0.2, 1.0), roundness_range=(-1.0, 1.0), n_brightest=None, peak_max=None, scale_threshold=True): # Validate the units, but do not strip them inputs = (data, convolved_data, threshold, peak_max) names = ('data', 'convolved_data', 'threshold', 'peak_max') check_units(inputs, names) super().__init__(data, xypos, kernel, n_brightest=n_brightest, peak_max=peak_max) self.convolved_data = convolved_data self.threshold = threshold self.sharpness_range = sharpness_range self.roundness_range = roundness_range if scale_threshold: self.threshold_eff = threshold * kernel.rel_err else: self.threshold_eff = threshold self.cutout_center = tuple((size - 1) // 2 for size in kernel.shape) self.default_columns = ('id', 'x_centroid', 'y_centroid', 'sharpness', 'roundness1', 'roundness2', 'n_pixels', 'peak', 'flux', 'mag', 'daofind_mag') def _get_init_attributes(self): """ Return a tuple of attribute names to copy during slicing. """ return ('data', 'unit', 'convolved_data', 'kernel', 'threshold', 'sharpness_range', 'roundness_range', 'n_brightest', 'peak_max', 'threshold_eff', 'cutout_shape', 'cutout_center', 'default_columns') @lazyproperty def cutout_convdata(self): """ The cutout of the convolved data centered on the source position. """ return self.make_cutouts(self.convolved_data) @lazyproperty def peak(self): """ The peak pixel value of the source in the original (unconvolved) data. """ return self.cutout_data[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def convdata_peak(self): """ The peak pixel value of the source in the convolved data. """ return self.cutout_convdata[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def roundness1(self): """ The roundness of the source based on symmetry, defined as the ratio of a measure of the object's bilateral (2-fold) to four-fold symmetry. A circular source will have a zero roundness. A source extended in x or y will have a negative or positive roundness, respectively. """ # 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) return 2.0 * sum2 / sum4 @lazyproperty def sharpness(self): """ The sharpness of the source, defined as 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. """ # 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.peak) / (self.kernel.n_pixels - 1)) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return (self.peak - data_mean) / self.convdata_peak def _marginal_weights(self, axis): """ Compute triangular weighting functions for the given axis. Parameters ---------- axis : {0, 1} The axis for which the marginal weights are computed: * 0: for the y axis (rows) * 1: for the x axis (columns) Returns ------- wt : 1D `~numpy.ndarray` The 1D weighting function for the given axis. wts : 2D `~numpy.ndarray` The 2D weighting function for the given axis. size : int The size of the cutout along the given axis. center : int The center pixel position of the cutout along the given axis. sigma : float The standard deviation of the Gaussian kernel along the given axis. dxx : 1D `~numpy.ndarray` The array of pixel offsets from the center pixel along the given axis. """ 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 y axis (rows) wt = np.transpose(ywt)[0] # 1D wts = xwt # 2D size = self.cutout_shape[0] center = ycen sigma = self.kernel.y_sigma dxx = np.arange(size) - center elif axis == 1: # marginal distributions along x axis (columns) wt = xwt[0] # 1D wts = ywt # 2D size = self.cutout_shape[1] center = xcen sigma = self.kernel.x_sigma dxx = center - np.arange(size) return wt, wts, size, center, sigma, dxx def _marginal_kernel_sums(self, wt, wts, axis, center, size): """ Compute weighted marginal kernel sums. Parameters ---------- wt : 1D `~numpy.ndarray` The 1D weighting function for the given axis. wts : 2D `~numpy.ndarray` The 2D weighting function for the given axis. axis : {0, 1} The axis for which the marginal sums are computed: * 0: for the y axis (rows) * 1: for the x axis (columns) center : int The center pixel position of the cutout along the given axis. size : int The size of the cutout along the given axis. Returns ------- result : dict A dict containing the following precomputed kernel-side quantities: * ``wt_sum``: the sum of the 1D weighting function. * ``kern_sum``: the sum of the 1D kernel distribution weighted by the 1D weighting function. * ``kern2_sum``: the sum of the square of the 1D kernel distribution weighted by the 1D weighting function. * ``kern_sum_1d``: the 1D kernel distribution weighted by the 2D weighting function. * ``dkern_dx``: the derivative of the 1D kernel distribution weighted by the 2D weighting function. * ``dkern_dx_sum``: the sum of the derivative of the 1D kernel distribution weighted by the 2D weighting function. * ``dkern_dx2_sum``: the sum of the square of the derivative of the 1D kernel distribution weighted by the 2D weighting function. * ``kern_dkern_dx_sum``: the sum of the product of the 1D kernel distribution and its derivative, weighted by the 2D weighting function. """ dx = center - np.arange(size) # Marginal sum: sum over the axis perpendicular to the given # axis, weighted by the 2D weighting function kern_sum_1d = np.sum(self.kernel.gaussian_kernel_unmasked * wts, axis=1 - axis) wt_sum = np.sum(wt) 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) return {'wt_sum': wt_sum, 'kern_sum': kern_sum, 'kern2_sum': kern2_sum, 'kern_sum_1d': kern_sum_1d, 'dkern_dx': dkern_dx, 'dkern_dx_sum': dkern_dx_sum, 'dkern_dx2_sum': dkern_dx2_sum, 'kern_dkern_dx_sum': kern_dkern_dx_sum} def _marginal_data_sums(self, wt, wts, axis, dxx, kern_sums): """ Compute weighted marginal data sums. Parameters ---------- wt : 1D `~numpy.ndarray` The 1D weighting function for the given axis. wts : 2D `~numpy.ndarray` The 2D weighting function for the given axis. axis : {0, 1} The axis for which the marginal sums are computed: * 0: for the y axis (rows) * 1: for the x axis (columns) dxx : 1D `~numpy.ndarray` The array of pixel offsets from the center pixel along the given axis. kern_sums : dict The precomputed kernel-side quantities returned by ``_marginal_kernel_sums``. Returns ------- result : dict A dict containing the following precomputed data-side quantities: * ``data_sum``: the sum of the 1D data distribution weighted by the 1D weighting function. * ``data_kern_sum``: the sum of the 1D data distribution weighted by the 1D kernel distribution and the 1D weighting function. * ``data_dkern_dx_sum``: the sum of the 1D data distribution weighted by the derivative of the 1D kernel distribution and the 2D weighting function. * ``data_dx_sum``: the sum of the 1D data distribution weighted by the pixel offsets and the 2D weighting function. """ cutout_data = self.cutout_data if isinstance(cutout_data, u.Quantity): cutout_data = cutout_data.value # Marginal sum: sum over the axis perpendicular to the given # axis, weighted by the 2D weighting function (cutout_data is # 3D with shape (N_sources, cutout_size_y, cutout_size_x)) data_sum_1d = np.sum(cutout_data * wts, axis=2 - axis) data_sum = np.sum(data_sum_1d * wt, axis=1) data_kern_sum = np.sum( data_sum_1d * kern_sums['kern_sum_1d'] * wt, axis=1) data_dkern_dx_sum = np.sum( data_sum_1d * kern_sums['dkern_dx'] * wt, axis=1) data_dx_sum = np.sum(data_sum_1d * dxx * wt, axis=1) return {'data_sum': data_sum, 'data_kern_sum': data_kern_sum, 'data_dkern_dx_sum': data_dkern_dx_sum, 'data_dx_sum': data_dx_sum} @staticmethod def _marginal_lstsq(kern_sums, data_sums, sigma, size): """ Perform the marginal least-squares fit and apply masks. Parameters ---------- kern_sums : dict The precomputed kernel-side quantities returned by ``_marginal_kernel_sums``. data_sums : dict The precomputed data-side quantities returned by ``_marginal_data_sums``. sigma : float The standard deviation of the Gaussian kernel along the given axis. size : int The size of the cutout along the given axis. Returns ------- result : Nx2 `~numpy.ndarray` An array of shape Nx2, where N is the number of detected sources, and each row contains the fitted fractional shift (dx) and amplitude (hx) for each source. """ wt_sum = kern_sums['wt_sum'] kern_sum = kern_sums['kern_sum'] kern2_sum = kern_sums['kern2_sum'] dkern_dx_sum = kern_sums['dkern_dx_sum'] dkern_dx2_sum = kern_sums['dkern_dx2_sum'] kern_dkern_dx_sum = kern_sums['kern_dkern_dx_sum'] data_sum = data_sums['data_sum'] data_kern_sum = data_sums['data_kern_sum'] data_dkern_dx_sum = data_sums['data_dkern_dx_sum'] data_dx_sum = data_sums['data_dx_sum'] # Perform linear least-squares fit (where data = 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 # 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)) 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 y axis (rows) * 1: for the x axis (columns) 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. """ wt, wts, size, center, sigma, dxx = ( self._marginal_weights(axis)) kern_sums = self._marginal_kernel_sums(wt, wts, axis, center, size) data_sums = self._marginal_data_sums(wt, wts, axis, dxx, kern_sums) return self._marginal_lstsq(kern_sums, data_sums, sigma, size) @lazyproperty def dx_hx(self): """ The fitted fractional shift (dx) and amplitude (hx) from the marginal Gaussian fit along the x axis. """ return self.daofind_marginal_fit(axis=1) @lazyproperty def dy_hy(self): """ The fitted fractional shift (dy) and amplitude (hy) from the marginal Gaussian fit along the y axis. """ return self.daofind_marginal_fit(axis=0) @lazyproperty def dx(self): """ The fitted fractional shift in x of the image centroid relative to the maximum pixel. """ return np.transpose(self.dx_hx)[0] @lazyproperty def dy(self): """ The fitted fractional shift in y of the image centroid relative to the maximum pixel. """ return np.transpose(self.dy_hy)[0] @lazyproperty def hx(self): """ The height of the best-fitting Gaussian to the marginal x distribution of the unconvolved source data. """ return np.transpose(self.dx_hx)[1] @lazyproperty def hy(self): """ The height of the best-fitting Gaussian to the marginal y distribution of the unconvolved source data. """ return np.transpose(self.dy_hy)[1] @lazyproperty def x_centroid(self): """ The fitted x centroid of the source, calculated as the sum of the x position of the maximum pixel and the fitted fractional shift in x from the marginal Gaussian fit. """ return np.transpose(self.xypos)[0] + self.dx @lazyproperty def y_centroid(self): """ The fitted y centroid of the source, calculated as the sum of the y position of the maximum pixel and the fitted fractional shift in y from the marginal Gaussian fit. """ 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 _threshold_eff_per_source(self): """ Per-source effective threshold values. If the input ``threshold`` is a scalar, then this returns an array of the same length as the number of sources, where each value is the same as the input ``threshold_eff``. If the input ``threshold`` is a 2D array, then this returns an array of the same length as the number of sources, where each value is the value of the input ``threshold_eff`` at the rounded (x, y) position of each source. """ if np.ndim(self.threshold_eff) < 2: return np.ones(len(self)) * self.threshold_eff xpos = np.round(self.xypos[:, 0]).astype(int) ypos = np.round(self.xypos[:, 1]).astype(int) return self.threshold_eff[ypos, xpos] @lazyproperty def daofind_mag(self): """ The "mag" parameter returned by the original DAOFIND algorithm. It is a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. """ # Ignore RuntimeWarning if flux is <= 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) return -2.5 * np.log10(self.convdata_peak / self._threshold_eff_per_source) @lazyproperty def n_pixels(self): """ The total number of pixels in the Gaussian kernel array. """ return np.full(len(self), fill_value=self.kernel.data.size) def apply_filters(self): """ Filter the catalog. """ attrs = ('x_centroid', 'y_centroid', 'hx', 'hy', 'sharpness', 'roundness1', 'roundness2', 'peak', 'flux') skip = () if np.all(self._threshold_eff_per_source == 0): skip = ('flux',) newcat = self._filter_finite(attrs, skip_attrs=skip) if newcat is None: return None bounds = [ ('sharpness', self.sharpness_range), ('roundness1', self.roundness_range), ('roundness2', self.roundness_range), ] return newcat._filter_bounds(bounds) astropy-photutils-3322558/photutils/detection/irafstarfinder.py000066400000000000000000000520711517052111400250310ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ IRAFStarFinder class. """ import warnings import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.detection.core import (_DEPR_DEFAULT, StarFinderBase, StarFinderCatalogBase, _handle_deprecated_range, _StarFinderKernel, _validate_n_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._quantity_helpers import check_units, isscalar from photutils.utils._repr import make_repr 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 or 2D `~numpy.ndarray` The absolute image value above which to select sources. If ``threshold`` is a 2D array, it must have the same shape as the input ``data``. 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) (:math:`\\sigma = \\mbox{FWHM} / (2 \\sqrt{2 \\log(2)})`). 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. .. deprecated:: 3.0 Use ``min_separation`` instead. sharplo : float, optional The lower bound on sharpness for object detection. .. deprecated:: 3.0 Use ``sharpness_range=(lower, upper)`` instead. sharphi : float, optional The upper bound on sharpness for object detection. .. deprecated:: 3.0 Use ``sharpness_range=(lower, upper)`` instead. roundlo : float, optional The lower bound on roundness for object detection. .. deprecated:: 3.0 Use ``roundness_range=(lower, upper)`` instead. roundhi : float, optional The upper bound on roundness for object detection. .. deprecated:: 3.0 Use ``roundness_range=(lower, upper)`` instead. 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. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` 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` (default) then the minimum separation is calculated as ``2.5 * fwhm``. Note that large values may result in long run times. .. versionchanged:: 3.0 The default ``min_separation`` changed from ``max(2, int(fwhm * 2.5 + 0.5))`` to ``2.5 * fwhm``. To recover the previous behavior, set ``min_separation=max(2, int(fwhm * 2.5 + 0.5))``. sharpness_range : tuple of 2 floats or `None`, optional The ``(lower, upper)`` inclusive bounds on sharpness for object detection. Objects with sharpness outside this range will be rejected. If `None`, no sharpness filtering is performed. The default is ``(0.5, 2.0)``. roundness_range : tuple of 2 floats or `None`, optional The ``(lower, upper)`` inclusive bounds on roundness for object detection. Objects with roundness outside this range will be rejected. If `None`, no roundness filtering is performed. The default is ``(0.0, 0.2)``. See Also -------- DAOStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peak_max`` must 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))`` * ``min_separation = max(2, int((fwhm * sepmin) + 0.5))`` 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. * `IRAFStarFinder` internally calculates a "sky" background level based on unmasked pixels within the kernel footprint. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. """ @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('brightest', 'n_brightest', '3.0', until='4.0') @deprecated_renamed_argument('peakmax', 'peak_max', '3.0', until='4.0') def __init__(self, threshold, fwhm, sigma_radius=1.5, minsep_fwhm=_DEPR_DEFAULT, sharplo=_DEPR_DEFAULT, sharphi=_DEPR_DEFAULT, roundlo=_DEPR_DEFAULT, roundhi=_DEPR_DEFAULT, exclude_border=False, n_brightest=None, peak_max=None, xycoords=None, min_separation=None, *, sharpness_range=(0.5, 2.0), roundness_range=(0.0, 0.2)): # Validate the units, but do not strip them inputs = (threshold, peak_max) names = ('threshold', 'peak_max') check_units(inputs, names) if not isscalar(fwhm): msg = 'fwhm must be a scalar value' raise TypeError(msg) sharpness_range = _handle_deprecated_range( sharplo, sharphi, sharpness_range, 'sharp', 'sharpness_range', (0.5, 2.0)) roundness_range = _handle_deprecated_range( roundlo, roundhi, roundness_range, 'round', 'roundness_range', (0.0, 0.2)) if sharpness_range is not None: if np.ndim(sharpness_range) != 1 or np.size(sharpness_range) != 2: msg = ('sharpness_range must be a 2-element (lower, upper) ' 'tuple or None') raise ValueError(msg) sharpness_range = tuple(sharpness_range) if roundness_range is not None: if np.ndim(roundness_range) != 1 or np.size(roundness_range) != 2: msg = ('roundness_range must be a 2-element (lower, upper) ' 'tuple or None') raise ValueError(msg) roundness_range = tuple(roundness_range) # Handle deprecated minsep_fwhm parameter if minsep_fwhm is not _DEPR_DEFAULT: msg = ("The 'minsep_fwhm' parameter is deprecated " 'and will be removed in a future version. Use ' "'min_separation' instead.") warnings.warn(msg, AstropyDeprecationWarning) if minsep_fwhm < 0: msg = 'minsep_fwhm must be >= 0' raise ValueError(msg) if min_separation is None: # Use the deprecated minsep_fwhm calculation to set the # min_separation min_separation = max(2, int((fwhm * minsep_fwhm) + 0.5)) self.threshold = threshold self.fwhm = fwhm self.sigma_radius = sigma_radius self.sharpness_range = sharpness_range self.roundness_range = roundness_range self.exclude_border = exclude_border self.n_brightest = _validate_n_brightest(n_brightest) self.peak_max = peak_max if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: msg = 'xycoords must be shaped as an Nx2 array' raise ValueError(msg) 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: msg = 'min_separation must be >= 0' raise ValueError(msg) self.min_separation = min_separation else: self.min_separation = 2.5 * self.fwhm def _repr_str_params(self): params = ('threshold', 'fwhm', 'sigma_radius', 'sharpness_range', 'roundness_range', 'exclude_border', 'n_brightest', 'peak_max', 'xycoords', 'min_separation') overrides = {} if not isscalar(self.threshold): overrides['threshold'] = ( f'') if self.xycoords is not None: overrides['xycoords'] = f'' return params, overrides def __repr__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides) def __str__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides, long=True) def _get_raw_catalog(self, data, *, mask=None): """ Get the raw catalog of sources from the input data. Parameters ---------- data : 2D `~numpy.ndarray` 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 ------- cat : `_IRAFStarFinderCatalog` or `None` A catalog of sources found in the input data. `None` is returned if no sources are found. """ 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: msg = 'No sources were found.' warnings.warn(msg, NoDetectionsWarning) return None return _IRAFStarFinderCatalog(data, convolved_data, xypos, self.kernel, sharpness_range=self.sharpness_range, roundness_range=self.roundness_range, n_brightest=self.n_brightest, peak_max=self.peak_max) @deprecated_positional_kwargs(since='3.0', until='4.0') 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. `None` is returned if no stars are found. The table contains the following parameters: * ``id``: unique object identification number. * ``x_centroid, y_centroid``: object centroid. * ``fwhm``: object FWHM. * ``sharpness``: object sharpness. * ``roundness``: object roundness. * ``orientation``: the angle between the ``x`` axis and the major axis source measured counter-clockwise in the range [0, 360) degrees. * ``n_pixels``: the total number of (positive) unmasked pixels. * ``peak``: the peak, sky-subtracted, pixel value of the object. * ``flux``: the object instrumental flux calculated as the sum of sky-subtracted data values within the kernel footprint. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. """ inputs = (data, self.threshold, self.peak_max) names = ('data', 'threshold', 'peak_max') check_units(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(StarFinderCatalogBase): """ 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` An 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``. sharpness_range : tuple of 2 floats, optional The ``(lower, upper)`` inclusive bounds on sharpness for object detection. Objects with sharpness outside this range will be rejected. roundness_range : tuple of 2 floats, optional The ``(lower, upper)`` inclusive bounds on roundness for object detection. Objects with roundness outside this range will be rejected. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, convolved_data, xypos, kernel, *, sharpness_range=(0.2, 1.0), roundness_range=(-1.0, 1.0), n_brightest=None, peak_max=None): # Validate the units, but do not strip them inputs = (data, convolved_data, peak_max) names = ('data', 'convolved_data', 'peak_max') check_units(inputs, names) super().__init__(data, xypos, kernel, n_brightest=n_brightest, peak_max=peak_max) self.convolved_data = convolved_data self.sharpness_range = sharpness_range self.roundness_range = roundness_range self.default_columns = ('id', 'x_centroid', 'y_centroid', 'fwhm', 'sharpness', 'roundness', 'orientation', 'n_pixels', 'peak', 'flux', 'mag') def _get_init_attributes(self): """ Return a tuple of attribute names to copy during slicing. """ return ('data', 'unit', 'convolved_data', 'kernel', 'sharpness_range', 'roundness_range', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') @lazyproperty def sky(self): """ Calculate the sky background level. The local sky level is roughly estimated using the IRAF starfind calculation as the average value in the non-masked regions within the kernel footprint. """ skymask = ~self.kernel.mask.astype(bool) # True=sky, False=obj # nsky is always > 0 because the kernel mask never covers the # entire footprint (the Gaussian kernel is always truncated # within the array, leaving unmasked border pixels). nsky = np.count_nonzero(skymask) axis = (1, 2) sky = np.sum(self.cutout_data_nosub * skymask, axis=axis) / nsky if self.unit is not None: sky <<= self.unit return sky @lazyproperty def cutout_data_nosub(self): """ The cutout data without sky subtraction or masking. """ return self.make_cutouts(self.data) @lazyproperty def cutout_data(self): """ The cutout data with sky subtraction and masking applied. """ # This is a freshly computed array, so in-place modification is # safe. 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 n_pixels(self): """ The total number of (positive) unmasked pixels in the cutout data. """ return np.count_nonzero(self.cutout_data, axis=(1, 2)) @lazyproperty def cutout_xorigin(self): """ The x pixel coordinate of the cutout origin. """ return np.transpose(self.xypos)[0] - self.kernel.x_radius @lazyproperty def cutout_yorigin(self): """ The y pixel coordinate of the cutout origin. """ return np.transpose(self.xypos)[1] - self.kernel.y_radius @lazyproperty def x_centroid(self): """ The x pixel coordinate of the object centroid. """ return self.cutout_x_centroid + self.cutout_xorigin @lazyproperty def y_centroid(self): """ The y pixel coordinate of the object centroid. """ return self.cutout_y_centroid + self.cutout_yorigin @lazyproperty def sharpness(self): """ The sharpness of the object. """ return self.fwhm / self.kernel.fwhm def apply_filters(self): """ Filter the catalog. """ attrs = ('x_centroid', 'y_centroid', 'sharpness', 'roundness', 'orientation', 'sky', 'peak', 'flux') initial_mask = np.count_nonzero(self.cutout_data, axis=(1, 2)) > 1 newcat = self._filter_finite(attrs, initial_mask=initial_mask) if newcat is None: return None bounds = [ ('sharpness', self.sharpness_range), ('roundness', self.roundness_range), ] return newcat._filter_bounds(bounds) astropy-photutils-3322558/photutils/detection/peakfinder.py000066400000000000000000000434071517052111400241410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for finding local peaks in an astronomical image. """ import warnings import numpy as np from astropy.table import QTable from scipy.ndimage import maximum_filter from photutils.utils._deprecation import deprecated_renamed_argument from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair from photutils.utils._quantity_helpers import process_quantities from photutils.utils._stats import nanmin from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['find_peaks'] def _verify_ring_candidates(data, peak_mask, needs_verify, footprint_bool, half, footprint_size): """ Verify ring candidates against the exact circular footprint. Ring candidates are pixels that are the local maximum within the inscribed box but not in the circumscribed box. These need per-pixel verification against the actual circular footprint. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image array. peak_mask : 2D bool `~numpy.ndarray` Boolean mask to update in place. `True` indicates a confirmed local maximum. needs_verify : 2D bool `~numpy.ndarray` Boolean mask of candidate pixels that require verification. footprint_bool : 2D bool `~numpy.ndarray` The circular footprint boolean mask. half : int Half the footprint size (``footprint_size // 2``), used to center the footprint on each candidate pixel. footprint_size : int The size of the circular footprint array along each axis. """ y_maybe, x_maybe = needs_verify.nonzero() if len(y_maybe) == 0: return ny, nx = data.shape for y, x in zip(y_maybe, x_maybe, strict=True): # Map footprint onto data, clipping to image boundaries y0 = y - half y1 = y0 + footprint_size x0 = x - half x1 = x0 + footprint_size dy0, dy1 = max(0, y0), min(ny, y1) dx0, dx1 = max(0, x0), min(nx, x1) fy0 = dy0 - y0 fy1 = footprint_size - (y1 - dy1) fx0 = dx0 - x0 fx1 = footprint_size - (x1 - dx1) local = data[dy0:dy1, dx0:dx1] fp_local = footprint_bool[fy0:fy1, fx0:fx1] local_max = local[fp_local].max() # Footprint extends beyond image: include cval=0.0 if (fy0 > 0 or fy1 < footprint_size or fx0 > 0 or fx1 < footprint_size): local_max = max(local_max, 0.0) # peak_mask is updated in place if data[y, x] == local_max: peak_mask[y, x] = True def _fast_circular_peaks(data, radius): """ Find pixels that are local maxima within circular regions. This is equivalent to:: idx = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(idx, idx) footprint = np.array((xx**2 + yy**2) <= radius**2, dtype=int) data_max = maximum_filter(data, footprint=footprint, mode='constant', cval=0.0) peaks = (data == data_max) but uses fast separable box filters with targeted circular verification, which is typically ~10-400x faster (depending on the radius). Parameters ---------- data : 2D `~numpy.ndarray` The 2D image array. Must be NaN-free because `~scipy.ndimage.maximum_filter` propagates NaNs, which would corrupt the local-maximum comparisons. radius : float The radius of the circular region in pixels. Returns ------- peak_mask : 2D bool `~numpy.ndarray` Boolean mask where `True` indicates a local maximum within the circular region. """ # Build the circular footprint idx = np.arange(-radius, radius + 1) radius_sq = radius ** 2 footprint_size = len(idx) xx, yy = np.meshgrid(idx, idx) footprint_bool = (xx ** 2 + yy ** 2) <= radius_sq # For even-sized footprints (non-integer radius), scipy's # maximum_filter places the center at index ``footprint_size // 2`` # (i.e., the origin is biased by +0.5 pixel). The same convention is # used here so that the fast path is bit-identical to the reference # maximum_filter(footprint=...) result. half = footprint_size // 2 # Circumscribed box (size = footprint_size): contains the footprint. # Any pixel that is the max in this box is definitely the max in the # circular footprint, since circle <= box. data_max_box = maximum_filter(data, size=footprint_size, mode='constant', cval=0.0) definite = (data == data_max_box) # Inscribed box: fits inside the circle. For even-sized footprints, # the circle center is shifted by 0.5 from the pixel center. We # account for this so the inscribed box stays inside the circle. if footprint_size % 2 == 0: half_side = int(np.floor(radius / np.sqrt(2) - 0.5)) else: half_side = int(np.floor(radius / np.sqrt(2))) side_insc = max(2 * half_side + 1, 3) data_max_insc = maximum_filter(data, size=side_insc, mode='constant', cval=0.0) # Candidates from inscribed box are a superset of true peaks candidates = (data == data_max_insc) # Ring candidates: max in inscribed box but not in circumscribed # box. These need per-pixel verification against the actual circular # footprint. needs_verify = candidates & ~definite peak_mask = definite.copy() # peak_mask is updated in place _verify_ring_candidates(data, peak_mask, needs_verify, footprint_bool, half, footprint_size) return peak_mask @deprecated_renamed_argument('npeaks', 'n_peaks', '3.0', until='4.0') def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None, border_width=None, n_peaks=np.inf, min_separation=None, 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. When ``min_separation`` is set, a fast algorithm is used that produces results equivalent to using a circular ``footprint`` of the given radius for `~scipy.ndimage.maximum_filter`, but is typically ~10-400x faster (depending on the radius). When set, ``box_size`` and ``footprint`` are not used for peak detection. 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. A peak is detected only if it is strictly greater than the ``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 : int, array_like of int, or None, optional The width in pixels to exclude around the border of the ``data``. If ``border_width`` is a scalar then ``border_width`` will be applied to all sides. If ``border_width`` has two elements, they must be in ``(ny, nx)`` order. If `None`, then no border is excluded. The border width values must be non-negative integers. n_peaks : int, optional The maximum number of peaks to return. When the number of detected peaks exceeds ``n_peaks``, the peaks with the highest peak intensities will be returned. min_separation : float or None, optional The minimum allowed separation (in pixels) between detected peaks, enforced using a circular region of this radius. Each detected peak must be the maximum value (or tied for the maximum) within a circle of this radius. This is equivalent to using a circular ``footprint`` of the given radius but uses a fast algorithm that is typically ~10-400x faster (depending on the radius). When set, ``box_size`` and ``footprint`` are not used for peak detection. If `None` (default), the peak detection uses ``box_size`` or ``footprint`` as specified. 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.QTable` 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). When ``min_separation`` is given, peaks are detected using a fast algorithm that is mathematically equivalent to a circular ``footprint`` of the given radius for `~scipy.ndimage.maximum_filter`. The algorithm uses two fast O(N) separable box filters (inscribed and circumscribed squares of the circle) to classify most candidates, then verifies only the remaining few against the exact circular region. 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``. Note that when ``min_separation`` is used, the centroid region size is determined by ``box_size`` (default 3), not by ``min_separation``. The peak detection uses ``mode='constant'`` with ``cval=0.0`` for `~scipy.ndimage.maximum_filter`, which means pixels outside the image boundary are treated as zero. For images with all-negative values, this may suppress legitimate peaks near the borders. Any NaN values in the input ``data`` are replaced with the minimum finite value before peak detection, and the corresponding pixels are automatically excluded from the results. The output column names (``x_peak``, ``y_peak``, ``peak_value``) differ from the star finder classes (e.g., `~photutils.detection.DAOStarFinder`), which use ``x_centroid``, ``y_centroid``, and ``flux``. """ arrays, unit = process_quantities((data, threshold, error), ('data', 'threshold', 'error')) data, threshold, error = arrays data = np.asanyarray(data) if centroid_func is not None and not callable(centroid_func): msg = 'centroid_func must be a callable object' raise TypeError(msg) if min_separation is not None and min_separation < 0: msg = 'min_separation must be >= 0' raise ValueError(msg) if np.all(data == data.flat[0]): msg = 'Input data is constant. No local peaks can be found.' warnings.warn(msg, NoDetectionsWarning) return None if not np.isscalar(threshold): threshold = np.asanyarray(threshold) if data.shape != threshold.shape: msg = ('threshold array must have the same shape as the ' 'input data') raise ValueError(msg) if border_width is not None: border_width = as_pair('border_width', border_width, lower_bound=(0, 1), upper_bound=data.shape) # Remove NaN values to avoid runtime warnings and exclude NaN pixels # from peak detection nan_mask = np.isnan(data) if np.any(nan_mask): data = np.copy(data) # ndarray data[nan_mask] = nanmin(data) mask = (nan_mask if mask is None else np.asanyarray(mask) | nan_mask) # peak_goodmask: good pixels are True if min_separation is not None and min_separation > 0: peak_goodmask = _fast_circular_peaks(data, min_separation) elif footprint is not None: data_max = maximum_filter(data, footprint=footprint, mode='constant', cval=0.0) peak_goodmask = (data == data_max) else: data_max = maximum_filter(data, size=box_size, mode='constant', cval=0.0) peak_goodmask = (data == data_max) # Exclude peaks that are masked if mask is not None: mask = np.asanyarray(mask, dtype=bool) if data.shape != mask.shape: msg = 'data and mask must have the same shape' raise ValueError(msg) peak_goodmask = np.logical_and(peak_goodmask, ~mask) # Exclude peaks that are too close to the border if border_width is not None: ny, nx = border_width if ny > 0: peak_goodmask[:ny, :] = False peak_goodmask[-ny:, :] = False if nx > 0: peak_goodmask[:, :nx] = False peak_goodmask[:, -nx:] = False # Exclude peaks below the threshold 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 n_x_peaks = len(x_peaks) if n_x_peaks == 0: msg = 'No local peaks were found.' warnings.warn(msg, NoDetectionsWarning) return None if n_x_peaks > n_peaks: idx = np.argsort(peak_values)[::-1][:n_peaks] x_peaks = x_peaks[idx] y_peaks = y_peaks[idx] peak_values = peak_values[idx] # 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 # When a footprint is provided, derive the centroid box_size # from the footprint shape so they are consistent. Ensure odd # dimensions for centroid_sources. if footprint is not None: centroid_box_size = tuple( s if s % 2 else s + 1 for s in footprint.shape) else: centroid_box_size = box_size x_centroids, y_centroids = centroid_sources( data, x_peaks, y_peaks, box_size=centroid_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 astropy-photutils-3322558/photutils/detection/starfinder.py000066400000000000000000000264111517052111400241660ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ StarFinder class. """ import warnings import numpy as np from astropy.utils import lazyproperty from photutils.detection.core import (StarFinderBase, StarFinderCatalogBase, _validate_n_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._quantity_helpers import check_units from photutils.utils._repr import make_repr from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinder'] class StarFinder(StarFinderBase): """ Detect stars in an image using a user-defined kernel. Parameters ---------- threshold : float or 2D `~numpy.ndarray` The absolute image value above which to select sources. If ``threshold`` is a 2D array, it must have the same shape as the input ``data``. 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 : `None` or float, optional The minimum separation (in pixels) for detected objects. If `None` (default) then the minimum separation is set to ``2.5 * (min(kernel.shape) // 2)``. Note that large values may result in long run times. .. versionchanged:: 3.0 The default ``min_separation`` changed from 5 to ``2.5 * (min(kernel.shape) // 2)``. To recover the previous behavior, set ``min_separation=5``. exclude_border : bool, optional Whether to exclude sources found within half the size of the convolution kernel from the image borders. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` 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 ``peak_max`` 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('brightest', 'n_brightest', '3.0', until='4.0') @deprecated_renamed_argument('peakmax', 'peak_max', '3.0', until='4.0') def __init__(self, threshold, kernel, min_separation=None, exclude_border=False, n_brightest=None, peak_max=None): # Validate the units check_units((threshold, peak_max), ('threshold', 'peak_max')) self.threshold = threshold kernel = np.asarray(kernel) if kernel.ndim != 2: msg = 'kernel must be a 2D array' raise ValueError(msg) self.kernel = kernel if min_separation is not None: if min_separation < 0: msg = 'min_separation must be >= 0' raise ValueError(msg) self.min_separation = min_separation else: self.min_separation = 2.5 * (min(self.kernel.shape) // 2) self.exclude_border = exclude_border self.n_brightest = _validate_n_brightest(n_brightest) self.peak_max = peak_max def _repr_str_params(self): params = ('threshold', 'kernel', 'min_separation', 'exclude_border', 'n_brightest', 'peak_max') overrides = {'kernel': f''} if not np.isscalar(self.threshold): overrides['threshold'] = ( f'') return params, overrides def __repr__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides) def __str__(self): params, overrides = self._repr_str_params() return make_repr(self, params, overrides=overrides, long=True) def _get_raw_catalog(self, data, *, mask=None): """ Get the raw catalog of sources from the input data. Parameters ---------- data : 2D `~numpy.ndarray` 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 ------- cat : `_StarFinderCatalog` or `None` A catalog of sources found in the input data. `None` is returned if no sources are found. """ kernel = self.kernel / np.max(self.kernel) # normalize max to 1.0 denom = np.sum(kernel**2) - (np.sum(kernel)**2 / kernel.size) if denom > 0: 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: msg = 'No sources were found.' warnings.warn(msg, NoDetectionsWarning) return None return _StarFinderCatalog(data, xypos, self.kernel, n_brightest=self.n_brightest, peak_max=self.peak_max) @deprecated_positional_kwargs(since='3.0', until='4.0') 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. * ``x_centroid, y_centroid``: object centroid. * ``fwhm``: object FWHM. * ``roundness``: object roundness. * ``orientation``: the angle between the ``x`` axis and the major axis source measured counter-clockwise in the range [0, 360) degrees. * ``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 peak_max criteria. """ # Validate the units check_units((data, self.threshold, self.peak_max), ('data', 'threshold', 'peak_max')) 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(StarFinderCatalogBase): """ 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` An Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. kernel: 2D `~numpy.ndarray` A 2D array of the PSF kernel. n_brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``n_brightest`` is set to `None`, all objects will be selected. peak_max : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peak_max`` will be rejected. This keyword 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 ``peak_max`` must have the same units. If ``peak_max`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, xypos, kernel, *, n_brightest=None, peak_max=None): super().__init__(data, xypos, kernel, n_brightest=n_brightest, peak_max=peak_max) self.default_columns = ('id', 'x_centroid', 'y_centroid', 'fwhm', 'roundness', 'orientation', 'max_value', 'flux', 'mag') def _get_init_attributes(self): """ Return a tuple of attribute names to copy during slicing. """ return ('data', 'unit', 'kernel', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') @lazyproperty def cutout_data(self): """ The cutout data arrays with negative values set to zero. """ cutouts = self.make_cutouts(self.data) cutouts[cutouts < 0] = 0.0 # exclude negative pixels return cutouts @lazyproperty def max_value(self): """ The maximum pixel value in the cutout data. """ return self.peak @lazyproperty def x_centroid(self): """ The x centroid of the source. """ xoff = self.cutout_shape[1] // 2 return self.cutout_x_centroid + self.xypos[:, 0] - xoff @lazyproperty def y_centroid(self): """ The y centroid of the source. """ yoff = self.cutout_shape[0] // 2 return self.cutout_y_centroid + self.xypos[:, 1] - yoff def apply_filters(self): """ Filter the catalog. """ attrs = ('x_centroid', 'y_centroid', 'fwhm', 'roundness', 'orientation', 'max_value', 'flux') newcat = self._filter_finite(attrs) if newcat is None: return None return newcat._filter_bounds([], peakattr='max_value') astropy-photutils-3322558/photutils/detection/tests/000077500000000000000000000000001517052111400226115ustar00rootroot00000000000000astropy-photutils-3322558/photutils/detection/tests/__init__.py000066400000000000000000000000001517052111400247100ustar00rootroot00000000000000astropy-photutils-3322558/photutils/detection/tests/conftest.py000066400000000000000000000023611517052111400250120ustar00rootroot00000000000000# 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 CircularGaussianPRF, make_psf_model_image @pytest.fixture(name='kernel') def fixture_kernel(): """ A 2D Gaussian 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(): """ A 2D image with 25 sources generated using a circular Gaussian PRF model. """ shape = (101, 101) model_shape = (11, 11) # sigma=1.5 -> FWHM = sigma * 2 * sqrt(2 * ln(2)) fwhm = 1.5 * 2.0 * np.sqrt(2.0 * np.log(2.0)) psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) 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 astropy-photutils-3322558/photutils/detection/tests/test_core.py000066400000000000000000000472201517052111400251570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils.detection.core module. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.detection import DAOStarFinder from photutils.detection.core import (_DEPR_DEFAULT, StarFinderCatalogBase, _StarFinderKernel, _validate_n_brightest) class TestStarFinderKernel: """ Tests for the _StarFinderKernel class. """ def test_fwhm_zero(self): """ Test that fwhm=0 raises a ValueError. """ match = 'fwhm must be positive' with pytest.raises(ValueError, match=match): _StarFinderKernel(fwhm=0) def test_fwhm_negative(self): """ Test that a negative fwhm raises a ValueError. """ match = 'fwhm must be positive' with pytest.raises(ValueError, match=match): _StarFinderKernel(fwhm=-1) def test_fwhm_nonscalar(self): """ Test that a non-scalar fwhm raises a TypeError. """ match = 'fwhm must be a scalar value' with pytest.raises(TypeError, match=match): _StarFinderKernel(fwhm=np.array([3.0])) def test_normalize_zerosum_false(self): """ Test kernel with normalize_zerosum=False. """ kernel = _StarFinderKernel(fwhm=2.0, normalize_zerosum=False) # Without zero-sum normalization, the kernel sums to a positive # value assert kernel.data.sum() > 0 @pytest.mark.parametrize(('ratio', 'theta'), [ (0.5, 0.0), (0.8, 45.0), (1.0, 90.0), (0.3, 120.0), ]) def test_elliptical_kernel(self, ratio, theta): """ Test kernel with various ratio and theta values. """ kernel = _StarFinderKernel(fwhm=3.0, ratio=ratio, theta=theta) assert kernel.data.shape[0] >= 5 assert kernel.data.shape[1] >= 5 # Zero-sum kernel assert abs(kernel.data.sum()) < 1.0e-10 # Check stored attributes assert kernel.ratio == ratio assert kernel.theta == theta def test_repr(self): """ Test the __repr__ of _StarFinderKernel. """ kernel = _StarFinderKernel(fwhm=3.0, ratio=0.5, theta=30.0) r = repr(kernel) assert '_StarFinderKernel(' in r assert 'fwhm=3.0' in r assert 'ratio=0.5' in r assert 'theta=30.0' in r assert 'sigma_radius=1.5' in r def test_str(self): """ Test the __str__ of _StarFinderKernel. """ kernel = _StarFinderKernel(fwhm=3.0, ratio=0.5, theta=30.0) s = str(kernel) assert 'photutils.detection.core._StarFinderKernel' in s assert 'fwhm: 3.0' in s assert 'ratio: 0.5' in s assert 'theta: 30.0' in s assert 'sigma_radius: 1.5' in s @pytest.mark.parametrize(('theta', 'expected'), [ (400.0, 40.0), (-30.0, 330.0), (360.0, 0.0), (0.0, 0.0), ]) def test_theta_normalization(self, theta, expected): """ Test that theta values are normalized to [0, 360). """ kernel = _StarFinderKernel(fwhm=3.0, ratio=0.5, theta=theta) assert kernel.theta == expected @pytest.mark.parametrize('ratio', [0, -0.5, 1.5]) def test_invalid_ratio(self, ratio): """ Test that invalid ratio values raise ValueError. """ match = 'ratio must be > 0 and <= 1.0' with pytest.raises(ValueError, match=match): _StarFinderKernel(fwhm=3.0, ratio=ratio) @pytest.mark.parametrize('sigma_radius', [0, -1]) def test_invalid_sigma_radius(self, sigma_radius): """ Test that non-positive sigma_radius raises ValueError. """ match = 'sigma_radius must be positive' with pytest.raises(ValueError, match=match): _StarFinderKernel(fwhm=3.0, sigma_radius=sigma_radius) class TestValidateNBrightest: """ Parametrized tests for the _validate_n_brightest function. """ @pytest.mark.parametrize('n_brightest', [-1, -0.5, -100]) def test_n_brightest_negative(self, n_brightest): """ Test that negative n_brightest values raise ValueError. """ match = 'n_brightest must be > 0' with pytest.raises(ValueError, match=match): _validate_n_brightest(n_brightest) def test_n_brightest_zero(self): """ Test that n_brightest=0 raises ValueError. """ match = 'n_brightest must be > 0' with pytest.raises(ValueError, match=match): _validate_n_brightest(0) @pytest.mark.parametrize('n_brightest', [3.1, 2.5, 1.9]) def test_n_brightest_not_integer(self, n_brightest): """ Test that non-integer n_brightest values raise ValueError. """ match = 'n_brightest must be an integer' with pytest.raises(ValueError, match=match): _validate_n_brightest(n_brightest) @pytest.mark.parametrize('n_brightest', [1, 5, 100]) def test_n_brightest_valid(self, n_brightest): """ Test that valid n_brightest values are returned unchanged. """ assert _validate_n_brightest(n_brightest) == n_brightest def test_n_brightest_none(self): """ Test that None is a valid n_brightest value. """ assert _validate_n_brightest(None) is None @pytest.mark.parametrize('n_brightest', [True, False]) def test_n_brightest_bool(self, n_brightest): """ Test that boolean n_brightest values raise TypeError. """ match = 'n_brightest must be an integer' with pytest.raises(TypeError, match=match): _validate_n_brightest(n_brightest) def _make_minimal_catalog_class(): """ Create a minimal concrete subclass of StarFinderCatalogBase that does NOT override ``_get_init_attributes`` and does NOT set ``default_columns``, so the base-class implementations can be tested. """ class _MinimalCatalog(StarFinderCatalogBase): @property def x_centroid(self): return self.cutout_x_centroid @property def y_centroid(self): return self.cutout_y_centroid def apply_filters(self): return self return _MinimalCatalog @pytest.fixture(name='minimal_catalog_cls') def fixture_minimal_catalog_cls(): """ Fixture that provides a minimal concrete subclass of StarFinderCatalogBase. """ return _make_minimal_catalog_class() class TestStarFinderCatalogBase: """ Tests for the StarFinderCatalogBase base-class methods. """ def test_get_init_attributes(self, minimal_catalog_cls): """ Test base _get_init_attributes returns expected tuple. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) expected = ('data', 'unit', 'kernel', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') assert cat._get_init_attributes() == expected def test_lazyproperties_class_cache(self, minimal_catalog_cls): """ Test that _lazyproperties is cached on the class and shared across instances. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat1 = minimal_catalog_cls(data, xypos, kernel) cat2 = minimal_catalog_cls(data, xypos, kernel) result1 = cat1._lazyproperties result2 = cat2._lazyproperties assert result1 is result2 def test_to_table_missing_default_columns(self, minimal_catalog_cls): """ Test that to_table raises when default_columns is not set. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) match = 'default_columns attribute is not set' with pytest.raises(AttributeError, match=match): cat.to_table() def test_to_table_explicit_columns(self, minimal_catalog_cls): """ Test that to_table works with explicit column names. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) columns = ('id', 'x_centroid', 'y_centroid') tbl = cat.to_table(columns=columns) assert len(tbl) == 1 assert tbl.colnames == list(columns) def test_getitem_integer_index(self, minimal_catalog_cls): """ Test indexing the catalog with an integer index. """ data = np.zeros((11, 11)) data[3, 3] = 10.0 data[7, 7] = 20.0 kernel = np.ones((3, 3)) xypos = np.array([[3, 3], [7, 7]]) cat = minimal_catalog_cls(data, xypos, kernel) assert len(cat) == 2 sub = cat[0] assert len(sub) == 1 assert sub.xypos[0, 0] == 3 def test_getitem_slice(self, minimal_catalog_cls): """ Test slicing the catalog. """ data = np.zeros((11, 11)) data[3, 3] = 10.0 data[5, 5] = 15.0 data[7, 7] = 20.0 kernel = np.ones((3, 3)) xypos = np.array([[3, 3], [5, 5], [7, 7]]) cat = minimal_catalog_cls(data, xypos, kernel) assert len(cat) == 3 sub = cat[1:] assert len(sub) == 2 assert sub.xypos[0, 0] == 5 assert sub.xypos[1, 0] == 7 def test_getitem_fancy_index(self, minimal_catalog_cls): """ Test indexing with a boolean mask (fancy indexing). """ data = np.zeros((11, 11)) data[3, 3] = 10.0 data[5, 5] = 15.0 data[7, 7] = 20.0 kernel = np.ones((3, 3)) xypos = np.array([[3, 3], [5, 5], [7, 7]]) cat = minimal_catalog_cls(data, xypos, kernel) # Force evaluation of a lazyproperty before slicing _ = cat.flux mask = np.array([True, False, True]) sub = cat[mask] assert len(sub) == 2 assert sub.xypos[0, 0] == 3 assert sub.xypos[1, 0] == 7 def test_roundness(self, minimal_catalog_cls): """ Test roundness computed on a symmetric source via base class. """ data = np.zeros((11, 11)) data[5, 5] = 100.0 data[4, 5] = 50.0 data[6, 5] = 50.0 data[5, 4] = 50.0 data[5, 6] = 50.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) # roundness should be finite for a well-defined source assert np.isfinite(cat.roundness[0]) def test_repr(self, minimal_catalog_cls): """ Test the __repr__ of StarFinderCatalogBase subclass. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [3, 3]]) cat = minimal_catalog_cls(data, xypos, kernel) r = repr(cat) assert '_MinimalCatalog(' in r assert 'nsources=2' in r def test_str(self, minimal_catalog_cls): """ Test the __str__ of StarFinderCatalogBase subclass. """ data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) s = str(cat) assert '_MinimalCatalog' in s assert 'nsources: 1' in s def test_make_cutouts_partial_overlap(self, minimal_catalog_cls): """ Test that make_cutouts pads with zeros for sources at image edges that only partially overlap the data. """ data = np.ones((10, 10)) * 5.0 kernel = np.ones((5, 5)) # Corners and edges: each cutout partially extends outside xypos = np.array([[0, 0], [9, 9], [0, 9], [9, 0]]) cat = minimal_catalog_cls(data, xypos, kernel) cutouts = cat.make_cutouts(data) assert cutouts.shape == (4, 5, 5) # Corner (0,0): only bottom-right 3x3 quadrant is inside image c00 = cutouts[0] assert np.all(c00[:2, :] == 0.0) # top rows outside assert np.all(c00[:, :2] == 0.0) # left cols outside assert np.all(c00[2:, 2:] == 5.0) # bottom-right inside # Corner (9,9): only top-left 3x3 quadrant is inside image c99 = cutouts[1] assert np.all(c99[3:, :] == 0.0) # bottom rows outside assert np.all(c99[:, 3:] == 0.0) # right cols outside assert np.all(c99[:3, :3] == 5.0) # top-left inside def test_make_cutouts_fully_inside(self, minimal_catalog_cls): """ Test that make_cutouts returns exact data for a fully inside source. """ data = np.arange(100, dtype=float).reshape(10, 10) kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cat = minimal_catalog_cls(data, xypos, kernel) cutouts = cat.make_cutouts(data) assert cutouts.shape == (1, 3, 3) expected = data[4:7, 4:7] np.testing.assert_array_equal(cutouts[0], expected) def test_select_brightest(self, minimal_catalog_cls): """ Test select_brightest selects the top sources by flux. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel, n_brightest=2) newcat = cat.select_brightest() assert len(newcat) == 2 # Brightest first assert newcat.flux[0] >= newcat.flux[1] def test_select_brightest_none(self, minimal_catalog_cls): """ Test that select_brightest with n_brightest=None keeps all sources. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel, n_brightest=None) newcat = cat.select_brightest() assert len(newcat) == 3 def test_reset_ids(self, minimal_catalog_cls): """ Test that reset_ids renumbers the catalog consecutively. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel) # Slice to drop the first source sub = cat[1:] assert sub.id[0] == 2 sub.reset_ids() np.testing.assert_array_equal(sub.id, [1, 2]) def test_apply_all_filters(self, minimal_catalog_cls): """ Test apply_all_filters chains apply_filters, select_brightest, and reset_ids. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel, n_brightest=2) result = cat.apply_all_filters() assert result is not None assert len(result) == 2 # IDs should be reset to [1, 2] np.testing.assert_array_equal(result.id, [1, 2]) def test_getitem_negative_index(self, minimal_catalog_cls): """ Test indexing with a negative integer index. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel) sub = cat[-1] assert len(sub) == 1 assert sub.xypos[0, 0] == 15 def test_getitem_empty_boolean_mask(self, minimal_catalog_cls): """ Test indexing with an all-False boolean mask. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10]]) cat = minimal_catalog_cls(data, xypos, kernel) mask = np.array([False, False]) sub = cat[mask] assert len(sub) == 0 def test_getitem_integer_array(self, minimal_catalog_cls): """ Test indexing with an integer array (fancy indexing). """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel) idx = np.array([2, 0]) sub = cat[idx] assert len(sub) == 2 assert sub.xypos[0, 0] == 15 assert sub.xypos[1, 0] == 5 def test_filter_bounds_none_range(self, minimal_catalog_cls): """ Test that _filter_bounds skips filtering when a range is None. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel) bounds = [('flux', None)] result = cat._filter_bounds(bounds) assert len(result) == 3 def test_filter_bounds_initial_mask(self, minimal_catalog_cls): """ Test _filter_bounds with an initial_mask. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10], [15, 15]]) cat = minimal_catalog_cls(data, xypos, kernel) # Pre-exclude the first source initial_mask = np.array([False, True, True]) result = cat._filter_bounds([], initial_mask=initial_mask) assert len(result) == 2 def test_default_columns_preserved_on_slice(self, minimal_catalog_cls): """ Test that default_columns is preserved when slicing. """ data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5], [10, 10]]) cat = minimal_catalog_cls(data, xypos, kernel) cat.default_columns = ('id', 'x_centroid', 'y_centroid') sub = cat[0] assert sub.default_columns == ('id', 'x_centroid', 'y_centroid') class TestStarFinderBaseCall: """ Test that StarFinderBase.__call__ delegates to find_stars. """ def test_call_delegates_to_find_stars(self, data): """ Test that __call__ returns the same result as find_stars. """ finder = DAOStarFinder(threshold=5.0, fwhm=2.0) tbl_call = finder(data) tbl_find = finder.find_stars(data) assert len(tbl_call) == len(tbl_find) for col in tbl_call.colnames: np.testing.assert_array_equal(tbl_call[col], tbl_find[col]) def test_deprecated_attr(data): """ Test that accessing the deprecated attribute on the StarFinderCatalogBase raises an warning. """ finder = DAOStarFinder(threshold=5.0, fwhm=2.0) cat = finder._get_raw_catalog(data) match = 'attribute was deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): _ = cat.xcentroid def test_deprecated_default(): """ Test repr for _DeprecatedDefault. """ default = _DEPR_DEFAULT result = '' assert repr(default) == result assert str(default) == result astropy-photutils-3322558/photutils/detection/tests/test_daofinder.py000066400000000000000000000507601517052111400261650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the daofinder module. """ 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.daofinder import DAOStarFinder from photutils.utils.exceptions import NoDetectionsWarning class TestDAOStarFinder: """ Test the DAOStarFinder class. """ def test_find(self, data): """ Test basic source detection and unit handling. """ 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) assert np.min(tbl0['flux']) > 150 # Test that sources are returned with threshold = 0 finder = DAOStarFinder(0, fwhm) tbl = finder(data) assert len(tbl) == 25 def test_inputs(self): """ Test that invalid inputs raise appropriate errors. """ match = 'fwhm must be a scalar value' with pytest.raises(TypeError, match=match): DAOStarFinder(threshold=3.0, fwhm=np.ones((2, 2))) match = 'fwhm must be positive' for fwhm in (-10, 0): with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=3.0, fwhm=fwhm) match = 'ratio must be > 0 and <= 1.0' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=3.0, fwhm=2, ratio=-10) match = 'sigma_radius must be positive' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=3.0, fwhm=2, sigma_radius=-10) match = 'n_brightest must be > 0' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, n_brightest=-1) match = 'n_brightest must be an integer' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, n_brightest=3.1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) match = 'xycoords must be shaped as an Nx2 array' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) @pytest.mark.parametrize(('theta', 'expected'), [ (400.0, 40.0), (-30.0, 330.0), (360.0, 0.0), (0.0, 0.0), ]) def test_theta_normalization(self, theta, expected): """ Test that theta values are normalized to [0, 360). """ finder = DAOStarFinder(threshold=5.0, fwhm=3.0, theta=theta) assert finder.theta == expected def test_nosources(self, data): """ Test that no sources returns None with a warning. """ match = 'No sources were found' finder = DAOStarFinder(threshold=100, fwhm=2) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None finder = DAOStarFinder(threshold=1, fwhm=2) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(-data) assert tbl is None def test_exclude_border(self): """ Test that border sources are excluded. """ 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_mask(self, data): """ Test source detection 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_mask_int(self, data): """ Test that an integer mask gives the same result as a boolean mask. """ finder = DAOStarFinder(threshold=1.0, fwhm=1.5) bool_mask = np.zeros(data.shape, dtype=bool) bool_mask[0:50, :] = True int_mask = bool_mask.astype(int) tbl_bool = finder(data, mask=bool_mask) tbl_int = finder(data, mask=int_mask) assert_array_equal(tbl_bool, tbl_int) def test_xycoords(self, data): """ Test source detection at specified coordinates. """ finder0 = DAOStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['x_centroid'], tbl0['y_centroid'], strict=True)) 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): """ Test the min_separation parameter. """ threshold = 1.0 fwhm = 1.0 finder1 = DAOStarFinder(threshold, fwhm) tbl1 = finder1(data) assert finder1.min_separation == 2.5 * fwhm 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_min_separation_default(self): """ Test that the default min_separation (None) gives 2.5 * fwhm. """ fwhm = 2.0 finder = DAOStarFinder(threshold=1.0, fwhm=fwhm) assert finder.min_separation == 2.5 * fwhm finder_old = DAOStarFinder(threshold=1.0, fwhm=fwhm, min_separation=0) assert finder_old.min_separation == 0 def test_brightest_filtering(self, data): """ Test that only the top brightest sources are selected. """ n_brightest = 10 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf), n_brightest=n_brightest) tbl = finder(data) assert len(tbl) == n_brightest # Combined with peak_max peak_max = 8 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf), n_brightest=n_brightest, peak_max=peak_max) tbl = finder(data) assert len(tbl) == 5 def test_sharpness(self, data): """ Test that no sources pass the sharpness criteria. """ finder = DAOStarFinder(threshold=1, fwhm=1.0, sharpness_range=(1.0, 1.0)) match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None @pytest.mark.parametrize('sharpness_range', [0.5, (0.5,), (1, 2, 3)]) def test_invalid_sharpness_range(self, sharpness_range): match = 'sharpness_range must be a 2-element .* tuple' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=1, fwhm=1.0, sharpness_range=sharpness_range) def test_sharpness_range_none(self, data): """ Test that sharpness_range=None disables sharpness filtering. """ finder_none = DAOStarFinder(threshold=1, fwhm=1.0, roundness_range=None, sharpness_range=None) tbl_none = finder_none(data) assert tbl_none is not None finder_strict = DAOStarFinder(threshold=1, fwhm=1.0, roundness_range=None, sharpness_range=(1.0, 1.0)) match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): tbl_strict = finder_strict(data) assert tbl_strict is None assert len(tbl_none) >= 1 def test_roundness(self, data): """ Test that no sources pass the roundness criteria. """ match = 'Sources were found, but none pass' finder = DAOStarFinder(threshold=1, fwhm=1.0, roundness_range=(1.0, 1.0)) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None @pytest.mark.parametrize('roundness_range', [0.5, (0.5,), (1, 2, 3)]) def test_invalid_roundness_range(self, roundness_range): match = 'roundness_range must be a 2-element .* tuple' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=1, fwhm=1.0, roundness_range=roundness_range) def test_roundness_range_none(self, data): """ Test that roundness_range=None disables roundness filtering. """ finder_none = DAOStarFinder(threshold=1, fwhm=1.0, sharpness_range=None, roundness_range=None) tbl_none = finder_none(data) assert tbl_none is not None finder_strict = DAOStarFinder(threshold=1, fwhm=1.0, sharpness_range=None, roundness_range=(1.0, 1.0)) match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): tbl_strict = finder_strict(data) assert tbl_strict is None assert len(tbl_none) >= 1 def test_peak_max(self, data): """ Test that no sources pass the peak_max criteria. """ match = 'Sources were found, but none pass' finder = DAOStarFinder(threshold=1, fwhm=1.0, peak_max=1.0) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None def test_peak_max_filtering(self, data): """ Test that sources with peak >= peak_max are filtered out. """ peak_max = 8 finder0 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf)) finder1 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf), peak_max=peak_max) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] <= peak_max) def test_single_detected_source(self, data): """ Test detection and slicing with a single source. """ finder = DAOStarFinder(7.9, 2, n_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_interval_ends_included(self): """ Test that filter interval endpoints are inclusive. """ # https://github.com/astropy/photutils/issues/1977 data = np.zeros((46, 64)) x = 33 y = 21 data[y - 1: y + 2, x - 1: x + 2] = [ [1.0, 2.0, 1.0], [2.0, 1.0e20, 2.0], [1.0, 2.0, 1.0], ] finder = DAOStarFinder( threshold=0, fwhm=2.5, roundness_range=(0, 1.0), sharpness_range=(0.2, 1.407913491884342), peak_max=1.0e20, ) tbl = finder.find_stars(data) assert len(tbl) == 1 assert tbl[0]['roundness1'] < 1.e-15 assert tbl[0]['roundness2'] == 0.0 assert tbl[0]['peak'] == 1.0e20 def test_data_not_mutated(self, data): """ Test that input data is not mutated by find_stars. """ data_copy = data.copy() finder = DAOStarFinder(threshold=1.0, fwhm=1.5) finder(data) assert_array_equal(data, data_copy) def test_data_not_mutated_with_mask(self, data): """ Test that input data is not mutated when a mask is used. """ data_copy = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True finder = DAOStarFinder(threshold=1.0, fwhm=1.5) finder(data, mask=mask) assert_array_equal(data, data_copy) def test_repr(self): """ Test the __repr__ of DAOStarFinder. """ finder = DAOStarFinder(threshold=5.0, fwhm=3.0) repr_ = repr(finder) assert 'DAOStarFinder(' in repr_ assert 'threshold=5.0' in repr_ assert 'fwhm=3.0' in repr_ assert 'ratio=1.0' in repr_ assert 'xycoords=None' in repr_ def test_str(self): """ Test the __str__ of DAOStarFinder. """ finder = DAOStarFinder(threshold=5.0, fwhm=3.0) str_ = str(finder) assert 'DAOStarFinder' in str_ assert 'threshold: 5.0' in str_ assert 'fwhm: 3.0' in str_ def test_repr_with_xycoords(self): """ Test that __repr__ shows array shape when xycoords are provided. """ xycoords = np.array([[5, 5], [10, 10]]) finder = DAOStarFinder(threshold=5.0, fwhm=3.0, xycoords=xycoords) assert '' in repr(finder) def test_threshold_2d_uniform(self, data): """ Test that a uniform 2D threshold gives the same results as a scalar threshold. """ threshold = 5.0 fwhm = 1.0 finder_scalar = DAOStarFinder(threshold, fwhm) finder_2d = DAOStarFinder(np.full(data.shape, threshold), fwhm) tbl_scalar = finder_scalar(data) tbl_2d = finder_2d(data) assert_array_equal(tbl_scalar, tbl_2d) def test_threshold_2d_varying(self, data): """ Test that a varying 2D threshold detects fewer sources in regions with a higher threshold. """ fwhm = 1.0 threshold_low = 1.0 threshold_high = 100.0 threshold_2d = np.full(data.shape, threshold_low) threshold_2d[0:50, :] = threshold_high finder_low = DAOStarFinder(threshold_low, fwhm) finder_2d = DAOStarFinder(threshold_2d, fwhm) tbl_low = finder_low(data) tbl_2d = finder_2d(data) assert len(tbl_low) > len(tbl_2d) # All 2D sources should be in the lower half assert all(tbl_2d['y_centroid'] >= 50) def test_threshold_2d_repr(self): """ Test repr with a 2D threshold array. """ threshold = np.ones((10, 10)) finder = DAOStarFinder(threshold=threshold, fwhm=3.0) assert '' in repr(finder) assert '' in str(finder) def test_threshold_2d_with_units(self, data): """ Test that a 2D threshold with units works correctly. """ units = u.Jy threshold = 5.0 fwhm = 1.0 threshold_2d = np.full(data.shape, threshold) * units finder = DAOStarFinder(threshold_2d, fwhm) tbl = finder(data << units) assert len(tbl) > 0 def test_scale_threshold_default(self, data): """ Test that scale_threshold=True (default) applies rel_err scaling. """ threshold = 5.0 fwhm = 1.5 finder_default = DAOStarFinder(threshold, fwhm) finder_explicit = DAOStarFinder(threshold, fwhm, scale_threshold=True) tbl_default = finder_default(data) tbl_explicit = finder_explicit(data) assert_array_equal(tbl_default, tbl_explicit) # Verify the effective threshold is scaled assert finder_default.threshold_eff != threshold def test_scale_threshold_false(self, data): """ Test that scale_threshold=False uses the threshold directly. """ threshold = 5.0 fwhm = 1.5 finder = DAOStarFinder(threshold, fwhm, scale_threshold=False) assert finder.threshold_eff == threshold tbl = finder(data) assert len(tbl) > 0 def test_scale_threshold_false_different_results(self, data): """ Test that scale_threshold=False gives different results than the default. """ threshold = 5.0 fwhm = 1.5 finder_scaled = DAOStarFinder(threshold, fwhm, scale_threshold=True) finder_unscaled = DAOStarFinder(threshold, fwhm, scale_threshold=False) tbl_scaled = finder_scaled(data) tbl_unscaled = finder_unscaled(data) # Different numbers of sources because effective thresholds # differ assert len(tbl_scaled) != len(tbl_unscaled) def test_scale_threshold_false_with_2d(self, data): """ Test that scale_threshold=False works with a 2D threshold array. """ fwhm = 1.5 threshold_2d = np.full(data.shape, 5.0) finder = DAOStarFinder(threshold_2d, fwhm, scale_threshold=False) tbl = finder(data) assert len(tbl) > 0 def test_scale_threshold_in_repr(self): """ Test that scale_threshold appears in repr. """ finder = DAOStarFinder(threshold=5.0, fwhm=3.0, scale_threshold=False) assert 'scale_threshold=False' in repr(finder) assert 'scale_threshold: False' in str(finder) def test_deprecated_sharplo_sharphi(self): """ Test that the deprecated 'sharplo'/'sharphi' keywords raise a warning and still work. """ match = "The 'sharplo' and 'sharphi' parameters are deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, sharplo=0.1) assert finder.sharpness_range == (0.1, 1.0) with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, sharphi=2.0) assert finder.sharpness_range == (0.2, 2.0) with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, sharplo=0.1, sharphi=2.0) assert finder.sharpness_range == (0.1, 2.0) def test_deprecated_roundlo_roundhi(self): """ Test that the deprecated 'roundlo'/'roundhi' keywords raise a warning and still work. """ match = "The 'roundlo' and 'roundhi' parameters are deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, roundlo=-0.5) assert finder.roundness_range == (-0.5, 1.0) with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, roundhi=0.5) assert finder.roundness_range == (-1.0, 0.5) with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, roundlo=-0.5, roundhi=0.5) assert finder.roundness_range == (-0.5, 0.5) def test_deprecated_brightest(self): """ Test that the deprecated 'brightest' keyword raises a warning and still works. """ match = "'brightest' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, brightest=5) assert finder.n_brightest == 5 def test_deprecated_peakmax(self): """ Test that the deprecated 'peakmax' keyword raises a warning and still works. """ match = "'peakmax' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, peakmax=100.0) assert finder.peak_max == 100.0 astropy-photutils-3322558/photutils/detection/tests/test_irafstarfinder.py000066400000000000000000000474461517052111400272440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the irafstarfinder module. """ 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.psf import CircularGaussianPRF from photutils.utils.exceptions import NoDetectionsWarning class TestIRAFStarFinder: """ Test the IRAFStarFinder class. """ def test_find(self, data): """ Test basic source detection and unit handling. """ 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) assert tbl0['orientation'].unit == u.deg def test_inputs(self): """ Test that invalid inputs raise appropriate errors. """ match = 'fwhm must be a scalar value' with pytest.raises(TypeError, match=match): IRAFStarFinder(threshold=3.0, fwhm=np.ones((2, 2))) match = 'fwhm must be positive' for fwhm in (-10, 0): with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=3.0, fwhm=fwhm) match = 'n_brightest must be > 0' with pytest.raises(ValueError, match=match): IRAFStarFinder(10, 1.5, n_brightest=-1) match = 'n_brightest must be an integer' with pytest.raises(ValueError, match=match): IRAFStarFinder(10, 1.5, n_brightest=3.1) match = 'minsep_fwhm must be >= 0' with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): IRAFStarFinder(10, 1.5, minsep_fwhm=-1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) match = 'xycoords must be shaped as an Nx2 array' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) def test_nosources(self): """ Test that no sources returns None with a warning. """ data = np.ones((3, 3)) match = 'No sources were found' finder = IRAFStarFinder(threshold=10, fwhm=1) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 finder = IRAFStarFinder(threshold=0.1, fwhm=0.1, min_separation=0) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(-data) assert tbl is None def test_mask(self, data): """ Test source detection 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_mask_int(self, data): """ Test that an integer mask gives the same result as a boolean mask. """ finder = IRAFStarFinder(threshold=1.0, fwhm=1.5) bool_mask = np.zeros(data.shape, dtype=bool) bool_mask[0:50, :] = True int_mask = bool_mask.astype(int) tbl_bool = finder(data, mask=bool_mask) tbl_int = finder(data, mask=int_mask) assert_array_equal(tbl_bool, tbl_int) def test_xycoords(self, data): """ Test source detection at specified coordinates. """ finder0 = IRAFStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['x_centroid'], tbl0['y_centroid'], strict=True)) 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): """ Test the min_separation parameter. """ threshold = 1.0 fwhm = 1.0 finder1 = IRAFStarFinder(threshold, fwhm) tbl1 = finder1(data) assert finder1.min_separation == 2.5 * fwhm finder2 = IRAFStarFinder(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): IRAFStarFinder(threshold=10, fwhm=1.5, min_separation=-1.0) def test_min_separation_default(self): """ Test that the default min_separation (None) gives 2.5 * fwhm. """ fwhm = 2.0 finder = IRAFStarFinder(threshold=1.0, fwhm=fwhm) assert finder.min_separation == 2.5 * fwhm # Previous (< 3.0) default behavior old_default = max(2, int(fwhm * 2.5 + 0.5)) finder_old = IRAFStarFinder(threshold=1.0, fwhm=fwhm, min_separation=old_default) assert finder_old.min_separation == old_default def test_n_brightest_filtering(self, data): """ Test that only the top n_brightest sources are selected. """ n_brightest = 10 finder = IRAFStarFinder(threshold=1.0, fwhm=2, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf), n_brightest=n_brightest) tbl = finder(data) assert len(tbl) == n_brightest def test_sharpness(self, data): """ Test that no sources pass the sharpness criteria. """ match = 'Sources were found, but none pass' finder = IRAFStarFinder(threshold=1, fwhm=1.0, sharpness_range=(2.0, 2.0)) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None @pytest.mark.parametrize('sharpness_range', [0.5, (0.5,), (1, 2, 3)]) def test_invalid_sharpness_range(self, sharpness_range): match = 'sharpness_range must be a 2-element .* tuple' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=1, fwhm=1.0, sharpness_range=sharpness_range) def test_sharpness_range_none(self, data): """ Test that sharpness_range=None disables sharpness filtering. """ finder_none = IRAFStarFinder(threshold=1, fwhm=2, roundness_range=None, sharpness_range=None) tbl_none = finder_none(data) assert tbl_none is not None finder_strict = IRAFStarFinder(threshold=1, fwhm=1.0, roundness_range=None, sharpness_range=(2.0, 2.0)) match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): tbl_strict = finder_strict(data) assert tbl_strict is None assert len(tbl_none) >= 1 def test_roundness(self, data): """ Test that no sources pass the roundness criteria. """ match = 'Sources were found, but none pass' finder = IRAFStarFinder(threshold=1, fwhm=1.0, roundness_range=(1.0, np.inf)) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None @pytest.mark.parametrize('roundness_range', [0.5, (0.5,), (1, 2, 3)]) def test_invalid_roundness_range(self, roundness_range): match = 'roundness_range must be a 2-element .* tuple' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=1, fwhm=1.0, roundness_range=roundness_range) def test_roundness_range_none(self, data): """ Test that roundness_range=None disables roundness filtering. """ finder_none = IRAFStarFinder(threshold=1, fwhm=2, sharpness_range=None, roundness_range=None) tbl_none = finder_none(data) assert tbl_none is not None finder_strict = IRAFStarFinder(threshold=1, fwhm=1.0, sharpness_range=None, roundness_range=(1.0, np.inf)) match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): tbl_strict = finder_strict(data) assert tbl_strict is None assert len(tbl_none) >= 1 def test_peak_max(self, data): """ Test that no sources pass the peak_max criteria. """ match = 'Sources were found, but none pass' finder = IRAFStarFinder(threshold=1, fwhm=1.0, peak_max=1.0) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None def test_peak_max_filtering(self, data): """ Test that sources with peak >= peak_max are filtered out. """ peak_max = 8 finder0 = IRAFStarFinder(threshold=1.0, fwhm=2, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf)) finder1 = IRAFStarFinder(threshold=1.0, fwhm=2, roundness_range=(-np.inf, np.inf), sharpness_range=(-np.inf, np.inf), peak_max=peak_max) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] <= peak_max) def test_single_detected_source(self, data): """ Test detection and slicing with a single source. """ finder = IRAFStarFinder(8.4, 2, n_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_all_border_sources(self): """ Test that all-border sources are excluded correctly. """ model1 = CircularGaussianPRF(flux=100, x_0=1, y_0=1, fwhm=2) model2 = CircularGaussianPRF(flux=100, x_0=50, y_0=50, fwhm=2) model3 = CircularGaussianPRF(flux=100, x_0=30, y_0=30, fwhm=2) threshold = 1 yy, xx = np.mgrid[:51, :51] data = model1(xx, yy) # Test single source within the border region finder = IRAFStarFinder(threshold=threshold, fwhm=2.0, roundness_range=(-0.1, 0.2), exclude_border=True) with pytest.warns(NoDetectionsWarning): tbl = finder(data) assert tbl is None # Test multiple sources all within the border region data += model2(xx, yy) with pytest.warns(NoDetectionsWarning): tbl = finder(data) assert tbl is None # Test multiple sources with some within the border region data += model3(xx, yy) tbl = finder(data) assert len(tbl) == 1 def test_interval_ends_included(self): """ Test that filter interval endpoints are inclusive. """ # https://github.com/astropy/photutils/issues/1977 data = np.zeros((46, 64)) x = 33 y = 21 data[y - 1: y + 2, x - 1: x + 2] = [ [0.1, 0.6, 0.1], [0.6, 0.8, 0.6], [0.1, 0.6, 0.1], ] finder = IRAFStarFinder( threshold=0, fwhm=2.5, roundness_range=(0, 0.2), peak_max=0.8, ) tbl = finder.find_stars(data) assert len(tbl) == 1 assert tbl[0]['roundness'] < 1.e-15 assert tbl[0]['peak'] == 0.8 def test_data_not_mutated(self, data): """ Test that input data is not mutated by find_stars. """ data_copy = data.copy() finder = IRAFStarFinder(threshold=1.0, fwhm=1.5) finder(data) assert_array_equal(data, data_copy) def test_data_not_mutated_with_mask(self, data): """ Test that input data is not mutated when a mask is used. """ data_copy = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True finder = IRAFStarFinder(threshold=1.0, fwhm=1.5) finder(data, mask=mask) assert_array_equal(data, data_copy) def test_repr(self): """ Test the __repr__ of IRAFStarFinder. """ finder = IRAFStarFinder(threshold=5.0, fwhm=3.0) repr_ = repr(finder) assert 'IRAFStarFinder(' in repr_ assert 'threshold=5.0' in repr_ assert 'fwhm=3.0' in repr_ assert 'min_separation=' in repr_ assert 'xycoords=None' in repr_ def test_str(self): """ Test the __str__ of IRAFStarFinder. """ finder = IRAFStarFinder(threshold=5.0, fwhm=3.0) str_ = str(finder) assert 'IRAFStarFinder' in str_ assert 'threshold: 5.0' in str_ assert 'fwhm: 3.0' in str_ def test_repr_with_xycoords(self): """ Test that __repr__ shows array shape when xycoords are provided. """ xycoords = np.array([[5, 5], [10, 10]]) finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, xycoords=xycoords) assert '' in repr(finder) def test_threshold_2d_uniform(self, data): """ Test that a uniform 2D threshold gives the same results as a scalar threshold. """ threshold = 5.0 fwhm = 1.0 finder_scalar = IRAFStarFinder(threshold, fwhm) finder_2d = IRAFStarFinder(np.full(data.shape, threshold), fwhm) tbl_scalar = finder_scalar(data) tbl_2d = finder_2d(data) assert_array_equal(tbl_scalar, tbl_2d) def test_threshold_2d_varying(self, data): """ Test that a varying 2D threshold detects fewer sources in regions with a higher threshold. """ fwhm = 1.0 threshold_low = 1.0 threshold_high = 100.0 threshold_2d = np.full(data.shape, threshold_low) threshold_2d[0:50, :] = threshold_high finder_low = IRAFStarFinder(threshold_low, fwhm) finder_2d = IRAFStarFinder(threshold_2d, fwhm) tbl_low = finder_low(data) tbl_2d = finder_2d(data) assert len(tbl_low) > len(tbl_2d) # All 2D sources should be in the lower half assert all(tbl_2d['y_centroid'] >= 50) def test_threshold_2d_repr(self): """ Test repr with a 2D threshold array. """ threshold = np.ones((10, 10)) finder = IRAFStarFinder(threshold=threshold, fwhm=3.0) assert '' in repr(finder) assert '' in str(finder) def test_threshold_2d_with_units(self, data): """ Test that a 2D threshold with units works correctly. """ units = u.Jy threshold = 5.0 fwhm = 1.0 threshold_2d = np.full(data.shape, threshold) * units finder = IRAFStarFinder(threshold_2d, fwhm) tbl = finder(data << units) assert len(tbl) > 0 def test_catalog_intermediate_properties(self, data): """ Test IRAF catalog intermediate properties: sky, cutout_data_nosub, cutout_xorigin, cutout_yorigin, sharpness. """ finder = IRAFStarFinder(threshold=1.0, fwhm=2.0, sharpness_range=(-np.inf, np.inf), roundness_range=(-np.inf, np.inf)) cat = finder._get_raw_catalog(data) assert cat is not None nsrc = len(cat) # sky should be finite and have same length as nsources sky = cat.sky assert sky.shape == (nsrc,) assert np.all(np.isfinite(sky)) # cutout_data_nosub should have shape (nsrc, ky, kx) with no # sky subtraction cdata = cat.cutout_data_nosub assert cdata.ndim == 3 assert cdata.shape[0] == nsrc # cutout_xorigin/cutout_yorigin should be finite 1D arrays xorig = cat.cutout_xorigin yorig = cat.cutout_yorigin assert xorig.shape == (nsrc,) assert yorig.shape == (nsrc,) assert np.all(np.isfinite(xorig)) assert np.all(np.isfinite(yorig)) # sharpness should be finite for detected sources sharpness = cat.sharpness assert sharpness.shape == (nsrc,) assert np.all(np.isfinite(sharpness)) def test_deprecated_sharplo_sharphi(self): """ Test that the deprecated 'sharplo'/'sharphi' keywords raise a warning and still work. """ match = "The 'sharplo' and 'sharphi' parameters are deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, sharplo=0.3) assert finder.sharpness_range == (0.3, 2.0) with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, sharphi=3.0) assert finder.sharpness_range == (0.5, 3.0) def test_deprecated_roundlo_roundhi(self): """ Test that the deprecated 'roundlo'/'roundhi' keywords raise a warning and still work. """ match = "The 'roundlo' and 'roundhi' parameters are deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, roundlo=-0.1) assert finder.roundness_range == (-0.1, 0.2) with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, roundhi=0.5) assert finder.roundness_range == (0.0, 0.5) def test_deprecated_brightest(self): """ Test that the deprecated 'brightest' keyword raises a warning and still works. """ match = "'brightest' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, brightest=5) assert finder.n_brightest == 5 def test_deprecated_peakmax(self): """ Test that the deprecated 'peakmax' keyword raises a warning and still works. """ match = "'peakmax' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, peakmax=100.0) assert finder.peak_max == 100.0 def test_deprecated_minsep_fwhm(self): """ Test that the deprecated 'minsep_fwhm' keyword raises a warning and still works. """ fwhm = 3.0 minsep_fwhm = 2.5 match = "The 'minsep_fwhm' parameter is deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=fwhm, minsep_fwhm=minsep_fwhm) expected = max(2, int((fwhm * minsep_fwhm) + 0.5)) assert finder.min_separation == expected def test_deprecated_minsep_fwhm_overridden(self): """ Test that min_separation takes priority over minsep_fwhm. """ match = "The 'minsep_fwhm' parameter is deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, minsep_fwhm=2.5, min_separation=7.0) assert finder.min_separation == 7.0 astropy-photutils-3322558/photutils/detection/tests/test_peakfinder.py000066400000000000000000000535711517052111400263450ustar00rootroot00000000000000# 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 from photutils.utils.exceptions import NoDetectionsWarning 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_mask_int(self, data): """ Test that an integer mask gives the same result as a boolean mask. """ bool_mask = np.zeros(data.shape, dtype=bool) bool_mask[0:50, :] = True int_mask = bool_mask.astype(int) tbl_bool = find_peaks(data, 0.1, box_size=3, mask=bool_mask) tbl_int = find_peaks(data, 0.1, box_size=3, mask=int_mask) assert_array_equal(tbl_bool, tbl_int) def test_maskshape(self, data): """ Test if mask shape doesn't match data shape. """ match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): find_peaks(data, 0.1, mask=np.ones((5, 5))) def test_thresholdshape(self, data): """ Test if threshold shape doesn't match data shape. """ match = 'threshold array must have the same shape as the input data' with pytest.raises(ValueError, match=match): find_peaks(data, np.ones((2, 2))) def test_n_peaks(self, data): """ Test n_peaks. """ tbl = find_peaks(data, 0.1, box_size=3, n_peaks=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=0) tbl2 = find_peaks(data, 0.1, box_size=3, border_width=(0, 0)) assert len(tbl0) == len(tbl1) assert len(tbl1) == len(tbl2) tbl3 = find_peaks(data, 0.1, box_size=3, border_width=25) tbl4 = find_peaks(data, 0.1, box_size=3, border_width=(25, 25)) assert len(tbl3) == len(tbl4) assert len(tbl3) < len(tbl0) tbl0 = find_peaks(data, 0.1, box_size=3, border_width=(34, 0)) tbl1 = find_peaks(data, 0.1, box_size=3, border_width=(0, 36)) assert np.min(tbl0['y_peak']) >= 34 assert np.min(tbl1['x_peak']) >= 36 match = 'border_width must be >= 0' with pytest.raises(ValueError, match=match): find_peaks(data, 0.1, box_size=3, border_width=-1) match = 'border_width must have integer values' with pytest.raises(ValueError, match=match): find_peaks(data, 0.1, box_size=3, border_width=3.1) def test_border_width_excludes_all(self, data): """ Test that a border_width encompassing the entire image returns None with a NoDetectionsWarning. """ match = 'No local peaks were found' with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 0.1, box_size=3, border_width=100) assert tbl is None def test_box_size_int(self, data): """ Test noninteger 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. """ match = 'centroid_func must be a callable object' with pytest.raises(TypeError, match=match): find_peaks(data, 0.1, box_size=2, centroid_func=True) def test_centroid_func_with_error(self, data): """ Test find_peaks with a centroid_func and an error array. """ error = np.ones_like(data) * 0.1 tbl = find_peaks(data, 0.1, box_size=3, centroid_func=centroid_com, error=error) assert 'x_centroid' in tbl.colnames assert 'y_centroid' in tbl.colnames assert len(tbl) > 0 def test_centroid_func_with_footprint(self, data): """ Test find_peaks with a footprint and centroid_func. Even-sized footprint dimensions should be rounded up to odd for the centroid box_size. """ footprint = np.ones((4, 4), dtype=bool) tbl = find_peaks(data, 0.1, footprint=footprint, centroid_func=centroid_com) assert 'x_centroid' in tbl.colnames assert 'y_centroid' in tbl.colnames assert len(tbl) > 0 def test_error_without_centroid_func(self, data): """ Test that error is silently ignored when centroid_func is None. """ error = np.ones_like(data) * 0.1 tbl1 = find_peaks(data, 0.1, box_size=3) tbl2 = find_peaks(data, 0.1, box_size=3, error=error) assert_array_equal(tbl1, tbl2) 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): """ Test that WCS and GWCS give the same sky coordinates. """ 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) def test_data_not_mutated(self, data): """ Test that input data is not mutated by find_peaks. """ data_copy = data.copy() find_peaks(data, 0.1, box_size=3) assert_equal(data, data_copy) def test_data_not_mutated_with_mask(self, data): """ Test that input data is not mutated when a mask is used. """ data_copy = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True find_peaks(data, 0.1, box_size=3, mask=mask) assert_equal(data, data_copy) @pytest.mark.parametrize('box_size', [3, 5, 7, 11]) def test_box_size_min_separation(self, box_size): """ Test that box_size imposes a minimum separation of ``box_size // 2 + 1`` pixels between detected peaks. """ min_sep = box_size // 2 + 1 size = 10 * box_size img = np.zeros((size, size)) # Place two peaks exactly at the minimum allowed separation cy = size // 2 cx1 = size // 2 cx2 = cx1 + min_sep img[cy, cx1] = 10.0 img[cy, cx2] = 10.0 tbl = find_peaks(img, 1.0, box_size=box_size) assert len(tbl) == 2 @pytest.mark.parametrize('box_size', [3, 5, 7, 11]) def test_box_size_below_min_separation(self, box_size): """ Test that peaks separated by less than ``box_size // 2 + 1`` pixels are merged (only the brighter one survives). """ min_sep = box_size // 2 + 1 size = 10 * box_size img = np.zeros((size, size)) # Place two peaks one pixel closer than the minimum separation; # only the brighter peak should survive cy = size // 2 cx1 = size // 2 cx2 = cx1 + min_sep - 1 img[cy, cx1] = 10.0 img[cy, cx2] = 8.0 tbl = find_peaks(img, 1.0, box_size=box_size) assert len(tbl) == 1 assert tbl['peak_value'][0] == 10.0 def test_min_separation(self, data): """ Test that min_separation enforces minimum Euclidean distance. """ tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, box_size=3, min_separation=10) assert len(tbl1) <= len(tbl0) # Check that all pairs of peaks are at least min_separation # apart if len(tbl1) > 1: x = np.array(tbl1['x_peak'], dtype=float) y = np.array(tbl1['y_peak'], dtype=float) for i in range(len(tbl1)): for j in range(i + 1, len(tbl1)): dist = np.sqrt((x[i] - x[j])**2 + (y[i] - y[j])**2) assert dist > 10 def test_min_separation_two_peaks(self): """ Test min_separation with two peaks at known separation. """ data = np.zeros((100, 100)) data[50, 30] = 10.0 data[50, 50] = 8.0 # Separation is 20 pixels; min_separation=15 should keep both tbl = find_peaks(data, 1.0, box_size=3, min_separation=15) assert len(tbl) == 2 # min_separation=25 should keep only the brightest tbl = find_peaks(data, 1.0, box_size=3, min_separation=25) assert len(tbl) == 1 assert tbl['peak_value'][0] == 10.0 def test_min_separation_plateau(self): """ Test that min_separation treats plateaus identically to an equivalent circular footprint (all equal-valued plateau pixels are local maxima). """ data = np.zeros((50, 50)) data[20:30, 20:30] = 10.0 # 10x10 plateau (diagonal ~12.7 px) for radius in (5, 15): idx = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(idx, idx) fp = np.array((xx**2 + yy**2) <= radius**2, dtype=int) tbl_ref = find_peaks(data, 1.0, footprint=fp) tbl_fast = find_peaks(data, 1.0, min_separation=radius) assert len(tbl_ref) == len(tbl_fast) assert_array_equal(tbl_ref['x_peak'], tbl_fast['x_peak']) assert_array_equal(tbl_ref['y_peak'], tbl_fast['y_peak']) def test_min_separation_with_units(self): """ Test min_separation with Quantity data. """ unit = u.Jy data = np.zeros((100, 100)) data[50, 30] = 10.0 data[50, 50] = 8.0 tbl = find_peaks(data << unit, 1.0 << unit, box_size=3, min_separation=25) assert len(tbl) == 1 assert tbl['peak_value'][0].value == 10.0 assert tbl['peak_value'][0].unit == unit def test_min_separation_with_npeaks(self): """ Test that min_separation and n_peaks work together. """ data = np.zeros((100, 100)) data[20, 20] = 10.0 data[20, 60] = 8.0 data[60, 20] = 6.0 data[60, 60] = 4.0 # All peaks are well-separated; # n_peaks=2 should keep brightest 2 tbl = find_peaks(data, 1.0, min_separation=5, n_peaks=2) assert len(tbl) == 2 assert tbl['peak_value'][0] == 10.0 assert tbl['peak_value'][1] == 8.0 def test_min_separation_negative(self, data): """ Test that negative min_separation raises ValueError. """ match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): find_peaks(data, 0.1, min_separation=-1) def test_min_separation_zero(self, data): """ Test that min_separation=0 gives the same result as None. """ tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, box_size=3, min_separation=0) assert_array_equal(tbl0, tbl1) def test_min_separation_with_footprint(self, data): """ Test that min_separation takes priority over footprint. """ footprint = np.ones((3, 3)) tbl = find_peaks(data, 0.1, footprint=footprint, min_separation=10) assert len(tbl) > 0 # Check minimum separation is enforced if len(tbl) > 1: x = np.array(tbl['x_peak'], dtype=float) y = np.array(tbl['y_peak'], dtype=float) for i in range(len(tbl)): for j in range(i + 1, len(tbl)): dist = np.sqrt((x[i] - x[j])**2 + (y[i] - y[j])**2) assert dist > 10 def test_min_separation_matches_circular_footprint(self): """ Test that min_separation produces the same peaks as an equivalent circular footprint passed to maximum_filter. """ rng = np.random.default_rng(42) data = rng.standard_normal((200, 200)) data[50, 50] = 20.0 data[120, 130] = 18.0 data[30, 170] = 15.0 threshold = 3.0 for radius in (5, 10, 25, 50): # Reference: actual circular footprint (slow but correct) idx = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(idx, idx) fp = np.array((xx**2 + yy**2) <= radius**2, dtype=int) tbl_ref = find_peaks(data, threshold, footprint=fp) tbl_fast = find_peaks(data, threshold, min_separation=radius) if tbl_ref is None: assert tbl_fast is None else: ref_xy = set(zip(tbl_ref['x_peak'].tolist(), tbl_ref['y_peak'].tolist(), strict=True)) fast_xy = set(zip(tbl_fast['x_peak'].tolist(), tbl_fast['y_peak'].tolist(), strict=True)) assert ref_xy == fast_xy def test_min_separation_rejects_non_maxima(self): """ Test that min_separation rejects peaks that are not the true local maximum within the circular region. This test would fail with a greedy KD-tree-only approach that uses a small box_size for initial peak detection, because such an approach would keep faint peaks that are not the maximum within a circle of min_separation (due to non-peak pixels with higher values in the neighborhood). """ data = np.zeros((100, 100)) # Bright peak with a declining gradient data[50, 50] = 100.0 for i in range(1, 30): data[50, 50 + i] = 100.0 - 2 * i # 98, 96, ..., 42 # Faint peak at (50, 85), which is 35 px from the bright peak. # The gradient pixel at (50, 65) = 100 - 2*15 = 70, which is # within radius 20 of (50, 85) and brighter (70 > 45). data[50, 85] = 45.0 # With min_separation=20: (50, 85) is NOT the local max within a # circle of radius 20 because (50, 65)=70 > 45. tbl = find_peaks(data, 1.0, min_separation=20) assert len(tbl) == 1 assert tbl['x_peak'][0] == 50 assert tbl['y_peak'][0] == 50 def test_min_separation_keeps_nearby_true_maxima(self): """ Test that two equal-valued peaks within min_separation of each other are both retained, matching the circular footprint result. """ radius = 12 data = np.zeros((100, 100)) # Two equal-valued peaks separated by less than min_separation # (dist = 11 px < radius = 12 px). Because they are equal, # each is tied for the max in its own circle, so both should # be detected (same behavior as maximum_filter with a circular # footprint). data[50, 40] = 10.0 data[50, 51] = 10.0 # Reference: actual circular footprint idx = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(idx, idx) fp = np.array((xx**2 + yy**2) <= radius**2, dtype=int) tbl_ref = find_peaks(data, 1.0, footprint=fp) tbl_fast = find_peaks(data, 1.0, min_separation=radius) assert len(tbl_ref) == 2 assert len(tbl_fast) == 2 assert_array_equal(tbl_ref['x_peak'], tbl_fast['x_peak']) assert_array_equal(tbl_ref['y_peak'], tbl_fast['y_peak']) def test_nan_no_false_peaks(self): """ Test that NaN pixels do not produce false peaks when the fill value (nanmin) happens to be a local maximum. """ data = np.full((50, 50), 5.0) data[25, 25] = 10.0 # one real peak data[10, 10] = np.nan # NaN pixel (fill value = 5.0 = background) data[10, 11] = np.nan tbl = find_peaks(data, 6.0, box_size=3) assert len(tbl) == 1 assert tbl['x_peak'][0] == 25 assert tbl['y_peak'][0] == 25 def test_nan_adjacent_to_peak(self): """ Test that NaN pixels adjacent to a real peak do not cause the peak to be lost or duplicated. """ data = np.zeros((50, 50)) data[25, 25] = 10.0 data[25, 26] = np.nan data[24, 25] = np.nan tbl = find_peaks(data, 1.0, box_size=3) assert len(tbl) == 1 assert tbl['x_peak'][0] == 25 assert tbl['y_peak'][0] == 25 def test_all_negative_data(self): """ Test peak detection with all-negative data. Peaks near the border may be suppressed because maximum_filter uses cval=0.0, but interior peaks should be detected correctly. """ data = np.full((50, 50), -10.0) data[25, 25] = -1.0 # brightest pixel, well inside border tbl = find_peaks(data, -5.0, box_size=3) assert tbl is not None assert len(tbl) == 1 assert tbl['x_peak'][0] == 25 assert tbl['y_peak'][0] == 25 def test_all_negative_border_suppression(self): """ Test that all-negative data near the border is suppressed by cval=0.0 in maximum_filter. """ data = np.full((50, 50), -10.0) # Peak at border and one well inside data[0, 0] = -1.0 data[25, 25] = -1.0 # The peak at (0,0) is above the threshold but cval=0.0 means # the border region has a "virtual" maximum of 0.0, which is # greater than -1.0, so this pixel won't be detected as a peak. tbl = find_peaks(data, -5.0, box_size=3) assert tbl is not None # The border peak should not be among the results assert not any((tbl['x_peak'] == 0) & (tbl['y_peak'] == 0)) # The interior peak should be detected assert any((tbl['x_peak'] == 25) & (tbl['y_peak'] == 25)) def test_min_separation_with_centroid_func(self, data): """ Test that min_separation works with centroid_func. The centroid box_size defaults to box_size (3) when min_separation is used. """ tbl = find_peaks(data, 0.1, min_separation=10, centroid_func=centroid_com) assert 'x_centroid' in tbl.colnames assert 'y_centroid' in tbl.colnames assert len(tbl) > 0 astropy-photutils-3322558/photutils/detection/tests/test_starfinder.py000066400000000000000000000276251517052111400263770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the starfinder module. """ import astropy.units as u import numpy as np import pytest from astropy.table import Table from astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_array_equal, assert_equal from photutils.detection import StarFinder from photutils.utils.exceptions import NoDetectionsWarning class TestStarFinder: """ Test the StarFinder class. """ def test_find(self, data, kernel): """ Test basic source detection and unit handling. """ 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 assert tbl1['orientation'].unit == u.deg # 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 assert tbl3['orientation'].unit == u.deg for col in tbl3.colnames: if col not in ('flux', 'max_value'): assert_equal(tbl3[col], tbl1[col]) def test_inputs(self, kernel): """ Test that invalid inputs raise appropriate errors. """ match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, min_separation=-1) match = 'n_brightest must be > 0' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, n_brightest=-1) match = 'n_brightest must be an integer' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, n_brightest=3.1) @pytest.mark.parametrize('ndim', [1, 3]) def test_kernel_not_2d(self, ndim): """ Test that non-2D kernels raise ValueError. """ bad_kernel = np.ones(5) if ndim == 1 else np.ones((3, 3, 3)) match = 'kernel must be a 2D array' with pytest.raises(ValueError, match=match): StarFinder(1, bad_kernel) def test_nosources(self, data, kernel): """ Test that no sources returns None with a warning. """ match = 'No sources were found' finder = StarFinder(100, kernel) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 finder = StarFinder(1, kernel) with pytest.warns(NoDetectionsWarning, match=match): tbl = finder(-data) assert tbl is None def test_exclude_border(self, data, kernel): """ Test that border sources are excluded. """ data = np.zeros((12, 12)) data[0:2, 0:2] = 1 data[9:12, 9:12] = 1 kernel = np.ones((3, 3)) finder0 = StarFinder(1, kernel, exclude_border=False) finder1 = StarFinder(1, kernel, exclude_border=True) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) def test_mask(self, data, kernel): """ Test source detection with a mask. """ 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['y_centroid']) > 50 def test_mask_int(self, data, kernel): """ Test that an integer mask gives the same result as a boolean mask. """ starfinder = StarFinder(1, kernel) bool_mask = np.zeros(data.shape, dtype=bool) bool_mask[0:50] = True int_mask = bool_mask.astype(int) tbl_bool = starfinder(data, mask=bool_mask) tbl_int = starfinder(data, mask=int_mask) assert_array_equal(tbl_bool, tbl_int) def test_min_separation(self, data, kernel): """ Test the min_separation parameter. """ 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_min_separation_default(self, kernel): """ Test that the default min_separation (None) gives 2.5 * (min(kernel.shape) // 2). """ finder = StarFinder(1, kernel) assert finder.min_separation == 2.5 * (min(kernel.shape) // 2) # Non-square kernel rect_kernel = np.ones((3, 7)) finder2 = StarFinder(1, rect_kernel) assert finder2.min_separation == 2.5 * (3 // 2) # Previous default behavior finder_old = StarFinder(1, kernel, min_separation=5) assert finder_old.min_separation == 5 def test_n_brightest(self, data, kernel): """ Test the n_brightest parameter. """ finder = StarFinder(1, kernel, n_brightest=10) tbl = finder(data) assert len(tbl) == 10 fluxes = tbl['flux'] assert fluxes[0] == np.max(fluxes) def test_peak_max(self, data, kernel): """ Test the peak_max parameter. """ finder1 = StarFinder(1, kernel, peak_max=None) finder2 = StarFinder(1, kernel, peak_max=11) tbl1 = finder1(data) tbl2 = finder2(data) assert len(tbl1) == 25 assert len(tbl2) == 16 match = 'Sources were found, but none pass' starfinder = StarFinder(10, kernel, peak_max=5) with pytest.warns(NoDetectionsWarning, match=match): tbl = starfinder(data) assert tbl is None def test_peak_max_limit(self): """ Test that the peak_max limit is inclusive. """ data = np.zeros((11, 11)) x = 5 y = 6 kernel = np.array([[0.1, 0.6, 0.1], [0.6, 0.8, 0.6], [0.1, 0.6, 0.1]]) data[y - 1: y + 2, x - 1: x + 2] = kernel finder = StarFinder(threshold=0, kernel=kernel, peak_max=0.8) tbl = finder.find_stars(data) assert len(tbl) == 1 assert tbl[0]['max_value'] == 0.8 def test_single_detected_source(self, data, kernel): """ Test detection and slicing with a single source. """ finder = StarFinder(11.5, kernel, n_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_repeated_calls(self, data, kernel): """ Test that calling find_stars twice gives identical results. """ finder = StarFinder(1, kernel) tbl1 = finder(data) tbl2 = finder(data) assert len(tbl1) == len(tbl2) for col in tbl1.colnames: assert_equal(tbl1[col], tbl2[col]) def test_quantity_units_mismatch(self, kernel): """ Test that mismatched data/threshold units raise an error. """ data = np.ones((11, 11)) finder = StarFinder(1 * u.Jy, kernel) match = 'must all have the same units' with pytest.raises(ValueError, match=match): finder(data << u.m) def test_quantity_with_negatives(self, data, kernel): """ Test detection with Quantity data containing negatives. """ unit = u.Jy data_neg = (data - 5.0) << unit finder = StarFinder(1 * unit, kernel) tbl = finder(data_neg) assert isinstance(tbl, Table) assert len(tbl) > 0 assert tbl['flux'].unit == unit def test_data_not_mutated(self, data, kernel): """ Test that input data is not mutated by find_stars. """ data = data - 5.0 # create some negative pixel values data_copy = data.copy() finder = StarFinder(1, kernel) finder(data) assert_equal(data, data_copy) def test_data_not_mutated_with_mask(self, data, kernel): """ Test that input data is not mutated when a mask is used. """ data = data - 5.0 data_copy = data.copy() mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True finder = StarFinder(1, kernel) finder(data, mask=mask) assert_equal(data, data_copy) def test_repr(self, kernel): """ Test the __repr__ of StarFinder. """ finder = StarFinder(threshold=5.0, kernel=kernel) repr_ = repr(finder) assert 'StarFinder(' in repr_ assert 'threshold=5.0' in repr_ assert '= 50) def test_threshold_2d_repr(self, kernel): """ Test repr with a 2D threshold array. """ threshold = np.ones((10, 10)) finder = StarFinder(threshold=threshold, kernel=kernel) assert '' in repr(finder) assert '' in str(finder) def test_threshold_2d_with_units(self, data, kernel): """ Test that a 2D threshold with units works correctly. """ unit = u.Jy threshold = 1.0 threshold_2d = np.full(data.shape, threshold) * unit finder = StarFinder(threshold_2d, kernel) tbl = finder(data << unit) assert len(tbl) > 0 def test_deprecated_brightest(self, kernel): """ Test that the deprecated 'brightest' keyword raises a warning and still works. """ match = "'brightest' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = StarFinder(threshold=5.0, kernel=kernel, brightest=5) assert finder.n_brightest == 5 def test_deprecated_peakmax(self, kernel): """ Test that the deprecated 'peakmax' keyword raises a warning and still works. """ match = "'peakmax' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): finder = StarFinder(threshold=5.0, kernel=kernel, peakmax=100.0) assert finder.peak_max == 100.0 astropy-photutils-3322558/photutils/geometry/000077500000000000000000000000001517052111400213245ustar00rootroot00000000000000astropy-photutils-3322558/photutils/geometry/__init__.py000066400000000000000000000007651517052111400234450ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing low-level geometry functions used by aperture photometry to calculate the overlap of aperture shapes with a pixel grid. These functions are not intended to be used directly by users, but are used by the higher-level `photutils.aperture` tools. """ from .circular_overlap import * # noqa: F401, F403 from .elliptical_overlap import * # noqa: F401, F403 from .rectangular_overlap import * # noqa: F401, F403 astropy-photutils-3322558/photutils/geometry/circular_overlap.pyx000066400000000000000000000203251517052111400254240ustar00rootroot00000000000000# 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 astropy-photutils-3322558/photutils/geometry/core.pxd000066400000000000000000000013351517052111400227730ustar00rootroot00000000000000# 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) astropy-photutils-3322558/photutils/geometry/core.pyx000066400000000000000000000275271517052111400230330ustar00rootroot00000000000000# 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 astropy-photutils-3322558/photutils/geometry/elliptical_overlap.pyx000066400000000000000000000152541517052111400257470ustar00rootroot00000000000000# 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 astropy-photutils-3322558/photutils/geometry/rectangular_overlap.pyx000066400000000000000000000077621517052111400261410ustar00rootroot00000000000000# 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) astropy-photutils-3322558/photutils/geometry/tests/000077500000000000000000000000001517052111400224665ustar00rootroot00000000000000astropy-photutils-3322558/photutils/geometry/tests/__init__.py000066400000000000000000000000001517052111400245650ustar00rootroot00000000000000astropy-photutils-3322558/photutils/geometry/tests/test_circular_overlap_grid.py000066400000000000000000000016071517052111400304440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circular_overlap_grid module. """ 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', grid_sizes) @pytest.mark.parametrize('circ_size', circ_sizes) @pytest.mark.parametrize('use_exact', use_exacts) @pytest.mark.parametrize('subsample', 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) astropy-photutils-3322558/photutils/geometry/tests/test_elliptical_overlap_grid.py000066400000000000000000000021761517052111400307640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the elliptical_overlap_grid module. """ 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', grid_sizes) @pytest.mark.parametrize('maj_size', maj_sizes) @pytest.mark.parametrize('min_size', min_sizes) @pytest.mark.parametrize('angle', angles) @pytest.mark.parametrize('use_exact', use_exacts) @pytest.mark.parametrize('subsample', 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) astropy-photutils-3322558/photutils/geometry/tests/test_rectangular_overlap_grid.py000066400000000000000000000016311517052111400311440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangular_overlap_grid module. """ 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', grid_sizes) @pytest.mark.parametrize('rect_size', rect_sizes) @pytest.mark.parametrize('angle', angles) @pytest.mark.parametrize('subsample', 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) astropy-photutils-3322558/photutils/isophote/000077500000000000000000000000001517052111400213235ustar00rootroot00000000000000astropy-photutils-3322558/photutils/isophote/__init__.py000066400000000000000000000007631517052111400234420ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing 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 astropy-photutils-3322558/photutils/isophote/ellipse.py000066400000000000000000001046431517052111400233420ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for fitting 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 from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) __all__ = ['Ellipse'] class Ellipse: """ 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 predefined, 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 them, 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 predefined 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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 @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('nclip', 'n_clip', '3.0', until='4.0') 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, n_clip=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 redistributing 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. n_clip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. .. deprecated:: 3.0 The ``nclip`` keyword is deprecated. Use ``n_clip`` instead. 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: sma = self._geometry.sma if self._geometry else 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: msg = ': Everything is fixed. Fit not possible.' warnings.warn(msg, 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=step, conver=conver, minit=minit_a, maxit=maxit, fflag=fflag, maxgerr=maxgerr, sclip=sclip, n_clip=n_clip, integrmode=integrmode, linear=linear, maxrit=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: msg = 'No meaningful fit was possible.' warnings.warn(msg, 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 and ((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=step, conver=conver, minit=minit, maxit=maxit, fflag=fflag, maxgerr=maxgerr, sclip=sclip, n_clip=n_clip, integrmode=integrmode, linear=linear, maxrit=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) @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('nclip', 'n_clip', '3.0', until='4.0') 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, n_clip=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 do not directly control the growth or reduction of the current fitting semimajor axis length. Instead, they are used by the sampling algorithm to determine the starting point for gradient computation and to calculate the areas of the elliptical sectors (when area integration mode is enabled). 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. n_clip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. .. deprecated:: 3.0 The ``nclip`` keyword is deprecated. Use ``n_clip`` instead. 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, n_clip, integrmode) else: isophote = self._iterative(sma, step, linear, geometry, sclip, n_clip, integrmode, conver, minit, maxit, fflag, maxgerr, going_inwards=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, n_clip, integrmode, conver, minit, maxit, fflag, maxgerr, *, going_inwards=False): if sma > 0.0: # iterative fitter sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, n_clip=n_clip, 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) return fitter.fit(conver=conver, minit=minit, maxit=maxit, fflag=fflag, maxgerr=maxgerr, going_inwards=going_inwards) def _non_iterative(self, sma, step, linear, geometry, sclip, n_clip, integrmode): sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, n_clip=n_clip, linear_growth=linear, geometry=geometry, integrmode=integrmode) sample.update(fixed_parameters=geometry.fix) # build isophote without iterating with an EllipseFitter return Isophote(sample, 0, valid=True, stop_code=4) @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( fixed_parameters=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.n_iter, isophote.valid, code) # add new isophote to list isophote_list.append(new_isophote) astropy-photutils-3322558/photutils/isophote/ellipse_model.pyx000066400000000000000000000131061517052111400247030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ Faster evaluation of ellipses from model.py. """ import cython import numpy as np cimport numpy as cnp __all__ = ['build_ellipse_model_c'] cnp.import_array() cdef extern from "math.h": double cos(double x) double sin(double x) double sqrt(double x) DTYPE = np.float64 ctypedef cnp.float64_t DTYPE_t cdef inline double get_intens_no_harmonics(double intens0, double phi, double a3, double b3, double a4, double b4): return intens0 cdef inline double get_intens_harmonics(double intens0, double phi, double a3, double b3, double a4, double b4): return ( intens0 + a3 * sin(3.0 * phi) + b3 * cos(3.0 * phi) + a4 * sin(4.0 * phi) + b4 * cos(4.0 * phi) ) def build_ellipse_model_c( unsigned int n_rows, unsigned int n_cols, cnp.ndarray[DTYPE_t, ndim=1] finely_spaced_sma, cnp.ndarray[DTYPE_t, ndim=1] intens_array, cnp.ndarray[DTYPE_t, ndim=1] eps_array, cnp.ndarray[DTYPE_t, ndim=1] pa_array, cnp.ndarray[DTYPE_t, ndim=1] x0_array, cnp.ndarray[DTYPE_t, ndim=1] y0_array, cnp.ndarray[DTYPE_t, ndim=1] a3_array = None, cnp.ndarray[DTYPE_t, ndim=1] b3_array = None, cnp.ndarray[DTYPE_t, ndim=1] a4_array = None, cnp.ndarray[DTYPE_t, ndim=1] b4_array = None, double phi_min = 0., double phi_max = 2.0*np.pi, ): cdef cython.Py_ssize_t len_sma len_sma = len(finely_spaced_sma) for array in (intens_array, eps_array, pa_array, x0_array, y0_array): if len(array) != len_sma: raise ValueError(f"All input arrays must be same length={len_sma}") harmonic_arrays = (a3_array, b3_array, a4_array, b4_array) harmonics_is_none = [array is None for array in harmonic_arrays] cdef double a3, b3, a4, b4 if all(harmonics_is_none): intens_func = get_intens_no_harmonics a3 = 0 b3 = 0 a4 = 0 b4 = 0 do_harmonics = False else: if any(harmonics_is_none): raise ValueError("Must supply all harmonic arrays if any is not None") for array in harmonic_arrays: if len(array) != len_sma: raise ValueError(f"All input arrays must be same length={len_sma}") intens_func = get_intens_harmonics do_harmonics = True cdef double phi, fx, fy, x, y cdef double one_m_fx, one_m_fy, one_m_fx_t_one_m_fy, one_m_fy_t_fx, one_m_fx_t_fy, fy_t_fx cdef double r, sma, q, pa, x0, y0, intens0, intens cdef int i, j, i_max, j_max cdef cython.Py_ssize_t index cdef bool i_ge_zero, i_p1_le_max # Define output array cdef double[:, :] result = np.zeros([n_rows, n_cols], dtype=DTYPE) cdef double[:, :] weight = np.zeros([n_rows, n_cols], dtype=DTYPE) i_max = n_cols - 1 j_max = n_rows - 1 for index in range(1, len_sma): with cython.boundscheck(False): sma = finely_spaced_sma[index] q = 1.0 - eps_array[index] pa = pa_array[index] x0 = x0_array[index] y0 = y0_array[index] intens0 = intens_array[index] phi = phi_min r = sma if do_harmonics: with cython.boundscheck(False): a3 = a3_array[index] b3 = b3_array[index] a4 = a4_array[index] b4 = b4_array[index] while phi <= phi_max: # get image coordinates of (r, phi) pixel x = r * cos(phi + pa) + x0 y = r * sin(phi + pa) + y0 # round down (this is equivalent to int(floor(x))) i = int(x) - (x < 0) j = int(y) - (y < 0) if (-1 <= i <= i_max) and (-1 <= j <= j_max): # get fractional deviations relative to target array fx = x - float(i) fy = y - float(j) intens = intens_func(intens0, phi, a3, b3, a4, b4) one_m_fx = (1.0 - fx) one_m_fy = (1.0 - fy) i_ge_zero = i >= 0 i_p1_le_max = (i + 1) <= i_max with cython.boundscheck(False): if j >= 0: if i_ge_zero: weight_pix = one_m_fx * one_m_fy # add up the isophote contribution to the overlapping pixels result[j, i] += intens * weight_pix # add up the fractional area contribution to the # overlapping pixels weight[j, i] += weight_pix if i_p1_le_max: weight_pix = one_m_fy * fx result[j, i + 1] += intens * weight_pix weight[j, i + 1] += weight_pix if (j + 1) <= j_max: if i_ge_zero: weight_pix = one_m_fx * fy result[j + 1, i] += intens * weight_pix weight[j + 1, i] += weight_pix if i_p1_le_max: weight_pix = fy * fx result[j + 1, i + 1] += intens * weight_pix weight[j + 1, i + 1] += weight_pix # step towards next pixel on ellipse phi = max((phi + 0.75 / r), phi_min) r = sma * q / sqrt((q * cos(phi))**2 + sin(phi)**2) # max(r, 0.5) could return nan - this is safer if not (r >= 0.5): r = 0.5 return np.asarray(result), np.asarray(weight) astropy-photutils-3322558/photutils/isophote/fitter.py000066400000000000000000000406151517052111400232000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for fitting 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=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, valid=False, stop_code=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, valid=False, stop_code=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)) and (i >= minit - 1): # Got a valid solution and a minimum number of # iterations has run sample.update(fixed_parameters=fixed_parameters) return Isophote(sample, i + 1, valid=True, stop_code=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=fixed_parameters) return Isophote(minimum_amplitude_sample, i + 1, valid=True, stop_code=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=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=fixed_parameters) return Isophote(sample, i + 1, valid=True, stop_code=-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=fixed_parameters) return Isophote(minimum_amplitude_sample, maxit, valid=True, stop_code=2) @staticmethod def _check_conditions(sample, maxgerr, going_inwards, lexceed): proceed = True # check if an acceptable gradient value could be computed. if sample.gradient_err and sample.gradient_rel_err: if not going_inwards and ( sample.gradient_rel_err > 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, n_clip=sample.n_clip, 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, n_clip=sample.n_clip, 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, n_clip=sample.n_clip, 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, **kwargs): # noqa: ARG002 """ Perform just a simple 1-pixel extraction at the current (x0, y0) position using bilinear interpolation. Parameters ---------- **kwargs : dict, optional Keyword arguments are ignored, but allowed 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=fixed_parameters) return CentralPixel(self._sample) astropy-photutils-3322558/photutils/isophote/geometry.py000066400000000000000000000475301517052111400235410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for storing parameters for the geometry of an ellipse. """ import math import numpy as np from astropy import log from photutils.utils._deprecation import deprecated_positional_kwargs __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 semiminor 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 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}') elif 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) 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 astropy-photutils-3322558/photutils/isophote/harmonics.py000066400000000000000000000102371517052111400236630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for computing and fitting harmonic functions. """ import numpy as np from scipy.optimize import leastsq __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. solution = leastsq(optimize_func, parameters, full_output=True) if solution[4] > 4: msg = f'Error in least squares fit: {solution[3]}' raise RuntimeError(msg) # 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]) astropy-photutils-3322558/photutils/isophote/integrator.py000066400000000000000000000266171517052111400240670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for integrating over pixels. """ import math import numpy as np __all__ = ['BILINEAR', 'INTEGRATORS', 'MEAN', 'MEDIAN', 'NEAREST_NEIGHBOR'] # 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(self._image.shape[1] - 1) self._j_range = range(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. Subclasses 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 its value from operating over a (generally variable) number of pixels that define a finite area that lies 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, or 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(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() return self._geometry.sector_angular_width / 2.0 + phi2 - self._phi 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, } astropy-photutils-3322558/photutils/isophote/isophote.py000066400000000000000000000764531517052111400235460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for storing the results of isophote fits. """ import astropy.units as u import numpy as np from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics, fit_upper_harmonic) from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated_getattr, deprecated_positional_kwargs) from photutils.utils._misc import _get_meta __all__ = ['Isophote', 'IsophoteList'] # Remove in 4.0 _DEPRECATED_ATTRIBUTES = { 'grad_error': 'gradient_err', 'grad_r_error': 'gradient_rel_err', 'niter': 'n_iter', 'ndata': 'n_data', 'nflag': 'n_flag', } 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. n_iter : 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. gradient_err : float The measurement error of the local radial intensity gradient. gradient_rel_err : 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 .. deprecated:: 3.0 Use ``n_data`` instead. n_data : int The number of extracted data points. nflag : int .. deprecated:: 3.0 Use ``n_flag`` instead. n_flag : 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, n_iter, valid, stop_code): self.sample = sample self.n_iter = n_iter 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.gradient_err = sample.gradient_err self.gradient_rel_err = sample.gradient_rel_err self.sarea = sample.sector_area self.n_data = sample.actual_points self.n_flag = 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) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') @staticmethod def _raise_sma_error(err): msg = "Comparison object does not have a 'sma' attribute" raise AttributeError(msg) from err # 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: self._raise_sma_error(err) def __gt__(self, other): try: return self.sma > other.sma except AttributeError as err: self._raise_sma_error(err) def __le__(self, other): try: return self.sma <= other.sma except AttributeError as err: self._raise_sma_error(err) def __ge__(self, other): try: return self.sma >= other.sma except AttributeError as err: self._raise_sma_error(err) def __eq__(self, other): try: return self.sma == other.sma except AttributeError as err: self._raise_sma_error(err) def __ne__(self, other): try: return self.sma != other.sma except AttributeError as err: self._raise_sma_error(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.gradient_rel_err if self.gradient_rel_err 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, valid=True, stop_code=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.gradient_err = None self.gradient_rel_err = None self.sarea = None self.n_data = sample.actual_points self.n_flag = 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: self._raise_sma_error(err) @property def eps(self): """ The ellipticity of the ellipse. """ return 0.0 @property def pa(self): """ The position angle (radians) of the ellipse. """ return 0.0 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 # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') 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): """ Sort the list of isophotes by semimajor axis length. """ self._list.sort() def insert(self, index, value): """ Insert an isophote at a given index. Parameters ---------- index : int The index where to insert the isophote. value : `~photutils.isophote.Isophote` The isophote to be inserted. """ self._list.insert(index, value) def append(self, value): """ Append an isophote to the list. Parameters ---------- value : `~photutils.isophote.Isophote` The isophote to be appended. """ self.insert(len(self) + 1, value) def extend(self, value): """ Extend the list with the isophotes from another `~photutils.isophote.IsophoteList` instance. Parameters ---------- value : `~photutils.isophote.IsophoteList` The isophotes to be appended. """ 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 gradient_err(self): """ The measurement error of the local radial intensity gradient. """ return self._collect_as_array('gradient_err') @property def gradient_rel_err(self): """ The relative error of local radial intensity gradient. """ return self._collect_as_array('gradient_rel_err') @property def sarea(self): """ The average sector area on the isophote (pixel**2). """ return self._collect_as_array('sarea') @property def n_data(self): """ The number of extracted data points. """ return self._collect_as_array('n_data') @property def n_flag(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('n_flag') @property def n_iter(self): """ The number of iterations used to fit the isophote. """ return self._collect_as_array('n_iter') @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') @deprecated_positional_kwargs(since='3.0', until='4.0') 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=columns) def get_names(self): """ Get the names of the properties of an `~photutils.isophote.IsophoteList` instance. Returns ------- list_names : list A list of the names of the properties. """ return list(_get_properties(self).keys()) 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. """ # deprecated IsophoteList property names to exclude _deprecated_props = {'npix_e', 'npix_c'} properties = {} for an_item in isophote_list.__class__.__dict__: p_type = isophote_list.__class__.__dict__[an_item] # Exclude the sample property and deprecated properties if (isinstance(p_type, property) and 'sample' not in an_item and an_item not in _deprecated_props): 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 = {} # Remove in 4.0 _deprecation_map = { 'grad_error': 'gradient_err', 'grad_rerror': 'gradient_rel_err', } # Replace with QTable in 4.0 isotable = create_empty_deprecated_qtable( _deprecation_map, since='3.0', until='4.0') 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', 'n_flag'), new_names=('intens_err', 'ellipticity', 'ellipticity_err', 'n_flag')): """ 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', 'gradient_err', 'gradient_rel_err', 'x0', 'x0_err', 'y0', 'y0_err', 'n_data', 'n_flag', 'n_iter', '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] = np.rad2deg(isotable[v]) << u.deg return isotable astropy-photutils-3322558/photutils/isophote/model.py000066400000000000000000000110651517052111400230000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for building a model elliptical galaxy image from a list of isophotes. """ import numpy as np from scipy.interpolate import LSQUnivariateSpline from photutils.utils._deprecation import deprecated_positional_kwargs from .ellipse_model import build_ellipse_model_c __all__ = ['build_ellipse_model'] @deprecated_positional_kwargs(since='3.0', until='4.0') def build_ellipse_model( shape, isolist, fill: float = 0.0, high_harmonics=False, sma_interval: float = 0.1, ): """ 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. sma_interval : optional, float The interval between node values of the semi-major axis, which is used to spline interpolate values of other shape parameters. Returns ------- result : 2D `~numpy.ndarray` The image with the model galaxy. """ if len(isolist) == 0: msg = 'isolist must not be empty' raise ValueError(msg) # 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, sma_interval, ) # 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) if high_harmonics: 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) grad_sma = -grad_array * finely_spaced_sma # Return deviations from ellipticity to their original amplitude # meaning kwargs_harm = { 'a3_array': a3_array * grad_sma, 'b3_array': b3_array * grad_sma, 'a4_array': a4_array * grad_sma, 'b4_array': b4_array * grad_sma, } else: kwargs_harm = {} # correct deviations caused by fluctuations in spline solution eps_array[np.where(eps_array < 0.0)] = 0.0 # for each interpolated isophote, generate intensity values on the # output image array result, weight = build_ellipse_model_c( shape[0], shape[1], finely_spaced_sma, intens_array, eps_array, pa_array, x0_array, y0_array, **kwargs_harm, ) # 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 astropy-photutils-3322558/photutils/isophote/sample.py000066400000000000000000000412511517052111400231610ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for sampling data along an elliptical path. """ import copy import numpy as np from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.integrator import INTEGRATORS from photutils.utils._deprecation import (deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) # Remove in 4.0 _DEPRECATED_SAMPLE_ATTRIBUTES = { 'gradient_error': 'gradient_err', 'gradient_relative_error': 'gradient_rel_err', 'nclip': 'n_clip', } __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. n_clip : int, optional The number of sigma-clip iterations. Set to zero to skip sigma-clipping. The default is 0. .. deprecated:: 3.0 The ``nclip`` keyword is deprecated. Use ``n_clip`` instead. 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_err : float The error associated with the local radial intensity gradient. gradient_rel_err : 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated_renamed_argument('nclip', 'n_clip', '3.0', until='4.0') def __init__(self, image, sma, x0=None, y0=None, astep=0.1, eps=0.2, position_angle=0.0, sclip=3.0, n_clip=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=astep, linear_growth=linear_growth) # sigma-clip parameters self.sclip = sclip self.n_clip = n_clip # extracted values associated with this sample. self.values = None self.mean = None self.gradient = None self.gradient_err = None self.gradient_rel_err = 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 # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_SAMPLE_ATTRIBUTES, since='3.0', until='4.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 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. # To force it to rerun, set "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 return np.array([np.array(angles), np.array(radii), np.array(intensities)]) def _sigma_clip(self, angles, radii, intensities): if self.n_clip > 0: for _ in range(self.n_clip): # 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, intensity in enumerate(intensities): if lower <= intensity < upper: r_angles.append(angles[k]) r_radii.append(radii[k]) r_intensities.append(intensity) count += 1 return r_angles, r_radii, r_intensities @deprecated_positional_kwargs(since='3.0', until='4.0') 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. Parameters ---------- fixed_parameters : `None` or array_like, optional An array of the fixed parameters. Must have 4 elements, corresponding to x center, y center, PA, and EPS. """ 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_err = 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_err if gradient >= (previous_gradient / 3.0): # gradient is negative! gradient, gradient_err = 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_err = None self.gradient = gradient self.gradient_err = gradient_err if gradient_err and gradient < 0.0: self.gradient_rel_err = gradient_err / np.abs(gradient) else: self.gradient_rel_err = 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, n_clip=self.n_clip, 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_err = (np.sqrt(sigma**2 / len(s[2]) + sigma_g**2 / len(sg[2])) / self.geometry.sma / step) return gradient, gradient_err 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 = radii * np.cos(angles + self.geometry.pa) + self.geometry.x0 y = radii * np.sin(angles + 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. """ @deprecated_positional_kwargs(since='3.0', until='4.0') def update(self, fixed_parameters=None): # noqa: ARG002 """ 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`. Parameters ---------- fixed_parameters : `None` or array_like, optional An array of the fixed parameters. Must have 4 elements, corresponding to x center, y center, PA, and EPS. This keyword is ignored in this subclass. """ s = self.extract() self.mean = s[2][0] self.gradient = None self.gradient_err = None self.gradient_rel_err = 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)]) astropy-photutils-3322558/photutils/isophote/tests/000077500000000000000000000000001517052111400224655ustar00rootroot00000000000000astropy-photutils-3322558/photutils/isophote/tests/__init__.py000066400000000000000000000000001517052111400245640ustar00rootroot00000000000000astropy-photutils-3322558/photutils/isophote/tests/data/000077500000000000000000000000001517052111400233765ustar00rootroot00000000000000astropy-photutils-3322558/photutils/isophote/tests/data/M51_table.fits000066400000000000000000000625001517052111400260010ustar00rootroot00000000000000SIMPLE = 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ęãastropy-photutils-3322558/photutils/isophote/tests/data/synth_highsnr_table.fits000066400000000000000000000702001517052111400303220ustar00rootroot00000000000000SIMPLE = 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ß\@astropy-photutils-3322558/photutils/isophote/tests/data/synth_lowsnr_table.fits000066400000000000000000000702001517052111400302040ustar00rootroot00000000000000SIMPLE = 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@astropy-photutils-3322558/photutils/isophote/tests/data/synth_table.fits000066400000000000000000000702001517052111400266000ustar00rootroot00000000000000SIMPLE = 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„›@astropy-photutils-3322558/photutils/isophote/tests/data/synth_table_mean.fits000066400000000000000000002354001517052111400276050ustar00rootroot00000000000000SIMPLE = 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 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 astropy-photutils-3322558/photutils/isophote/tests/test_ellipse.py000066400000000000000000000131171517052111400255360ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse module. """ import math 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 make_noise_image from photutils.datasets.load import _get_path 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 # define an off-center position and a tilted sma POS = 384 PA = np.deg2rad(10.0) # 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) 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' match2 = 'Mean of empty slice' match3 = 'invalid value encountered' match4 = 'No meaningful fit was possible' ctx1 = pytest.warns(RuntimeWarning, match=match1) 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=np.deg2rad(40.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 astropy-photutils-3322558/photutils/isophote/tests/test_fitter.py000066400000000000000000000157101517052111400253770ustar00rootroot00000000000000# 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.load 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 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(fixed_parameters=DEFAULT_FIX) assert_allclose(sample.mean, 200.02, atol=0.01) assert_allclose(sample.gradient, -4.222, atol=0.001) assert_allclose(sample.gradient_err, 0.0003, atol=0.0001) assert_allclose(sample.gradient_rel_err, 7.45e-05, atol=1.0e-5) assert_allclose(sample.sector_area, 2.00, atol=0.01) 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(fixed_parameters=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) def test_fitting_small_radii(): sample = EllipseSample(DATA, 2.0) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isinstance(isophote, Isophote) assert isophote.n_data == 13 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 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) 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) 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 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. # 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=np.deg2rad(36.0)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.n_data == 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=np.deg2rad(77.5 + 90)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.n_data == 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=np.deg2rad(90), 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, astep=0.1, linear_growth=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.n_data == 1 assert isophote.intens <= 7560.0 assert isophote.intens >= 7550.0 astropy-photutils-3322558/photutils/isophote/tests/test_geometry.py000066400000000000000000000130031517052111400257260ustar00rootroot00000000000000# 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=astep, linear_growth=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, astep=0.2, linear_growth=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, astep=0.2, linear_growth=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, astep=0.2, linear_growth=False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry(np.deg2rad(45)) 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, astep=0.2, linear_growth=False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry(np.deg2rad(45)) 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(np.deg2rad(225)) 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, astep=0.2, linear_growth=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, astep=20.0, linear_growth=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, astep=0.2, linear_growth=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, astep=20.0, linear_growth=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, astep=0.2, linear_growth=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, astep=0.2, linear_growth=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, astep=0.2, linear_growth=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) astropy-photutils-3322558/photutils/isophote/tests/test_harmonics.py000066400000000000000000000166501517052111400260710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the harmonics module. """ import numpy as np from astropy.modeling.models import Gaussian2D from numpy.testing import assert_allclose from scipy.optimize import leastsq 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 def test_harmonics_1(): # this is an almost as-is example taken from stackoverflow npts = 100 # number of data points theta = np.linspace(0, 4 * np.pi, npts) # 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(theta + 0.1) + 0.5 + 0.01 * rng.standard_normal(npts) # 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 def optimize_func(x): return x[0] * np.sin(theta + 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(theta + 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) def test_harmonics_2(): # this uses the actual functional form used for fitting ellipses npts = 100 theta = np.linspace(0, 4 * np.pi, npts) 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(theta) + b1_0 * np.cos(theta) + a2_0 * np.sin(2 * theta) + b2_0 * np.cos(2 * theta) + 0.01 * rng.standard_normal(npts)) harmonics = fit_first_and_second_harmonics(theta, data) y0, a1, b1, a2, b2 = harmonics[0] data_fit = (y0 + a1 * np.sin(theta) + b1 * np.cos(theta) + a2 * np.sin(2 * theta) + b2 * np.cos(2 * theta) + 0.01 * rng.standard_normal(npts)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.01) def test_harmonics_3(): """ Tests an upper harmonic fit. """ npts = 100 theta = np.linspace(0, 4 * np.pi, npts) 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 * theta) + b1_0 * np.cos(order * theta) + 0.01 * rng.standard_normal(npts)) harmonic = fit_upper_harmonic(theta, data, order) y0, a1, b1 = harmonic[0] rng = np.random.default_rng(0) data_fit = (y0 + a1 * np.sin(order * theta) + b1 * np.cos(order * theta) + 0.01 * rng.standard_normal(npts)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.014) 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) def test_upper_harmonics_sign(): """ Regression test for #1486/#1501. """ angle = np.deg2rad(40.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=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 astropy-photutils-3322558/photutils/isophote/tests/test_integrator.py000066400000000000000000000121661517052111400262620ustar00rootroot00000000000000# 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.load 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 astropy-photutils-3322558/photutils/isophote/tests/test_isophote.py000066400000000000000000000261201517052111400257310ustar00rootroot00000000000000# 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.load 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 CentralPixel, Isophote, IsophoteList 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]) @pytest.mark.remote_data 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.n_iter == 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(fixed_parameters=DEFAULT_FIX) sample1.update(fixed_parameters=DEFAULT_FIX) sample2.update(fixed_parameters=DEFAULT_FIX) iso0 = Isophote(sample0, k, valid=True, stop_code=0) iso1 = Isophote(sample1, k, valid=True, stop_code=0) iso2 = Isophote(sample2, k, valid=True, stop_code=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) self.data = data @staticmethod def build_list(data, sma0, *, slen=5): iso_list = [] for k in range(slen): sample = EllipseSample(data, float(k + sma0)) sample.update(fixed_parameters=DEFAULT_FIX) iso_list.append(Isophote(sample, k, valid=True, stop_code=0)) return IsophoteList(iso_list) 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.gradient_err, np.ndarray) assert isinstance(result.gradient_rel_err, np.ndarray) assert isinstance(result.sarea, np.ndarray) assert isinstance(result.n_iter, np.ndarray) assert isinstance(result.n_data, np.ndarray) assert isinstance(result.n_flag, 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_central_pixel(self): # test the central_pixel method. sample = EllipseSample(self.data, 10.0) sample.update() cenpix = CentralPixel(sample) assert cenpix.x0 == cenpix.sample.geometry.x0 assert cenpix.y0 == cenpix.sample.geometry.y0 assert cenpix.eps == 0.0 assert cenpix.pa == 0.0 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 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 astropy-photutils-3322558/photutils/isophote/tests/test_model.py000066400000000000000000000126561517052111400252100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model module. """ import warnings import numpy as np import pytest from astropy.io import fits from astropy.modeling.models import Gaussian2D from astropy.utils.data import get_pkg_data_filename from photutils.datasets.load import _get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import IsophoteList from photutils.isophote.model import build_ellipse_model from photutils.isophote.tests.make_test_data import make_test_image @pytest.mark.remote_data 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, np.deg2rad(10.0)) 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.abs(np.mean(residual)) <= 5.0 @pytest.mark.parametrize('sma_interval', [0.05, 0.1]) def test_model_simulated_data(sma_interval): 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) # Catch warnings that may arise from empty slices. This started # to happen on windows with scipy 1.15.0. 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[0:50, 0:50]), sma_interval=sma_interval) assert data.shape == model.shape residual = data - model assert np.abs(np.mean(residual)) <= 0.01 assert np.abs(np.median(residual)) <= 0.01 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, np.deg2rad(10.0)) g.find_center(data) ellipse = Ellipse(data, geometry=g) match1 = 'Degrees of freedom' match2 = 'Mean of empty slice' match3 = 'invalid value encountered' ctx1 = pytest.warns(RuntimeWarning, match=match1) 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, n_clip=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 def test_model_inputs(): match = 'isolist must not be empty' with pytest.raises(ValueError, match=match): build_ellipse_model((10, 10), IsophoteList([])) def test_model_harmonics(): """ Test that high harmonics are included in build_ellipse_model. """ x0 = y0 = 50 xsig = 10 ysig = 5 eps = ysig / xsig theta = np.deg2rad(41) m = Gaussian2D(100, x0, y0, xsig, ysig, theta) yy, xx = np.mgrid[:101, :101] data = m(xx, yy) yy -= y0 xx -= x0 dt = np.arctan2(yy, xx) - np.deg2rad(10) harm = (0.1 * np.sin(3 * dt) + 0.1 * np.cos(3 * dt) + 0.6 * np.sin(4 * dt) - 0.5 * np.cos(4 * dt)) harm -= np.min(harm) data += 5 * harm geometry = EllipseGeometry(x0=x0, y0=y0, sma=30, eps=eps, pa=theta) ellipse = Ellipse(data, geometry=geometry) isolist = ellipse.fit_image(fix_center=True, fix_eps=True) model_image = build_ellipse_model(data.shape, isolist, high_harmonics=True) residual = data - model_image mask = model_image > 0 assert np.std(residual[mask]) < 0.4 def test_model_integration(): """ Test that model integration does not stop as soon as the angle reaches the edge of the image. """ data = make_test_image(nx=80, ny=110, i0=100.0, sma=60.0, eps=0.5, pa=np.pi / 3.0, noise=0.05, seed=0) g = EllipseGeometry(40, 55, 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.nanmean(data[105:, :5]), sma_interval=0.05) assert np.nanmean(np.abs(model[100:, 60:] - data[100:, 60:])) < 2 astropy-photutils-3322558/photutils/isophote/tests/test_positional_kwargs.py000066400000000000000000000121001517052111400276270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.model import build_ellipse_model from photutils.isophote.sample import CentralEllipseSample, EllipseSample from photutils.isophote.tests.make_test_data import make_test_image class TestEllipsePositionalKwargs: """ Test Ellipse.__init__, fit_image, fit_isophote for positional optional args. """ def setup_method(self): self.data = make_test_image(seed=0) def test_init_positional_warns(self): geometry = EllipseGeometry(x0=256, y0=256, sma=10, eps=0.2, pa=np.pi / 2) match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): Ellipse(self.data, geometry) def test_init_keyword_no_warning(self): geometry = EllipseGeometry(x0=256, y0=256, sma=10, eps=0.2, pa=np.pi / 2) Ellipse(self.data, geometry=geometry) def test_fit_image_positional_warns(self): ellipse = Ellipse(self.data) match = 'fit_image' with pytest.warns(AstropyDeprecationWarning, match=match): ellipse.fit_image(10.0) def test_fit_image_keyword_no_warning(self): ellipse = Ellipse(self.data) ellipse.fit_image(sma0=10.0) def test_fit_isophote_positional_warns(self): ellipse = Ellipse(self.data) match = 'fit_isophote' with pytest.warns(AstropyDeprecationWarning, match=match): ellipse.fit_isophote(40.0, 0.1) def test_fit_isophote_keyword_no_warning(self): ellipse = Ellipse(self.data) ellipse.fit_isophote(40.0, step=0.1) class TestEllipseGeometryPositionalKwargs: """ Test EllipseGeometry.__init__ and find_center. """ def test_init_positional_warns(self): match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2) def test_init_keyword_no_warning(self): EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, astep=0.2) def test_find_center_positional_warns(self): data = make_test_image(seed=0) geometry = EllipseGeometry(x0=256, y0=256, sma=10, eps=0.2, pa=np.pi / 2) match = 'find_center' with pytest.warns(AstropyDeprecationWarning, match=match): geometry.find_center(data, 0.5) def test_find_center_keyword_no_warning(self): data = make_test_image(seed=0) geometry = EllipseGeometry(x0=256, y0=256, sma=10, eps=0.2, pa=np.pi / 2) geometry.find_center(data, threshold=0.5) class TestBuildEllipseModelPositionalKwargs: """ Test build_ellipse_model. """ def test_positional_warns(self): data = make_test_image(seed=0) ellipse = Ellipse(data) isolist = ellipse.fit_image(sma0=10.0) match = 'build_ellipse_model' with pytest.warns(AstropyDeprecationWarning, match=match): build_ellipse_model(data.shape, isolist, 0.0) def test_keyword_no_warning(self): data = make_test_image(seed=0) ellipse = Ellipse(data) isolist = ellipse.fit_image(sma0=10.0) build_ellipse_model(data.shape, isolist, fill=0.0) class TestEllipseSamplePositionalKwargs: """ Test EllipseSample.__init__ and update. """ def setup_method(self): self.data = make_test_image(seed=0) def test_init_positional_warns(self): match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): EllipseSample(self.data, 40.0, 256.0) def test_init_keyword_no_warning(self): EllipseSample(self.data, 40.0, x0=256.0) def test_update_positional_warns(self): sample = EllipseSample(self.data, 40.0) fix = np.array([False, False, False, False]) match = 'update' with pytest.warns(AstropyDeprecationWarning, match=match): sample.update(fix) def test_update_keyword_no_warning(self): sample = EllipseSample(self.data, 40.0) fix = np.array([False, False, False, False]) sample.update(fixed_parameters=fix) class TestCentralEllipseSamplePositionalKwargs: """ Test CentralEllipseSample.update. """ def test_update_positional_warns(self): data = make_test_image(seed=0) sample = CentralEllipseSample(data, 0.0) fix = np.array([False, False, False, False]) match = 'update' with pytest.warns(AstropyDeprecationWarning, match=match): sample.update(fix) def test_update_keyword_no_warning(self): data = make_test_image(seed=0) sample = CentralEllipseSample(data, 0.0) fix = np.array([False, False, False, False]) sample.update(fixed_parameters=fix) astropy-photutils-3322558/photutils/isophote/tests/test_regression.py000066400000000000000000000203271517052111400262620ustar00rootroot00000000000000# 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 with respect to 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 was not designed. 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.load import _get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.integrator import BILINEAR # @pytest.mark.parametrize('name', ['M51', 'synth', 'synth_lowsnr', # 'synth_highsnr']) @pytest.mark.parametrize('name', ['synth_highsnr']) @pytest.mark.remote_data def test_regression(name): """ 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. """ integrmode = BILINEAR verbose = False 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() ttype = [] tsma = [] tintens = [] tint_err = [] tpix_stddev = [] trms = [] tellip = [] tpa = [] tx0 = [] ty0 = [] trerr = [] tndata = [] tnflag = [] tniter = [] tstop = [] 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 or 0.0 pix_stddev_i = iso.pix_stddev or 0.0 rms_i = iso.rms or 0.0 ellip_i = iso.sample.geometry.eps or 0.0 pa_i = iso.sample.geometry.pa or 0.0 x0_i = iso.sample.geometry.x0 y0_i = iso.sample.geometry.y0 rerr_i = (iso.sample.gradient_rel_err or 0.0) ndata_i = iso.n_data nflag_i = iso.n_flag niter_i = iso.n_iter stop_i = iso.stop_code # convert to old code reference system pa_i = np.rad2deg(pa_i - np.pi / 2) 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] or 0 stop_t = table['STOP'][row] or -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: ttype.extend(('data', 'ref', 'diff')) tsma.extend((sma_i, sma_t, sma_d)) tintens.extend((intens_i, intens_t, intens_d)) tint_err.extend((int_err_i, int_err_t, int_err_d)) tpix_stddev.extend((pix_stddev_i, pix_stddev_t, pix_stddev_d)) trms.extend((rms_i, rms_t, rms_d)) tellip.extend((ellip_i, ellip_t, ellip_d)) tpa.extend((pa_i, pa_t, pa_d)) tx0.extend((x0_i, x0_t, x0_d)) ty0.extend((y0_i, y0_t, y0_d)) trerr.extend((rerr_i, rerr_t, rerr_d)) tndata.extend((ndata_i, ndata_t, ndata_d)) tnflag.extend((nflag_i, nflag_t, nflag_d)) tniter.extend((niter_i, niter_t, niter_d)) tstop.extend((stop_i, stop_t, stop_d)) 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. if verbose: tbl = Table() tbl['type'] = ttype tbl['sma'] = tsma tbl['intens'] = tintens tbl['int_err'] = tint_err tbl['pix_stddev'] = tpix_stddev tbl['rms'] = trms tbl['ellip'] = tellip tbl['pa'] = tpa tbl['x0'] = tx0 tbl['y0'] = ty0 tbl['rerr'] = trerr tbl['ndata'] = tndata tbl['nflag'] = tnflag tbl['niter'] = tniter tbl['stop'] = tstop tbl.write('test_regression.ecsv', overwrite=True) astropy-photutils-3322558/photutils/isophote/tests/test_sample.py000066400000000000000000000035701517052111400253640ustar00rootroot00000000000000# 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(fixed_parameters=DEFAULT_FIX) iso = Isophote(sample, 0, valid=True, stop_code=0) assert iso.pix_stddev < amax assert iso.pix_stddev > amin def test_coordinates(): sample = EllipseSample(DATA, 50.0) sample.update(fixed_parameters=DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) def test_sclip(): sample = EllipseSample(DATA, 50.0, n_clip=3) sample.update(fixed_parameters=DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) astropy-photutils-3322558/photutils/morphology/000077500000000000000000000000001517052111400216705ustar00rootroot00000000000000astropy-photutils-3322558/photutils/morphology/__init__.py000066400000000000000000000004131517052111400237770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for measuring morphological properties of objects in an astronomical image. """ from .core import * # noqa: F401, F403 from .non_parametric import * # noqa: F401, F403 astropy-photutils-3322558/photutils/morphology/core.py000066400000000000000000000066361517052111400232050ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for measuring morphological properties of sources. """ import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['data_properties'] @deprecated_positional_kwargs(since='3.0', until='4.0') 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 or array_like, optional The background level previously present in the input ``data``. ``background`` may be a scalar value or a 2D array with the same shape as ``data``. The input ``background`` is not subtracted from ``data``, which should already be background-subtracted; providing it only enables background-related properties to be measured. 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 scalar `~photutils.segmentation.SourceCatalog` object (single source) containing the morphological properties. Raises ------ ValueError If ``data`` is not a 2D array. ValueError If ``mask`` is provided and does not have the same shape as ``data``. ValueError If ``mask`` is provided and all pixels are masked. ValueError If ``background`` is provided and is not a scalar or a 2D array with the same shape as ``data``. """ # Prevent circular import from photutils.segmentation import SegmentationImage, SourceCatalog data = np.asanyarray(data) if len(data.shape) != 2: msg = 'data must be a 2D array' raise ValueError(msg) seg_arr = np.ones(data.shape, dtype=int) if mask is not None: mask = np.asarray(mask, dtype=bool) if mask.shape != data.shape: msg = 'mask must have the same shape as data' raise ValueError(msg) if np.all(mask): msg = 'All pixels in data are masked' raise ValueError(msg) seg_arr[mask] = 0 segment_image = SegmentationImage(seg_arr) if background is not None: background = np.asarray(background) if background.ndim == 0: background = np.full(data.shape, float(background)) elif background.shape != data.shape: msg = ('background must be a scalar or a 2D array ' 'with the same shape as data') raise ValueError(msg) # mask is encoded in seg_arr (masked pixels set to 0), so # mask=None is intentional here return SourceCatalog(data, segment_image, mask=None, background=background, wcs=wcs)[0] astropy-photutils-3322558/photutils/morphology/non_parametric.py000066400000000000000000000061631517052111400252510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for measuring non-parametric morphologies of sources. """ import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['gini'] @deprecated_positional_kwargs(since='3.0', until='4.0') def gini(data, mask=None): r""" Calculate the `Gini coefficient `_ of an array. The Gini coefficient of the distribution of absolute flux values is calculated using the prescription from `Lotz et al. 2004 `_ (Eq. 6) as: .. math:: G = \frac{1}{\overline{|x|} \, n \, (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\overline{|x|}` is the mean of the absolute value of all pixel values :math:`x_i`. If the sum of all pixel values is zero, the Gini coefficient is zero. 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. Negative pixel values are used via their absolute value. Invalid values (NaN and inf) in the input are automatically excluded from the calculation. If only a single finite pixel remains after filtering, the Gini coefficient is 0.0. Parameters ---------- data : array_like The 1D or 2D data array or object that can be converted to an array. mask : array_like, optional A boolean mask with the same shape as ``data`` where `True` values indicate masked pixels. Masked pixels are excluded from the calculation. Returns ------- result : float The Gini coefficient of the input array. Raises ------ ValueError If ``mask`` is provided and does not have the same shape as ``data``. """ data = np.asarray(data) if mask is not None: mask = np.asarray(mask, dtype=bool) if mask.shape != data.shape: msg = 'mask must have the same shape as data' raise ValueError(msg) values = np.ravel(data[~mask]) else: values = np.ravel(data) # Exclude invalid values (NaN, inf) values = np.abs(values[np.isfinite(values)]) npix = values.size if npix == 0: return np.nan if npix == 1: return 0.0 normalization = np.mean(values) * npix * (npix - 1) if normalization == 0.0: return 0.0 kernel = (2.0 * np.arange(1, npix + 1) - npix - 1) * np.sort(values) return np.sum(kernel) / normalization astropy-photutils-3322558/photutils/morphology/tests/000077500000000000000000000000001517052111400230325ustar00rootroot00000000000000astropy-photutils-3322558/photutils/morphology/tests/__init__.py000066400000000000000000000000001517052111400251310ustar00rootroot00000000000000astropy-photutils-3322558/photutils/morphology/tests/test_core.py000066400000000000000000000063561517052111400254050ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose from photutils.datasets import make_wcs from photutils.morphology import data_properties def test_data_properties(): """ Test basics of ``data_properties`` with and without a mask. """ 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 = ['x_centroid', 'y_centroid'] 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 def test_data_properties_invalid_data_shape(): """ Test that data must be a 2D array. """ match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): data_properties(np.ones(10)) # 1D with pytest.raises(ValueError, match=match): data_properties([1, 2, 3]) # 1D list with pytest.raises(ValueError, match=match): data_properties(np.ones((3, 3, 3))) # 3D def test_data_properties_mask_invalid_shape(): """ Test that mask must have the same shape as data. """ data = np.ones((10, 10)) match = 'mask must have the same shape as data' with pytest.raises(ValueError, match=match): data_properties(data, mask=np.zeros((5, 5), dtype=bool)) match = 'mask must have the same shape as data' with pytest.raises(ValueError, match=match): data_properties(data, mask=np.zeros((10, 5), dtype=bool)) def test_data_properties_bkg(): """ Test with a scalar and 2D array background. """ 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 bkg_2d = np.full((3, 3), 2.0) props2 = data_properties(data, background=bkg_2d) assert props2.background_sum == 18.0 def test_data_properties_bkg_invalid(): """ Test that invalid background inputs raise ``ValueError``. """ data = np.ones((3, 3)) match = 'background must be a scalar or a 2D array' with pytest.raises(ValueError, match=match): data_properties(data, background=[1.0, 2.0]) with pytest.raises(ValueError, match=match): data_properties(data, background=np.ones((2, 2))) def test_data_properties_all_masked(): """ Test that an all-True mask raises ``ValueError``. """ data = np.ones((4, 4)) mask = np.ones((4, 4), dtype=bool) match = 'All pixels in data are masked' with pytest.raises(ValueError, match=match): data_properties(data, mask=mask) def test_data_properties_quantity(): """ Test that ``~astropy.units.Quantity`` input is accepted. """ data = np.ones((3, 3)) * u.Jy props = data_properties(data) assert props.area.value == 9.0 astropy-photutils-3322558/photutils/morphology/tests/test_non_parametric.py000066400000000000000000000075151517052111400274540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the non_parametric module. """ import numpy as np import pytest from photutils.morphology.non_parametric import gini def test_gini(): """ Test Gini coefficient calculation with simple cases. """ 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 def test_gini_1d(): """ Test Gini coefficient with 1D input. """ assert gini(np.ones(100)) == 0.0 data_1d = np.zeros(100) data_1d[50] = 1 assert gini(data_1d) == 1.0 def test_gini_mask(): """ Test Gini coefficient calculation with a mask. """ shape = (100, 100) data1 = np.ones(shape) data1[50, 50] = 0 mask1 = np.zeros(data1.shape, dtype=bool) mask1[50, 50] = True data2 = np.zeros(shape) data2[50, 50] = 1 data2[0, 0] = 100 mask2 = np.zeros(data2.shape, dtype=bool) mask2[0, 0] = True assert gini(data1, mask=mask1) == 0.0 assert gini(data2, mask=mask2) == 1.0 def test_gini_mask_invalid_shape(): """ Test that mask must have the same shape as data. """ data = np.ones((10, 10)) mask_wrong_shape = np.zeros((5, 5), dtype=bool) match = 'mask must have the same shape' with pytest.raises(ValueError, match=match): gini(data, mask=mask_wrong_shape) def test_gini_invalid_values_filtered(): """ Test that NaN and inf are automatically excluded. """ # All valid: point-like data = np.zeros((5, 5)) data[2, 2] = 1.0 assert gini(data) == 1.0 # Same with NaNs in other pixels - should get same result data_nan = data.astype(float) data_nan[0, 0] = np.nan data_nan[1, 1] = np.nan assert gini(data_nan) == 1.0 # Same with inf in other pixels - should get same result data_inf = data.astype(float) data_inf[0, 0] = np.inf data_inf[1, 1] = -np.inf assert gini(data_inf) == 1.0 # All NaN returns nan assert np.isnan(gini(np.full((5, 5), np.nan))) # All inf returns nan (no finite values) assert np.isnan(gini(np.full((5, 5), np.inf))) # Mix: one valid pixel, rest NaN - Gini of single value is 0 data_one = np.full((5, 5), np.nan) data_one[2, 2] = 1.0 assert gini(data_one) == 0.0 def test_gini_all_zeros(): """ Test that an all-zero array returns 0.0 (normalization early-return). """ assert gini(np.zeros((100, 100))) == 0.0 assert gini(np.zeros(10)) == 0.0 def test_gini_bounded(): """ Test that Gini coefficient is in [0, 1] for diverse inputs. """ rng = np.random.default_rng(seed=0) # Uniform random values in (0, 1) — strictly between extremes result = gini(rng.random((50, 50))) assert 0.0 < result < 1.0 # Mixed-sign data must also be bounded data_mixed_sign = np.array([-4.0, 1.0, 1.0, 1.0]) result_mixed = gini(data_mixed_sign) assert 0.0 <= result_mixed <= 1.0 # Gradient array — monotonically increasing, result in (0, 1) result_grad = gini(np.arange(1.0, 101.0)) assert 0.0 < result_grad < 1.0 def test_gini_negative_values(): """ Test that negative pixel values are treated via their absolute value per the Lotz et al. formula. """ # Negating all values must give the same result because only |x_i| # and |mean| enter the formula data_pos = np.array([1.0, 2.0, 3.0]) data_neg = -data_pos assert gini(data_neg) == gini(data_pos) # Mixed sign: result must be in [0, 1] data_mixed = np.array([-5.0, -1.0, 0.0, 2.0, 4.0]) result = gini(data_mixed) assert 0.0 <= result <= 1.0 # 2D array with negative values data_2d = np.array([[-3.0, -1.0], [0.0, 1.0]]) result_2d = gini(data_2d) assert 0.0 <= result_2d <= 1.0 astropy-photutils-3322558/photutils/morphology/tests/test_positional_kwargs.py000066400000000000000000000025361517052111400302100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.morphology import data_properties, gini class TestDataPropertiesPositionalKwargs: """ Test data_properties warns for positional optional args. """ def setup_method(self): self.data = np.random.default_rng(0).random((10, 10)) def test_positional_warns(self): mask = np.zeros((10, 10), dtype=bool) match = 'data_properties' with pytest.warns(AstropyDeprecationWarning, match=match): data_properties(self.data, mask) def test_keyword_no_warning(self): mask = np.zeros((10, 10), dtype=bool) data_properties(self.data, mask=mask) class TestGiniPositionalKwargs: """ Test gini warns for positional optional args. """ def test_positional_warns(self): data = np.arange(100, dtype=float) mask = np.zeros(100, dtype=bool) match = 'gini' with pytest.warns(AstropyDeprecationWarning, match=match): gini(data, mask) def test_keyword_no_warning(self): data = np.arange(100, dtype=float) mask = np.zeros(100, dtype=bool) gini(data, mask=mask) astropy-photutils-3322558/photutils/profiles/000077500000000000000000000000001517052111400213145ustar00rootroot00000000000000astropy-photutils-3322558/photutils/profiles/__init__.py000066400000000000000000000004471517052111400234320ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for generating radial profiles and curves of growth. """ from .core import * # noqa: F401, F403 from .curve_of_growth import * # noqa: F401, F403 from .radial_profile import * # noqa: F401, F403 astropy-photutils-3322558/photutils/profiles/core.py000066400000000000000000000376021517052111400226260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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._deprecation import deprecated_positional_kwargs from photutils.utils._quantity_helpers import process_quantities from photutils.utils._stats import nanmax, nansum __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 profile apertures. ``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. See the subclass documentation for details on how ``radii`` is interpreted. 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'``. """ # Define axis labels used by `~photutils.profiles.ProfileBase.plot`. # Subclasses may override these. _xlabel = 'Radius (pixels)' _ylabel = 'Profile' 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: msg = 'error must have the same shape as data' raise ValueError(msg) 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): """ Validate and return the radii array. """ radii = np.array(radii) if radii.ndim != 1 or radii.size < 2: msg = 'radii must be a 1D array and have at least two values' raise ValueError(msg) if radii.min() < 0: msg = 'minimum radii must be >= 0' raise ValueError(msg) if not np.all(radii[1:] > radii[:-1]): msg = 'radii must be strictly increasing' raise ValueError(msg) 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: msg = 'mask must have the same shape as data' raise ValueError(msg) # Keep only non-finite values not already masked by the user badmask &= ~mask combined_mask = mask | badmask # all masked pixels else: combined_mask = badmask if np.any(badmask): msg = ('Input data contains non-finite values (e.g., NaN ' 'or inf) that were automatically masked.') warnings.warn(msg, AstropyUserWarning) return combined_mask @property @abc.abstractmethod def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. """ @property @abc.abstractmethod def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ @property @abc.abstractmethod def profile_error(self): """ The profile errors as a 1D `~numpy.ndarray`. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ @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 def _compute_photometry(self, apertures): """ Compute aperture fluxes, flux errors, and areas for the given apertures. Parameters ---------- apertures : list A list of aperture objects. Elements may be `None`, in which case the corresponding flux, error, and area are set to zero. Returns ------- flux : `~numpy.ndarray` The aperture fluxes. flux_err : `~numpy.ndarray` The aperture flux errors. areas : `~numpy.ndarray` The aperture areas. """ fluxes = [] flux_errs = [] areas = [] for aperture in apertures: if aperture is None: flux, flux_err = [0.0], [0.0] area = 0.0 else: flux, flux_err = 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: flux_errs.append(flux_err[0]) areas.append(area) fluxes = np.array(fluxes) flux_errs = np.array(flux_errs) areas = np.array(areas) if self.unit is not None: fluxes <<= self.unit flux_errs <<= self.unit return fluxes, flux_errs, areas @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of radius. """ return self._compute_photometry(self._circular_apertures) @deprecated_positional_kwargs(since='3.0', until='4.0') 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': with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) normalization = nanmax(self.profile) elif method == 'sum': with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) normalization = nansum(self.profile) else: msg = "invalid method, must be 'max' or 'sum'" raise ValueError(msg) if normalization == 0 or not np.isfinite(normalization): msg = ('The profile cannot be normalized because the max or ' 'sum is zero or non-finite.') warnings.warn(msg, 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 self._normalize_hook(normalization) def _normalize_hook(self, normalization): # noqa: B027 """ Hook called by `normalize` after normalizing ``profile`` and ``profile_error``. This hook is only called when normalization succeeds (i.e., when the normalization value is non-zero and finite). Subclasses can override this to normalize additional lazy properties (e.g., ``data_profile``). Parameters ---------- normalization : float The normalization value applied to the profile. """ 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._unnormalize_hook() self.normalization_value = 1.0 def _unnormalize_hook(self): # noqa: B027 """ Hook called by `unnormalize` after unnormalizing ``profile`` and ``profile_error``, but before resetting ``normalization_value``. Subclasses can override this to unnormalize additional lazy properties (e.g., ``data_profile``). """ @staticmethod def _trim_to_monotonic(xarr, profile, name): """ Trim arrays to the first monotonically increasing region. This is used by interpolation methods that require a monotonically increasing profile. Parameters ---------- xarr : 1D `~numpy.ndarray` The x-axis values (e.g., radius or half-size). profile : 1D `~numpy.ndarray` The profile values. name : str A descriptive name for the profile used in the error message. Returns ------- xarr, profile : tuple of `~numpy.ndarray` The trimmed arrays. """ finite_mask = np.isfinite(profile) if not np.all(finite_mask): # Keep only the leading finite segment first_nonfinite = np.argmin(finite_mask) xarr = xarr[:first_nonfinite] profile = profile[:first_nonfinite] # np.diff produces an array of length n-1: diff[i] represents # the step from profile[i] to profile[i+1]. A value <= 0 means # the profile stopped increasing at that step. diff = np.diff(profile) <= 0 if np.any(diff): # idx is an index into the *diff* array, not the profile # array. diff[idx] <= 0 means the drop occurs between # profile[idx] and profile[idx+1], so profile[idx] is # the last good value. We therefore need profile[:idx+1] # (inclusive) to retain it. idx = np.argmax(diff) # first non-monotonic step in diff-space xarr = xarr[:idx + 1] profile = profile[:idx + 1] if len(xarr) < 2: msg = (f'The {name} profile is not monotonically ' 'increasing even at the smallest values -- cannot ' 'interpolate. Try using different input values ' '(especially the starting values) and/or using the ' '"exact" aperture overlap method.') raise ValueError(msg) return xarr, profile def __repr__(self): cls_name = self.__class__.__name__ n_radii = len(self.radii) normalized = self.normalization_value != 1.0 return (f'{cls_name}(xycen={self.xycen}, n_radii={n_radii}, ' f'normalized={normalized})') @deprecated_positional_kwargs(since='3.0', until='4.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, optional 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(self._xlabel) ylabel = self._ylabel if self.unit is not None: ylabel = f'{ylabel} ({self.unit})' ax.set_ylabel(ylabel) return lines @deprecated_positional_kwargs(since='3.0', until='4.0') 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, optional Any keyword arguments accepted by `matplotlib.pyplot.fill_between`. Returns ------- poly : `matplotlib.collections.PolyCollection` or `None` A `~matplotlib.collections.PolyCollection` containing the plotted polygons, or `None` if no errors were input. """ if len(self.profile_error) == 0: msg = 'Errors were not input' warnings.warn(msg, 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 return ax.fill_between(self.radius, ymin, ymax, **kws) astropy-photutils-3322558/photutils/profiles/curve_of_growth.py000066400000000000000000001114601517052111400250730ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating curves of growth. """ import numpy as np from astropy.utils import lazyproperty from scipy.interpolate import PchipInterpolator from photutils.profiles.core import ProfileBase __all__ = ['CurveOfGrowth', 'EllipticalCurveOfGrowth', 'EnsquaredCurveOfGrowth'] 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'``. See Also -------- EllipticalCurveOfGrowth, EnsquaredCurveOfGrowth, RadialProfile Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_2dg >>> 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) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the curve of growth. >>> xycen = centroid_2dg(data) >>> radii = np.arange(1, 26) >>> cog = CurveOfGrowth(data, xycen, radii, error=error) >>> 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 [ 135.14750208 514.49674293 1076.4617132 1771.53866121 2510.94382666 3238.51695898 3907.08459943 4456.90125492 4891.00892262 5236.59326527 5473.66400376 5643.72239573 5738.24972738 5803.31693644 5842.00525018 5850.45854739 5855.76123671 5844.9631235 5847.72359025 5843.23189459 5852.05251106 5875.32009699 5869.86235184 5880.64741302 5872.16333953] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 3.72215309 7.44430617 11.16645926 14.88861235 18.61076543 22.33291852 26.05507161 29.7772247 33.49937778 37.22153087 40.94368396 44.66583704 48.38799013 52.11014322 55.8322963 59.55444939 63.27660248 66.99875556 70.72090865 74.44306174 78.16521482 81.88736791 85.609521 89.33167409 93.05382717] Plot the curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) # Plot the curve of growth fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax) cog.plot_error(ax=ax) 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 photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) # Plot the curve of growth cog.normalize() fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax) cog.plot_error(ax=ax) 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_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') cog.apertures[5].plot(ax=ax, color='C0', lw=2) cog.apertures[10].plot(ax=ax, color='C1', lw=2) cog.apertures[15].plot(ax=ax, color='C3', lw=2) """ # Define y-axis label used by `~photutils.profiles.ProfileBase.plot` _ylabel = 'Curve of Growth' def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): if np.min(radii) <= 0: msg = 'radii must be > 0' raise ValueError(msg) 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. """ return self._circular_apertures @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of radius. """ return self._compute_photometry(self.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`. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ 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 method 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. """ 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 method 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. """ radius, profile = self._trim_to_monotonic( self.radius, self.profile, 'curve-of-growth') return PchipInterpolator(profile, radius, extrapolate=False)(ee) class EnsquaredCurveOfGrowth(ProfileBase): """ Class to create a curve of growth using concentric square apertures. The ensquared curve of growth profile represents the square aperture flux as a function of the square half-size (half side length). 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. half_sizes : 1D float `~numpy.ndarray` An array of the square half side lengths. ``half_sizes`` must be strictly increasing with a minimum value greater than zero, and contain at least 2 values. The spacing does not need to be constant. The full side length of each square aperture is ``2 * half_sizes``. 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 -------- CurveOfGrowth, EllipticalCurveOfGrowth Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_2dg >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import EnsquaredCurveOfGrowth 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) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the ensquared curve of growth. >>> xycen = centroid_2dg(data) >>> half_sizes = np.arange(1, 26) >>> ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) >>> print(ecog.half_size) # 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(ecog.profile) # doctest: +FLOAT_CMP [ 171.35182895 640.63717997 1328.55725483 2142.84258293 2954.12152275 3717.5208724 4356.82277842 4844.97997426 5199.74452363 5480.78438494 5641.63617089 5751.92491894 5790.90751883 5819.30778391 5832.38652883 5825.14679788 5833.55196333 5833.54737611 5851.79194687 5856.58494602 5869.76637039 5872.91078217 5868.62195688 5850.11085443 5838.889818 ] >>> print(ecog.profile_error) # doctest: +FLOAT_CMP [ 4.2 8.4 12.6 16.8 21. 25.2 29.4 33.6 37.8 42. 46.2 50.4 54.6 58.8 63. 67.2 71.4 75.6 79.8 84. 88.2 92.4 96.6 100.8 105. ] Normalize the profile and plot the normalized ensquared curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EnsquaredCurveOfGrowth # 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the ensquared curve of growth half_sizes = np.arange(1, 26) ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) # Plot the ensquared curve of growth ecog.normalize() fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax) ecog.plot_error(ax=ax) 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_2dg from photutils.datasets import make_noise_image from photutils.profiles import EnsquaredCurveOfGrowth # 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the ensquared curve of growth half_sizes = np.arange(1, 26) ecog = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') ecog.apertures[5].plot(ax=ax, color='C0', lw=2) ecog.apertures[10].plot(ax=ax, color='C1', lw=2) ecog.apertures[15].plot(ax=ax, color='C3', lw=2) """ # Axis labels used by `~photutils.profiles.ProfileBase.plot`. _xlabel = 'Half-Size (pixels)' _ylabel = 'Ensquared Curve of Growth' def __init__(self, data, xycen, half_sizes, *, error=None, mask=None, method='exact', subpixels=5): if np.min(half_sizes) <= 0: msg = 'half_sizes must be > 0' raise ValueError(msg) super().__init__(data, xycen, half_sizes, error=error, mask=mask, method=method, subpixels=subpixels) # self.radii is set by the parent class self.half_sizes = self.radii def __repr__(self): cls_name = self.__class__.__name__ n_half_sizes = len(self.half_sizes) normalized = self.normalization_value != 1.0 return (f'{cls_name}(xycen={self.xycen}, ' f'n_half_sizes={n_half_sizes}, ' f'normalized={normalized})') @lazyproperty def half_size(self): """ The profile half-sizes (half side lengths) in pixels as a 1D `~numpy.ndarray`. This is the same as the input ``half_sizes``. Note that these are the half side lengths of the square apertures used to measure the profile. The full side length of each square aperture is ``2 * half_size``. They can be used directly to measure the ensquared energy/flux at a given half-size. """ return self.half_sizes @lazyproperty def radius(self): """ The profile half-sizes (half side lengths) in pixels as a 1D `~numpy.ndarray`. This is an alias for `half_size`. """ return self.half_sizes @lazyproperty def apertures(self): """ A list of `~photutils.aperture.RectangularAperture` objects used to measure the profile. """ from photutils.aperture import RectangularAperture return [RectangularAperture(self.xycen, 2 * hs, 2 * hs) for hs in self.half_sizes] @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of size. """ return self._compute_photometry(self.apertures) @lazyproperty def profile(self): """ The ensquared curve-of-growth profile as a 1D `~numpy.ndarray`. """ return self._photometry[0] @lazyproperty def profile_error(self): """ The ensquared curve-of-growth profile errors as a 1D `~numpy.ndarray`. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ return self._photometry[1] @lazyproperty def area(self): """ The unmasked area in each square aperture as a function of size as a 1D `~numpy.ndarray`. """ return self._photometry[2] def calc_ee_at_half_size(self, half_size): """ Calculate the ensquared energy at a given half-size using a cubic interpolator based on the profile data. Note that this method assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large half-size. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``half_sizes``. Parameters ---------- half_size : float or 1D `~numpy.ndarray` The square half side length(s). Returns ------- ee : `~numpy.ndarray` The ensquared energy at each half-size. Returns NaN for half-sizes outside the range of the profile data. """ return PchipInterpolator(self.half_size, self.profile, extrapolate=False)(half_size) def calc_half_size_at_ee(self, ee): """ Calculate the half-size at a given ensquared energy using a cubic interpolator based on the profile data. Note that this method assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large half-size. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``half_sizes``. This interpolator returns values only for regions where the ensquared curve-of-growth profile is monotonically increasing. Parameters ---------- ee : float or 1D `~numpy.ndarray` The ensquared energy. Returns ------- half_size : `~numpy.ndarray` The half-size at each ensquared energy. Returns NaN for ensquared energies outside the range of the profile data. """ half_size, profile = self._trim_to_monotonic( self.half_size, self.profile, 'ensquared curve-of-growth') return PchipInterpolator(profile, half_size, extrapolate=False)(ee) class EllipticalCurveOfGrowth(ProfileBase): """ Class to create a curve of growth using concentric elliptical apertures with a fixed axis ratio and orientation. The elliptical curve of growth profile represents the elliptical aperture flux as a function of the semimajor axis length. 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 ellipse semimajor-axis lengths. ``radii`` must be strictly increasing with a minimum value greater than zero, and contain at least 2 values. The spacing does not need to be constant. axis_ratio : float The ratio of the semiminor axis to the semimajor axis (``b / a``). Must be in the range ``0 < axis_ratio <= 1``. 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. 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 -------- CurveOfGrowth, EnsquaredCurveOfGrowth Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_2dg >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import EllipticalCurveOfGrowth Create an artificial elliptical source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the elliptical curve of growth with an axis ratio of 0.5 and a rotation angle of 42 degrees. >>> xycen = centroid_2dg(data) >>> radii = np.arange(1, 40) >>> ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, ... theta=np.deg2rad(42), error=error) >>> print(ecog.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 26 27 28 29 30 31 32 33 34 35 36 37 38 39] >>> print(ecog.profile) # doctest: +FLOAT_CMP [ 67.39762867 267.711181 588.47524874 1021.31994307 1546.53867489 2152.12698084 2824.97954482 3541.64650208 4284.363828 5040.93586551 5777.06397177 6488.33779084 7179.10371288 7826.17773764 8418.08027957 8948.7310004 9418.56480323 9810.0373925 10163.11467352 10477.42357537 10731.89184641 10920.11723061 11092.34059512 11235.12552706 11347.43721424 11454.03577845 11520.64656354 11555.89668261 11571.27935302 11583.89774142 11605.79810845 11639.93073462 11648.27293403 11660.34772581 11662.89065496 11643.07787619 11630.36674411 11636.61537567 11636.60448497] >>> print(ecog.profile_error) # doctest: +FLOAT_CMP [ 2.63195969 5.26391938 7.89587907 10.52783875 13.15979844 15.79175813 18.42371782 21.05567751 23.6876372 26.31959688 28.95155657 31.58351626 34.21547595 36.84743564 39.47939533 42.11135501 44.7433147 47.37527439 50.00723408 52.63919377 55.27115346 57.90311314 60.53507283 63.16703252 65.79899221 68.4309519 71.06291159 73.69487127 76.32683096 78.95879065 81.59075034 84.22271003 86.85466972 89.4866294 92.11858909 94.75054878 97.38250847 100.01446816 102.64642785] Normalize the profile and plot the normalized elliptical curve of growth. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg from photutils.datasets import make_noise_image from photutils.profiles import EllipticalCurveOfGrowth # Create an artificial elliptical source gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the elliptical curve of growth radii = np.arange(1, 40) ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, theta=np.deg2rad(42), error=error) # Plot the elliptical curve of growth ecog.normalize() fig, ax = plt.subplots(figsize=(8, 6)) ecog.plot(ax=ax) ecog.plot_error(ax=ax) 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_2dg from photutils.datasets import make_noise_image from photutils.profiles import EllipticalCurveOfGrowth # Create an artificial elliptical source gmodel = Gaussian2D(42.1, 47.8, 52.4, 9.4, 4.7, np.deg2rad(42)) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the elliptical curve of growth radii = np.arange(1, 40) ecog = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, theta=np.deg2rad(42), error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') ecog.apertures[5].plot(ax=ax, color='C0', lw=2) ecog.apertures[10].plot(ax=ax, color='C1', lw=2) ecog.apertures[15].plot(ax=ax, color='C3', lw=2) """ # Define axis labels used by `~photutils.profiles.ProfileBase.plot` _xlabel = 'Semimajor Axis (pixels)' _ylabel = 'Elliptical Curve of Growth' def __init__(self, data, xycen, radii, axis_ratio, *, theta=0.0, error=None, mask=None, method='exact', subpixels=5): if np.min(radii) <= 0: msg = 'radii must be > 0' raise ValueError(msg) if not 0 < axis_ratio <= 1: msg = 'axis_ratio must be in the range 0 < axis_ratio <= 1' raise ValueError(msg) self.axis_ratio = axis_ratio self.theta = theta super().__init__(data, xycen, radii, error=error, mask=mask, method=method, subpixels=subpixels) @lazyproperty def radius(self): """ The profile semimajor-axis lengths in pixels as a 1D `~numpy.ndarray`. This is the same as the input ``radii``. Note that these are the semimajor-axis lengths of the elliptical apertures used to measure the profile. Thus, they are the semimajor-axis values that enclose the given flux. """ return self.radii @lazyproperty def apertures(self): """ A list of `~photutils.aperture.EllipticalAperture` objects used to measure the profile. """ from photutils.aperture import EllipticalAperture return [EllipticalAperture(self.xycen, a, a * self.axis_ratio, theta=self.theta) for a in self.radii] @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of semimajor axis. """ return self._compute_photometry(self.apertures) @lazyproperty def profile(self): """ The elliptical curve-of-growth profile as a 1D `~numpy.ndarray`. """ return self._photometry[0] @lazyproperty def profile_error(self): """ The elliptical curve-of-growth profile errors as a 1D `~numpy.ndarray`. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ return self._photometry[1] @lazyproperty def area(self): """ The unmasked area in each elliptical aperture as a function of semimajor axis as a 1D `~numpy.ndarray`. """ return self._photometry[2] def calc_ee_at_radius(self, radius): """ Calculate the encircled energy at a given semimajor-axis length using a cubic interpolator based on the profile data. Note that this method 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 semimajor-axis length(s). Returns ------- ee : `~numpy.ndarray` The encircled energy at each radius. Returns NaN for radii outside the range of the profile data. """ return PchipInterpolator(self.radius, self.profile, extrapolate=False)(radius) def calc_radius_at_ee(self, ee): """ Calculate the semimajor-axis length at a given encircled energy using a cubic interpolator based on the profile data. Note that this method 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 elliptical curve-of-growth profile is monotonically increasing. Parameters ---------- ee : float or 1D `~numpy.ndarray` The encircled energy. Returns ------- radius : `~numpy.ndarray` The semimajor-axis length at each encircled energy. Returns NaN for encircled energies outside the range of the profile data. """ radius, profile = self._trim_to_monotonic( self.radius, self.profile, 'elliptical curve-of-growth') return PchipInterpolator(profile, radius, extrapolate=False)(ee) astropy-photutils-3322558/photutils/profiles/radial_profile.py000066400000000000000000000537011517052111400246500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating radial profiles. """ import warnings import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian1D, Moffat1D from astropy.stats import gaussian_sigma_to_fwhm from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.profiles.core import ProfileBase __all__ = ['RadialProfile'] 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 `CurveOfGrowth` class, where the input radii are the radii of the circular apertures used to compute the cumulative flux. 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 -------- CurveOfGrowth 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 photutils.centroids import centroid_2dg >>> 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) >>> bkg_sig = 2.1 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the radial profile. >>> xycen = centroid_2dg(data) >>> edge_radii = np.arange(25) >>> rp = RadialProfile(data, xycen, edge_radii, error=error) >>> 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] >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.30187860e+01 4.02502046e+01 3.57758011e+01 3.16071235e+01 2.61511082e+01 2.10539746e+01 1.63701300e+01 1.16674718e+01 8.12828014e+00 5.78962699e+00 3.59342666e+00 2.35353336e+00 1.20355937e+00 7.67093923e-01 4.24650784e-01 8.67989701e-02 5.11484374e-02 -9.82041768e-02 2.37482124e-02 -3.66602855e-02 6.84802299e-02 1.72239596e-01 -3.86056497e-02 7.30423743e-02] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.18479813 0.68404352 0.52985783 0.4478116 0.39493271 0.35723008 0.32860388 0.30591356 0.28735575 0.27181133 0.25854415 0.24704749 0.23695963 0.22801451 0.22001149 0.21279603 0.20624688 0.20026744 0.19477961 0.18971954 0.18503438 0.18068002 0.17661928 0.17282057] Plot the radial profile, including the raw data profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, color='C0') rp.plot_error(ax=ax) ax.scatter(rp.data_radius, rp.data_profile, s=1, color='C1') 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 photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile rp.normalize() fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax) rp.plot_error(ax=ax) Plot three 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_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm, origin='lower') rp.apertures[5].plot(ax=ax, color='C0', lw=2) rp.apertures[10].plot(ax=ax, color='C1', lw=2) rp.apertures[15].plot(ax=ax, color='C3', lw=2) Fit 1D Gaussian and Moffat models to the normalized radial profile. >>> rp.normalize() >>> rp.gaussian_fit # doctest: +FLOAT_CMP >>> rp.moffat_fit # doctest: +ELLIPSIS >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.009084813327846 >>> print(rp.moffat_fwhm) # doctest: +FLOAT_CMP 10.868426507928344 Plot the fitted 1D Gaussian and Moffat models on the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_2dg 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) bkg_sig = 2.1 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=0) data += noise error = np.zeros_like(data) + bkg_sig # Find the source centroid xycen = centroid_2dg(data) # Create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error) # Plot the radial profile rp.normalize() fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, label='Radial Profile') rp.plot_error(ax=ax) ax.plot(rp.radius, rp.gaussian_profile, label='Gaussian Fit') ax.plot(rp.radius, rp.moffat_profile, label='Moffat Fit') ax.legend() """ # Define y-axis label used by `~photutils.profiles.ProfileBase.plot` _ylabel = 'Radial Profile' # Define the fit properties that should be invalidated when the # profile normalization is changed, so they are always consistent # with the current profile. _fit_properties = ('_profile_nanmask', 'gaussian_fit', 'gaussian_profile', 'gaussian_fwhm', 'moffat_fit', 'moffat_profile', 'moffat_fwhm') @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, as `~photutils.aperture.CircularAnnulus` objects. If the minimum of ``radii`` is zero, then the innermost element will be a `~photutils.aperture.CircularAperture` with radius equal to ``radii[1]``. """ # Prevent circular imports 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 _flux_err(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 no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ if self.error is None: return self._flux_err # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux_err / 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. The cached fit is automatically invalidated when the profile normalization is changed, so the fit is always consistent with the current profile. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely non-finite or masked; ' 'cannot fit a Gaussian.') warnings.warn(msg, AstropyUserWarning) return None amplitude = np.max(profile) sum_profile = np.sum(profile) if sum_profile == 0: msg = ('The profile sum is zero; the Gaussian fit initial ' 'guess may be inaccurate.') warnings.warn(msg, AstropyUserWarning) std = 1.0 # fallback to avoid zero initial guess else: std = np.sqrt(abs(np.sum(profile * radius**2) / sum_profile)) std = max(std, 1.0) # guard against near-zero initial guess g_init = Gaussian1D(amplitude=amplitude, mean=0.0, stddev=std) g_init.mean.fixed = True fitter = TRFLSQFitter() gaussian_fit = fitter(g_init, radius, profile) if radius.min() > 0.3 * gaussian_fit.stddev.value: msg = ('Gaussian fit may be unreliable because the input ' 'radii do not extend close to the source center.') warnings.warn(msg, AstropyUserWarning) return gaussian_fit @lazyproperty def gaussian_profile(self): """ The fitted 1D Gaussian profile to the radial profile as a 1D `~numpy.ndarray`. The cached profile is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.gaussian_fit is None: return None 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. The cached value is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.gaussian_fit is None: return None return self.gaussian_fit.stddev.value * gaussian_sigma_to_fwhm @lazyproperty def moffat_fit(self): """ The fitted 1D Moffat to the radial profile as a `~astropy.modeling.functional_models.Moffat1D` model. The cached fit is automatically invalidated when the profile normalization is changed, so the fit is always consistent with the current profile. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely non-finite or masked; ' 'cannot fit a Moffat.') warnings.warn(msg, AstropyUserWarning) return None amplitude = np.max(profile) sum_profile = np.sum(profile) if sum_profile == 0: msg = ('The profile sum is zero; the Moffat fit initial ' 'guess may be inaccurate.') warnings.warn(msg, AstropyUserWarning) gamma = 1.0 # fallback to avoid zero initial guess else: # Estimate gamma from the half-max radius half_max = amplitude / 2.0 above = profile >= half_max gamma = (max(np.max(radius[above]), 1.0) if np.any(above) else 1.0) m_init = Moffat1D(amplitude=amplitude, x_0=0.0, gamma=gamma, alpha=2.5) m_init.x_0.fixed = True m_init.gamma.bounds = (0, None) m_init.alpha.bounds = (1, 25) fitter = TRFLSQFitter() return fitter(m_init, radius, profile) @lazyproperty def moffat_profile(self): """ The fitted 1D Moffat profile to the radial profile as a 1D `~numpy.ndarray`. The cached profile is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.moffat_fit is None: return None return self.moffat_fit(self.radius) @lazyproperty def moffat_fwhm(self): """ The full-width at half-maximum (FWHM) in pixels of the 1D Moffat fitted to the radial profile. The cached value is automatically invalidated when the profile normalization is changed. Returns `None` if the fit failed (e.g., the profile is entirely non-finite or masked). """ if self.moffat_fit is None: return None return self.moffat_fit.fwhm @lazyproperty def _data_profile(self): """ The raw data profile returned as 1D arrays (`~numpy.ndarray`) of radii and data values. Returns the radii and values of the unmasked data points within the maximum radius defined by the input radii. Pixels flagged in ``self.mask`` (including auto-masked non-finite values) are excluded. """ shape = self.data.shape max_radius = np.max(self.radii) x_min = int(max(np.floor(self.xycen[0] - max_radius), 0)) x_max = int(min(np.ceil(self.xycen[0] + max_radius), shape[1])) y_min = int(max(np.floor(self.xycen[1] - max_radius), 0)) y_max = int(min(np.ceil(self.xycen[1] + max_radius), shape[0])) yidx, xidx = np.indices((y_max - y_min, x_max - x_min)) xidx += x_min yidx += y_min # Calculate the radii of the pixels from the center and select # those within the maximum radius defined by the input radii radii = np.hypot(xidx - self.xycen[0], yidx - self.xycen[1]) within = radii <= max_radius radii = radii[within] yidx_sub = yidx[within] xidx_sub = xidx[within] # Exclude masked pixels (user mask and auto-masked non-finite # values) valid = ~self.mask[yidx_sub, xidx_sub] radii = radii[valid] data_values = self.data[yidx_sub[valid], xidx_sub[valid]] return radii, data_values @lazyproperty def data_radius(self): """ The radii of the raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[0] @lazyproperty def data_profile(self): """ The raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[1] def _invalidate_fit_cache(self): """ Remove cached Gaussian and Moffat fit lazy properties so they are recomputed on next access using the current profile. """ for key in self._fit_properties: self.__dict__.pop(key, None) def _normalize_hook(self, normalization): """ Also normalize ``data_profile`` if it has been computed, and invalidate fit caches so they are recomputed on next access. """ if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = self.data_profile / normalization self._invalidate_fit_cache() def _unnormalize_hook(self): """ Also unnormalize ``data_profile`` if it has been computed, and invalidate fit caches so they are recomputed on next access. """ if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = (self.data_profile * self.normalization_value) self._invalidate_fit_cache() astropy-photutils-3322558/photutils/profiles/tests/000077500000000000000000000000001517052111400224565ustar00rootroot00000000000000astropy-photutils-3322558/photutils/profiles/tests/__init__.py000066400000000000000000000000001517052111400245550ustar00rootroot00000000000000astropy-photutils-3322558/photutils/profiles/tests/conftest.py000066400000000000000000000014311517052111400246540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Common test fixtures for the profiles module tests. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D @pytest.fixture(name='profile_data') def fixture_profile_data(): """ Fixture that generates a 2D Gaussian profile with error and mask arrays for testing the profile classes. """ 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 astropy-photutils-3322558/photutils/profiles/tests/test_curve_of_growth.py000066400000000000000000001002701517052111400272710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the curve_of_growth module. """ import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture import (CircularAperture, EllipticalAperture, RectangularAperture) from photutils.profiles import (CurveOfGrowth, EllipticalCurveOfGrowth, EnsquaredCurveOfGrowth, ProfileBase) from photutils.utils._optional_deps import HAS_MATPLOTLIB class TestCurveOfGrowth: def test_basic(self, profile_data): """ Test basic CurveOfGrowth functionality. """ 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_units(self, profile_data): """ Test CurveOfGrowth with units. """ 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 match = 'must all have the same units' with pytest.raises(ValueError, match=match): CurveOfGrowth(data << unit, xycen, radii, error=error, mask=None) def test_error(self, profile_data): """ Test CurveOfGrowth with error array. """ 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_mask(self, profile_data): """ Test CurveOfGrowth with a mask. """ 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_normalize(self, profile_data): """ Test CurveOfGrowth normalize and unnormalize methods. """ 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) match = "invalid method, must be 'max' or 'sum'" with pytest.raises(ValueError, match=match): cg1.normalize(method='invalid') cg1.__dict__['profile'] -= np.nanmax(cg1.__dict__['profile']) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): cg1.normalize(method='max') def test_interp(self, profile_data): """ Test CurveOfGrowth encircled energy interpolation methods. """ xycen, data, _, _ = 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_interp_nonmonotonic_start(self, profile_data): """ Test that `calc_radius_at_ee` raises ValueError when the profile is non-monotonic at the very first point (covers the len(radius) < 2 branch). """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) # Force non-monotonicity at the first point: diff[0] = 0 # (non-positive) so idx=0 and radius[0:0] has fewer than 2 # elements profile = cg1.profile.copy() profile[0] = profile[1] cg1.__dict__['profile'] = profile match = 'The curve-of-growth profile is not monotonically increasing' with pytest.raises(ValueError, match=match): cg1.calc_radius_at_ee(np.array([0.5])) def test_trim_to_monotonic_nan(self): """ Test that `_trim_to_monotonic` keeps only the leading finite segment when NaN values are present (covers the NaN-trimming branch in core.py). """ xarr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) profile = np.array([1.0, 3.0, np.nan, 5.0, 7.0]) xarr_out, profile_out = ProfileBase._trim_to_monotonic( xarr, profile, 'test') assert_equal(xarr_out, np.array([1.0, 2.0])) assert_equal(profile_out, np.array([1.0, 3.0])) def test_inputs(self, profile_data): """ Test CurveOfGrowth input validation. """ xycen, data, error, _ = profile_data match = 'radii must be > 0' radii = np.arange(10) with pytest.raises(ValueError, match=match): CurveOfGrowth(data, xycen, radii, error=None, mask=None) match = 'radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=match): CurveOfGrowth(data, xycen, [1], error=None, mask=None) with pytest.raises(ValueError, match=match): CurveOfGrowth(data, xycen, np.arange(1, 7).reshape(2, 3), error=None, mask=None) match = 'radii must be strictly increasing' radii = np.arange(1, 10)[::-1] with pytest.raises(ValueError, match=match): CurveOfGrowth(data, xycen, radii, error=None, mask=None) unit1 = u.Jy unit2 = u.km radii = np.arange(1, 36) match = 'must all have the same units' with pytest.raises(ValueError, match=match): CurveOfGrowth(data << unit1, xycen, radii, error=error << unit2) def test_no_mutation(self, profile_data): """ Test that input data, error, mask, and radii arrays are not mutated by CurveOfGrowth. """ xycen, data, error, mask = profile_data # Introduce a NaN to trigger the badmask / mask-merge code path. # Use a pixel outside the fixture's masked region (mask[:39, :50]). data2 = data.copy() data2[50, 70] = np.nan mask2 = mask.copy() data2_orig = data2.copy() error_orig = error.copy() mask2_orig = mask2.copy() radii = np.arange(1, 36) radii_orig = radii.copy() match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): CurveOfGrowth(data2, xycen, radii, error=error, mask=mask2) assert_equal(data2, data2_orig) assert_equal(error, error_orig) assert_equal(mask2, mask2_orig) assert_equal(radii, radii_orig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self, profile_data): """ Test CurveOfGrowth plot methods. """ xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.plot() match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): 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() def test_all_masked(self, profile_data): """ Test CurveOfGrowth with all data masked. When every pixel is masked the profile should be all zero (zero flux in every aperture). """ xycen, data, _, _ = profile_data all_mask = np.ones(data.shape, dtype=bool) radii = np.arange(1, 36) cg = CurveOfGrowth(data, xycen, radii, error=None, mask=all_mask) assert_allclose(cg.profile, 0.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_error_none(self, profile_data): """ Test that ``plot_error()`` returns ``None`` when no errors were input. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) cg = CurveOfGrowth(data, xycen, radii, error=None) match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): result = cg.plot_error() assert result is None def test_repr(self, profile_data): """ Test __repr__ output format. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) cg = CurveOfGrowth(data, xycen, radii) r = repr(cg) assert 'CurveOfGrowth' in r assert f'xycen={xycen}' in r assert f'n_radii={len(radii)}' in r assert 'normalized=False' in r class TestEnsquaredCurveOfGrowth: def test_basic(self, profile_data): """ Test basic EnsquaredCurveOfGrowth functionality. """ xycen, data, _, _ = profile_data half_sizes = np.arange(1, 37) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) assert_equal(ecg1.half_size, half_sizes) assert_equal(ecg1.radius, half_sizes) assert ecg1.area.shape == (36,) assert ecg1.profile.shape == (36,) assert ecg1.profile_error.shape == (0,) assert_allclose(ecg1.area[0], 4.0) # 2x2 square assert len(ecg1.apertures) == 36 assert isinstance(ecg1.apertures[0], RectangularAperture) half_sizes = np.arange(1, 36) ecg2 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) assert ecg2.area[0] > 0.0 assert isinstance(ecg2.apertures[0], RectangularAperture) def test_units(self, profile_data): """ Test EnsquaredCurveOfGrowth with units. """ xycen, data, error, _ = profile_data half_sizes = np.arange(1, 36) unit = u.Jy ecg1 = EnsquaredCurveOfGrowth(data << unit, xycen, half_sizes, error=error << unit, mask=None) assert ecg1.profile.unit == unit assert ecg1.profile_error.unit == unit match = 'must all have the same units' with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data << unit, xycen, half_sizes, error=error, mask=None) def test_error(self, profile_data): """ Test EnsquaredCurveOfGrowth with error array. """ xycen, data, error, _ = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error, mask=None) assert ecg1.profile.shape == (35,) assert ecg1.profile_error.shape == (35,) def test_mask(self, profile_data): """ Test EnsquaredCurveOfGrowth with a mask. """ xycen, data, error, mask = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error, mask=None) ecg2 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error, mask=mask) assert ecg1.profile.sum() > ecg2.profile.sum() assert np.sum(ecg1.profile_error**2) > np.sum(ecg2.profile_error**2) def test_normalize(self, profile_data): """ Test EnsquaredCurveOfGrowth normalize and unnormalize methods. """ xycen, data, _, _ = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) ecg2 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) profile1 = ecg1.profile ecg1.normalize() profile2 = ecg1.profile assert np.mean(profile2) < np.mean(profile1) ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='sum') ecg1.normalize(method='max') ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='max') ecg1.normalize(method='sum') ecg1.normalize(method='max') ecg1.normalize(method='max') ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='sum') profile3 = ecg1.profile assert np.mean(profile3) < np.mean(profile1) ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) match = "invalid method, must be 'max' or 'sum'" with pytest.raises(ValueError, match=match): ecg1.normalize(method='invalid') ecg1.__dict__['profile'] -= np.nanmax(ecg1.__dict__['profile']) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): ecg1.normalize(method='max') def test_interp(self, profile_data): """ Test EnsquaredCurveOfGrowth encircled energy interpolation methods. """ xycen, data, _, _ = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) ecg1.normalize() ee_half_sizes = np.array([0, 5, 10, 20, 25, 50], dtype=float) ee_vals = ecg1.calc_ee_at_half_size(ee_half_sizes) half_sizes_back = ecg1.calc_half_size_at_ee(ee_vals) ee_half_sizes[[0, -1]] = np.nan assert_allclose(half_sizes_back, ee_half_sizes, rtol=1e-6) half_sizes = np.linspace(0.1, 36, 200) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None, method='center') ee_vals = ecg1.calc_ee_at_half_size(ee_half_sizes) match = 'The ensquared curve-of-growth profile is not monotonically' with pytest.raises(ValueError, match=match): ecg1.calc_half_size_at_ee(ee_vals) def test_interp_nonmonotonic_start(self, profile_data): """ Test that `calc_half_size_at_ee` raises ValueError when the profile is non-monotonic at the very first point (covers the len(half_size) < 2 branch). """ xycen, data, _, _ = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) # Force non-monotonicity at the first point profile = ecg1.profile.copy() profile[0] = profile[1] ecg1.__dict__['profile'] = profile match = 'The ensquared curve-of-growth profile is not monotonically' with pytest.raises(ValueError, match=match): ecg1.calc_half_size_at_ee(np.array([0.5])) def test_inputs(self, profile_data): """ Test EnsquaredCurveOfGrowth input validation. """ xycen, data, error, _ = profile_data match = 'half_sizes must be > 0' half_sizes = np.arange(10) with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) match = 'radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data, xycen, [1], error=None, mask=None) with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data, xycen, np.arange(1, 7).reshape(2, 3), error=None, mask=None) match = 'radii must be strictly increasing' half_sizes = np.arange(1, 10)[::-1] with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) unit1 = u.Jy unit2 = u.km half_sizes = np.arange(1, 36) match = 'must all have the same units' with pytest.raises(ValueError, match=match): EnsquaredCurveOfGrowth(data << unit1, xycen, half_sizes, error=error << unit2) def test_no_mutation(self, profile_data): """ Test that input data, error, mask, and half_sizes arrays are not mutated by EnsquaredCurveOfGrowth. """ xycen, data, error, mask = profile_data data2 = data.copy() data2[50, 70] = np.nan mask2 = mask.copy() data2_orig = data2.copy() error_orig = error.copy() mask2_orig = mask2.copy() half_sizes = np.arange(1, 36) half_sizes_orig = half_sizes.copy() match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): EnsquaredCurveOfGrowth(data2, xycen, half_sizes, error=error, mask=mask2) assert_equal(data2, data2_orig) assert_equal(error, error_orig) assert_equal(mask2, mask2_orig) assert_equal(half_sizes, half_sizes_orig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self, profile_data): """ Test EnsquaredCurveOfGrowth plot methods. """ xycen, data, error, _ = profile_data half_sizes = np.arange(1, 36) ecg1 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=None, mask=None) ecg1.plot() match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): ecg1.plot_error() ecg2 = EnsquaredCurveOfGrowth(data, xycen, half_sizes, error=error, mask=None) ecg2.plot() pc1 = ecg2.plot_error() assert_allclose(pc1.get_facecolor(), [[0.5, 0.5, 0.5, 0.3]]) pc2 = ecg2.plot_error(facecolor='blue') assert_allclose(pc2.get_facecolor(), [[0, 0, 1, 1]]) unit = u.Jy ecg3 = EnsquaredCurveOfGrowth(data << unit, xycen, half_sizes, error=error << unit, mask=None) ecg3.plot() ecg3.plot_error() def test_repr(self, profile_data): """ Test __repr__ output format. """ xycen, data, _, _ = profile_data half_sizes = np.arange(1, 36) ecg = EnsquaredCurveOfGrowth(data, xycen, half_sizes) r = repr(ecg) assert 'EnsquaredCurveOfGrowth' in r assert f'xycen={xycen}' in r assert f'n_half_sizes={len(half_sizes)}' in r assert 'normalized=False' in r class TestEllipticalCurveOfGrowth: def test_basic(self, profile_data): """ Test basic EllipticalCurveOfGrowth functionality. """ xycen, data, _, _ = profile_data radii = np.arange(1, 37) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) assert_equal(ecg1.radius, radii) assert ecg1.area.shape == (36,) assert ecg1.profile.shape == (36,) assert ecg1.profile_error.shape == (0,) assert_allclose(ecg1.area[0], np.pi * 1.0 * 0.5) assert len(ecg1.apertures) == 36 assert isinstance(ecg1.apertures[0], EllipticalAperture) radii = np.arange(1, 36) ecg2 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) assert ecg2.area[0] > 0.0 assert isinstance(ecg2.apertures[0], EllipticalAperture) def test_axis_ratio_1(self, profile_data): """ Test that axis_ratio=1 gives the same result as CurveOfGrowth. """ xycen, data, error, _ = profile_data radii = np.arange(1, 36) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) ecg = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=1.0, error=error, mask=None) assert_allclose(ecg.profile, cog.profile) assert_allclose(ecg.profile_error, cog.profile_error) def test_units(self, profile_data): """ Test EllipticalCurveOfGrowth with units. """ xycen, data, error, _ = profile_data radii = np.arange(1, 36) unit = u.Jy ecg1 = EllipticalCurveOfGrowth(data << unit, xycen, radii, axis_ratio=0.5, error=error << unit, mask=None) assert ecg1.profile.unit == unit assert ecg1.profile_error.unit == unit match = 'must all have the same units' with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data << unit, xycen, radii, axis_ratio=0.5, error=error, mask=None) def test_error(self, profile_data): """ Test EllipticalCurveOfGrowth with error array. """ xycen, data, error, _ = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=error, mask=None) assert ecg1.profile.shape == (35,) assert ecg1.profile_error.shape == (35,) def test_mask(self, profile_data): """ Test EllipticalCurveOfGrowth with a mask. """ xycen, data, error, mask = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=error, mask=None) ecg2 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=error, mask=mask) assert ecg1.profile.sum() > ecg2.profile.sum() assert np.sum(ecg1.profile_error**2) > np.sum(ecg2.profile_error**2) def test_normalize(self, profile_data): """ Test EllipticalCurveOfGrowth normalize and unnormalize methods. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) ecg2 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) profile1 = ecg1.profile ecg1.normalize() profile2 = ecg1.profile assert np.mean(profile2) < np.mean(profile1) ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='sum') ecg1.normalize(method='max') ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='max') ecg1.normalize(method='sum') ecg1.normalize(method='max') ecg1.normalize(method='max') ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) ecg1.normalize(method='sum') profile3 = ecg1.profile assert np.mean(profile3) < np.mean(profile1) ecg1.unnormalize() assert_allclose(ecg1.profile, ecg2.profile) match = "invalid method, must be 'max' or 'sum'" with pytest.raises(ValueError, match=match): ecg1.normalize(method='invalid') ecg1.__dict__['profile'] -= np.nanmax(ecg1.__dict__['profile']) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): ecg1.normalize(method='max') def test_interp(self, profile_data): """ Test EllipticalCurveOfGrowth encircled energy interpolation methods. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) ecg1.normalize() ee_radii = np.array([0, 5, 10, 20, 25, 50], dtype=float) ee_vals = ecg1.calc_ee_at_radius(ee_radii) radii_back = ecg1.calc_radius_at_ee(ee_vals) ee_radii[[0, -1]] = np.nan assert_allclose(radii_back, ee_radii, rtol=1e-6) radii = np.linspace(0.1, 36, 200) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None, method='center') ee_vals = ecg1.calc_ee_at_radius(ee_radii) match = 'The elliptical curve-of-growth profile is not monotonically' with pytest.raises(ValueError, match=match): ecg1.calc_radius_at_ee(ee_vals) def test_interp_nonmonotonic_start(self, profile_data): """ Test that `calc_radius_at_ee` raises ValueError when the profile is non-monotonic at the very first point (covers the len(radius) < 2 branch). """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) # Force non-monotonicity at the first point profile = ecg1.profile.copy() profile[0] = profile[1] ecg1.__dict__['profile'] = profile match = 'The elliptical curve-of-growth profile is not monotonically' with pytest.raises(ValueError, match=match): ecg1.calc_radius_at_ee(np.array([0.5])) def test_inputs(self, profile_data): """ Test EllipticalCurveOfGrowth input validation. """ xycen, data, error, _ = profile_data match = 'radii must be > 0' radii = np.arange(10) with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) match = 'radii must be a 1D array and have at least two values' with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, [1], axis_ratio=0.5, error=None, mask=None) with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, np.arange(1, 7).reshape(2, 3), axis_ratio=0.5, error=None, mask=None) match = 'radii must be strictly increasing' radii = np.arange(1, 10)[::-1] with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) match = 'axis_ratio must be in the range 0 < axis_ratio <= 1' radii = np.arange(1, 36) with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.0) with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=-0.5) with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=1.5) unit1 = u.Jy unit2 = u.km radii = np.arange(1, 36) match = 'must all have the same units' with pytest.raises(ValueError, match=match): EllipticalCurveOfGrowth(data << unit1, xycen, radii, axis_ratio=0.5, error=error << unit2) def test_no_mutation(self, profile_data): """ Test that input data, error, mask, and radii arrays are not mutated by EllipticalCurveOfGrowth. """ xycen, data, error, mask = profile_data data2 = data.copy() data2[50, 70] = np.nan mask2 = mask.copy() data2_orig = data2.copy() error_orig = error.copy() mask2_orig = mask2.copy() radii = np.arange(1, 36) radii_orig = radii.copy() match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): EllipticalCurveOfGrowth(data2, xycen, radii, axis_ratio=0.5, error=error, mask=mask2) assert_equal(data2, data2_orig) assert_equal(error, error_orig) assert_equal(mask2, mask2_orig) assert_equal(radii, radii_orig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self, profile_data): """ Test EllipticalCurveOfGrowth plot methods. """ xycen, data, error, _ = profile_data radii = np.arange(1, 36) ecg1 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=None, mask=None) ecg1.plot() match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): ecg1.plot_error() ecg2 = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5, error=error, mask=None) ecg2.plot() pc1 = ecg2.plot_error() assert_allclose(pc1.get_facecolor(), [[0.5, 0.5, 0.5, 0.3]]) pc2 = ecg2.plot_error(facecolor='blue') assert_allclose(pc2.get_facecolor(), [[0, 0, 1, 1]]) unit = u.Jy ecg3 = EllipticalCurveOfGrowth(data << unit, xycen, radii, axis_ratio=0.5, error=error << unit, mask=None) ecg3.plot() ecg3.plot_error() def test_repr(self, profile_data): """ Test __repr__ output format. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) ecg = EllipticalCurveOfGrowth(data, xycen, radii, axis_ratio=0.5) r = repr(ecg) assert 'EllipticalCurveOfGrowth' in r assert f'xycen={xycen}' in r assert f'n_radii={len(radii)}' in r assert 'normalized=False' in r astropy-photutils-3322558/photutils/profiles/tests/test_positional_kwargs.py000066400000000000000000000056511517052111400276350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.profiles import CurveOfGrowth, RadialProfile from photutils.utils._optional_deps import HAS_MATPLOTLIB @pytest.fixture def profile_data(): """ Create a simple radial profile for testing. """ gmodel = Gaussian2D(42.1, 50, 50, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) xycen = (50, 50) return data, xycen class TestProfileBaseNormalizePositionalKwargs: """ Test that ProfileBase.normalize warns for positional optional args. """ def test_positional_warns(self, profile_data): data, xycen = profile_data radii = np.arange(1, 20) cog = CurveOfGrowth(data, xycen, radii) match = 'normalize' with pytest.warns(AstropyDeprecationWarning, match=match): cog.normalize('max') def test_keyword_no_warning(self, profile_data): data, xycen = profile_data radii = np.arange(1, 20) cog = CurveOfGrowth(data, xycen, radii) cog.normalize(method='max') class TestProfileBasePlotPositionalKwargs: """ Test that ProfileBase.plot warns for positional optional args. """ @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_positional_warns(self, profile_data): data, xycen = profile_data edge_radii = np.arange(20) rp = RadialProfile(data, xycen, edge_radii) match = 'plot' with pytest.warns(AstropyDeprecationWarning, match=match): rp.plot(None) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_keyword_no_warning(self, profile_data): data, xycen = profile_data edge_radii = np.arange(20) rp = RadialProfile(data, xycen, edge_radii) rp.plot(ax=None) class TestProfileBasePlotErrorPositionalKwargs: """ Test that ProfileBase.plot_error warns for positional optional args. """ @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_positional_warns(self, profile_data): data, xycen = profile_data error = np.ones_like(data) edge_radii = np.arange(20) rp = RadialProfile(data, xycen, edge_radii, error=error) match = 'plot_error' with pytest.warns(AstropyDeprecationWarning, match=match): rp.plot_error(None) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_keyword_no_warning(self, profile_data): data, xycen = profile_data error = np.ones_like(data) edge_radii = np.arange(20) rp = RadialProfile(data, xycen, edge_radii, error=error) rp.plot_error(ax=None) astropy-photutils-3322558/photutils/profiles/tests/test_radial_profile.py000066400000000000000000000645461517052111400270620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the radial_profile module. """ import warnings import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Moffat1D 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_MATPLOTLIB class TestRadialProfile: def test_basic(self, profile_data): """ Test basic RadialProfile functionality. """ 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_normalization(self, profile_data): """ Test RadialProfile normalize and unnormalize methods. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=error, mask=None) profile = rp1.profile profile_error = rp1.profile_error data_profile = rp1.data_profile rp1.normalize() assert np.max(rp1.profile) == 1.0 assert np.max(rp1.profile_error) <= np.max(profile_error) assert np.max(rp1.data_profile) <= np.max(data_profile) rp1.unnormalize() assert_allclose(rp1.profile, profile) assert_allclose(rp1.profile_error, profile_error) assert_allclose(rp1.data_profile, data_profile) def test_data(self, profile_data): """ Test RadialProfile data_radius and data_profile attributes. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) data_radius = rp1.data_radius data_profile = rp1.data_profile assert np.max(data_radius) <= np.max(edge_radii) assert data_radius.shape == data_profile.shape assert np.min(data_profile) >= np.min(data) assert np.max(data_profile) <= np.max(data) def test_data_mask(self, profile_data): """ Test that masked pixels are excluded from data_radius and data_profile. """ xycen, data, _, mask = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, mask=None) rp2 = RadialProfile(data, xycen, edge_radii, mask=mask) # Applying a mask should reduce the number of data points returned assert len(rp2.data_radius) < len(rp1.data_radius) assert rp2.data_radius.shape == rp2.data_profile.shape # Auto-masked non-finite pixels should also be excluded xcen, ycen = xycen data2 = data.copy() data2[int(ycen), int(xcen)] = np.nan match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): rp3 = RadialProfile(data2, xycen, edge_radii, mask=None) assert np.all(np.isfinite(rp3.data_profile)) assert len(rp3.data_profile) < len(rp1.data_profile) def test_inputs(self, profile_data): """ Test RadialProfile input validation. """ xycen, data, _, _ = profile_data match = 'minimum radii must be >= 0' edge_radii = np.arange(-1, 10) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'radii must be a 1D array and have at least two values' edge_radii = [1] with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) edge_radii = np.arange(6).reshape(2, 3) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'radii must be strictly increasing' edge_radii = np.arange(10)[::-1] with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'error must have the same shape as data' edge_radii = np.arange(10) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=np.ones(3), mask=None) match = 'mask must have the same shape as data' edge_radii = np.arange(10) mask = np.ones(3, dtype=bool) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=mask) def test_gaussian(self, profile_data): """ Test RadialProfile Gaussian fit attributes. """ 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_unit(self, profile_data): """ Test RadialProfile with units. """ 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 match = 'must all have the same units' with pytest.raises(ValueError, match=match): RadialProfile(data << unit, xycen, edge_radii, error=error, mask=None) def test_no_mutation(self, profile_data): """ Test that input data, error, mask, and radii arrays are not mutated by RadialProfile. """ xycen, data, error, mask = profile_data # Introduce a NaN to trigger the badmask / mask-merge code path. # Use a pixel outside the fixture's masked region (mask[:39, :50]). data2 = data.copy() data2[50, 70] = np.nan mask2 = mask.copy() data2_orig = data2.copy() error_orig = error.copy() mask2_orig = mask2.copy() edge_radii = np.arange(36) radii_orig = edge_radii.copy() match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): RadialProfile(data2, xycen, edge_radii, error=error, mask=mask2) assert_equal(data2, data2_orig) assert_equal(error, error_orig) assert_equal(mask2, mask2_orig) assert_equal(edge_radii, radii_orig) def test_error(self, profile_data): """ Test RadialProfile with error array. """ 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_gaussian_zero_sum(self, profile_data): """ Test that ``gaussian_fit`` issues a warning and falls back to ``std=1.0`` when the profile sum is zero (covers the ``sum_profile == 0`` branch in ``gaussian_fit``). """ xycen, data, _, _ = profile_data # All-zero data produces a zero-sum profile zero_data = np.zeros_like(data) edge_radii = np.arange(36) rp = RadialProfile(zero_data, xycen, edge_radii) with pytest.warns(AstropyUserWarning) as warning_list: gfit = rp.gaussian_fit messages = [str(w.message) for w in warning_list] assert any('The profile sum is zero' in m for m in messages) # The fit should still return a Gaussian1D model assert isinstance(gfit, Gaussian1D) def test_normalize_nan(self, profile_data): """ If the profile has NaNs (e.g., aperture outside 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_nonfinite(self, profile_data): """ Test RadialProfile handling of non-finite data values. """ 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) match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): 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=match): rp4 = RadialProfile(data, xycen, edge_radii, error=error2, mask=None) assert_allclose(rp1.profile, rp4.profile) def test_all_masked(self, profile_data): """ Test RadialProfile with all data masked. When every pixel is masked the profile should be all NaN (division by zero area). """ xycen, data, _, _ = profile_data all_mask = np.ones(data.shape, dtype=bool) edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, error=None, mask=all_mask) assert np.all(np.isnan(rp.profile)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self, profile_data): """ Test RadialProfile plot methods. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None) rp1.plot() match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): rp1.plot_error() rp2 = RadialProfile(data, xycen, edge_radii, error=error) rp2.plot() pc1 = rp2.plot_error() assert_allclose(pc1.get_facecolor(), [[0.5, 0.5, 0.5, 0.3]]) pc2 = rp2.plot_error(facecolor='blue') assert_allclose(pc2.get_facecolor(), [[0, 0, 1, 1]]) unit = u.Jy rp3 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit) rp3.plot() rp3.plot_error() @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_error_none(self, profile_data): """ Test that ``plot_error()`` returns ``None`` when no errors were input. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, error=None) match = 'Errors were not input' with pytest.warns(AstropyUserWarning, match=match): result = rp.plot_error() assert result is None def test_gaussian_std_guard(self, profile_data): """ Test that the ``std = max(std, 1.0)`` guard is exercised when the computed std from a near-delta-function profile is close to zero. """ xycen, data, _, _ = profile_data # Create a near-delta-function: all zero except one pixel at the # center so the weighted mean radius is nearly zero. delta = np.zeros_like(data) cy, cx = round(xycen[1]), round(xycen[0]) delta[cy, cx] = 1.0 edge_radii = np.arange(36) rp = RadialProfile(delta, xycen, edge_radii) # The radii warning may also be emitted because the fitted # stddev can be small enough that radius.min() > 0.3 * stddev. with pytest.warns(AstropyUserWarning): gfit = rp.gaussian_fit # The initial std guess gets clamped to 1.0, and the fit should # still produce a valid Gaussian assert isinstance(gfit, Gaussian1D) assert gfit.stddev.value > 0 def test_gaussian_radii_warning(self, profile_data): """ Test that a warning is issued when the input radii do not extend close to the source center. """ xycen, data, _, _ = profile_data # Use edge_radii that start far from the center so that # radius.min() > 0.3 * stddev. edge_radii = np.arange(20, 36) rp = RadialProfile(data, xycen, edge_radii) match = 'Gaussian fit may be unreliable' with pytest.warns(AstropyUserWarning, match=match): _ = rp.gaussian_fit def test_repr(self, profile_data): """ Test __repr__ output format. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) r = repr(rp) assert 'RadialProfile' in r assert f'xycen={xycen}' in r assert f'n_radii={len(edge_radii)}' in r assert 'normalized=False' in r rp.normalize() r = repr(rp) assert 'normalized=True' in r def test_gaussian_fit_all_masked(self, profile_data): """ Test that gaussian_fit returns None when the profile is entirely masked. """ xycen, data, _, _ = profile_data mask = np.ones(data.shape, dtype=bool) edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, mask=mask) match = 'The radial profile is entirely non-finite or masked' with pytest.warns(AstropyUserWarning, match=match): result = rp.gaussian_fit assert result is None assert rp.gaussian_profile is None assert rp.gaussian_fwhm is None def test_normalize_all_nan(self, profile_data): """ Test that normalize warns when the profile is all NaN. """ xycen, data, _, _ = profile_data mask = np.ones(data.shape, dtype=bool) edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, mask=mask) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): rp.normalize() def test_moffat(self, profile_data): """ Test RadialProfile Moffat fit attributes. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp.moffat_fit, Moffat1D) assert rp.moffat_profile.shape == (35,) assert rp.moffat_fwhm > 0 assert rp.moffat_fwhm < 30.0 # Check that x_0 is fixed at 0 assert rp.moffat_fit.x_0.value == 0.0 edge_radii = np.arange(201) rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.moffat_fit, Moffat1D) assert rp2.moffat_profile.shape == (200,) assert rp2.moffat_fwhm > 0 def test_moffat_fwhm_consistency(self, profile_data): """ Test that ``moffat_fwhm`` is consistent with ``moffat_fit.fwhm``. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) assert_allclose(rp.moffat_fwhm, rp.moffat_fit.fwhm) def test_moffat_profile_values(self, profile_data): """ Test that ``moffat_profile`` equals the fit model evaluated at the profile radii. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) expected = rp.moffat_fit(rp.radius) assert_allclose(rp.moffat_profile, expected) def test_moffat_zero_sum(self, profile_data): """ Test that ``moffat_fit`` issues a warning and falls back to ``gamma=1.0`` when the profile sum is zero. """ xycen, data, _, _ = profile_data zero_data = np.zeros_like(data) edge_radii = np.arange(36) rp = RadialProfile(zero_data, xycen, edge_radii) with pytest.warns(AstropyUserWarning) as warning_list: mfit = rp.moffat_fit messages = [str(w.message) for w in warning_list] assert any('The profile sum is zero' in m for m in messages) assert isinstance(mfit, Moffat1D) def test_moffat_fit_all_masked(self, profile_data): """ Test that ``moffat_fit`` returns ``None`` when the profile is entirely masked. """ xycen, data, _, _ = profile_data mask = np.ones(data.shape, dtype=bool) edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, mask=mask) match = 'The radial profile is entirely non-finite or masked' with pytest.warns(AstropyUserWarning, match=match): result = rp.moffat_fit assert result is None assert rp.moffat_profile is None assert rp.moffat_fwhm is None def test_moffat_no_above_half_max(self, profile_data): """ Test that ``moffat_fit`` handles the case where no profile values are above half the maximum (gamma fallback to 1.0). """ xycen, data, _, _ = profile_data # Create a near-delta-function profile so that all annular # averages may be below half-max (only center pixel has flux). delta = np.zeros_like(data) cy, cx = round(xycen[1]), round(xycen[0]) delta[cy, cx] = 1.0 edge_radii = np.arange(36) rp = RadialProfile(delta, xycen, edge_radii) # The fit may warn about convergence for such a narrow profile. with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) mfit = rp.moffat_fit assert isinstance(mfit, Moffat1D) assert mfit.gamma.value > 0 assert mfit.alpha.value >= 1 def test_moffat_lazyproperty(self, profile_data): """ Test that ``moffat_fit``, ``moffat_profile``, and ``moffat_fwhm`` are lazily computed and cached. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) fit1 = rp.moffat_fit fit2 = rp.moffat_fit assert fit1 is fit2 prof1 = rp.moffat_profile prof2 = rp.moffat_profile assert prof1 is prof2 fwhm1 = rp.moffat_fwhm fwhm2 = rp.moffat_fwhm assert fwhm1 == fwhm2 def test_moffat_normalized(self, profile_data): """ Test that normalizing the profile invalidates the Moffat fit cache and the fit is recomputed on the normalized profile. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) amp_before = rp.moffat_fit.amplitude.value rp.normalize() # After normalization, the fit should be recomputed on the # normalized profile with max ~1.0, so the amplitude should # differ from the unnormalized fit. amp_after = rp.moffat_fit.amplitude.value assert amp_after != pytest.approx(amp_before, rel=0.1) assert amp_after == pytest.approx(1.0, abs=0.2) def test_moffat_with_error(self, profile_data): """ Test Moffat fit with error input. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, error=error) assert isinstance(rp.moffat_fit, Moffat1D) assert rp.moffat_profile.shape == rp.profile.shape assert rp.moffat_fwhm > 0 def test_moffat_with_mask(self, profile_data): """ Test Moffat fit with a partial mask. """ xycen, data, _, mask = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, mask=mask) assert isinstance(rp.moffat_fit, Moffat1D) assert rp.moffat_fwhm > 0 def test_moffat_nonfinite_data(self, profile_data): """ Test Moffat fit with non-finite data values. """ xycen, data, error, _ = profile_data data2 = data.copy() data2[40, 40] = np.nan edge_radii = np.arange(36) match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): rp = RadialProfile(data2, xycen, edge_radii, error=error) assert isinstance(rp.moffat_fit, Moffat1D) assert rp.moffat_fwhm > 0 def test_gaussian_fit_invalidated_on_normalize(self, profile_data): """ Test that Gaussian fit properties are invalidated when the profile is normalized, and the recomputed fit matches the normalized profile. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) # Access the fit before normalization gfit_before = rp.gaussian_fit gprof_before = rp.gaussian_profile fwhm_before = rp.gaussian_fwhm amp_before = gfit_before.amplitude.value rp.normalize() # The fit should be a new object (recomputed) gfit_after = rp.gaussian_fit assert gfit_after is not gfit_before # The amplitude should be close to 1 for the normalized profile assert gfit_after.amplitude.value == pytest.approx(1.0, abs=0.2) assert gfit_after.amplitude.value != pytest.approx(amp_before, rel=0.1) # The gaussian_profile should also be recomputed gprof_after = rp.gaussian_profile assert gprof_after is not gprof_before assert_allclose(gprof_after, gfit_after(rp.radius)) # FWHM should be approximately the same (shape doesn't change) fwhm_after = rp.gaussian_fwhm assert_allclose(fwhm_after, fwhm_before, rtol=0.1) def test_moffat_fit_invalidated_on_normalize(self, profile_data): """ Test that Moffat fit properties are invalidated when the profile is normalized, and the recomputed fit matches the normalized profile. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) # Access the fit before normalization mfit_before = rp.moffat_fit mprof_before = rp.moffat_profile fwhm_before = rp.moffat_fwhm amp_before = mfit_before.amplitude.value rp.normalize() # The fit should be a new object (recomputed) mfit_after = rp.moffat_fit assert mfit_after is not mfit_before # The amplitude should differ from the unnormalized fit assert mfit_after.amplitude.value != pytest.approx(amp_before, rel=0.1) # The moffat_profile should also be recomputed mprof_after = rp.moffat_profile assert mprof_after is not mprof_before assert_allclose(mprof_after, mfit_after(rp.radius)) # FWHM should be approximately the same (shape doesn't change) fwhm_after = rp.moffat_fwhm assert_allclose(fwhm_after, fwhm_before, rtol=0.1) def test_fit_invalidated_on_unnormalize(self, profile_data): """ Test that Gaussian and Moffat fits are invalidated when unnormalize is called, and the recomputed fits match the original (unnormalized) profile. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) # Get fits on the original profile gfit_orig = rp.gaussian_fit mfit_orig = rp.moffat_fit gfwhm_orig = rp.gaussian_fwhm mfwhm_orig = rp.moffat_fwhm # Normalize and access fits on the normalized profile rp.normalize() gfit_norm = rp.gaussian_fit mfit_norm = rp.moffat_fit assert gfit_norm is not gfit_orig assert mfit_norm is not mfit_orig # Unnormalize and verify fits are recomputed to match original rp.unnormalize() gfit_unnorm = rp.gaussian_fit mfit_unnorm = rp.moffat_fit # Should be new objects (not the cached normalized ones) assert gfit_unnorm is not gfit_norm assert mfit_unnorm is not mfit_norm # Amplitudes should match the original fits assert_allclose(gfit_unnorm.amplitude.value, gfit_orig.amplitude.value, rtol=0.01) assert_allclose(mfit_unnorm.amplitude.value, mfit_orig.amplitude.value, rtol=0.01) # FWHMs should match the originals assert_allclose(rp.gaussian_fwhm, gfwhm_orig, rtol=0.01) assert_allclose(rp.moffat_fwhm, mfwhm_orig, rtol=0.01) def test_fit_not_accessed_before_normalize(self, profile_data): """ Test that fits computed after normalization (without prior access) correspond to the normalized profile. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) # Normalize without ever accessing the fit first rp.normalize() # The fit should be on the normalized profile assert rp.gaussian_fit.amplitude.value == pytest.approx(1.0, abs=0.2) assert rp.moffat_fit.amplitude.value == pytest.approx(1.0, abs=0.2) assert rp.gaussian_profile is not None assert rp.moffat_profile is not None astropy-photutils-3322558/photutils/psf/000077500000000000000000000000001517052111400202615ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf/__init__.py000066400000000000000000000014471517052111400224000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for performing point-spread-function (PSF) photometry. """ from .epsf_builder import * # noqa: F401, F403 from .epsf_stars import * # noqa: F401, F403 from .flags import * # noqa: F401, F403 from .functional_models import * # noqa: F401, F403 from .gridded_models import * # noqa: F401, F403 from .groupers import * # noqa: F401, F403 from .image_models import * # noqa: F401, F403 from .iterative import * # noqa: F401, F403 from .model_helpers import * # noqa: F401, F403 from .model_io import * # noqa: F401, F403 from .model_plotting import * # noqa: F401, F403 from .photometry import * # noqa: F401, F403 from .simulation import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 astropy-photutils-3322558/photutils/psf/_components.py000066400000000000000000001756151517052111400231760ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Private implementation classes for PSF photometry components. This is a private module. The classes within are implementation details of the PSFPhotometry class and are not intended for direct public use. """ import contextlib import warnings import weakref from copy import deepcopy import astropy import astropy.units as u import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NDData, NoOverlapError, overlap_slices from astropy.table import QTable, Table, hstack, join from astropy.utils import minversion from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import CircularAperture from photutils.datasets import make_model_image as _make_model_image from photutils.utils._deprecation import DeprecatedColumnQTable from photutils.utils._misc import _get_meta from .flags import PSF_FLAGS __all__ = ['PSFDataProcessor', 'PSFFitter', 'PSFResultsAssembler'] def _apply_bounds_to_param(model, param_name, param_value, bound_value): """ Apply bounds to a specific model parameter. This is a general helper function for applying symmetric bounds around a parameter value. Parameters ---------- model : `~astropy.modeling.Model` The model containing the parameter. param_name : str Name of the parameter to apply bounds to. param_value : float Current value of the parameter. bound_value : float The bound offset (parameter will be bounded to [param_value - bound_value, param_value + bound_value]). """ if bound_value is not None: param_obj = getattr(model, param_name) param_obj.bounds = (param_value - bound_value, param_value + bound_value) def _create_flat_model_class(n_sources, psf_model): """ Create a new flat model class for the given number of sources. This function creates a dynamically-generated flat model class that avoids CompoundModel by creating a custom model class where all parameters are top-level (e.g., x_0, y_0, flux_0, x_1, y_1, flux_1, etc.) rather than nested. This eliminates the parameter tree traversal and nested structure overhead of CompoundModel. The dynamic flat model class directly evaluates each PSF and sums them, providing much better performance for large groups. Parameters ---------- n_sources : int Number of sources in the group. psf_model : `~astropy.modeling.Model` Base PSF model to be used for all sources. Returns ------- model_class : type Dynamically created flat model class for the specified number of sources. """ base_param_names = list(psf_model.param_names) base_psf_model = psf_model.copy() # Create class attributes dictionary class_attrs = {} # Add parameters as class attributes (with default values) for i in range(n_sources): for base_param in base_param_names: flat_param_name = f'{base_param}_{i}' # Use default value and fixed status from base PSF model base_param_obj = getattr(base_psf_model, base_param) default_value = base_param_obj.value is_fixed = base_param_obj.fixed param = Parameter(default=default_value, fixed=is_fixed) class_attrs[flat_param_name] = param # Add methods def model_init(self, **kwargs): super(type(self), self).__init__(**kwargs) self.n_sources = n_sources self.base_psf_model = base_psf_model self.base_param_names = base_param_names def evaluate(self, x, y, *params): """ Evaluate the flat PSF group model. This efficiently evaluates each PSF with its parameters and sums the results, avoiding CompoundModel overhead. """ result = np.zeros_like(x, dtype=float) # Evaluate each source's PSF contribution for i in range(self.n_sources): # Extract parameters for this source source_params = [] for j, _ in enumerate(self.base_param_names): param_idx = i * len(self.base_param_names) + j source_params.append(params[param_idx]) # Evaluate this source's PSF and add to result result += self.base_psf_model.evaluate(x, y, *source_params) return result class_attrs['__init__'] = model_init class_attrs['evaluate'] = evaluate class_attrs['__doc__'] = (f'Cached flat PSF model for ' f'{n_sources} sources.') # Create the dynamic class with descriptive name class_name = f'FlatPSFGroupModel_{n_sources}' return type(class_name, (Fittable2DModel,), class_attrs) def _instantiate_flat_model(model_class, sources, psf_model, param_mapper, *, xy_bounds=None): """ Create an instance of a flat model class with source-specific parameter values and bounds. Parameters ---------- model_class : type Flat model class created by `create_flat_model_class`. sources : `~astropy.table.Table` or list of `~astropy.table.Row` List of source rows from the sources table for the group. psf_model : `~astropy.modeling.Model` Base PSF model used for parameter defaults. param_mapper : object Parameter mapper for handling column name mappings and model parameters. Must have `alias_to_model_param` and `init_colnames` attributes. xy_bounds : tuple of float or None, optional Bounds for x and y position parameters as (x_bound, y_bound). If provided, fitting positions will be constrained to within these bounds of the initial values. Returns ------- model : `~astropy.modeling.Model` Instance of the flat model with parameter values set from sources and bounds applied. """ alias_map = param_mapper.alias_to_model_param init_colnames = param_mapper.init_colnames # Create model instance model = model_class() # Set source-specific parameter values and bounds for i, source in enumerate(sources): for base_param in psf_model.param_names: flat_param_name = f'{base_param}_{i}' # Get initial value from source init_value = None for alias, col_name in init_colnames.items(): if alias_map[alias] == base_param: init_value = source[col_name] if isinstance(init_value, u.Quantity): init_value = init_value.value break if init_value is None: init_value = getattr(psf_model, base_param).value # Set parameter value setattr(model, flat_param_name, init_value) # Apply xy bounds if needed if (xy_bounds is not None and base_param == alias_map.get('x') and xy_bounds[0] is not None): _apply_bounds_to_param(model, flat_param_name, init_value, xy_bounds[0]) elif (xy_bounds is not None and base_param == alias_map.get('y') and xy_bounds[1] is not None): _apply_bounds_to_param(model, flat_param_name, init_value, xy_bounds[1]) return model # Weak reference cache for flat model classes to prevent memory leaks _FLAT_MODEL_CACHE = weakref.WeakValueDictionary() def _get_flat_model(sources, psf_model, param_mapper, *, xy_bounds=None): """ Get or create a flat model for a group of sources. This function caches the dynamically-generated flat model classes to avoid recreating them for groups with the same characteristics, improving performance when processing many groups. The caching uses weak references to prevent memory leaks and includes the PSF model type in the cache key to avoid collisions between different model types with similar parameter names. Parameters ---------- sources : `~astropy.table.Table` or list of `~astropy.table.Row` List of source rows from the sources table for the group. psf_model : `~astropy.modeling.Model` Base PSF model to be used for all sources. param_mapper : object Parameter mapper for handling column name mappings and model parameters. xy_bounds : tuple of float or None, optional Bounds for x and y position parameters as (x_bound, y_bound). Returns ------- model : `~astropy.modeling.Model` Flat model instance for the group of sources with parameters set from the sources and bounds applied. """ n_sources = len(sources) # Create robust cache key to avoid model type collisions # Use number of sources and the PSF model class name model_class = psf_model.__class__ model_type = f'{model_class.__module__}.{model_class.__name__}' cache_key = (n_sources, model_type) # Get cached model class or create new one if cache_key not in _FLAT_MODEL_CACHE: model_class = _create_flat_model_class(n_sources, psf_model) _FLAT_MODEL_CACHE[cache_key] = model_class model_class = _FLAT_MODEL_CACHE[cache_key] # Create instance with source-specific parameter values return _instantiate_flat_model(model_class, sources, psf_model, param_mapper, xy_bounds=xy_bounds) class PSFDataProcessor: """ Helper class to handle data validation, preprocessing, and cutout extraction for PSF photometry. This class encapsulates all data-related operations including validation, source finding, initial parameter estimation, and cutout extraction. Parameters ---------- param_mapper : _PSFParameterMapper Parameter mapper for handling column name mappings and model parameters. fit_shape : tuple of int The shape of the PSF fitting region as a (ny, nx) tuple. finder : object, optional Source finder instance for detecting sources if ``init_params`` is not provided. Must have a ``__call__`` method that accepts data and mask arrays and returns a table of detected sources. aperture_radius : float, optional Radius in pixels of circular apertures for initial flux estimation when flux values are not provided in ``init_params``. local_bkg_estimator : object, optional Local background estimator for determining background levels around sources. Must have a ``__call__`` method. """ def __init__(self, param_mapper, fit_shape, *, finder=None, aperture_radius=None, local_bkg_estimator=None): self.param_mapper = param_mapper self.fit_shape = fit_shape self.finder = finder self.aperture_radius = aperture_radius self.local_bkg_estimator = local_bkg_estimator self.data_unit = None self.finder_results = None # Cache for offset grids self._cached_offsets = None self._cache_key = None def validate_array(self, array, name, *, data_shape=None): """ Validate input arrays (data, error, mask). Parameters ---------- array : array-like or None Input array to validate. Can be None for optional arrays. name : str Name of the array for error messages (e.g., 'data', 'error', 'mask'). data_shape : tuple of int, optional Expected shape of the array. If provided, validates that the array shape matches this shape. Returns ------- array : `~numpy.ndarray` or None Validated 2D array or None if input was None. Raises ------ ValueError If the array is not 2D or if the shape doesn't match ``data_shape`` when provided. """ if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != 2: msg = f'{name} must be a 2D array' raise ValueError(msg) if data_shape is not None and array.shape != data_shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array def normalize_init_units(self, init_params, colname): """ Normalize the units of a column in the input init_params table to match the input data units. Parameters ---------- init_params : `~astropy.table.Table` Table containing initial parameters for PSF fitting. colname : str Name of the column to normalize units for. Returns ------- init_params : `~astropy.table.Table` The input table with normalized units for the specified column. Raises ------ ValueError If there are unit compatibility issues between the column and the data units. """ values = init_params[colname] if isinstance(values, u.Quantity): if self.data_unit is None: msg = (f'init_params {colname} column has units, but the ' 'input data does not have units') raise ValueError(msg) try: init_params[colname] = values.to(self.data_unit) except u.UnitConversionError as exc: msg = (f'init_params {colname} column has units that are ' 'incompatible with the input data units') raise ValueError(msg) from exc elif self.data_unit is not None: msg = ('The input data has units, but the init_params ' f'{colname} column does not have units.') raise ValueError(msg) return init_params def validate_init_params(self, init_params): """ Validate the input init_params table and rename columns to expected format. Parameters ---------- init_params : `~astropy.table.Table` or None Table containing initial parameters for PSF fitting. Must contain columns for x and y positions. May contain flux and local_bkg columns. Returns ------- init_params : `~astropy.table.Table` or None Validated table with renamed columns matching expected format, or None if input was None. Raises ------ TypeError If ``init_params`` is not an astropy Table. ValueError If required position columns are missing. """ if init_params is None: return init_params if not isinstance(init_params, Table): msg = 'init_params must be an astropy Table' raise TypeError(msg) # Copy to preserve the input init_params init_params = self.param_mapper.rename_table_columns( init_params.copy()) if (self.param_mapper.init_colnames['x'] not in init_params.colnames or self.param_mapper.init_colnames['y'] not in init_params.colnames): msg = ('init_params must contain valid column names for the ' 'x and y source positions') raise ValueError(msg) flux_col = self.param_mapper.init_colnames['flux'] if flux_col in init_params.colnames: init_params = self.normalize_init_units(init_params, flux_col) if 'local_bkg' in init_params.colnames: # Non-finite local_bkg will not be subtracted, and a flag # will be set in the results. init_params = self.normalize_init_units(init_params, 'local_bkg') return init_params def get_aper_fluxes(self, data, mask, init_params): """ Estimate aperture fluxes at the initial (x, y) positions. Parameters ---------- data : `~numpy.ndarray` 2D image data array. mask : `~numpy.ndarray` or None 2D boolean mask array with the same shape as ``data``, where ``True`` indicates masked pixels. init_params : `~astropy.table.Table` Table containing initial source positions with validated column names. Returns ------- flux : `~numpy.ndarray` Array of aperture flux estimates for each source. """ x_pos = init_params[self.param_mapper.init_colnames['x']] y_pos = init_params[self.param_mapper.init_colnames['y']] apertures = CircularAperture(zip(x_pos, y_pos, strict=True), r=self.aperture_radius) flux, _ = apertures.do_photometry(data, mask=mask) return flux def find_sources_if_needed(self, data, mask, init_params): """ Find sources using the finder if initial positions are not provided. Parameters ---------- data : `~numpy.ndarray` 2D image data array. mask : `~numpy.ndarray` or None 2D boolean mask array with the same shape as ``data``. init_params : `~astropy.table.Table` or None Table containing initial source parameters. If provided, an 'id' column is added if missing. If None, sources are found using the finder. Returns ------- sources : `~astropy.table.Table` or None Table containing source information with required columns for PSF fitting, or None if no sources were found. Raises ------ ValueError If ``init_params`` is None and no finder was provided. """ if init_params is not None: if 'id' not in init_params.colnames: init_params['id'] = np.arange(len(init_params)) + 1 return init_params if self.finder is None: msg = 'finder must be defined if init_params is not input' raise ValueError(msg) if self.data_unit is not None: sources = self.finder(data << self.data_unit, mask=mask) else: sources = self.finder(data, mask=mask) self.finder_results = sources if sources is None: return None return self._convert_finder_to_init(sources) def _convert_finder_to_init(self, sources): """ Convert the output table of the finder to a table with initial (x, y) position column names. Parameters ---------- sources : `~astropy.table.Table` Table returned by the source finder containing detected source positions. Returns ------- init_params : `~astropy.table.QTable` Table with standardized column names suitable for PSF fitting initialization. Raises ------ ValueError If the sources table does not contain valid x and y coordinate columns. """ # Find the first valid column names for x and y x_name_found = self.param_mapper.find_column(sources, 'x') y_name_found = self.param_mapper.find_column(sources, 'y') if x_name_found is None or y_name_found is None: msg = ("The table returned by the 'finder' must contain columns " 'for x and y coordinates. Valid column names are: ' f"x: {self.param_mapper.VALID_INIT_COLNAMES['x']}, " f"y: {self.param_mapper.VALID_INIT_COLNAMES['y']}") raise ValueError(msg) # Create a new table with only the needed columns init_params = QTable() init_params['id'] = np.arange(len(sources)) + 1 x_col = self.param_mapper.init_colnames['x'] y_col = self.param_mapper.init_colnames['y'] init_params[x_col] = sources[x_name_found] init_params[y_col] = sources[y_name_found] return init_params def estimate_flux_and_bkg_if_needed(self, data, mask, init_params): """ Estimate initial fluxes and backgrounds if not provided. Parameters ---------- data : `~numpy.ndarray` 2D image data array. mask : `~numpy.ndarray` or None 2D boolean mask array with the same shape as ``data``. init_params : `~astropy.table.Table` Table containing initial source parameters. Will be modified in-place to add flux and/or local_bkg columns if missing. Returns ------- init_params : `~astropy.table.Table` The input table with added flux and local_bkg columns if they were missing. Raises ------ ValueError If aperture_radius is None when flux estimation is needed. """ x_col = self.param_mapper.init_colnames['x'] y_col = self.param_mapper.init_colnames['y'] flux_col = self.param_mapper.init_colnames['flux'] if 'local_bkg' not in init_params.colnames: if self.local_bkg_estimator is None: local_bkg = np.zeros(len(init_params)) else: local_bkg = self.local_bkg_estimator( data, init_params[x_col], init_params[y_col], mask=mask) if self.data_unit is not None: local_bkg <<= self.data_unit init_params['local_bkg'] = local_bkg if flux_col not in init_params.colnames: # Check for aperture_radius before attempting to use it if self.aperture_radius is None: msg = ('aperture_radius must be defined if a flux column is ' 'not in init_params') raise ValueError(msg) flux = self.get_aper_fluxes(data, mask, init_params) if self.data_unit is not None: flux <<= self.data_unit # Only subtract local_bkg if it's finite local_bkg = init_params['local_bkg'] if hasattr(local_bkg, 'value'): # Handle Quantity local_bkg_vals = local_bkg.value else: local_bkg_vals = np.asarray(local_bkg) # Subtract only finite local_bkg values finite_mask = np.isfinite(local_bkg_vals) if np.any(finite_mask): flux[finite_mask] -= local_bkg[finite_mask] init_params[flux_col] = flux return init_params def get_fit_offsets(self): """ Return cached (y_offsets, x_offsets) arrays for fit_shape. Returns ------- offsets : tuple of `~numpy.ndarray` Tuple containing (y_offsets, x_offsets) arrays with shape ``fit_shape``, where each array contains coordinate offsets from the origin. """ ny, nx = self.fit_shape cache_key = (ny, nx) # Optimized cache management if (self._cached_offsets is None or self._cache_key != cache_key): # Create new cache with validated shape self._cached_offsets = np.mgrid[0:ny, 0:nx] self._cache_key = cache_key return self._cached_offsets def should_skip_source(self, row, data_shape): """ Quick validation to skip obviously invalid sources early. Parameters ---------- row : `~astropy.table.Row` Row from the sources table containing source parameters. data_shape : tuple of int Shape of the input data array as (ny, nx). Returns ------- should_skip : bool True if the source should be skipped, False otherwise. reason : str or None Reason for skipping ('invalid_position', 'non_finite_flux', 'no_overlap') or None if not skipping. """ x_cen = row[self.param_mapper.init_colnames['x']] y_cen = row[self.param_mapper.init_colnames['y']] flux_init = row[self.param_mapper.init_colnames['flux']] # check for non-finite positions if not (np.isfinite(x_cen) and np.isfinite(y_cen)): return True, 'invalid_position' # check for non-finite flux if not np.isfinite(flux_init): return True, 'non_finite_flux' # source that are clearly beyond any possible overlap half_fit = max(self.fit_shape) // 2 clear_margin = half_fit + 1 # a bit beyond the fit region if (x_cen < -clear_margin or y_cen < -clear_margin or x_cen >= data_shape[1] + clear_margin or y_cen >= data_shape[0] + clear_margin): return True, 'no_overlap' return False, None def get_source_cutout_data(self, row, data, mask, y_offsets, x_offsets): """ Extract per-source pixel indices and cutout data. Parameters ---------- row : `~astropy.table.Row` Row from the sources table containing source parameters including position and local background. data : `~numpy.ndarray` 2D image data array. mask : `~numpy.ndarray` or None 2D boolean mask array with the same shape as ``data``. y_offsets, x_offsets : `~numpy.ndarray` 2D array of y- and x-coordinate offsets from ``get_fit_offsets()``. Returns ------- cutout_data : dict Dictionary containing cutout information: - 'valid' : bool, whether the cutout is valid - 'reason' : str or None, reason if invalid - 'xx' : array, x pixel coordinates (flattened) - 'yy' : array, y pixel coordinates (flattened) - 'cutout' : array, data values (background-subtracted) - 'npix' : int, number of pixels in cutout - 'cen_index' : int or nan, index of center pixel """ x_cen = row[self.param_mapper.init_colnames['x']] y_cen = row[self.param_mapper.init_colnames['y']] try: slc_lg, _ = overlap_slices(data.shape, self.fit_shape, (y_cen, x_cen), mode='trim') except NoOverlapError: return {'valid': False, 'reason': 'no_overlap', 'xx': None, 'yy': None, 'cutout': None, 'npix': 0, 'cen_index': np.nan, } y_start = slc_lg[0].start x_start = slc_lg[1].start ny_cutout = slc_lg[0].stop - y_start nx_cutout = slc_lg[1].stop - x_start trimmed_y_offsets = y_offsets[:ny_cutout, :nx_cutout] trimmed_x_offsets = x_offsets[:ny_cutout, :nx_cutout] yy = trimmed_y_offsets + y_start xx = trimmed_x_offsets + x_start if mask is not None: inv_mask = ~mask[yy, xx] if np.count_nonzero(inv_mask) == 0: return {'valid': False, 'reason': 'fully_masked', 'xx': None, 'yy': None, 'cutout': None, 'npix': 0, 'cen_index': np.nan, } yy_flat = yy[inv_mask] xx_flat = xx[inv_mask] else: yy_flat = yy.ravel() xx_flat = xx.ravel() cutout = data[yy_flat, xx_flat] # Local background subtraction (local_bkg = 0 if not provided) # Only subtract if the local_bkg is finite (not NaN or inf) local_bkg = row['local_bkg'] if np.any(local_bkg != 0): if isinstance(local_bkg, u.Quantity): local_bkg_value = local_bkg.value else: local_bkg_value = local_bkg # Only subtract if local_bkg is finite if np.isfinite(local_bkg_value): cutout -= local_bkg_value # Center pixel index (before trimming) x_cen_idx = np.ceil(x_cen - 0.5).astype(int) y_cen_idx = np.ceil(y_cen - 0.5).astype(int) cen_match = np.where((xx_flat == x_cen_idx) & (yy_flat == y_cen_idx))[0] cen_index = cen_match[0] if len(cen_match) > 0 else np.nan return {'valid': True, 'reason': None, 'xx': xx_flat, 'yy': yy_flat, 'cutout': cutout, 'npix': len(xx_flat), 'cen_index': cen_index, } class PSFFitter: """ Helper class to handle PSF model fitting operations. This class encapsulates all fitting-related operations including model creation, fitting execution, and parameter extraction. Parameters ---------- psf_model : `~astropy.modeling.Model` PSF model to be fit to sources. This model will be copied for each source and fitted parameters will be set. param_mapper : _PSFParameterMapper Parameter mapper for handling column name mappings and model parameters. fitter : `~astropy.modeling.fitting.Fitter`, optional Astropy fitter instance to use for PSF fitting. If None, defaults to `~astropy.modeling.fitting.TRFLSQFitter`. fitter_maxiters : int, optional Maximum number of fitting iterations. Default is 100. xy_bounds : tuple of float or None, optional Bounds for x and y position parameters as (x_bound, y_bound). If provided, fitting positions will be constrained to within these bounds of the initial values. group_warning_threshold : int, optional Threshold for issuing warnings about large group sizes. Default is 25. """ def __init__(self, psf_model, param_mapper, *, fitter=None, fitter_maxiters=100, xy_bounds=None, group_warning_threshold=25): self.psf_model = psf_model self.param_mapper = param_mapper self.fitter = fitter if fitter is not None else TRFLSQFitter() self.fitter_maxiters = fitter_maxiters self.xy_bounds = xy_bounds self.group_warning_threshold = group_warning_threshold def make_psf_model(self, sources): """ Create a single PSF model or a flat model for a group of sources. This method avoids CompoundModel by creating a custom model class where all parameters are top-level (e.g., x_0, y_0, flux_0, x_1, y_1, flux_1, etc.) rather than nested. This eliminates the parameter tree traversal and nested structure overhead of CompoundModel. The dynamic flat model class directly evaluates each PSF and sums them, providing much better performance for large groups. Flat model classes are cached based on the number of sources and PSF model characteristics to improve performance for repeated group sizes. Parameters ---------- sources : list of `~astropy.table.Row` List of source rows from the sources table for the group. Returns ------- model : `~astropy.modeling.Model` PSF model for the group of sources, either a single PSF model or a flat model for multiple sources. """ n_sources = len(sources) if n_sources == 1: # For single sources, just use the PSF model directly source = sources[0] model = self.psf_model.copy() alias_map = self.param_mapper.alias_to_model_param init_colnames = self.param_mapper.init_colnames for alias, col_name in init_colnames.items(): model_param = alias_map[alias] value = source[col_name] if isinstance(value, u.Quantity): # PSF model parameters must be unitless to be fit value = value.value setattr(model, model_param, value) # Set the model name to the source ID if available if 'id' in source.colnames: model.name = source['id'] self._apply_xy_bounds(model, alias_map) return model # For multiple sources, use cached flat model class return _get_flat_model(sources, self.psf_model, self.param_mapper, xy_bounds=self.xy_bounds) def _apply_bounds_to_param(self, model, param_name, param_value, bound_value): """ Apply bounds to a specific model parameter. This method is now a wrapper around the standalone helper function. """ _apply_bounds_to_param(model, param_name, param_value, bound_value) def _apply_xy_bounds(self, model, alias_map): """ Apply xy_bounds to a model. """ if self.xy_bounds is not None: if self.xy_bounds[0] is not None: x_param_name = alias_map['x'] x_param = getattr(model, x_param_name) self._apply_bounds_to_param(model, x_param_name, x_param.value, self.xy_bounds[0]) if self.xy_bounds[1] is not None: y_param_name = alias_map['y'] y_param = getattr(model, y_param_name) self._apply_bounds_to_param(model, y_param_name, y_param.value, self.xy_bounds[1]) def run_fitter(self, psf_model, xi, yi, cutout, error): """ Fit the PSF model to the input cutout data. Parameters ---------- psf_model : `~astropy.modeling.Model` PSF model to fit, typically created by ``make_psf_model``. xi, yi : `~numpy.ndarray` 1D array of x and y pixel coordinates for the cutout. cutout : `~numpy.ndarray` 1D array of data values corresponding to the pixel coordinates. error : `~numpy.ndarray` or None 2D error array for computing fit weights. If provided, weights are computed as 1/error for the cutout pixels. Returns ------- fit_model : `~astropy.modeling.Model` Fitted PSF model with optimized parameters. fit_info : dict Dictionary containing fit information including parameter covariance, convergence status, and error messages. Raises ------ ValueError If error array contains non-positive or non-finite values. """ kwargs = {'inplace': True} if minversion(astropy, '7.0') else {} if self.fitter_maxiters is not None: kwargs.update({'maxiter': self.fitter_maxiters}) weights = None if error is not None: # Extract cutout weights from full error array. Weights are # (1 / error), yielding residuals (objective function) of # (data - model) / error. The fitter minimizes the squared # residuals. If errors are input, the residuals returned by # the fitter are scaled by the errors, i.e., the objective # function is equivalent to chi2. If errors are not input, # the residuals are just (data - model), and the objective # function is a sum-of-squares. Note that the residuals # returned by astropy fitters are reversed in sign from the # definition here (model - data). err = error[yi, xi] if np.any(err <= 0) or np.any(~np.isfinite(err)): msg = ('Error array contains non-positive or non-finite ' 'values. Cannot compute fit weights.') raise ValueError(msg) weights = 1.0 / error[yi, xi] # keep fit-info entries (but exclude residual vectors) fit_info_keys = ('param_cov', 'ierr', 'message', 'status') with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) fit_model = self.fitter(psf_model, xi, yi, cutout, weights=weights, **kwargs) # clear any model cache, if supported by the model with contextlib.suppress(AttributeError): fit_model.clear_cache() fit_info = {key: self.fitter.fit_info.get(key) for key in fit_info_keys if self.fitter.fit_info.get(key) is not None} return fit_model, fit_info # @staticmethod def extract_source_covariances(self, group_cov, num_sources, nfitparam): """ Extract individual source covariance matrices from group covariance. This method assumes that the group covariance matrix is block-diagonal, with each block corresponding to a source. The extracted source covariance matrices do not include the covariances between different sources. Parameters ---------- group_cov : `~numpy.ndarray` 2D covariance matrix for the entire group of sources. num_sources : int Number of sources in the group. nfitparam : int Number of fitted parameters per source. Returns ------- source_covs : list of `~numpy.ndarray` List of 2D covariance matrices, one for each source in the group. """ source_covs = [] for i in range(num_sources): start = i * nfitparam end = (i + 1) * nfitparam source_cov = group_cov[start:end, start:end] source_covs.append(source_cov) return source_covs def split_flat_model(self, flat_model, n_sources): """ Split a flat model into individual source models. For flat models, create individual PSF models with parameters extracted from the flat model's parameter values. Parameters ---------- flat_model : `~astropy.modeling.Model` The flat model containing parameters for all sources. n_sources : int Number of sources in the flat model. Returns ------- source_models : list of `~astropy.modeling.Model` List of individual PSF models, one for each source. """ source_models = [] param_names = self.param_mapper.fitted_param_names for i in range(n_sources): # Create a copy of the base PSF model source_model = self.psf_model.copy() # Extract parameters for this source from the flat model for param_name in param_names: flat_param_name = f'{param_name}_{i}' if hasattr(flat_model, flat_param_name): param_value = getattr(flat_model, flat_param_name).value setattr(source_model, param_name, param_value) source_models.append(source_model) return source_models class PSFResultsAssembler: """ Helper class to handles results table assembly and quality metrics calculation. This class encapsulates all operations related to assembling final results tables, calculating quality metrics, and generating flags. Parameters ---------- param_mapper : _PSFParameterMapper Parameter mapper for handling column name mappings and model parameters. fit_shape : tuple of int The shape of the PSF fitting region as a (ny, nx) tuple. xy_bounds : tuple of float or None, optional Bounds for x and y position parameters as (x_bound, y_bound). Used for flag calculations to detect sources near boundaries. """ def __init__(self, param_mapper, fit_shape, *, xy_bounds=None): self.param_mapper = param_mapper self.fit_shape = fit_shape self.xy_bounds = xy_bounds def get_fit_error_indices(self, fit_info): """ Get the indices of fits that did not converge. Parameters ---------- fit_info : list of dict List of fit information dictionaries from the fitter, containing 'ierr' or 'status' keys for convergence info. Returns ------- bad_indices : `~numpy.ndarray` Array of integer indices for fits that did not converge. """ # Same "good" flags for both leastsq and least_squares converged_status = {1, 2, 3, 4} n_sources = len(fit_info) ierr_vals = np.full(n_sources, None, dtype=object) status_vals = np.full(n_sources, None, dtype=object) # Extract all ierr and status values for idx, info in enumerate(fit_info): ierr_vals[idx] = info.get('ierr') status_vals[idx] = info.get('status') # Create masks for non-None values ierr_valid = ierr_vals != None # noqa: E711 status_valid = status_vals != None # noqa: E711 # Check convergence status for valid values converged_status_list = list(converged_status) ierr_bad = np.zeros(n_sources, dtype=bool) status_bad = np.zeros(n_sources, dtype=bool) for i in range(n_sources): if ierr_valid[i]: ierr_bad[i] = ierr_vals[i] not in converged_status_list if status_valid[i]: status_bad[i] = status_vals[i] not in converged_status_list # Combine conditions bad_mask = (ierr_valid & ierr_bad) | (status_valid & status_bad) bad_indices = np.where(bad_mask)[0] return bad_indices.astype(int) def param_errors_to_table(self, fit_param_errs, data_unit): """ Convert the fitter's parameter errors to an astropy Table. Parameters ---------- fit_param_errs : `~numpy.ndarray` 2D array of parameter errors with shape (n_sources, n_params). data_unit : `~astropy.units.Unit` or None Unit of the input data, used to apply appropriate units to flux error columns. Returns ------- table : `~astropy.table.QTable` Table containing error columns for fitted parameters, with NaN values for non-fitted (fixed) parameters. """ table = QTable() # create error columns for models parameters that were fit mapper = self.param_mapper fitted_params = mapper.fitted_param_names model_param_to_alias = mapper.model_param_to_alias err_colnames = mapper.err_colnames fitted_aliases = [model_param_to_alias[param] for param in fitted_params] fitted_err_cols = [err_colnames[alias] for alias in fitted_aliases] table_data = {err_col: fit_param_errs[:, i] for i, err_col in enumerate(fitted_err_cols)} table = QTable(table_data) # ensure columns for non-fitted (fixed) params exist all_err_cols = list(err_colnames.values()) for err_col in all_err_cols: if err_col not in table.colnames: table[err_col] = np.nan # apply data_unit to flux_err column if data_unit is not None: flux_err_col = err_colnames['flux'] table[flux_err_col] <<= data_unit return table[all_err_cols] def create_fit_results(self, fit_model_all_params, fit_param_errs, valid_mask, data_unit): """ Create the table of fitted parameter values and errors. Parameters ---------- fit_model_all_params : `~astropy.table.Table` Table containing fitted model parameters for all sources. fit_param_errs : `~numpy.ndarray` 2D array of parameter errors with shape (n_sources, n_params). valid_mask : `~numpy.ndarray` or None Boolean array indicating which sources had valid fits. data_unit : `~astropy.units.Unit` or None Unit of the input data for applying to flux-related columns. Returns ------- fit_table : `~astropy.table.QTable` Table containing fitted parameter values and their errors, with NaN values for invalid sources. """ mapper = self.param_mapper alias_to_model = mapper.alias_to_model_param model_param_to_alias = mapper.model_param_to_alias fit_colnames = mapper.fit_colnames err_colnames = mapper.err_colnames col_names = ['id', *list(alias_to_model.values())] fit_params = fit_model_all_params[col_names] # Rename model parameter columns to *_fit for col_name in list(fit_params.colnames): if col_name == 'id': continue alias = model_param_to_alias[col_name] fit_params.rename_column(col_name, fit_colnames[alias]) param_errs = self.param_errors_to_table(fit_param_errs, data_unit) fit_table = hstack([fit_params, param_errs]) # Sort columns to match the expected order col_order = ['id', *list(fit_colnames.values()), *list(err_colnames.values())] fit_table = fit_table[col_order] # Overwrite fit and error columns with NaN for invalid sources if valid_mask is not None: invalid = ~np.array(valid_mask, dtype=bool) for col_name in fit_table.colnames: if col_name == 'id': continue col = fit_table[col_name] unit = getattr(col, 'unit', None) if unit is not None: col[invalid] = (np.nan * unit) else: col[invalid] = np.nan return fit_table def calc_fit_metrics(self, results_tbl, sum_abs_residuals, cen_residuals, reduced_chi2): """ Calculate fit quality metrics qfit, cfit, and reduced_chi2. Parameters ---------- results_tbl : `~astropy.table.QTable` Results table containing fitted flux values. sum_abs_residuals : array-like Array of sum of absolute residuals for each source. cen_residuals : array-like Array of central pixel residuals for each source. reduced_chi2 : array-like Array of reduced chi-squared values for each source. Returns ------- qfit : `~numpy.ndarray` Array of qfit quality metrics (sum of absolute residuals divided by flux). cfit : `~numpy.ndarray` Array of cfit quality metrics (central pixel residual divided by flux). reduced_chi2 : `~numpy.ndarray` Array of reduced chi-squared values. """ flux_col = self.param_mapper.fit_colnames['flux'] flux_vals = results_tbl[flux_col] if isinstance(flux_vals, u.Quantity): flux_vals = flux_vals.value nsrc = len(sum_abs_residuals) qfit = np.full(nsrc, np.nan, dtype=float) cfit = np.full(nsrc, np.nan, dtype=float) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # create masks for valid data valid_sa = np.isfinite(sum_abs_residuals) valid_cr = np.isfinite(cen_residuals) nonzero_flux = (flux_vals != 0) # qfit: sum of absolute residuals divided by flux qfit_mask = valid_sa & nonzero_flux qfit[qfit_mask] = (sum_abs_residuals[qfit_mask] / flux_vals[qfit_mask]) # cfit: central pixel residual divided by flux cfit_mask = valid_cr & nonzero_flux cfit[cfit_mask] = (cen_residuals[cfit_mask] / flux_vals[cfit_mask]) return qfit, cfit, reduced_chi2 def define_flags(self, results_tbl, shape, fit_error_indices, fit_info, fitted_models_table, valid_mask, invalid_reasons, init_params): """ Define per-source bitwise flags summarizing fit conditions. Parameters ---------- results_tbl : `~astropy.table.QTable` Results table containing fitted parameters and metrics. shape : tuple of int Shape of the input data array as (ny, nx). fit_error_indices : `~numpy.ndarray` or None Array of indices for sources with convergence issues. fit_info : list of dict List of fit information dictionaries. fitted_models_table : `~astropy.table.Table` Table containing fitted model parameters with bounds info. valid_mask : `~numpy.ndarray` or None Boolean array indicating valid sources. invalid_reasons : list or None List of reasons why sources were invalid. init_params : `~astropy.table.QTable` Initial parameter guesses for sources, containing local_bkg. Returns ------- flags : `~numpy.ndarray` Array of integer flags where each bit indicates a specific condition: - 1: n_pixels_fit smaller than full fit_shape region - 2: fitted position outside input image bounds - 4: non-positive flux - 8: possible non-convergence - 16: missing parameter covariance - 32: near a positional bound - 64: no overlap with data - 128: fully masked source - 256: too few pixels for fitting - 512: non-finite fitted position - 1024: non-finite fitted flux - 2048: non-finite local background """ flags = np.zeros(len(results_tbl), dtype=int) x_col = self.param_mapper.fit_colnames['x'] y_col = self.param_mapper.fit_colnames['y'] flux_col = self.param_mapper.fit_colnames['flux'] # Flag=1: n_pixels_fit smaller than full fit_shape region flag1_mask = (results_tbl['n_pixels_fit'] < np.prod(self.fit_shape)) flags[flag1_mask] |= PSF_FLAGS.N_PIXELS_FIT_PARTIAL # Flag=2: fitted position outside input image bounds ny, nx = shape x_fit = results_tbl[x_col] y_fit = results_tbl[y_col] flag2_mask = ((x_fit < -0.5) | (y_fit < -0.5) | (x_fit > nx - 0.5) | (y_fit > ny - 0.5)) flags[flag2_mask] |= PSF_FLAGS.OUTSIDE_BOUNDS # Flag=4: non-positive flux flag4_mask = results_tbl[flux_col] <= 0 flags[flag4_mask] |= PSF_FLAGS.NEGATIVE_FLUX # Flag=8: possible non-convergence if fit_error_indices is not None: flags[fit_error_indices] |= PSF_FLAGS.NO_CONVERGENCE # Flag=16: missing parameter covariance missing_cov_mask = np.array(['param_cov' not in info for info in fit_info]) flags[missing_cov_mask] |= PSF_FLAGS.NO_COVARIANCE # Flag=32: near a positional bound bound_tol = 0.01 if self.xy_bounds is not None: alias_to_model = self.param_mapper.alias_to_model_param x_param = alias_to_model['x'] y_param = alias_to_model['y'] # Extract all parameter values and bounds into arrays x_vals = np.array([row[x_param] for row in fitted_models_table]) y_vals = np.array([row[y_param] for row in fitted_models_table]) # Create masks for valid sources and finite positions finite_mask = (np.isfinite(x_vals) & np.isfinite(y_vals) & valid_mask) # Check bounds for valid sources for index in np.where(finite_mask)[0]: row = fitted_models_table[index] for param in (x_param, y_param): bnds = row[f'{param}_bounds'] bounds = np.array([i for i in bnds if i is not None]) if bounds.size == 0: continue if np.any(np.abs(bounds - row[param]) <= bound_tol): flags[index] |= PSF_FLAGS.NEAR_BOUND break # Flag=64, 128, 256: invalid source reasons if invalid_reasons is not None: reasons = np.array(invalid_reasons, dtype=object) flags[reasons == 'no_overlap'] |= PSF_FLAGS.NO_OVERLAP flags[reasons == 'fully_masked'] |= PSF_FLAGS.FULLY_MASKED flags[reasons == 'too_few_pixels'] |= PSF_FLAGS.TOO_FEW_PIXELS flags[reasons == 'non_finite_flux'] |= PSF_FLAGS.NON_FINITE_FLUX # Flag=512: non-finite fitted position x_col = self.param_mapper.fit_colnames['x'] y_col = self.param_mapper.fit_colnames['y'] x_fit = results_tbl[x_col] y_fit = results_tbl[y_col] non_finite_pos_mask = ~np.isfinite(x_fit) | ~np.isfinite(y_fit) flags[non_finite_pos_mask] |= PSF_FLAGS.NON_FINITE_POSITION # Flag=1024: non-finite fitted flux (also check fitted values) flux_col = self.param_mapper.fit_colnames['flux'] flux_fit = results_tbl[flux_col] non_finite_flux_mask = ~np.isfinite(flux_fit) flags[non_finite_flux_mask] |= PSF_FLAGS.NON_FINITE_FLUX # Flag=2048: non-finite local background local_bkg_vals = init_params['local_bkg'] if hasattr(local_bkg_vals, 'value'): # Handle Quantity local_bkg_vals = local_bkg_vals.value non_finite_bkg_mask = ~np.isfinite(local_bkg_vals) flags[non_finite_bkg_mask] |= PSF_FLAGS.NON_FINITE_LOCALBKG return flags def assemble_results_table(self, init_params, fit_params, data_shape, state, calc_fit_metrics_func, define_flags_func, class_name, metadata_attrs): """ Assemble the final results table. The final results table is built by merging the input ``init_params`` table with the ``fit_params`` table. Additional columns are added for ``n_pixels_fit``, ``group_size``, ``qfit``, ``cfit``, and ``flags``. This method also cleans up the state dictionary as data is consumed to reduce memory usage. Parameters ---------- init_params : `~astropy.table.QTable` Initial parameter guesses for sources. fit_params : `~astropy.table.QTable` Fitted parameters with uncertainties. data_shape : tuple of int Shape of the input data array as (ny, nx). state : dict State dictionary containing fitting data and metadata. calc_fit_metrics_func : callable Function to calculate fit quality metrics (qfit, cfit). define_flags_func : callable Function to define per-source bitwise flags. class_name : str Name of the calling class for warning messages. metadata_attrs : dict Dictionary of metadata attributes to add to the table. Returns ------- results_tbl : `~astropy.table.QTable` Results table containing: - Source ID and group ID - Initial parameter estimates - Fitted parameters with uncertainties - Quality metrics (qfit, cfit) - Bitwise flags indicating fit conditions - Iterator statistics (n_pixels_fit, group_size) """ # Add metrics and flags column to fit_params. The results in the # state container match the order of the fit_params results, # which are in the same source ID order as the init_params. # Consume n_pixels_fit and group_size data, removing from state fit_params['n_pixels_fit'] = state.pop('n_pixels_fit') fit_params['group_size'] = state.pop('group_size') # Calculate fit metrics and remove the underlying data qfit, cfit, reduced_chi2 = calc_fit_metrics_func(fit_params) fit_params['qfit'] = qfit fit_params['cfit'] = cfit fit_params['reduced_chi2'] = reduced_chi2 # Clean up residual data after metrics calculation state.pop('sum_abs_residuals', None) state.pop('cen_residuals', None) state.pop('reduced_chi2', None) # Calculate flags and check for convergence warnings before cleanup fit_params['flags'] = define_flags_func( fit_params, data_shape, init_params) # Join the fit_params table (with metrics and flags) to the # init_params table. By default, join will sort the rows by the # 'id' column. results_tbl = join(init_params, fit_params, keys='id', join_type='left') # Reorder columns to place group_size after group_id # (both columns should always be present). group_size = results_tbl['group_size'] results_tbl.remove_column('group_size') index = results_tbl.colnames.index('group_id') + 1 results_tbl.add_column(group_size, index=index) # Check for fit convergence warnings before cleaning up state fit_error_indices = state.get('fit_error_indices') if (fit_error_indices is not None and len(fit_error_indices) > 0): msg = ('One or more fit(s) may not have converged. Please ' 'check the "flags" column in the output table.') warnings.warn(msg, AstropyUserWarning) # Clean up flag-related state data after use state.pop('fit_error_indices', None) state.pop('fitted_models_table', None) state.pop('valid_mask_by_id', None) meta = _get_meta() meta['psf_class'] = class_name # Add attribute metadata meta.update(metadata_attrs) # Replace with QTable in 4.0 psf_deprecation_map = {'npixfit': 'n_pixels_fit'} result = DeprecatedColumnQTable(results_tbl, meta=meta) result.deprecation_map = psf_deprecation_map result._deprecation_since = '3.0' result._deprecation_until = '4.0' return result def _make_model_image_docstring(func): func.__doc__ = """ 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, optional The shape of the region around the center of the fit model to render in the output image. If ``psf_shape`` is a scalar integer, then a square shape of size ``psf_shape`` will be used. If `None`, then the bounding box of the model will be used. This keyword must be specified if the model does not have a ``bounding_box`` attribute. include_local_bkg : 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. Non-finite local background values (NaN or inf) are treated as zero and not included in the output image. Returns ------- array : 2D `~numpy.ndarray` The rendered image from the fit PSF models. This image will not have any units. """ return func def _make_residual_image_docstring(func): func.__doc__ = """ 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, optional The shape of the region around the center of the fit model to subtract. If ``psf_shape`` is a scalar integer, then a square shape of size ``psf_shape`` will be used. If `None`, then the bounding box of the model will be used. This keyword must be specified if the model does not have a ``bounding_box`` attribute. include_local_bkg : 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. Non-finite local background values (NaN or inf) are not subtracted from the residual image. Returns ------- array : 2D `~numpy.ndarray` The residual image of the ``data`` minus the fit PSF models minus the optional``local_bkg``. """ return func class _ModelImageMaker: """ Class to create model and residual images from fit PSF models. Parameters ---------- psf_model : `astropy.modeling.Model` The PSF model. model_params : `~astropy.table.Table` The model parameters. local_bkg : `~numpy.ndarray`, optional The local background values. progress_bar : bool, optional Whether to display a progress bar. """ def __init__(self, psf_model, model_params, *, local_bkg=None, progress_bar=False): self.psf_model = psf_model self.model_params = model_params self.local_bkg = local_bkg self.progress_bar = progress_bar @_make_model_image_docstring def make_model_image(self, shape, *, psf_shape=None, include_local_bkg=False): psf_model = self.psf_model model_params = self.model_params local_bkgs = self.local_bkg progress_bar = self.progress_bar if include_local_bkg: # add local_bkg, but set non-finite values to 0 to avoid # corrupting the model image model_params = model_params.copy() local_bkgs_clean = local_bkgs.copy() # Replace non-finite values with 0 nonfinite_mask = ~np.isfinite(local_bkgs_clean) if np.any(nonfinite_mask): local_bkgs_clean[nonfinite_mask] = 0 model_params['local_bkg'] = local_bkgs_clean 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) @_make_residual_image_docstring def make_residual_image(self, data, *, psf_shape=None, include_local_bkg=False): 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=psf_shape, include_local_bkg=include_local_bkg) else: residual = self.make_model_image( data.shape, psf_shape=psf_shape, include_local_bkg=include_local_bkg) np.subtract(data, residual, out=residual) return residual astropy-photutils-3322558/photutils/psf/epsf_builder.py000066400000000000000000002226121517052111400233030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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 inspect import warnings from dataclasses import dataclass import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NoOverlapError, PartialOverlapError, overlap_slices from astropy.stats import SigmaClip from astropy.utils.decorators import deprecated from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from scipy.ndimage import convolve from photutils.centroids import centroid_com from photutils.psf.epsf_stars import EPSFStar, EPSFStars, LinkedEPSFStar from photutils.psf.image_models import ImagePSF from photutils.psf.utils import _interpolate_missing_data from photutils.utils._parameters import (SigmaClipSentinelDefault, as_pair, create_default_sigmaclip) from photutils.utils._progress_bars import add_progress_bar from photutils.utils._round import round_half_away from photutils.utils._stats import nanmedian __all__ = ['EPSFBuildResult', 'EPSFBuilder', 'EPSFFitter'] SIGMA_CLIP = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) class _SmoothingKernel: """ Utility class for ePSF smoothing kernel generation and convolution. This class encapsulates the creation of smoothing kernels used in ePSF building and provides consistent smoothing operations. """ # Pre-computed kernels based on polynomial fits QUARTIC_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]]) QUADRATIC_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]]) @classmethod def get_kernel(cls, kernel_type): """ Get a smoothing kernel by type. Parameters ---------- kernel_type : {'quartic', 'quadratic'} or array_like The type of kernel to retrieve or a custom kernel array. Returns ------- kernel : 2D `numpy.ndarray` The smoothing kernel. Raises ------ TypeError If `kernel_type` is not supported. Notes ----- The predefined kernels are derived from polynomial fits: - 'quartic': From Polynomial2D fit with degree=4 to 5x5 array of zeros with 1.0 at the center. Based on fourth degree polynomial. - 'quadratic': From Polynomial2D fit with degree=2 to 5x5 array of zeros with 1.0 at the center. Based on second degree polynomial. """ if isinstance(kernel_type, np.ndarray): return kernel_type if kernel_type == 'quartic': return cls.QUARTIC_KERNEL if kernel_type == 'quadratic': return cls.QUADRATIC_KERNEL msg = (f'Unsupported kernel type: {kernel_type}. Supported types ' 'are "quartic", "quadratic", or ndarray.') raise TypeError(msg) @staticmethod def apply_smoothing(data, kernel_type): """ Apply smoothing to data using the specified kernel. Parameters ---------- data : 2D `numpy.ndarray` The data to smooth. kernel_type : {'quartic', 'quadratic'}, array_like, or `None` The type of kernel to use for smoothing, or `None` for no smoothing. Returns ------- smoothed_data : 2D `numpy.ndarray` The smoothed data. Returns original data if `kernel_type` is `None`. """ if kernel_type is None: return data kernel = _SmoothingKernel.get_kernel(kernel_type) return convolve(data, kernel) class _EPSFValidator: """ Class to validate ePSF building parameters and data. This class centralizes all validation logic with context-aware error messages. """ @staticmethod def validate_oversampling(oversampling, *, context=''): """ Validate oversampling parameters. Parameters ---------- oversampling : int or tuple The oversampling factor(s). context : str, optional Additional context for error messages. Raises ------ ValueError If oversampling is invalid. """ if oversampling is None: msg = "'oversampling' must be specified" raise ValueError(msg) try: oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 0)) except (TypeError, ValueError) as e: msg = f'Invalid oversampling parameter - {e}' if context: msg = f'{context}: {msg}' raise ValueError(msg) from None return oversampling @staticmethod def validate_shape_compatibility(stars, oversampling, *, shape=None): """ Validate that ePSF shape is compatible with star dimensions. Performs validation of shape compatibility between requested ePSF shape and star cutout dimensions, accounting for oversampling factors and providing detailed diagnostics. Parameters ---------- stars : EPSFStars The input stars. oversampling : tuple The oversampling factors (y, x). shape : tuple, optional Requested ePSF shape (height, width). Raises ------ ValueError If shape is incompatible with stars and oversampling. Error messages include suggested minimum shapes and detailed diagnostic information. """ if not stars: msg = ('Cannot validate shape compatibility with empty star list. ' 'Please provide at least one star for ePSF building.') raise ValueError(msg) # Collect star dimension statistics star_heights = [star.shape[0] for star in stars] star_widths = [star.shape[1] for star in stars] max_height = max(star_heights) max_width = max(star_widths) # Check for extremely small stars that may cause issues min_star_size = 3 # minimum reasonable star cutout size problematic_stars = [] for i, star in enumerate(stars): if min(star.shape) < min_star_size: problematic_stars.append(f'Star {i}: {star.shape}') if problematic_stars: msg = (f"Found {len(problematic_stars)} star(s) with very small " f"dimensions (< {min_star_size}x{min_star_size}): " f"{', '.join(problematic_stars)}. Consider using larger " 'star cutouts for better ePSF quality.') raise ValueError(msg) # Compute minimum required ePSF shape with proper padding # The +1 ensures odd dimensions for proper centering min_epsf_height = max_height * oversampling[0] + 1 min_epsf_width = max_width * oversampling[1] + 1 # Validate requested shape if provided if shape is not None: shape = np.array(shape) if shape.ndim != 1 or len(shape) != 2: msg = 'Shape must be a 2-element sequence' raise ValueError(msg) if shape[0] < min_epsf_height or shape[1] < min_epsf_width: # Provide detailed diagnostic information msg = (f'Requested ePSF shape {shape} is incompatible with ' f'star dimensions and oversampling.\n\n' f' Oversampling factors: {oversampling}\n' f' Minimum required ePSF shape: ' f'({min_epsf_height}, {min_epsf_width})\n' f'Solution: Use shape >= ' f'({min_epsf_height}, {min_epsf_width}) ' f'or reduce oversampling factors.') raise ValueError(msg) # Check for odd dimensions (for proper centering) if shape[0] % 2 == 0 or shape[1] % 2 == 0: msg = (f'Requested ePSF shape {shape} has even dimensions. ' f'Odd dimensions are recommended for proper ePSF ' f'centering. Consider using ' f'({shape[0] + shape[0] % 2}, ' f'{shape[1] + shape[1] % 2}) instead.') warnings.warn(msg, AstropyUserWarning) @staticmethod def validate_stars(stars, *, context=''): """ Validate EPSFStars object and individual star data. Parameters ---------- stars : EPSFStars The stars to validate. context : str, optional Additional context for error messages. Raises ------ ValueError, TypeError If stars are invalid. """ # Check basic type and structure if not hasattr(stars, '__len__') or len(stars) == 0: msg = 'EPSFStars object must contain at least one star' if context: msg = f'{context}: {msg}' raise ValueError(msg) # Validate individual stars invalid_stars = [] for i, star in enumerate(stars): try: # Check for valid data if not hasattr(star, 'data') or star.data is None: invalid_stars.append((i, 'missing data')) continue # Check for finite values if not np.any(np.isfinite(star.data)): invalid_stars.append((i, 'no finite data values')) continue # Check for reasonable dimensions if min(star.shape) < 3: invalid_stars.append((i, f'too small ({star.shape})')) continue # Check for center coordinates if not hasattr(star, 'cutout_center'): invalid_stars.append((i, 'missing cutout_center')) continue except (AttributeError, TypeError, ValueError) as e: invalid_stars.append((i, f'validation error: {e}')) if invalid_stars: error_details = [f'Star {i}: {issue}' for i, issue in invalid_stars[:5]] if len(invalid_stars) > 5: error_details.append(f'... and {len(invalid_stars) - 5} more') msg = (f'Found {len(invalid_stars)} invalid stars out of ' f'{len(stars)} total:\n' + '\n'.join(error_details)) if context: msg = f'{context}: {msg}' raise ValueError(msg) @staticmethod def validate_center_accuracy(center_accuracy): """ Validate center accuracy parameter. Parameters ---------- center_accuracy : float The center accuracy threshold. Raises ------ ValueError If center accuracy is invalid. """ if not isinstance(center_accuracy, (int, float)): msg = (f'center_accuracy must be a number, got ' f'{type(center_accuracy)}') raise TypeError(msg) if center_accuracy <= 0.0: msg = ('center_accuracy must be positive, got ' f'{center_accuracy}. Typical values are 1e-3 to 1e-4.') raise ValueError(msg) if center_accuracy > 1.0: msg = (f'center_accuracy {center_accuracy} seems unusually large. ' 'Values > 1.0 may prevent convergence. ' 'Typical values are 1e-3 to 1e-4.') warnings.warn(msg, AstropyUserWarning) @staticmethod def validate_maxiters(maxiters): """ Validate maximum iterations parameter. Parameters ---------- maxiters : int The maximum number of iterations. Raises ------ ValueError, TypeError If maxiters is invalid. """ if not isinstance(maxiters, int): msg = f'maxiters must be an integer, got {type(maxiters)}' raise TypeError(msg) if maxiters <= 0: msg = 'maxiters must be a positive number' raise ValueError(msg) maxiters_warn_threshold = 100 if maxiters > maxiters_warn_threshold: msg = (f'maxiters {maxiters} seems unusually large. ' f'Values > {maxiters_warn_threshold} may indicate ' 'convergence issues. Consider checking your data and ' 'parameters.') warnings.warn(msg, AstropyUserWarning) class _CoordinateTransformer: """ Handle coordinate transformations between pixel and oversampled spaces. This class centralizes all coordinate system conversions used in ePSF building, providing consistent transformations between the input star coordinate system and the oversampled ePSF coordinate system. Parameters ---------- oversampling : tuple of int The (y, x) oversampling factors for the ePSF. """ def __init__(self, oversampling): self.oversampling = np.asarray(oversampling) def star_to_epsf_coords(self, star_x, star_y, epsf_origin): """ Transform star-relative coordinates to ePSF grid coordinates. Parameters ---------- star_x, star_y : array_like Star coordinates in undersampled units relative to star center. epsf_origin : tuple The (x, y) origin of the ePSF in oversampled coordinates. Returns ------- epsf_x, epsf_y : array_like Integer coordinates in the oversampled ePSF grid. """ # Apply oversampling transformation x_oversampled = self.oversampling[1] * star_x y_oversampled = self.oversampling[0] * star_y # Add ePSF center offset epsf_xcenter, epsf_ycenter = epsf_origin epsf_x = round_half_away( x_oversampled + epsf_xcenter).astype(int) epsf_y = round_half_away( y_oversampled + epsf_ycenter).astype(int) return epsf_x, epsf_y def compute_epsf_shape(self, star_shapes): """ Compute the appropriate ePSF shape from input star shapes. Parameters ---------- star_shapes : list of tuple List of (height, width) tuples for each star. Returns ------- epsf_shape : tuple The (height, width) shape for the oversampled ePSF. """ if not star_shapes: msg = 'Need at least one star to compute ePSF shape' raise ValueError(msg) # Find maximum star dimensions max_height = max(shape[0] for shape in star_shapes) max_width = max(shape[1] for shape in star_shapes) # Apply oversampling (both are integers, so product is integer) epsf_height = max_height * self.oversampling[0] epsf_width = max_width * self.oversampling[1] # Ensure odd dimensions for centered origin if epsf_height % 2 == 0: epsf_height += 1 if epsf_width % 2 == 0: epsf_width += 1 return (epsf_height, epsf_width) def compute_epsf_origin(self, epsf_shape): """ Compute the geometric origin (center) coordinates for an ePSF. Parameters ---------- epsf_shape : tuple The (height, width) shape of the ePSF. The shape should have odd dimensions to ensure a well-defined center. Returns ------- origin : tuple The (x, y) origin coordinates in the ePSF coordinate system. """ origin_x = (epsf_shape[1] - 1) / 2.0 origin_y = (epsf_shape[0] - 1) / 2.0 return (origin_x, origin_y) def oversampled_to_undersampled(self, x, y): """ Convert oversampled coordinates to undersampled coordinates. Parameters ---------- x, y : array_like or float Coordinates in the oversampled grid. Returns ------- x_under, y_under : array_like or float Coordinates in the undersampled (original) grid. """ return x / self.oversampling[1], y / self.oversampling[0] def undersampled_to_oversampled(self, x, y): """ Convert undersampled coordinates to oversampled coordinates. Parameters ---------- x, y : array_like or float Coordinates in the undersampled (original) grid. Returns ------- x_over, y_over : array_like or float Coordinates in the oversampled grid. """ return x * self.oversampling[1], y * self.oversampling[0] class _ProgressReporter: """ Utility class for managing progress reporting during ePSF building. This class encapsulates all progress bar functionality, providing a clean interface for setting up, updating, and finalizing progress reporting during the iterative ePSF building process. Parameters ---------- enabled : bool Whether progress reporting is enabled. maxiters : int Maximum number of iterations for progress tracking. Attributes ---------- enabled : bool Whether progress reporting is active. maxiters : int Maximum iterations for progress bar setup. _pbar : progress bar or `None` The underlying progress bar instance. """ def __init__(self, enabled, maxiters): """ Initialize a _ProgressReporter. Parameters ---------- enabled : bool Whether progress reporting is enabled. maxiters : int The maximum number of iterations. """ self.enabled = enabled self.maxiters = maxiters self._pbar = None def setup(self): """ Initialize the progress bar for ePSF building. Sets up the progress bar with appropriate description and maximum iterations if progress reporting is enabled. Returns ------- self : _ProgressReporter Returns `self` for method chaining. """ if not self.enabled: self._pbar = None return self desc = f'EPSFBuilder ({self.maxiters} maxiters)' self._pbar = add_progress_bar(total=self.maxiters, desc=desc) return self def update(self): """ Update the progress bar by one iteration. Only updates if progress reporting is enabled and progress bar is initialized. """ if self._pbar is not None: self._pbar.update() def write_convergence_message(self, iteration): """ Write convergence message to progress bar. Parameters ---------- iteration : int The iteration number at which convergence occurred. """ if self._pbar is not None: self._pbar.write(f'EPSFBuilder converged after {iteration} ' f'iterations (of {self.maxiters} maximum ' 'iterations)') def close(self): """ Close and finalize the progress bar. Should be called when ePSF building is complete, regardless of convergence status. """ if self._pbar is not None: self._pbar.close() @dataclass class EPSFBuildResult: """ Container for ePSF building results. This class provides structured access to the results of the ePSF building process, including convergence information and diagnostic data that can help users understand and validate the building process. Attributes ---------- epsf : `ImagePSF` object The final constructed ePSF model. fitted_stars : `EPSFStars` object The input stars with updated centers and fluxes derived from fitting the final ePSF. iterations : int The number of iterations performed during the building process. This will be <= maxiters specified in EPSFBuilder. converged : bool Whether the building process converged based on the center accuracy criterion. `True` if star centers moved less than the specified accuracy between the final iterations. final_center_accuracy : float The maximum center displacement in the final iteration, in pixels. This indicates how much the star centers changed in the last iteration and can be used to assess convergence quality. n_excluded_stars : int The number of individual stars (including those from linked stars) that were excluded from fitting due to repeated fit failures. excluded_star_indices : list Indices of stars that were excluded from fitting during the building process. These correspond to positions in the flattened star list (stars.all_stars). Notes ----- This result object maintains backward compatibility by implementing tuple unpacking, so existing code like: epsf, stars = epsf_builder(stars) will continue to work unchanged. The additional information is available as attributes for users who want more detailed results. Examples -------- >>> from photutils.psf import EPSFBuilder >>> epsf_builder = EPSFBuilder(oversampling=4) # doctest: +SKIP >>> result = epsf_builder(stars) # doctest: +SKIP >>> print(result.iterations) # doctest: +SKIP >>> print(result.final_center_accuracy) # doctest: +SKIP >>> print(result.n_excluded_stars) # doctest: +SKIP """ epsf: 'ImagePSF' fitted_stars: 'EPSFStars' iterations: int converged: bool final_center_accuracy: float n_excluded_stars: int excluded_star_indices: list def __iter__(self): """ Allow tuple unpacking for backward compatibility. Returns ------- iterator An iterator that yields (epsf, fitted_stars) for compatibility with existing code that expects a 2-tuple. """ return iter((self.epsf, self.fitted_stars)) def __getitem__(self, index): """ Allow indexing for backward compatibility. Parameters ---------- index : int Index to access (0 for epsf, 1 for fitted_stars). Returns ------- value The ePSF (index 0) or fitted stars (index 1). """ if index == 0: return self.epsf if index == 1: return self.fitted_stars msg = 'EPSFBuildResult index must be 0 (epsf) or 1 (fitted_stars)' raise IndexError(msg) @deprecated(since='3.0', message=('EPSFFitter is deprecated and will be removed in a ' 'version 4.0. Use EPSFBuilder with the fitter, ' 'fit_shape, and fitter_maxiters parameters instead.')) 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. If `None`, then the default `~astropy.modeling.fitting.TRFLSQFitter` will be used. 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, 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=None, fit_boxsize=5, **fitter_kwargs): if fitter is None: fitter = TRFLSQFitter() self.fitter = fitter self.fitter_has_fit_info = hasattr(self.fitter, 'fit_info') if fit_boxsize is not None: self.fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(3, 1), check_odd=True) else: self.fit_boxsize = None # Remove any fitter keyword arguments that we need to set remove_kwargs = {'x', 'y', 'z', 'weights'} self.fitter_kwargs = { k: v for k, v in fitter_kwargs.items() if k not in remove_kwargs } def __call__(self, epsf, stars): """ Fit an ePSF model to stars. Parameters ---------- epsf : `ImagePSF` 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, ImagePSF): msg = 'The input epsf must be an ImagePSF' raise TypeError(msg) # Perform the fit fitted_stars = [] for star in stars: if isinstance(star, EPSFStar): # Skip fitting stars that have been excluded; return # directly since no modification is needed if star._excluded_from_fit: fitted_star = star else: 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: # Skip fitting stars that have been excluded; return # directly since no modification is needed if linked_star._excluded_from_fit: fitted_star.append(linked_star) else: 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: msg = ('stars must contain only EPSFStar and/or ' 'LinkedEPSFStar objects') raise TypeError(msg) 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. """ # Create a shallow copy to avoid mutating the input star. This # is a shallow copy, so the large numpy arrays (_data, weights, # mask) are shared and not duplicated; only the object wrapper # and small scalar attributes are new. star = copy.copy(star) 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): 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: # Handle case where the fitter does not 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 = 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 # Check if fitted position is outside the data cutout if (x_center < 0 or x_center >= star.shape[1] or y_center < 0 or y_center >= star.shape[0]): fit_error_status = 3 # pragma: no cover if fit_error_status != 3: 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 output 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 (ny, nx) shape of the output ePSF. If the input shape is even along any axis, it will be made odd by adding one. If the ``shape`` is `None`, it will be derived from the sizes of the input ``stars`` and the ePSF ``oversampling`` factor. 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 during each iteration step. 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. 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. recentering_func : callable, optional A callable object 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 (x, y) centroids. 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. The size is in the input star (i.e., undersampled) pixel space; it is automatically scaled by the oversampling factor when applied to the oversampled ePSF grid. 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 values and be greater than or equal to 3 for both axes. recentering_maxiters : int, optional The maximum number of recentering iterations to perform during each ePSF build iteration. 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 building iterations to stop. fitter : `~astropy.modeling.fitting.Fitter` or `EPSFFitter`, optional A `~astropy.modeling.fitting.Fitter` object used to fit the ePSF to stars. If `None`, then the default `~astropy.modeling.fitting.TRFLSQFitter` will be used. .. deprecated:: 3.0 Passing an `EPSFFitter` instance is deprecated; use the ``fitter``, ``fit_shape``, and ``fitter_maxiters`` parameters instead. fit_shape : 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_shape`` is a scalar then a square box of size ``fit_shape`` will be used. If ``fit_shape`` has two elements, they must be in ``(ny, nx)`` order. ``fit_shape`` 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_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each star. The value can be increased if the fit is not converging. This parameter is passed to the ``fitter`` if it supports the ``maxiter`` parameter and ignored otherwise. maxiters : int, optional The maximum number of ePSF building 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. """ def __init__(self, *, oversampling=4, shape=None, smoothing_kernel='quartic', sigma_clip=SIGMA_CLIP, recentering_func=centroid_com, recentering_boxsize=(5, 5), recentering_maxiters=20, center_accuracy=1.0e-3, fitter=None, fit_shape=5, fitter_maxiters=100, maxiters=10, progress_bar=True): # Validate and store oversampling using the validator self.oversampling = _EPSFValidator.validate_oversampling( oversampling, context='EPSFBuilder initialization') # Initialize coordinate transformer for consistent transformations self.coord_transformer = _CoordinateTransformer(self.oversampling) if shape is not None: self.shape = as_pair('shape', shape, lower_bound=(0, 0)) 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, 1), check_odd=True) self.smoothing_kernel = smoothing_kernel # Handle fitter parameter - accept both astropy Fitter and # deprecated EPSFFitter for backward compatibility if isinstance(fitter, EPSFFitter): msg = ('Passing an EPSFFitter instance to EPSFBuilder is ' 'deprecated. Use the fitter, fit_shape, and ' 'fitter_maxiters parameters instead.') warnings.warn(msg, AstropyDeprecationWarning) self.fitter = fitter.fitter self.fit_shape = fitter.fit_boxsize self.fitter_maxiters = None self._fitter_kwargs = fitter.fitter_kwargs else: if fitter is None: fitter = TRFLSQFitter() if not callable(fitter): msg = 'fitter must be a callable astropy Fitter instance' raise TypeError(msg) self.fitter = fitter # Validate fit_shape if fit_shape is not None: self.fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(3, 1), check_odd=True) else: self.fit_shape = None # Validate fitter_maxiters self.fitter_maxiters = self._validate_fitter_maxiters( fitter_maxiters) # Build fitter keyword arguments self._fitter_kwargs = {} if self.fitter_maxiters is not None: self._fitter_kwargs['maxiter'] = self.fitter_maxiters self._fitter_has_fit_info = hasattr(self.fitter, 'fit_info') # Validate center accuracy using the validator _EPSFValidator.validate_center_accuracy(center_accuracy) self.center_accuracy_sq = center_accuracy**2 # Validate maxiters using the validator _EPSFValidator.validate_maxiters(maxiters) self.maxiters = maxiters self.progress_bar = progress_bar if sigma_clip is SIGMA_CLIP: sigma_clip = create_default_sigmaclip(sigma=SIGMA_CLIP.sigma, maxiters=SIGMA_CLIP.maxiters) if not isinstance(sigma_clip, SigmaClip): msg = 'sigma_clip must be an astropy.stats.SigmaClip instance' raise TypeError(msg) self._sigma_clip = sigma_clip # store each ePSF build iteration self._epsf = [] def __call__(self, stars): """ Build an ePSF from input stars. Parameters ---------- stars : `EPSFStars` The stars used to build the ePSF. Returns ------- result : `EPSFBuildResult` The result of the ePSF building process. """ return self.build_epsf(stars) def _validate_fitter_maxiters(self, fitter_maxiters): """ Validate the ``fitter_maxiters`` parameter. Parameters ---------- fitter_maxiters : int Maximum number of fitter iterations to validate. Returns ------- fitter_maxiters : int or `None` The validated value, or `None` if the fitter does not support the ``maxiter`` parameter. """ spec = inspect.signature(self.fitter.__call__) has_maxiter = ('maxiter' in spec.parameters or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in spec.parameters.values())) if not has_maxiter: msg = ("'fitter_maxiters' will be ignored because " 'it is not accepted by the input fitter') warnings.warn(msg, AstropyUserWarning) return None return fitter_maxiters def _create_initial_epsf(self, stars): """ Create an initial `ImagePSF` object with zero data. This method initializes the ePSF building process by creating a blank ImagePSF model with the appropriate size and coordinate system. The initial ePSF data are all zeros and will be populated through the iterative building process. Shape Determination Algorithm ----------------------------- 1. If shape is explicitly provided, use it (ensuring odd dimensions) 2. Otherwise, determine shape from input stars and oversampling: - Take the maximum star cutout dimensions - Apply oversampling factor: new_size = old_size * oversampling - Ensure resulting dimensions are odd (add 1 if even) This ensures that oversampled arrays have a well-defined center pixel, which is crucial for PSF modeling and fitting. Coordinate System Setup ----------------------- The method establishes the coordinate system for the ImagePSF. The origin is set to the geometric center of the data array, which ensures that the PSF center aligns with the array center. The coordinate system is consistent with the expectations of the ImagePSF class and allows for straightforward mapping between star-relative coordinates and ePSF grid coordinates during the building process. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. The method uses stars._max_shape to ensure the ePSF is large enough to contain all stars. Returns ------- epsf : `ImagePSF` object The initial ePSF model with: - data: Zero-filled array of appropriate dimensions - origin: Set to the array center in (x, y) order - oversampling: Copied from the EPSFBuilder configuration - fill_value: Set to 0.0 for regions outside the PSF Notes ----- The initial ePSF has zero flux and data values. These will be populated through the iterative building process as residuals from individual stars are combined. The method ensures that: - Array dimensions are always odd (ensuring a center pixel) - The coordinate system is properly established - All necessary attributes are set for downstream processing Examples -------- For stars with maximum shape (25, 25) and oversampling=4: - x_shape = 25 * 4 = 100 (even), add 1 -> 101 - y_shape = 25 * 4 = 100 (even), add 1 -> 101 - Final shape: (101, 101) - Origin: (50.0, 50.0) For stars with maximum shape (25, 25) and oversampling=3: - x_shape = 25 * 3 = 75 (already odd) - y_shape = 25 * 3 = 75 (already odd) - Final shape: (75, 75) - Origin: (37.0, 37.0) """ oversampling = self.oversampling shape = self.shape # Define the ePSF shape using coordinate transformer if shape is not None: shape = as_pair('shape', shape, lower_bound=(0, 0), check_odd=True) else: # Use coordinate transformer to compute shape from star # dimensions star_shapes = [star.shape for star in stars] shape = self.coord_transformer.compute_epsf_shape(star_shapes) # Initialize with zeros data = np.zeros(shape, dtype=float) # Use coordinate transformer to compute origin origin_xy = self.coord_transformer.compute_epsf_origin(shape) return ImagePSF(data=data, origin=origin_xy, oversampling=oversampling, fill_value=0.0) def _resample_residual(self, star, epsf, *, out_image=None): """ 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 : `ImagePSF` object The ePSF model. out_image : 2D `~numpy.ndarray`, optional A 2D array to hold the resampled residual image. If `None`, a new array will be created. 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. xidx_centered, yidx_centered = star._xyidx_centered stardata = (star._data_values_normalized - epsf.evaluate(x=xidx_centered, y=yidx_centered, flux=1.0, x_0=0.0, y_0=0.0)) # Use coordinate transformer to map to the oversampled ePSF grid xidx, yidx = self.coord_transformer.star_to_epsf_coords( xidx_centered, yidx_centered, epsf.origin) epsf_shape = epsf.data.shape if out_image is None: out_image = 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] out_image[yidx_, xidx_] = stardata[mask] return out_image def _resample_residuals(self, stars, epsf): """ Compute normalized residual images for all the input stars. Optimized to minimize memory allocations. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `ImagePSF` object The ePSF model. Returns ------- epsf_resid : 3D `~numpy.ndarray` A 3D cube containing the resampled residual images. """ epsf_shape = epsf.data.shape n_good_stars = stars.n_good_stars if n_good_stars == 0: # Return empty array with correct shape return np.zeros((0, epsf_shape[0], epsf_shape[1])) # Pre-allocate with NaN (default for missing data) shape = (n_good_stars, epsf_shape[0], epsf_shape[1]) epsf_resid = np.full(shape, np.nan) # Loop over stars and compute residuals directly into the # pre-allocated array for i, star in enumerate(stars.all_good_stars): self._resample_residual(star, epsf, out_image=epsf_resid[i]) 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. """ return _SmoothingKernel.apply_smoothing(epsf_data, self.smoothing_kernel) def _normalize_epsf(self, epsf_data): """ Normalize the ePSF data so that the sum of the array values equals the product of the oversampling factors. The normalization accounts for oversampling. For proper normalization with flux=1.0, the sum of the ePSF data array should equal the product of the oversampling factors. Parameters ---------- epsf_data : 2D `~numpy.ndarray` A 2D array containing the ePSF image. Returns ------- result : 2D `~numpy.ndarray` The normalized ePSF data. Notes ----- For an oversampled PSF image, the sum of array values should equal the product of the oversampling factors (e.g., for oversampling=(4, 4), sum should be 16.0). This ensures that the ImagePSF model with flux=1.0 represents a properly normalized PSF. """ oversampling_product = np.prod(self.oversampling) current_sum = np.sum(epsf_data) if current_sum == 0: msg = 'Cannot normalize ePSF: data sum is zero' raise ValueError(msg) return epsf_data * (oversampling_product / current_sum) def _recenter_epsf(self, epsf, *, centroid_func=None, box_size=None, maxiters=None, center_accuracy=None): """ Recenter the ePSF data by shifting to the array center. This method uses iterative centroiding to find the center of the ePSF and applies sub-pixel shifts using interpolation. This provides accurate centering even when the PSF is offset by fractional pixels. Algorithm Overview ------------------ 1. Find the centroid of the ePSF using the centroid function 2. Calculate the sub-pixel shift needed to center the PSF 3. Apply the shift using spline interpolation via epsf.evaluate() 4. Iterate until convergence or max iterations reached Parameters ---------- epsf : `ImagePSF` object The ePSF model containing the data to be recentered. 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. If `None`, uses the builder's configured recentering_func. 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 iteration. The size is in the input star (i.e., undersampled) pixel space; it is automatically scaled by the oversampling factor when applied to the oversampled ePSF grid. 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. If `None`, uses the builder's configured recentering_boxsize. maxiters : int, optional The maximum number of recentering iterations to perform. If `None`, uses the builder's configured recentering_maxiters . center_accuracy : float, optional The desired accuracy for the center position. The centering iterations will stop if the center of the ePSF changes by less than ``center_accuracy`` pixels between iterations. If `None`, uses 1.0e-4. Returns ------- result : 2D `~numpy.ndarray` The recentered ePSF data array with the same shape as input. Notes ----- This method uses spline interpolation to apply sub-pixel shifts, which preserves the PSF shape more accurately than integer pixel shifting. The interpolation is done using the ImagePSF's evaluate method. """ # Use instance defaults if not specified if centroid_func is None: centroid_func = self.recentering_func if box_size is None: box_size = self.recentering_boxsize if maxiters is None: maxiters = self.recentering_maxiters if center_accuracy is None: center_accuracy = 1.0e-4 # Scale box_size from undersampled (input star) space to # oversampled ePSF space, ensuring odd dimensions. box_size = np.asarray(box_size) oversampled_box = box_size * self.oversampling # Ensure odd dimensions so the box is centered on a pixel oversampled_box = tuple(s + 1 if s % 2 == 0 else s for s in oversampled_box) oversampled_box = np.array(oversampled_box, dtype=int) # The center of the ePSF in oversampled pixel coordinates. # This is where we want the PSF center to be. xcenter, ycenter = self.coord_transformer.compute_epsf_origin( epsf.data.shape) # Create coordinate grids in undersampled units for evaluate() y, x = np.indices(epsf.data.shape, dtype=float) x, y = self.coord_transformer.oversampled_to_undersampled(x, y) # The origin in undersampled units (for use with evaluate) x_origin, y_origin = ( self.coord_transformer.oversampled_to_undersampled(xcenter, ycenter)) dx_total, dy_total = 0.0, 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 epsf_data = epsf.data while (iter_num < maxiters and center_dist_sq >= center_accuracy_sq): iter_num += 1 # Get a cutout around the expected center for centroiding slices_large, _ = overlap_slices( epsf_data.shape, oversampled_box, (ycenter, xcenter)) epsf_cutout = epsf_data[slices_large] mask = ~np.isfinite(epsf_cutout) # Find the centroid in the cutout (in oversampled pixel coords) xcenter_new, ycenter_new = centroid_func(epsf_cutout, mask=mask) # Convert cutout coordinates to full array coordinates xcenter_new += slices_large[1].start ycenter_new += slices_large[0].start # Calculate the shift in oversampled pixels dx = xcenter_new - xcenter dy = ycenter_new - ycenter center_dist_sq = dx ** 2 + dy ** 2 if center_dist_sq >= center_dist_sq_prev: # Shift is getting larger, stop iterating break center_dist_sq_prev = center_dist_sq # Accumulate total shift in undersampled units dx_under, dy_under = ( self.coord_transformer.oversampled_to_undersampled(dx, dy)) dx_total += dx_under dy_total += dy_under # Apply the shift using evaluate (uses spline # interpolation). The shift is applied by moving the origin. epsf_data = epsf.evaluate(x=x, y=y, flux=1.0, x_0=x_origin - dx_total, y_0=y_origin - 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 : `ImagePSF` object, optional The initial ePSF model. If not input, then the ePSF will be built from scratch. Returns ------- epsf : `ImagePSF` object The updated ePSF. """ if epsf is None: # Create an initial ePSF (array of zeros) epsf = self._create_initial_epsf(stars) # Compute a 3D stack of 2D residual images residuals = self._resample_residuals(stars, epsf) # Compute the sigma-clipped median 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) residuals = nanmedian(residuals, axis=0) # Interpolate any missing data (np.nan values) in the residual # image mask = ~np.isfinite(residuals) if np.any(mask): residuals = _interpolate_missing_data(residuals, mask, method='cubic') # Add the residuals to the previous ePSF image new_epsf = epsf.data + residuals # Smooth the ePSF smoothed_data = self._smooth_epsf(new_epsf) # Recenter the ePSF # Create an intermediate ePSF for recentering operations. # Use the current epsf's origin if it exists, otherwise compute # center. temp_epsf = ImagePSF(data=smoothed_data, origin=epsf.origin, oversampling=self.oversampling, fill_value=0.0) # Apply recentering to the smoothed data recentered_data = self._recenter_epsf(temp_epsf) # Normalize the ePSF data normalized_data = self._normalize_epsf(recentered_data) return ImagePSF(data=normalized_data, oversampling=self.oversampling, fill_value=0.0) def _check_convergence(self, stars, centers, fit_failed): """ Check if the ePSF building has converged. Convergence is determined by checking the movement of star centers between iterations. The method calculates the squared distance of center movements for successfully fitted stars and applies enhanced convergence criteria that consider both the maximum movement and the overall stability of the star centers. This provides a more robust convergence detection mechanism that is less sensitive to outliers and provides better diagnostic information on the quality of convergence. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. centers : `~numpy.ndarray` Previous star center positions. fit_failed : `~numpy.ndarray` Boolean array tracking failed fits. Returns ------- converged : bool `True` if convergence criteria are met. center_dist_sq : `~numpy.ndarray` Squared distances of center movements. new_centers : `~numpy.ndarray` Updated star center positions. """ # Calculate center movements for successfully fitted stars only new_centers = stars.cutout_center_flat dx_dy = new_centers - centers # Filter out failed fits for convergence calculation good_stars = np.logical_not(fit_failed) if not np.any(good_stars): # No good stars - cannot determine convergence # Return high values to prevent false convergence return False, np.array([self.center_accuracy_sq * 10]), new_centers dx_dy_good = dx_dy[good_stars] center_dist_sq = np.sum(dx_dy_good * dx_dy_good, axis=1, dtype=np.float64) # Enhanced convergence criteria max_movement = np.max(center_dist_sq) # Primary convergence check primary_converged = max_movement < self.center_accuracy_sq # Secondary check: ensure most stars are stable # 80% of stars must be stable stable_fraction_threshold = 0.8 stable_fraction = (np.sum(center_dist_sq < self.center_accuracy_sq) / len(center_dist_sq)) stability_converged = stable_fraction > stable_fraction_threshold # Combined convergence: both criteria must be met for robust # results converged = primary_converged and stability_converged return converged, center_dist_sq, new_centers def _fit_stars(self, epsf, stars): """ Fit an ePSF model to stars. Parameters ---------- epsf : `ImagePSF` 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 that 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, ImagePSF): msg = 'The input epsf must be an ImagePSF' raise TypeError(msg) fitted_stars = [] for star in stars: if isinstance(star, EPSFStar): if star._excluded_from_fit: fitted_star = star else: fitted_star = self._fit_star(epsf, star) elif isinstance(star, LinkedEPSFStar): fitted_star = [] for linked_star in star: if linked_star._excluded_from_fit: fitted_star.append(linked_star) else: fitted_star.append(self._fit_star(epsf, linked_star)) fitted_star = LinkedEPSFStar(fitted_star) fitted_star.constrain_centers() else: msg = ('stars must contain only EPSFStar and/or ' 'LinkedEPSFStar objects') raise TypeError(msg) fitted_stars.append(fitted_star) return EPSFStars(fitted_stars) def _fit_star(self, epsf, star): """ Fit an ePSF model to a single star. Parameters ---------- epsf : `ImagePSF` An ePSF model to be fitted to the star. star : `EPSFStar` The star to be fit. Returns ------- star : `EPSFStar` The fitted star with updated cutout center and flux. """ fit_shape = self.fit_shape fitter = self.fitter fitter_kwargs = self._fitter_kwargs fitter_has_fit_info = self._fitter_has_fit_info # Create a shallow copy to avoid mutating the input star. This # is a shallow copy, so the large numpy arrays (_data, weights, # mask) are shared and not duplicated; only the object wrapper # and small scalar attributes are new. star = copy.copy(star) if fit_shape is not None: try: xcenter, ycenter = star.cutout_center large_slc, _ = overlap_slices(star.shape, fit_shape, (ycenter, xcenter), mode='strict') except (PartialOverlapError, NoOverlapError): 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: # Handle case where the fitter does not 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 = 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 # Check if fitted position is outside the data cutout if (x_center < 0 or x_center >= star.shape[1] or y_center < 0 or y_center >= star.shape[0]): fit_error_status = 3 # fitted position outside cutout if fit_error_status != 3: 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 def _process_iteration(self, stars, epsf, iter_num): """ Process a single iteration of ePSF building. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `ImagePSF` object Current ePSF model. iter_num : int Current iteration number. Returns ------- epsf : `ImagePSF` object Updated ePSF model. stars : `EPSFStars` object Updated stars with new fitted centers. fit_failed : `~numpy.ndarray` Boolean array tracking failed fits. """ # Build/improve the ePSF epsf = self._build_epsf_step(stars, epsf=epsf) # Fit the new ePSF to the stars to find improved centers with warnings.catch_warnings(): message = '.*The fit may be unsuccessful;.*' warnings.filterwarnings('ignore', message=message, category=AstropyUserWarning) stars = self._fit_stars(epsf, stars) # Reset ePSF flux to 1.0 after fitting (fitting modifies the # flux) epsf.flux = 1.0 # 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): msg = 'The ePSF fitting failed for all stars.' raise ValueError(msg) # Permanently exclude fitting any star where the fit fails # after 3 iterations if iter_num > 3 and np.any(fit_failed): for i in fit_failed.nonzero()[0]: star = stars.all_stars[i] # Only warn for stars being newly excluded if not star._excluded_from_fit: if star._fit_error_status == 1: reason = ('its fitting region extends beyond the ' 'star cutout image') elif star._fit_error_status == 3: reason = ('its fitted position is outside the ' 'data cutout') else: # _fit_error_status == 2 reason = 'the fit did not converge' msg = (f'The star at ({star._center_original[0]:.2f}, ' f'{star._center_original[1]:.2f}) (index=' f'{star.id_label - 1}) has been excluded from ' f'ePSF fitting because {reason}.') warnings.warn(msg, AstropyUserWarning) star._excluded_from_fit = True # Store the ePSF from this iteration self._epsf.append(epsf) return epsf, stars, fit_failed def _finalize_build(self, epsf, stars, progress_reporter, iter_num, converged, final_center_accuracy, excluded_star_indices): """ Finalize the ePSF building process and create result object. Parameters ---------- epsf : `ImagePSF` object Final ePSF model. stars : `EPSFStars` object Final fitted stars. progress_reporter : `_ProgressReporter` Progress reporter instance for handling completion messages. iter_num : int Number of completed iterations. converged : bool Whether the building process converged. final_center_accuracy : float Final center accuracy achieved. excluded_star_indices : list Indices of excluded stars. Returns ------- result : `EPSFBuildResult` Structured result containing ePSF, stars, and build diagnostics. """ # Handle progress reporting completion if iter_num < self.maxiters: progress_reporter.write_convergence_message(iter_num) progress_reporter.close() # Create structured result return EPSFBuildResult( epsf=epsf, fitted_stars=stars, iterations=iter_num, converged=converged, final_center_accuracy=final_center_accuracy, n_excluded_stars=len(excluded_star_indices), excluded_star_indices=excluded_star_indices, ) def build_epsf(self, stars, *, epsf=None): """ Build iteratively an ePSF from star cutouts. This method builds an ePSF from an initial model when ``epsf`` is provided, or from scratch when ``epsf`` is `None`. In the latter case, it is equivalent to invoking an `EPSFBuilder` instance on the input ``stars``. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `ImagePSF` object, optional The initial ePSF model. If `None`, then the ePSF will be built from scratch. Returns ------- result : `EPSFBuildResult` or tuple The ePSF building results. Returns an `EPSFBuildResult` object with detailed information about the building process. For backward compatibility, the result can be unpacked as a tuple: ``(epsf, fitted_stars) = epsf_builder(stars)``. Notes ----- The structured result object contains: - epsf: The final constructed ePSF - fitted_stars: Stars with updated centers/fluxes - iterations: Number of iterations performed - converged: Whether convergence was achieved - final_center_accuracy: Final center movement accuracy - n_excluded_stars: Number of stars excluded due to fit failures - excluded_star_indices: Indices of excluded stars """ _EPSFValidator.validate_stars(stars, context='ePSF building') _EPSFValidator.validate_shape_compatibility(stars, self.oversampling, shape=self.shape) # Initialize variables for building process fit_failed = np.zeros(stars.n_stars, dtype=bool) centers = stars.cutout_center_flat # Setup progress tracking progress_reporter = _ProgressReporter(self.progress_bar, self.maxiters).setup() # Initialize iteration variables and tracking iter_num = 0 converged = False center_dist_sq = np.array([self.center_accuracy_sq + 1.0]) excluded_star_indices = [] # Main iteration loop while (iter_num < self.maxiters and not np.all(fit_failed) and not converged): iter_num += 1 # Process one iteration epsf, stars, fit_failed = self._process_iteration( stars, epsf, iter_num) # Track newly excluded stars if iter_num > 3 and np.any(fit_failed): new_excluded = fit_failed.nonzero()[0] for idx in new_excluded: if idx not in excluded_star_indices: excluded_star_indices.append(idx) # Check convergence based on center movements converged, center_dist_sq, centers = self._check_convergence( stars, centers, fit_failed) # Update progress bar progress_reporter.update() # Calculate the final center accuracy final_converged = converged final_center_accuracy = np.max(center_dist_sq) ** 0.5 # Finalize and return structured results return self._finalize_build(epsf, stars, progress_reporter, iter_num, final_converged, final_center_accuracy, excluded_star_indices) astropy-photutils-3322558/photutils/psf/epsf_stars.py000066400000000000000000001342341517052111400230130ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for extracting 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, NoOverlapError, PartialOverlapError, StdDevUncertainty, overlap_slices) 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 __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. flux : float or `None`, optional The flux of the star. If `None`, then the flux will be estimated from the input ``data``. 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, flux=None, origin=(0, 0), wcs_large=None, id_label=None): self._data = np.asanyarray(data) # Validate data dimensionality and shape if self._data.ndim != 2: msg = f'Input data must be 2-dimensional, got {self._data.ndim}D' raise ValueError(msg) if self._data.size == 0: msg = 'Input data cannot be empty' raise ValueError(msg) self.shape = self._data.shape # Validate and process weights if weights is not None: weights = np.asanyarray(weights) if weights.shape != self._data.shape: msg = (f'Weights shape {weights.shape} must match data shape ' f'{self._data.shape}') raise ValueError(msg) # Check for valid weight values if not np.all(np.isfinite(weights)): msg = ('Non-finite weight values detected. These will ' 'be set to zero.') warnings.warn(msg, AstropyUserWarning) weights = np.where(np.isfinite(weights), weights, 0.0) # Copy to avoid modifying the input weights self.weights = weights.astype(float, copy=True) else: self.weights = np.ones_like(self._data, dtype=float) # Create initial mask from weights self.mask = (self.weights <= 0.0) # Mask out invalid image data and provide informative warning invalid_data = ~np.isfinite(self._data) if np.any(invalid_data): self.weights[invalid_data] = 0.0 self.mask[invalid_data] = True msg = ('Input data array contains invalid data that will be ' 'masked.') warnings.warn(msg, AstropyUserWarning) # Validate origin origin = np.asarray(origin) if origin.shape != (2,): msg = f'Origin must have exactly 2 elements, got {len(origin)}' raise ValueError(msg) if not np.all(np.isfinite(origin)): msg = 'Origin coordinates must be finite' raise ValueError(msg) self.origin = origin.astype(int) self.wcs_large = wcs_large self.id_label = id_label if cutout_center is None: cutout_center = ((self.shape[1] - 1) / 2.0, (self.shape[0] - 1) / 2.0) # Set cutout_center (triggers validation via setter) self.cutout_center = cutout_center # Keep track of the original center position (before fitting) # for reference self._center_original = cutout_center + self.origin if flux is not None: self.flux = float(flux) self._has_all_zero_data = False # Unknown for explicit flux else: # Check if completely masked before attempting flux estimation if np.all(self.mask): msg = ('Star cutout is completely masked; no valid data ' 'available') raise ValueError(msg) # Check if all unmasked data values are exactly zero # Store flag for later warning (to avoid duplicate warnings) unmasked_data = self._data[~self.mask] self._has_all_zero_data = bool(np.all(unmasked_data == 0.0)) # Warn if all data is zero if self._has_all_zero_data: msg = 'All unmasked data values in star cutout are zero' warnings.warn(msg, AstropyUserWarning) # Estimate flux self.flux = self.estimate_flux() # Note: We allow flux <= 0 for real sources that may have # negative net flux due to background subtraction or similar # effects self._excluded_from_fit = False self._fit_error_status = 0 # 0: no error, >0: error during fitting self._fitinfo = None def __array__(self): """ Array representation of the 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. Initially set to the geometric center of the cutout, this value is updated during ePSF building iterations to reflect the fitted center position as the star is aligned with the ePSF model. """ return self._cutout_center @cutout_center.setter def cutout_center(self, value): # Convert to array-like for validation value = np.asarray(value) # Validate shape if value.shape != (2,): msg = ('cutout_center must have exactly two elements in ' f'(x, y) form, got shape {value.shape}') raise ValueError(msg) # Validate finite values if not np.all(np.isfinite(value)): msg = 'All cutout_center coordinates must be finite' raise ValueError(msg) # Validate bounds (should be within the cutout image) x, y = value if not (0 <= x < self.shape[1]): msg = (f'cutout_center x-coordinate {x} is outside the ' f'cutout bounds [0, {self.shape[1]})') warnings.warn(msg, AstropyUserWarning) if not (0 <= y < self.shape[0]): msg = (f'cutout_center y-coordinate {y} is outside the ' f'cutout bounds [0, {self.shape[0]})') warnings.warn(msg, AstropyUserWarning) 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. Returns ------- flux : float The estimated star's flux. If there is no valid data in the cutout, `numpy.nan` will be returned. """ if not np.any(self.mask): return float(np.sum(self.data)) # Interpolate missing data to estimate total flux data_interp = _interpolate_missing_data(self.data, mask=self.mask, method='cubic') return float(np.sum(data_interp)) def register_epsf(self, epsf): """ Register and scale (in flux) the input ``epsf`` to the star. Parameters ---------- epsf : `ImagePSF` The ePSF to register. Returns ------- data : `~numpy.ndarray` A 2D array of the registered/scaled ePSF. """ # evaluate the input ePSF on the star cutout grid yy, xx = np.indices(self.shape, dtype=float) return epsf.evaluate(xx, yy, flux=self.flux, x_0=self.cutout_center[0], y_0=self.cutout_center[1]) def compute_residual_image(self, epsf): """ Compute the residual image of the star data minus the registered/scaled ePSF. Parameters ---------- epsf : `ImagePSF` The ePSF to subtract. Returns ------- data : `~numpy.ndarray` A 2D array of the residual image. """ return self.data - self.register_epsf(epsf) @property def _xyidx_centered(self): """ 1D arrays of x and y indices of unmasked pixels, with respect to the star center, in the cutout reference frame. Returns ------- x_centered, y_centered : tuple of `~numpy.ndarray` The x and y indices centered on the star position. """ yidx, xidx = np.indices(self._data.shape) x_centered = xidx[~self.mask].ravel() - self.cutout_center[0] y_centered = yidx[~self.mask].ravel() - self.cutout_center[1] return x_centered, y_centered @lazyproperty def _data_values_normalized(self): """ 1D array of unmasked cutout data values, normalized by the star's total flux. """ return self.data[~self.mask].ravel() / self.flux 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: msg = ('stars_list must be a list of EPSFStar and/or ' 'LinkedEPSFStar objects') raise TypeError(msg) def __len__(self): """ Return the number of stars in this container. """ return len(self._data) def __getitem__(self, index): """ Return a new EPSFStars instance containing the indexed star(s). """ return self.__class__(self._data[index]) def __delitem__(self, index): """ Delete the star at the given index. """ del self._data[index] def __iter__(self): """ Iterate over the stars in this container. """ yield from self._data def __getstate__(self): """ Return state for pickling (avoids __getattr__ recursion). """ return self.__dict__ def __setstate__(self, d): """ Restore state from pickling. """ self.__dict__ = d def __getattr__(self, attr): """ Delegate attribute access to the underlying star list. This allows accessing star attributes (like ``cutout_center``, ``center``, ``flux``) directly on the EPSFStars container, returning an array of values from all contained stars. """ result = [getattr(star, attr) for star in self._data] if attr in ['cutout_center', 'center', 'flux', '_excluded_from_fit']: result = np.array(result) if len(self._data) == 1: result = result[0] return result @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 np.array([star.cutout_center for star in self.all_stars]) @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 np.array([star.center for star in self.all_stars]) @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) class LinkedEPSFStar: """ 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. Note that unlike `EPSFStars` (which is a collection of potentially unrelated stars), `LinkedEPSFStar` represents a single logical star observed in multiple images. 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): msg = 'stars_list must contain only EPSFStar objects' raise TypeError(msg) if star.wcs_large is None: msg = ('Each EPSFStar object must have a valid wcs_large ' 'attribute') raise ValueError(msg) self._data = list(stars_list) def __len__(self): """ Return the number of EPSFStar objects in this linked star. """ return len(self._data) def __getitem__(self, index): """ Return the EPSFStar at the given index. """ return self._data[index] def __iter__(self): """ Iterate over the EPSFStar objects in this linked star. """ yield from self._data def __getattr__(self, attr): """ Delegate attribute access to the underlying star list. This provides access to common star attributes like cutout_center, center, flux, etc. as arrays when accessed on the LinkedEPSFStar. """ if attr.startswith('_'): msg = f"'{type(self).__name__}' object has no attribute '{attr}'" raise AttributeError(msg) result = [getattr(star, attr) for star in self._data] if attr in ('cutout_center', 'center', 'flux', '_excluded_from_fit'): result = np.array(result) if len(self._data) == 1: result = result[0] return result def __getstate__(self): """ Return state for pickling (avoids __getattr__ recursion). """ return self.__dict__ def __setstate__(self, d): """ Restore state from pickling. """ self.__dict__ = d @property def all_stars(self): """ A flat list of all `EPSFStar` objects in this linked star. Since LinkedEPSFStar only contains EPSFStar objects (not nested LinkedEPSFStar), this is simply the internal list. """ return self._data @property def cutout_center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers with respect to the input cutout ``data`` array, as a 2D array (``n_all_stars`` x 2). """ return np.array([star.cutout_center for star in self._data]) @property def center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers with respect to the original (large) image (not the cutout image) as a 2D array (``n_all_stars`` x 2). """ return np.array([star.center for star in self._data]) @property def n_stars(self): """ The number of `EPSFStar` objects in this linked star. For LinkedEPSFStar this is the same as n_all_stars since there is no nesting. """ return len(self._data) @property def n_all_stars(self): """ The total number of `EPSFStar` objects in this linked star. For LinkedEPSFStar this is the same as n_stars since there is no nesting. """ return len(self._data) @property def n_good_stars(self): """ The number of `EPSFStar` objects that have not been excluded from fitting. """ return len(self.all_good_stars) @property def all_good_stars(self): """ A list of all `EPSFStar` objects that have not been excluded from fitting. """ return [star for star in self._data if not star._excluded_from_fit] @property def all_excluded(self): """ Whether all `EPSFStar` objects in this linked star have been excluded from fitting during the ePSF build process. """ return all(star._excluded_from_fit for star in self._data) 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 if self.all_excluded: msg = ('Cannot constrain centers of linked stars because ' 'they have all been excluded during the ePSF ' 'build process.') warnings.warn(msg, AstropyUserWarning) return # Convert pixel coordinates to sky coordinates # Note: each star may have a different WCS, so we cannot # vectorize good_stars = self.all_good_stars sky_coords = np.array([ star.wcs_large.pixel_to_world_values(*star.center) for star in good_stars]) # Compute mean sky coordinate using spherical averaging mean_lon, mean_lat = _compute_mean_sky_coordinate(sky_coords) # Convert mean sky coordinate back to pixel coordinates for each # star for star in good_stars: pixel_center = star.wcs_large.world_to_pixel_values( mean_lon, mean_lat) star.cutout_center = np.asarray(pixel_center) - star.origin def _compute_mean_sky_coordinate(sky_coords): """ Compute the mean sky coordinate using spherical trigonometry. This method properly handles coordinate system singularities by converting to Cartesian coordinates for averaging, then converting back to spherical coordinates. Parameters ---------- sky_coords : array-like, shape (N, 2) Array of sky coordinates in degrees, where each row contains (longitude, latitude). Returns ------- mean_lon, mean_lat : float Mean longitude and latitude in degrees. """ lon, lat = sky_coords.T lon_rad = np.deg2rad(lon) lat_rad = np.deg2rad(lat) # Convert to Cartesian coordinates for averaging x_cart = np.cos(lat_rad) * np.cos(lon_rad) y_cart = np.cos(lat_rad) * np.sin(lon_rad) z_cart = np.sin(lat_rad) # Compute mean Cartesian coordinates mean_x = np.mean(x_cart) mean_y = np.mean(y_cart) mean_z = np.mean(z_cart) # Convert mean Cartesian coordinates back to spherical hypot = np.hypot(mean_x, mean_y) mean_lon = np.rad2deg(np.arctan2(mean_y, mean_x)) mean_lat = np.rad2deg(np.arctan2(mean_z, hypot)) return mean_lon, mean_lat def _normalize_data_input(data): """ Normalize the input data to a list of NDData objects. Parameters ---------- data : `~astropy.nddata.NDData` or list of `~astropy.nddata.NDData` The input data to normalize. Returns ------- data : list of `~astropy.nddata.NDData` The normalized list of NDData objects. Raises ------ TypeError If the input data is not an NDData object or list of NDData objects. """ if isinstance(data, NDData): return [data] if isinstance(data, list): return data msg = 'data must be a single NDData object or list of NDData objects' raise TypeError(msg) def _normalize_catalog_input(catalogs): """ Normalize the input catalogs to a list of Table objects. Parameters ---------- catalogs : `~astropy.table.Table` or list of `~astropy.table.Table` The input catalogs to normalize. Returns ------- catalogs : list of `~astropy.table.Table` The normalized list of Table objects. Raises ------ TypeError If the input catalogs is not a Table object or list of Table objects. """ if isinstance(catalogs, Table): return [catalogs] if isinstance(catalogs, list): return catalogs msg = 'catalogs must be a single Table object or list of Table objects' raise TypeError(msg) def _validate_nddata_list(data): """ Validate that a list contains only valid NDData objects. Parameters ---------- data : list of `~astropy.nddata.NDData` The list of NDData objects to validate. Raises ------ TypeError If any element is not an NDData object. ValueError If any NDData object has no data array or non-2D data. """ for i, img in enumerate(data): if not isinstance(img, NDData): msg = (f'All data elements must be NDData objects. ' f'Element {i} is {type(img)}') raise TypeError(msg) if img.data.ndim != 2: msg = (f'All NDData objects must contain 2D data. ' f'Object at index {i} has {img.data.ndim}D data') raise ValueError(msg) def _validate_catalog_list(catalogs): """ Validate that a list contains only valid Table objects. Parameters ---------- catalogs : list of `~astropy.table.Table` The list of Table objects to validate. Raises ------ TypeError If any element is not a Table object. """ for i, cat in enumerate(catalogs): if not isinstance(cat, Table): msg = (f'All catalog elements must be Table objects. ' f'Element {i} is {type(cat)}') raise TypeError(msg) if len(cat) == 0: msg = (f'Catalog at index {i} is empty. No stars will ' 'be extracted from this catalog.') warnings.warn(msg, AstropyUserWarning) def _validate_coordinate_consistency(data, catalogs): """ Validate coordinate system consistency between data and catalogs. This function ensures that the necessary coordinate information (either pixel coordinates or WCS for sky coordinates) is available to extract stars. Parameters ---------- data : list of `~astropy.nddata.NDData` The list of NDData objects. catalogs : list of `~astropy.table.Table` The list of Table catalogs. Raises ------ ValueError If the coordinate information is inconsistent or missing. """ if len(catalogs) == 1 and len(data) > 1: # Single catalog with multiple images requires skycoord and WCS if 'skycoord' not in catalogs[0].colnames: msg = ('When inputting a single catalog with multiple NDData ' "objects, the catalog must have a 'skycoord' column.") raise ValueError(msg) if any(img.wcs is None for img in data): msg = ('When inputting a single catalog with multiple NDData ' 'objects, each NDData object must have a wcs attribute.') raise ValueError(msg) else: # Multiple catalogs (or single catalog with single image) for i, cat in enumerate(catalogs): has_xy = 'x' in cat.colnames and 'y' in cat.colnames has_skycoord = 'skycoord' in cat.colnames if not has_xy and not has_skycoord: msg = (f'Catalog at index {i} must have either ' "'x' and 'y' columns or a 'skycoord' column.") raise ValueError(msg) # If only skycoord is available, ensure WCS is present if has_skycoord and not has_xy: data_idx = i if len(data) == len(catalogs) else 0 if (data_idx < len(data) and data[data_idx].wcs is None): msg = (f'When catalog at index {i} contains only skycoord ' f'positions, the corresponding NDData object must ' 'have a wcs attribute.') raise ValueError(msg) if any(img.wcs is None for img in data): msg = ('When inputting catalog(s) with only skycoord ' 'positions, each NDData object must have a ' 'wcs attribute.') raise ValueError(msg) if len(data) != len(catalogs): msg = ('When inputting multiple catalogs, the number of ' 'catalogs must match the number of input images.') raise ValueError(msg) 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 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. """ data = _normalize_data_input(data) catalogs = _normalize_catalog_input(catalogs) _validate_nddata_list(data) _validate_catalog_list(catalogs) _validate_coordinate_consistency(data, catalogs) size = as_pair('size', size, lower_bound=(3, 1), check_odd=True) if len(catalogs) == 1: # may include linked stars stars_out, overlap_fail_count = _extract_linked_stars( data, catalogs[0], size) else: # no linked stars stars_out, overlap_fail_count = _extract_unlinked_stars( data, catalogs, size) if overlap_fail_count > 0: msg = (f'{overlap_fail_count} star(s) were not extracted ' 'because their cutout region extended beyond the ' 'input image.') warnings.warn(msg, AstropyUserWarning) return EPSFStars(stars_out) def _extract_linked_stars(data, catalog, size): """ Extract stars that may be linked across multiple images. Parameters ---------- data : list of `~astropy.nddata.NDData` A list of `~astropy.nddata.NDData` objects containing the 2D images from which to extract the stars. Each `~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 must be defined in sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). size : int or array_like (int) 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. Returns ------- stars : list of `EPSFStar` or `LinkedEPSFStar` objects A list of `EPSFStar` and/or `LinkedEPSFStar` instances containing the extracted stars. Stars that are linked across multiple images will be represented as a single `LinkedEPSFStar` instance containing the corresponding `EPSFStar` instances from each image. Failed extractions are represented as `None`. overlap_fail_count : int The number of stars that failed extraction because their cutout region extended beyond the input image. """ # Use pixel coords only for single image use_xy = len(data) == 1 # Extract stars from each image results = [_extract_stars(img, catalog, size=size, use_xy=use_xy) for img in data] stars = [r[0] for r in results] overlap_fail_count = sum(r[1] for r in results) # Transpose to associate linked stars across images stars = list(map(list, zip(*stars, strict=True))) # Process each potential linked star group stars_out = [] for star_group in stars: good_stars = [star for star in star_group if star is not None] if not good_stars: continue # No valid stars in any image if len(good_stars) == 1: # Single star, not linked stars_out.append(good_stars[0]) else: # Multiple stars - create linked star stars_out.append(LinkedEPSFStar(good_stars)) return stars_out, overlap_fail_count def _extract_unlinked_stars(data, catalogs, size): """ Extract stars from individual catalogs (no linking). Parameters ---------- data : list of `~astropy.nddata.NDData` A list of `~astropy.nddata.NDData` objects containing the 2D images from which to extract the stars. catalogs : list of `~astropy.table.Table` A list of catalogs of sources to be extracted from the input ``data``. Each catalog corresponds to the list of `~astropy.nddata.NDData` objects input in ``data`` (i.e., a separate source catalog for each 2D image). 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`. size : int or array_like (int) 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. Returns ------- stars : list of `EPSFStar` objects A list of `EPSFStar` instances containing the extracted stars. Failed extractions are represented as `None`. overlap_fail_count : int The number of stars that failed extraction because their cutout region extended beyond the input image. """ stars_out = [] total_overlap_fail_count = 0 for img, cat in zip(data, catalogs, strict=True): extracted, overlap_fail_count = _extract_stars( img, cat, size=size, use_xy=True) stars_out.extend(extracted) total_overlap_fail_count += overlap_fail_count # Filter out None values return ([star for star in stars_out if star is not None], total_overlap_fail_count) 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. Failed extractions are represented as `None`. overlap_fail_count : int The number of stars that failed extraction because their cutout region extended beyond the input image. """ 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 = np.asarray(catalog['x']) ycenters = np.asarray(catalog['y']) if 'id' in colnames: ids = catalog['id'] else: ids = np.arange(len(catalog), dtype=int) + 1 fluxes = catalog['flux'] if 'flux' in colnames else None # Prepare uncertainty handling - defer weight array creation # until we know which cutouts we need uncertainty_info = _prepare_uncertainty_info(data) data_mask = data.mask # Cache mask reference stars = [] nonfinite_weights_count = 0 overlap_fail_count = 0 flux_failures = [] # Collect flux estimation failures all_zero_stars = [] # Collect stars with all-zero data for i, (xcenter, ycenter) in enumerate(zip(xcenters, ycenters, strict=True)): try: large_slc, _ = overlap_slices(data.data.shape, size, (ycenter, xcenter), mode='strict') except (PartialOverlapError, NoOverlapError): stars.append(None) overlap_fail_count += 1 continue # Extract data cutout data_cutout = data.data[large_slc] # Create weights cutout only for this specific region weights_cutout, has_nonfinite = _create_weights_cutout( uncertainty_info, data_mask, large_slc) if has_nonfinite: nonfinite_weights_count += 1 origin = (large_slc[1].start, large_slc[0].start) cutout_center = (xcenter - origin[0], ycenter - origin[1]) flux = fluxes[i] if fluxes is not None else None try: # Suppress all-zero warning in EPSFStar (we emit our own below) with warnings.catch_warnings(): msg = 'All unmasked data values in star cutout are zero' warnings.filterwarnings('ignore', message=msg, category=AstropyUserWarning) star = EPSFStar(data_cutout, weights=weights_cutout, cutout_center=cutout_center, origin=origin, wcs_large=data.wcs, id_label=ids[i], flux=flux) stars.append(star) # Track stars with all-zero data if hasattr(star, '_has_all_zero_data') and star._has_all_zero_data: all_zero_stars.append((xcenter, ycenter)) except ValueError as exc: # Collect flux estimation failures; emit warnings later flux_failures.append((xcenter, ycenter, exc)) stars.append(None) # Emit consolidated warning for non-finite weights if nonfinite_weights_count > 0: msg = (f'{nonfinite_weights_count} star cutout(s) had ' 'non-finite weight values which were set to zero. ' 'Please check the input uncertainty values in the ' 'NDData object.') warnings.warn(msg, AstropyUserWarning) # Emit individual flux estimation failure warnings. These may be a # consequence of having all non-finite weights (data then becomes # completely masked), so we emit them after the non-finite weights # warning. for xcenter, ycenter, exc in flux_failures: msg = (f'Failed to create EPSFStar for object at ' f'({xcenter:.2f}, {ycenter:.2f}): {exc}') warnings.warn(msg, AstropyUserWarning) # Emit warnings for stars with all-zero data for xcenter, ycenter in all_zero_stars: msg = (f'Star at ({xcenter:.1f}, {ycenter:.1f}) has all ' 'unmasked data values equal to zero') warnings.warn(msg, AstropyUserWarning) return stars, overlap_fail_count def _prepare_uncertainty_info(data): """ Prepare uncertainty information for efficient weight computation. This function analyzes the input NDData's uncertainty and returns a dictionary with information needed to compute weights for cutout regions without creating the full weight array. Parameters ---------- data : `~astropy.nddata.NDData` The NDData object containing the data and possibly uncertainty. Returns ------- info : dict A dictionary with keys: - 'type' : str One of 'none', 'weights', or 'uncertainty'. - 'array' : `~numpy.ndarray` (only if type='weights') The weight array from the input data. - 'uncertainty' : `~astropy.nddata.NDUncertainty` (only if type='uncertainty') The uncertainty object for on-the-fly conversion to weights. """ if data.uncertainty is None: return {'type': 'none'} if data.uncertainty.uncertainty_type == 'weights': return { 'type': 'weights', 'array': data.uncertainty.array, } # For other uncertainties, prepare the conversion return { 'type': 'uncertainty', 'uncertainty': data.uncertainty, } def _create_weights_cutout(uncertainty_info, data_mask, slices): """ Create a weights cutout for a specific region. This avoids creating the full weights array when only a small cutout is needed, improving memory efficiency. Parameters ---------- uncertainty_info : dict Dictionary containing uncertainty information. data_mask : `~numpy.ndarray` or None Mask array for the data. slices : tuple of slice Slices defining the cutout region. Returns ------- weights_cutout : `~numpy.ndarray` The weights array for the cutout region. has_nonfinite : bool True if non-finite weights were found and set to zero. """ cutout_shape = (slices[0].stop - slices[0].start, slices[1].stop - slices[1].start) if uncertainty_info['type'] == 'none': weights_cutout = np.ones(cutout_shape, dtype=float) elif uncertainty_info['type'] == 'weights': weights_cutout = np.asarray( uncertainty_info['array'][slices], dtype=float) else: # Convert uncertainty to weights for this cutout only uncertainty_cutout = uncertainty_info['uncertainty'].array[slices] with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # Convert to standard deviation representation if needed if hasattr(uncertainty_info['uncertainty'], 'represent_as'): uncertainty_cutout = ( uncertainty_info['uncertainty'] .represent_as(StdDevUncertainty).array[slices]) # First compute weights, then check for non-finite values weights_cutout = 1.0 / uncertainty_cutout # Check for non-finite weights and track if found has_nonfinite = not np.all(np.isfinite(weights_cutout)) if has_nonfinite: # Set non-finite weights to 0 weights_cutout = np.where(np.isfinite(weights_cutout), weights_cutout, 0.0) # Apply mask if present if data_mask is not None: mask_cutout = data_mask[slices] weights_cutout[mask_cutout] = 0.0 return weights_cutout, has_nonfinite astropy-photutils-3322558/photutils/psf/flags.py000066400000000000000000000377461517052111400217500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for working with PSF photometry flags, including centralized flag definitions and decoding utilities. """ from dataclasses import dataclass from typing import ClassVar import numpy as np from photutils.utils._deprecation import (deprecated_getattr, deprecated_positional_kwargs) __all__ = ['PSF_FLAGS', 'decode_psf_flags'] @dataclass(frozen=True) class _PSFFlagDefinition: """ A single PSF flag definition. Attributes ---------- bit_value : int The bit value (power of 2) for this flag. name : str Short name for the flag (used in decode_psf_flags). description : str Brief description of what this flag indicates. detailed_description : str Detailed description for use in docstrings. """ bit_value: int name: str description: str detailed_description: str class _PSFFlags: """ Centralized definition of PSF photometry flags. This class provides a single source of truth for all PSF flag definitions, including bit values, names, and descriptions. It enables consistent flag handling across the PSF photometry codebase and supports dynamic docstring generation. Examples -------- >>> from photutils.psf.flags import _PSFFlags >>> flags = _PSFFlags() >>> flags.N_PIXELS_FIT_PARTIAL 1 >>> flags.get_name(1) 'n_pixels_fit_partial' >>> flags.get_description(8) 'possible non-convergence' """ # Define all PSF flags with their properties FLAG_DEFINITIONS: ClassVar = [ _PSFFlagDefinition( bit_value=1, name='n_pixels_fit_partial', description=('n_pixels_fit smaller than full fit_shape ' 'region'), detailed_description=('The number of fitted pixels ' '(n_pixels_fit) is smaller than the ' 'full fit_shape region, indicating ' 'partial PSF fitting'), ), _PSFFlagDefinition( bit_value=2, name='outside_bounds', description='fitted position outside input image bounds', detailed_description=('The fitted source position is outside the ' 'bounds of the input image'), ), _PSFFlagDefinition( bit_value=4, name='negative_flux', description='non-positive flux', detailed_description=('The fitted flux value is negative or zero, ' 'which is non-physical'), ), _PSFFlagDefinition( bit_value=8, name='no_convergence', description='possible non-convergence', detailed_description=('The PSF fitting algorithm may not have ' 'converged to a stable solution'), ), _PSFFlagDefinition( bit_value=16, name='no_covariance', description='missing parameter covariance', detailed_description=('Parameter covariance matrix is not ' 'available, preventing error estimation'), ), _PSFFlagDefinition( bit_value=32, name='near_bound', description='fitted parameter near a bound', detailed_description=('One or more fitted parameters are very ' 'close to their imposed bounds'), ), _PSFFlagDefinition( bit_value=64, name='no_overlap', description='no overlap with data', detailed_description=('The source PSF fitting region has no ' 'overlap with valid data pixels'), ), _PSFFlagDefinition( bit_value=128, name='fully_masked', description='fully masked source', detailed_description=('All pixels in the source fitting region ' 'are masked'), ), _PSFFlagDefinition( bit_value=256, name='too_few_pixels', description='too few pixels for fitting', detailed_description=('Insufficient unmasked pixels available ' 'for reliable PSF fitting'), ), _PSFFlagDefinition( bit_value=512, name='non_finite_position', description='non-finite fitted position', detailed_description=('The fitted x or y position is NaN or inf, ' 'indicating an invalid or failed fit'), ), _PSFFlagDefinition( bit_value=1024, name='non_finite_flux', description='non-finite fitted flux', detailed_description=('The fitted flux value is NaN or inf, ' 'indicating an invalid or failed fit'), ), _PSFFlagDefinition( bit_value=2048, name='non_finite_localbkg', description='non-finite local background', detailed_description=('The local background value is NaN or ' 'inf, so it was not subtracted before ' 'fitting'), ), ] # Remove in 4.0 _DEPRECATED_FLAG_NAMES: ClassVar = { 'npixfit_partial': 'n_pixels_fit_partial', } # Remove in 4.0 _DEPRECATED_CONSTANT_NAMES: ClassVar = { 'NPIXFIT_PARTIAL': 'N_PIXELS_FIT_PARTIAL', } def __init__(self): for flag_def in self.FLAG_DEFINITIONS: # Create uppercase constants (e.g., N_PIXELS_FIT_PARTIAL = 1) setattr(self, flag_def.name.upper(), flag_def.bit_value) # Create lookup dictionaries for efficient access self._bit_to_def = {fd.bit_value: fd for fd in self.FLAG_DEFINITIONS} self._name_to_def = {fd.name: fd for fd in self.FLAG_DEFINITIONS} # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, self._DEPRECATED_CONSTANT_NAMES, since='3.0', until='4.0') @property def all_flags(self): """ Return all flag definitions. """ return self.FLAG_DEFINITIONS.copy() @property def bit_values(self): """ Return all bit values. """ return [fd.bit_value for fd in self.FLAG_DEFINITIONS] @property def names(self): """ Return all flag names. """ return [fd.name for fd in self.FLAG_DEFINITIONS] @property def flag_dict(self): """ Return dictionary mapping bit values to names. """ return {fd.bit_value: fd.name for fd in self.FLAG_DEFINITIONS} def get_definition(self, identifier): """ Get flag definition by bit value or name. Parameters ---------- identifier : int or str Either the bit value (int) or name (str) of the flag. Returns ------- definition : `_PSFFlagDefinition` The flag definition. Raises ------ KeyError If the identifier is not found. """ if isinstance(identifier, int): if identifier not in self._bit_to_def: msg = f'No flag with bit value {identifier}' raise KeyError(msg) return self._bit_to_def[identifier] if isinstance(identifier, str): # Remove in 4.0 if identifier in self._DEPRECATED_FLAG_NAMES: import warnings from astropy.utils.exceptions import AstropyDeprecationWarning new_name = self._DEPRECATED_FLAG_NAMES[identifier] warnings.warn( f"The flag name '{identifier}' is deprecated " f"in version 3.0. Use '{new_name}' instead. " 'It will be removed in version 4.0.', AstropyDeprecationWarning, stacklevel=2, ) identifier = new_name if identifier not in self._name_to_def: msg = f"No flag with name '{identifier}'" raise KeyError(msg) return self._name_to_def[identifier] msg = 'identifier must be int (bit value) or str (name)' raise TypeError(msg) def get_name(self, bit_value): """ Get flag name from bit value. Parameters ---------- bit_value : int The bit value of the flag. Returns ------- name : str The name of the flag. """ return self.get_definition(bit_value).name def get_bit_value(self, name): """ Get flag bit value from name. Parameters ---------- name : str The name of the flag. Returns ------- bit_value : int The bit value of the flag. """ return self.get_definition(name).bit_value def get_description(self, bit_value): """ Get flag description from bit value. Parameters ---------- bit_value : int The bit value of the flag. Returns ------- description : str The brief description of the flag. """ return self.get_definition(bit_value).description def get_detailed_description(self, bit_value): """ Get detailed flag description from bit value. Parameters ---------- bit_value : int The bit value of the flag. Returns ------- detailed_description : str The detailed description of the flag. """ return self.get_definition(bit_value).detailed_description # Create a singleton instance for global use PSF_FLAGS = _PSFFlags() def _update_decode_docstring(func): """ Decorator to update function docstring with PSF flag documentation. This decorator can be applied to functions like decode_psf_flags to automatically replace manually defined flag lists with dynamically generated ones. Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with updated docstring. """ if not hasattr(func, '__doc__') or func.__doc__ is None: return func docstring = func.__doc__ # Look for the placeholder text placeholder = '' if placeholder in docstring: # Generate the flag descriptions flag_descriptions = [''] indent = ' ' * 4 for flag_def in PSF_FLAGS.FLAG_DEFINITIONS: name = flag_def.name bit_val = flag_def.bit_value desc = flag_def.description line = f"{indent}- ``'{name}'`` : bit {bit_val}, {desc}" flag_descriptions.append(line) # Replace the placeholder with the flag descriptions flag_text = '\n'.join(flag_descriptions) new_docstring = docstring.replace(placeholder, flag_text) func.__doc__ = new_docstring return func @_update_decode_docstring @deprecated_positional_kwargs(since='3.0', until='4.0') def decode_psf_flags(flags, return_bit_values=False): # numpydoc ignore: RT05 """ Decode PSF photometry bit flags into individual components. This function takes integer flag values from PSF photometry results and returns a list of human-readable descriptions of the issues that occurred during fitting. This is useful for understanding what problems were encountered without needing to manually perform bitwise operations. Parameters ---------- flags : int or array-like of int Integer flag value(s) to decode. Each bit in the flag represents a specific condition that occurred during PSF fitting. return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag descriptions (strings). Default is `False`. Returns ------- decoded : list of str, list of int, list of list of str, or \ list of list of int List of active flag names (or bit values), or list of lists if input is an array. Each string (or integer) represents a specific condition that was detected during PSF fitting. If no flags are set, an empty list is returned. Possible flag names are: Examples -------- Decode a single flag value: >>> from photutils.psf import decode_psf_flags >>> issues = decode_psf_flags(5) # bits 1 and 4 set >>> print(issues) ['n_pixels_fit_partial', 'negative_flux'] >>> 'n_pixels_fit_partial' in issues True >>> 'no_convergence' in issues False Decode multiple flag values: >>> flags = [0, 8, 136] # 0, bit 8, bits 8+128 >>> decoded_list = decode_psf_flags(flags) >>> len(decoded_list) 3 >>> decoded_list[0] # No issues [] >>> decoded_list[1] # Convergence issue ['no_convergence'] >>> decoded_list[2] # Multiple issues ['no_convergence', 'fully_masked'] Check for specific issues: >>> issues = decode_psf_flags(136) >>> if 'no_convergence' in issues: ... print("Fit may not have converged") Fit may not have converged >>> if issues: # Any issues present ... print(f"Found {len(issues)} issues: {', '.join(issues)}") Found 2 issues: no_convergence, fully_masked Working with PSF photometry results: >>> import numpy as np >>> from astropy.modeling import models >>> from astropy.table import Table >>> from photutils.psf import (CircularGaussianPRF, PSFPhotometry, ... decode_psf_flags) >>> # Create minimal test data >>> yy, xx = np.mgrid[:21, :21] >>> m1 = CircularGaussianPRF(flux=-10, x_0=10, y_0=10, fwhm=2) >>> m2 = CircularGaussianPRF(flux=10, x_0=3, y_0=3, fwhm=2) >>> m3 = CircularGaussianPRF(flux=10, x_0=21, y_0=21, fwhm=2) >>> data = m1(xx, yy) + m2(xx, yy) + m3(xx, yy) >>> psf_model = CircularGaussianPRF(flux=1, x_0=10, y_0=10, fwhm=2) >>> init_params = Table({'x': (10, 3, 21), 'y': (10, 3, 21), ... 'flux': (1, 10, 10)}) >>> photometry = PSFPhotometry(psf_model, (3, 3)) >>> results = photometry(data, init_params=init_params) >>> issues_list = decode_psf_flags(results['flags']) >>> for i, issues in enumerate(issues_list): ... if issues: ... print(f"Source {i+1}: {', '.join(issues)}") Source 1: negative_flux Source 3: n_pixels_fit_partial, no_covariance, too_few_pixels, \ non_finite_position, non_finite_flux """ # Get flag definitions from centralized source flag_definitions = PSF_FLAGS.flag_dict def _decode_single_flag(flag_value): """ Decode a single integer flag value. """ if not isinstance(flag_value, (int, np.integer)): msg = 'Flag value must be an integer' raise TypeError(msg) if flag_value < 0: msg = 'Flag value must be a non-negative integer' raise ValueError(msg) active_flags = [] for bit_value, description in flag_definitions.items(): if flag_value & bit_value: if return_bit_values: active_flags.append(bit_value) else: active_flags.append(description) return active_flags # Handle both single values and arrays if np.isscalar(flags): return _decode_single_flag(flags) # Convert to numpy array for consistent handling flags_array = np.asarray(flags) if flags_array.ndim == 0: # Handle 0-d arrays (scalar arrays) return _decode_single_flag(flags_array.item()) # Handle 1-d or higher dimensional arrays return [_decode_single_flag(flag) for flag in flags_array.flat] astropy-photutils-3322558/photutils/psf/functional_models.py000066400000000000000000001751371517052111400243560ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Functional PSF models. """ import astropy.units as u import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.modeling.utils import ellipse_extent from astropy.units import UnitsError from scipy.special import erf, j1, jn_zeros __all__ = [ 'AiryDiskPSF', 'CircularGaussianPRF', 'CircularGaussianPSF', 'CircularGaussianSigmaPRF', 'GaussianPRF', 'GaussianPSF', 'MoffatPSF', ] FLOAT_EPSILON = float(np.finfo(np.float32).tiny) GAUSSIAN_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0))) def _gaussian_amplitude(flux, xsigma, ysigma): # output units should match the input flux units if isinstance(xsigma, u.Quantity): xsigma = xsigma.value ysigma = ysigma.value return flux / (2.0 * np.pi * xsigma * ysigma) class GaussianPSF(Fittable2DModel): r""" A 2D Gaussian PSF model. This model is evaluated by sampling the 2D Gaussian at the input coordinates. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. x_fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian along the x axis. y_fwhm : float, optional FWHM of the Gaussian along the y axis. theta : float, optional The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). bbox_factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF Notes ----- The Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{2 \pi \sigma_{x} \sigma_{y}} \exp \left( -a\left(x - x_{0}\right)^{2} - b \left(x - x_{0}\right) \left(y - y_{0}\right) - c \left(y - y_{0}\right)^{2} \right) where :math:`F` denotes the total integrated flux, :math:`(x_{0}, y_{0})` denotes the position of the peak, and :math:`\sigma_{x}` and :math:`\sigma_{y}` denote are the standard deviations along the x and y axes, respectively. .. math:: a = \frac{\cos^{2}{\theta}}{2 \sigma_{x}^{2}} + \frac{\sin^{2}{\theta}}{2 \sigma_{y}^{2}} b = \frac{\sin{2 \theta}}{2 \sigma_{x}^{2}} - \frac{\sin{2 \theta}}{2 \sigma_{y}^{2}} c = \frac{\sin^{2}{\theta}} {2 \sigma_{x}^{2}} + \frac{\cos^{2}{\theta}}{2 \sigma_{y}^{2}} where :math:`\theta` is the rotation angle of the Gaussian. The FWHMs of the Gaussian along the x and y axes are given by: .. math:: \rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}} \rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF() >>> model.x_fwhm.fixed = False >>> model.y_fwhm.fixed = False >>> model.theta.fixed = False By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import GaussianPSF model = GaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1, y_fwhm=5.82, theta=21.7) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') x_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the x axis') y_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the y axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle (optional)'), fixed=True) def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default, theta=theta.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma) @property def x_sigma(self): """ Gaussian sigma (standard deviation) along the x-axis. """ return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y-axis. """ return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, *, factor=5.5): """ Calculate a bounding box defining the limits of the model. The limits are adjusted for rotation. Parameters ---------- factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ a = factor * self.x_sigma b = factor * self.y_sigma dx, dy = ellipse_extent(a, b, self.theta) return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-7.006904852376157, upper=7.006904852376157) } model=GaussianPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-8.9178789030242, upper=8.9178789030242) } model=GaussianPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) cost2 = np.cos(theta) ** 2 sint2 = np.sin(theta) ** 2 sin2t = np.sin(2.0 * theta) xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA xstd2 = xstd ** 2 ystd2 = ystd ** 2 xdiff = x - x_0 ydiff = y - y_0 a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2)) b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2)) c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2)) # output units should match the input flux units if isinstance(xstd, u.Quantity): xstd = xstd.value ystd = ystd.value amplitude = flux / (2 * np.pi * xstd * ystd) return amplitude * np.exp( -(a * xdiff**2) - (b * xdiff * ydiff) - (c * ydiff**2)) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the partial derivatives of the 2D Gaussian function with respect to the parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) cost = np.cos(theta) sint = np.sin(theta) cost2 = cost ** 2 sint2 = sint ** 2 cos2t = np.cos(2.0 * theta) sin2t = np.sin(2.0 * theta) xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA xstd2 = xstd ** 2 ystd2 = ystd ** 2 xstd3 = xstd ** 3 ystd3 = ystd ** 3 xdiff = x - x_0 ydiff = y - y_0 xdiff2 = xdiff ** 2 ydiff2 = ydiff ** 2 a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2)) b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2)) c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2)) amplitude = flux / (2 * np.pi * xstd * ystd) exp = np.exp(-(a * xdiff2) - (b * xdiff * ydiff) - (c * ydiff2)) g = amplitude * exp da_dtheta = sint * cost * ((1.0 / ystd2) - (1.0 / xstd2)) db_dtheta = (cos2t / xstd2) - (cos2t / ystd2) dc_dtheta = -da_dtheta da_dxstd = -cost2 / xstd3 db_dxstd = -sin2t / xstd3 dc_dxstd = -sint2 / xstd3 da_dystd = -sint2 / ystd3 db_dystd = sin2t / ystd3 dc_dystd = -cost2 / ystd3 dg_dflux = g / flux dg_dx_0 = g * ((2.0 * a * xdiff) + (b * ydiff)) dg_dy_0 = g * ((b * xdiff) + (2.0 * c * ydiff)) damp_dxstd = -amplitude / xstd damp_dystd = -amplitude / ystd dexp_dxstd = -exp * (da_dxstd * xdiff2 + db_dxstd * xdiff * ydiff + dc_dxstd * ydiff2) dexp_dystd = -exp * (da_dystd * xdiff2 + db_dystd * xdiff * ydiff + dc_dystd * ydiff2) dg_dxstd = damp_dxstd * exp + amplitude * dexp_dxstd dg_dystd = damp_dystd * exp + amplitude * dexp_dystd # chain rule for change of variables from sigma to fwhm # std => fwhm * GAUSSIAN_FWHM_TO_SIGMA # dstd/dfwhm => GAUSSIAN_FWHM_TO_SIGMA dg_dxfwhm = dg_dxstd * GAUSSIAN_FWHM_TO_SIGMA dg_dyfwhm = dg_dystd * GAUSSIAN_FWHM_TO_SIGMA dg_dtheta = g * (-(da_dtheta * xdiff2 + db_dtheta * xdiff * ydiff + dc_dtheta * ydiff2)) # chain rule for unit change; # theta[rad] => theta[deg] * pi / 180; drad/dtheta = pi / 180 dg_dtheta *= np.pi / 180.0 return [dg_dflux, dg_dx_0, dg_dy_0, dg_dxfwhm, dg_dyfwhm, dg_dtheta] @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: msg = "Units of 'x' and 'y' inputs should match" raise UnitsError(msg) return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'x_fwhm': inputs_unit[self.inputs[0]], 'y_fwhm': inputs_unit[self.inputs[0]], 'theta': u.deg, 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianPSF(Fittable2DModel): r""" A circular 2D Gaussian PSF model. This model is evaluated by sampling the 2D Gaussian at the input coordinates. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF Notes ----- The circular Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{2 \pi \sigma^{2}} \exp \left( {\frac{-(x - x_{0})^{2} - (y - y_{0})^{2}} {2 \sigma^{2}}} \right) where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, and :math:`\sigma` is the standard deviation, respectively. The FWHM of the Gaussian is given by: .. math:: \rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``fwhm`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianPSF >>> model = CircularGaussianPSF() >>> model.fwhm.fixed = False By default, the ``fwhm`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPSF model = CircularGaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fwhm=fwhm.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def sigma(self): """ Gaussian sigma (standard deviation). """ return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, *, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPSF >>> model = CircularGaussianPSF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, fwhm): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ sigma2 = (fwhm * GAUSSIAN_FWHM_TO_SIGMA) ** 2 # output units should match the input flux units sigma2_norm = sigma2 if isinstance(sigma2, u.Quantity): sigma2_norm = sigma2.value amplitude = flux / (2 * np.pi * sigma2_norm) return amplitude * np.exp(-0.5 * ((x - x_0) ** 2 + (y - y_0) ** 2) / sigma2) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, fwhm): """ Calculate the partial derivatives of the 2D Gaussian function with respect to the parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ return GaussianPSF().fit_deriv(x, y, flux, x_0, y_0, fwhm, fwhm, 0.0)[:-2] @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'fwhm': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class GaussianPRF(Fittable2DModel): r""" A 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. x_fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian along the x axis. y_fwhm : float, optional FWHM of the Gaussian along the y axis. theta : float, optional The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). bbox_factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the bounding_box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, CircularGaussianPRF, MoffatPSF Notes ----- The Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left( \frac{x^\prime + 0.5}{\sqrt{2} \sigma_{x}} \right) - {\rm erf} \left( \frac{x^\prime - 0.5}{\sqrt{2} \sigma_{x}} \right) \right] \left[ {\rm erf} \left( \frac{y^\prime + 0.5}{\sqrt{2} \sigma_{y}} \right) - {\rm erf} \left( \frac{y^\prime - 0.5}{\sqrt{2} \sigma_{y}} \right) \right] where :math:`F` is the total integrated flux, :math:`\sigma_{x}` and :math:`\sigma_{y}` are the standard deviations along the x and y axes, respectively, and :math:`{\rm erf}` denotes the error function. .. math:: x^\prime = (x - x_0) \cos(\theta) + (y - y_0) \sin(\theta) y^\prime = -(x - x_0) \sin(\theta) + (y - y_0) \cos(\theta) where :math:`(x_{0}, y_{0})` is the position of the peak and :math:`\theta` is the rotation angle of the Gaussian. The FWHMs of the Gaussian along the x and y axes are given by: .. math:: \rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}} \rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import GaussianPRF >>> model = GaussianPRF() >>> model.x_fwhm.fixed = False >>> model.y_fwhm.fixed = False >>> model.theta.fixed = False By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import GaussianPRF model = GaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1, y_fwhm=5.82, theta=21.7) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') x_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the x axis') y_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the y axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle (optional)'), fixed=True) def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default, theta=theta.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma) @property def x_sigma(self): """ Gaussian sigma (standard deviation) along the x-axis. """ return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y-axis. """ return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, *, factor=5.5): """ Calculate a bounding box defining the limits of the model. The limits are adjusted for rotation. Parameters ---------- factor : float, optional The multiple of the x and y FWHMs used to define the limits. zzzz Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ a = factor * self.x_sigma b = factor * self.y_sigma dx, dy = ellipse_extent(a, b, self.theta) return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import GaussianPRF >>> model = GaussianPRF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-7.006904852376157, upper=7.006904852376157) } model=GaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-8.9178789030242, upper=8.9178789030242) } model=GaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) x_sigma = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA y_sigma = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA dx = x - x_0 dy = y - y_0 cost = np.cos(theta) sint = np.sin(theta) x0 = dx * cost + dy * sint y0 = -dx * sint + dy * cost dpix = 0.5 if isinstance(x0, u.Quantity): dpix <<= x0.unit return (flux / 4.0 * ((erf((x0 + dpix) / (np.sqrt(2) * x_sigma)) - erf((x0 - dpix) / (np.sqrt(2) * x_sigma))) * (erf((y0 + dpix) / (np.sqrt(2) * y_sigma)) - erf((y0 - dpix) / (np.sqrt(2) * y_sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: msg = "Units of 'x' and 'y' inputs should match" raise UnitsError(msg) return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'x_fwhm': inputs_unit[self.inputs[0]], 'y_fwhm': inputs_unit[self.inputs[0]], 'theta': u.deg, 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianPRF(Fittable2DModel): r""" A circular 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPRF, GaussianPSF, CircularGaussianPSF, MoffatPSF Notes ----- The circular Gaussian function is defined as: .. 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 :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma` is the standard deviation of the Gaussian, and :math:`{\rm erf}` denotes the error function. The FWHM of the Gaussian is given by: .. math:: \rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``fwhm`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF() >>> model.fwhm.fixed = False By default, the ``fwhm`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPRF model = CircularGaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fwhm=fwhm.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def sigma(self): """ Gaussian sigma (standard deviation). """ return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, *, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, fwhm): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ x0 = x - x_0 y0 = y - y_0 sigma = fwhm * GAUSSIAN_FWHM_TO_SIGMA dpix = 0.5 if isinstance(x0, u.Quantity): dpix <<= x0.unit return (flux / 4.0 * ((erf((x0 + dpix) / (np.sqrt(2) * sigma)) - erf((x0 - dpix) / (np.sqrt(2) * sigma))) * (erf((y0 + dpix) / (np.sqrt(2) * sigma)) - erf((y0 - dpix) / (np.sqrt(2) * sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'fwhm': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianSigmaPRF(Fittable2DModel): r""" A circular 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. This model is equivalent to `CircularGaussianPRF`, but it is parameterized in terms of the standard deviation (sigma) instead of the full width at half maximum (FWHM). Parameters ---------- 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. sigma : float, optional Width of the Gaussian PSF. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` parent class. See Also -------- GaussianPSF, GaussianPRF, CircularGaussianPSF, CircularGaussianPRF Notes ----- The circular Gaussian function is defined as: .. 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 :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma` is the standard deviation of the Gaussian, and :math:`{\rm erf}` denotes the error function. The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``sigma`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianSigmaPRF >>> model = CircularGaussianSigmaPRF() >>> model.sigma.fixed = False By default, the ``sigma`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianSigmaPRF model = CircularGaussianSigmaPRF(flux=71.4, x_0=24.3, y_0=25.2, sigma=5.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') sigma = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Sigma (standard deviation) of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, sigma=sigma.default, bbox_factor=5.5, **kwargs): super().__init__(sigma=sigma, x_0=x_0, y_0=y_0, flux=flux, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def fwhm(self): """ Gaussian FWHM. """ return self.sigma / GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, *, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, sigma): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The coordinates at which to evaluate the model. flux : float The total flux of the star. x_0, y_0 : float The position of the star. sigma : float The width of the Gaussian PRF. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ dpix = 0.5 if isinstance(x_0, u.Quantity): dpix *= x_0.unit return (flux / 4 * ((erf((x - x_0 + dpix) / (np.sqrt(2) * sigma)) - erf((x - x_0 - dpix) / (np.sqrt(2) * sigma))) * (erf((y - y_0 + dpix) / (np.sqrt(2) * sigma)) - erf((y - y_0 - dpix) / (np.sqrt(2) * sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: msg = "Units of 'x' and 'y' inputs should match" raise UnitsError(msg) return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'sigma': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class MoffatPSF(Fittable2DModel): r""" A 2D Moffat PSF model. This model is evaluated by sampling the 2D Moffat function at the input coordinates. The Moffat profile is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. alpha : float, optional The characteristic radius of the Moffat profile. beta : float, optional The asymptotic power-law slope of the Moffat profile wings at large radial distances. Larger values provide less flux in the profile wings. For large ``beta``, this profile approaches a Gaussian profile. ``beta`` must be greater than 1. If ``beta`` is set to 1, then the Moffat profile is a Lorentz function, whose integral is infinite. For this normalized model, if ``beta`` is set to 1, then the profile will be zero everywhere. bbox_factor : float, optional The multiple of the FWHM used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF Notes ----- The Moffat profile is defined as: .. math:: f(x, y) = F \frac{\beta - 1}{\pi \alpha^2} \left(1 + \frac{\left(x - x_{0}\right)^{2} + \left(y - y_{0}\right)^{2}}{\alpha^{2}}\right)^{-\beta} where :math:`F` is the total integrated flux and :math:`(x_{0}, y_{0})` is the position of the peak. Note that :math:`\beta` must be greater than 1. The FWHM of the Moffat profile is given by: .. math:: \rm{FWHM} = 2 \alpha \sqrt{2^{1 / \beta} - 1} The model is normalized such that, for :math:`\beta > 1`: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``alpha`` and ``beta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import MoffatPSF >>> model = MoffatPSF() >>> model.alpha.fixed = False >>> model.beta.fixed = False By default, the ``alpha`` parameter is bounded to be strictly positive and the ``beta`` parameter is bounded to be greater than 1. References ---------- .. [1] https://en.wikipedia.org/wiki/Moffat_distribution .. [2] https://ui.adsabs.harvard.edu/abs/1969A%26A.....3..455M/abstract .. [3] https://ned.ipac.caltech.edu/level5/Stetson/Stetson2_2_1.html Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import MoffatPSF model = MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') alpha = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Characteristic radius of the Moffat profile') beta = Parameter( default=2, bounds=(1.0 + FLOAT_EPSILON, None), fixed=True, description='Power-law index of the Moffat profile') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, alpha=alpha.default, beta=beta.default, bbox_factor=10.0, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, alpha=alpha, beta=beta, **kwargs) self.bbox_factor = bbox_factor @property def fwhm(self): """ The FWHM of the Moffat profile. """ return 2.0 * self.alpha * np.sqrt(2 ** (1.0 / self.beta) - 1) def _calc_bounding_box(self, *, factor=10.0): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the FWHM used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.fwhm return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import MoffatPSF >>> model = MoffatPSF(x_0=0, y_0=0, alpha=2, beta=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-20.39298114135835, upper=20.39298114135835) y: Interval(lower=-20.39298114135835, upper=20.39298114135835) } model=MoffatPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-14.27508679895084, upper=14.27508679895084) y: Interval(lower=-14.27508679895084, upper=14.27508679895084) } model=MoffatPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, alpha, beta): """ Calculate the value of the 2D Moffat model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. alpha : float, optional The characteristic radius of the Moffat profile. beta : float, optional The asymptotic power-law slope of the Moffat profile wings at large radial distances. Larger values provide less flux in the profile wings. For large ``beta``, this profile approaches a Gaussian profile. ``beta`` must be greater than 1. If ``beta`` is set to 1, then the Moffat profile is a Lorentz function, whose integral is infinite. For this normalized model, if ``beta`` is set to 1, then the profile will be zero everywhere. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ # output units should match the input flux units alpha2 = alpha.copy() if isinstance(alpha, u.Quantity): alpha2 = alpha.value amp = flux * (beta - 1) / (np.pi * alpha2 ** 2) r2 = (x - x_0) ** 2 + (y - y_0) ** 2 return amp * (1 + (r2 / alpha**2)) ** (-beta) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'alpha': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class AiryDiskPSF(Fittable2DModel): r""" A 2D Airy disk PSF model. This model is evaluated by sampling the 2D Airy disk function at the input coordinates. The Airy disk profile is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x-axis. y_0 : float, optional Position of the peak along the y-axis. radius : float, optional The radius of the Airy disk at the first zero. bbox_factor : float, optional The multiple of the FWHM used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, MoffatPSF Notes ----- The Airy disk profile is defined as: .. math:: f(r) = \frac{F}{4 \pi (R / R_z)^2} \left[ \frac{2 J_1\left(\frac{\pi r}{R / R_z}\right)} {\frac{\pi r}{R / R_z}} \right]^2 where :math:`r` is radial distance from the peak .. math:: r = \sqrt{(x - x_0)^2 + (y - y_0)^2} :math:`F` is the total integrated flux, :math:`J_1` is the first order `Bessel function `_ of the first kind, :math:`R` is the input ``radius`` parameter, and :math:`R_z = 1.2196698912665045` is the solution to the equation :math:`J_1(\pi R_z) = 0`. For an optical system, the radius of the first zero represents the limiting angular resolution. The limiting angular resolution is :math:`R_z \, \lambda / D \approx 1.22 \, \lambda / D`, where :math:`\lambda` is the wavelength of the light and :math:`D` is the diameter of the aperture. The full width at half maximum (FWHM) of the Airy disk profile is given by: .. math:: \rm{FWHM} = 1.028993969962188 \, \frac{R}{R_z} = 0.8436659602162364 \, R The model is normalized such that: .. math:: \int_{0}^{2 \pi} \int_{0}^{\infty} f(r) \,r \,dr \,d\theta = \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``radius`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import AiryDiskPSF >>> model = AiryDiskPSF() >>> model.radius.fixed = False By default, the ``radius`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Airy_disk Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.psf import AiryDiskPSF model = AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=5) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots() ax.imshow(data, norm=norm, origin='lower') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') radius = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Radius of the Airy disk at the first zero') _rz = jn_zeros(1, 1)[0] / np.pi def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, radius=radius.default, bbox_factor=10.0, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, radius=radius, **kwargs) self.bbox_factor = bbox_factor @property def fwhm(self): """ The FWHM of the Airy disk profile. """ return 2.0 * 1.616339948310703 * self.radius / self._rz / np.pi def _calc_bounding_box(self, *, factor=10.0): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the FWHM used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.fwhm return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import AiryDiskPSF >>> model = AiryDiskPSF(x_0=0, y_0=0, radius=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-25.30997880648709, upper=25.30997880648709) y: Interval(lower=-25.30997880648709, upper=25.30997880648709) } model=AiryDiskPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-17.71698516454096, upper=17.71698516454096) y: Interval(lower=-17.71698516454096, upper=17.71698516454096) } model=AiryDiskPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, radius): """ Calculate the value of the 2D Airy disk model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. radius : float, optional The radius of the Airy disk at the first zero. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ r = np.sqrt((x - x_0) ** 2 + (y - y_0) ** 2) / (radius / self._rz) if isinstance(r, u.Quantity): # scipy function cannot handle Quantity, so turn into array r = r.to_value(u.dimensionless_unscaled) # Since r can be zero, we have to take care to treat that case # separately so as not to raise a numpy warning z = np.ones(r.shape) rt = np.pi * r[r > 0] z[r > 0] = (2.0 * j1(rt) / rt) ** 2 if isinstance(flux, u.Quantity): # make z a quantity to allow in-place multiplication z <<= u.dimensionless_unscaled normalization = (4.0 / np.pi) * (radius / self._rz) ** 2 if isinstance(normalization, u.Quantity): normalization = normalization.value z *= (flux / normalization) return z @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'radius': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} astropy-photutils-3322558/photutils/psf/gridded_models.py000066400000000000000000000670031517052111400236060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Gridded PSF models. """ import copy import itertools import numpy as np from astropy.io import registry from astropy.modeling import Fittable2DModel, Parameter from astropy.nddata import NDData from astropy.utils.decorators import lazyproperty from scipy.interpolate import RectBivariateSpline from photutils.psf.model_io import (GriddedPSFModelRead, _get_metadata, _read_stdpsf, is_stdpsf, is_webbpsf, stdpsf_reader, webbpsf_reader) from photutils.psf.model_plotting import (_ModelGridPlotter, _plot_grid_docstring) from photutils.utils._parameters import as_pair __all__ = ['GriddedPSFModel', 'STDPSFGrid'] __doctest_skip__ = ['STDPSFGrid'] class GriddedPSFModel(Fittable2DModel): """ A model for a grid of 2D ePSF models. The ePSF models are defined at fiducial detector locations and are (x, y) detector position. The fiducial detector locations are must form a rectangular grid. The model has three model parameters: an image intensity scaling factor (``flux``) which is applied to the input 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 evaluated. 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 length of the x and y axes must both be at least 4. ``N_psf`` must not be 2 or 3. All elements of the input image data must be finite. The PSF peak is assumed to be located at the center of the input image. Please see the Notes section below for details on the normalization of the input image data. If ``N_psf`` is 1, the model will be evaluated using the single ePSF image at every (x, y) position. This is equivalent to using the `~photutils.psf.ImagePSF` model with the single ePSF. The meta attribute must be dictionary 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 ``nddata.data[i]``. The grid positions must form a rectangular grid. * ``'oversampling'``: The integer oversampling factor(s) of the input ePSF images. 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. flux : float, optional The flux scaling factor for the model. This is the total flux of the source, assuming the input ePSF images are properly normalized. x_0, y_0 : float, optional The (x, y) position of the PSF peak in the image in the output coordinate grid on which the model is evaluated. fill_value : float, optional The value to use for points outside the input pixel grid. The default is 0.0. Methods ------- read(*args, **kwargs) Class method to create a `GriddedPSFModel` instance from a STDPSF FITS file. This method uses :func:`~photutils.psf.stdpsf_reader` with the provided parameters. Notes ----- The fitted PSF model flux represents the total flux of the source, assuming the input image was properly normalized. This flux is determined as a multiplicative scale factor applied to the input image PSF, after accounting for any oversampling. Theoretically, the sum of all values in the PSF image over an infinite grid should equal 1.0 (assuming no oversampling). However, when the PSF is represented over a finite region, the sum of the values may be less than 1.0. For oversampled PSF images, the normalization should be adjusted so that the sum of the array values equals the product of the oversampling factors (e.g., oversampling squared if the oversampling is the same along both axes). If the input image only covers a finite region of the PSF, the sum may again be less than the product of the oversampling factors. Correction factors based on the encircled or ensquared energy of the PSF can be used to estimate the proper scaling for the finite region of the input PSF image and ensure correct flux normalization. Internally, the grid of ePSFs will be arranged and stored such that it is sorted first by the y reference pixel coordinate and then by the x reference pixel coordinate. """ 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._data, self._grid_xypos = self._define_grid(nddata) self._meta = nddata.meta.copy() # _meta to avoid the meta descriptor self._oversampling = as_pair('oversampling', nddata.meta['oversampling'], lower_bound=(0, 0)) self.fill_value = fill_value self._xgrid = np.unique(self.grid_xypos[:, 0]) # sorted self._ygrid = np.unique(self.grid_xypos[:, 1]) # sorted self.meta['grid_shape'] = (len(self._ygrid), len(self._xgrid)) self._interpolator = {} super().__init__(flux, x_0, y_0) @staticmethod def _validate_data(data): """ Validate the input ePSF data. Parameters ---------- data : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Raises ------ TypeError If the input data is not an NDData instance. ValueError If the input data is not a 3D numpy ndarray or if the input data contains NaNs or infs. """ if not isinstance(data, NDData): msg = 'data must be an NDData instance' raise TypeError(msg) if data.data.ndim != 3: msg = 'The NDData data attribute must be a 3D numpy ndarray' raise ValueError(msg) if not np.all(np.isfinite(data.data)): msg = 'All elements of input data must be finite' raise ValueError(msg) if data.data.shape[0] in (2, 3): msg = 'The number of ePSFs must not be 2 or 3' raise ValueError(msg) # this is required by RectBivariateSpline for kx=3, ky=3 if np.any(np.array(data.data.shape[1:]) < 4): msg = ('The length of the PSF x and y axes must both be at ' 'least 4') raise ValueError(msg) if 'oversampling' not in data.meta: msg = "'oversampling' must be in the nddata meta dictionary" raise ValueError(msg) @staticmethod def _is_rectangular_grid(grid_xypos): """ Determine if the given (x, y) pixel positions form an axis- aligned rectangular grid. Spacing does not need to be uniform along x or y, but there must be at least two unique x and y values, and all combinations of x and y must be present. Parameters ---------- grid_xypos : (N, 2) array of (x, y) pairs The fiducial (x, y) positions of the ePSFs. Returns ------- result : bool Returns `True` if the input ``grid_xypos`` forms a rectangular grid. """ if len(grid_xypos) < 4: # pragma: no cover return False x_vals = np.unique(grid_xypos[:, 0]) # sorted y_vals = np.unique(grid_xypos[:, 1]) # sorted # Must have at least 2 unique x and y values to form a 2D grid if len(x_vals) < 2 or len(y_vals) < 2: return False expected_points = {(x, y) for x in x_vals for y in y_vals} return set(map(tuple, grid_xypos)) == expected_points def _validate_grid(self, data): """ Validate the input ePSF grid. Parameters ---------- data : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Raises ------ ValueError If the input grid_xypos does not form a rectangular grid. """ try: grid_xypos = np.array(data.meta['grid_xypos']) except KeyError as exc: msg = "'grid_xypos' must be in the nddata meta dictionary" raise ValueError(msg) from exc if len(grid_xypos) != data.data.shape[0]: msg = ('The length of grid_xypos must match the number of ' 'input ePSFs') raise ValueError(msg) if len(grid_xypos) != 1 and not self._is_rectangular_grid(grid_xypos): msg = 'grid_xypos must form a rectangular grid' raise ValueError(msg) def _define_grid(self, nddata): """ Sort the input ePSF data into a rectangular 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. """ self._validate_data(nddata) self._validate_grid(nddata) grid_xypos = np.array(nddata.meta['grid_xypos']) # sort by y and then by x (last key is primary) idx = np.lexsort((grid_xypos[:, 0], grid_xypos[:, 1])) return nddata.data[idx], grid_xypos[idx] def __str__(self): keywords = [] keys = ('STDPSF', 'instrument', 'detector', 'filter') for key in keys: if key in self.meta: name = key.capitalize() if key != 'STDPSF' else key keywords.append((name, self.meta[key])) keywords.extend([('Number of PSFs', len(self.grid_xypos)), ('Grid shape', self.meta['grid_shape']), ('Grid positions', self.grid_xypos.tolist()), ('PSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', self.oversampling.tolist()), ('Fill Value', self.fill_value)]) return self._format_str(keywords=keywords) def __repr__(self): kwargs = {'oversampling': self.oversampling.tolist(), 'fill_value': self.fill_value} return self._format_repr(args=[], kwargs=kwargs) @property def data(self): """ The 3D array of ePSFs. The shape is ``(N_psf, ePSF_ny, ePSF_nx)``. """ return self._data @property def grid_xypos(self): """ The (x, y) positions of the ePSFs. 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 ``nddata.data[i]``. The grid positions must form a rectangular grid. """ return self._grid_xypos 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 the model attributes, including the ePSF grid data. Returns ------- result : `GriddedPSFModel` A copy of this model with only the model parameters copied. """ 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. Returns ------- result : `GriddedPSFModel` A deep copy of this model. """ return copy.deepcopy(self) @property def oversampling(self): """ The integer oversampling factor(s) of the input ePSF images. 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. """ return self._oversampling @oversampling.setter def oversampling(self, value): """ Set the oversampling factor(s) of the input ePSF images. Parameters ---------- value : int or tuple of int The integer oversampling factor(s) of the input ePSF images. 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. """ self._oversampling = as_pair('oversampling', value, lower_bound=(0, 0)) def _calc_bounding_box(self): """ Set a bounding box defining the limits of the model. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ dy, dx = np.array(self.data.shape[1:]) / 2 / self.oversampling return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from itertools import product >>> import numpy as np >>> from astropy.nddata import NDData >>> from photutils.psf import GaussianPSF, GriddedPSFModel >>> psfs = [] >>> yy, xx = np.mgrid[0:101, 0:101] >>> for i in range(16): ... theta = np.deg2rad(i * 10.0) ... gmodel = GaussianPSF(flux=1, x_0=50, y_0=50, x_fwhm=10, ... y_fwhm=5, theta=theta) ... psfs.append(gmodel(xx, yy)) >>> xgrid = [0, 40, 160, 200] >>> ygrid = [0, 60, 140, 200] >>> meta = {} >>> meta['grid_xypos'] = list(product(xgrid, ygrid)) >>> meta['oversampling'] = 4 >>> nddata = NDData(psfs, meta=meta) >>> model = GriddedPSFModel(nddata, flux=1, x_0=0, y_0=0) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-12.625, upper=12.625) y: Interval(lower=-12.625, upper=12.625) } model=GriddedPSFModel(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box() @lazyproperty def origin(self): """ A 1D `~numpy.ndarray` (x, y) pixel coordinates within the model's 2D image of the origin of the coordinate system. """ xyorigin = (np.array(self.data.shape) - 1) / 2 return xyorigin[::-1] @lazyproperty def _interp_xyidx(self): """ The x and y indices for the interpolator. """ xidx = np.arange(self.data.shape[2]) yidx = np.arange(self.data.shape[1]) return xidx, yidx def _calc_interpolator(self, grid_idx): """ Calculate the `~scipy.interpolate.RectBivariateSpline` interpolator for an input ePSF image at the given reference (x, y) position. The resulting interpolator is cached in the `_interpolator` dictionary for reuse. Parameters ---------- grid_idx : int The index of the ePSF image in the reference grid. Returns ------- interp : `~scipy.interpolate.RectBivariateSpline` The interpolator for the input ePSF image. """ # check if the interpolator is already cached if grid_idx in self._interpolator: return self._interpolator[grid_idx] # RectBivariateSpline expects the data to be in (x, y) axis order data = self.data[grid_idx] interp = RectBivariateSpline(*self._interp_xyidx, data.T, kx=3, ky=3, s=0) # cache the interpolator for reuse self._interpolator[grid_idx] = interp return interp def _find_bounding_points(self, x, y): """ Find the grid indices and reference (x, y) points of the four bounding grid points for a given (x, y) coordinate. If the point is outside the grid, the nearest grid points are selected. The input grid points do not need to be sorted. Parameters ---------- x, y : float The (x_0, y_0) position of the model. Returns ------- grid_idx : `~numpy.ndarray` The indices of the four bounding points in the sorted grid. The order is lower-left, lower-right, upper-left, upper-right. grid_xy : `~numpy.ndarray` The x and y coordinates of the four bounding points. The order is left, right, bottom, top. """ # Find the insertion indices for x and y in the sorted grids xidx = np.searchsorted(self._xgrid, x) - 1 yidx = np.searchsorted(self._ygrid, y) - 1 # Clip the indices to valid ranges xidx = np.clip(xidx, 0, len(self._xgrid) - 2) yidx = np.clip(yidx, 0, len(self._ygrid) - 2) # Find the four bounding points in the sorted grid # (x0, y0) is the lower-left corner of the grid # (x1, y1) is the upper-right corner of the grid x0, x1 = self._xgrid[xidx], self._xgrid[xidx + 1] y0, y1 = self._ygrid[yidx], self._ygrid[yidx + 1] # Find the indices of these points in grid_xypos xcoords, ycoords = self.grid_xypos.T lower_left = np.where((xcoords == x0) & (ycoords == y0))[0][0] lower_right = np.where((xcoords == x1) & (ycoords == y0))[0][0] upper_left = np.where((xcoords == x0) & (ycoords == y1))[0][0] upper_right = np.where((xcoords == x1) & (ycoords == y1))[0][0] grid_idx = np.array((lower_left, lower_right, upper_left, upper_right)) grid_xy = np.array((x0, x1, y0, y1)) return grid_idx, grid_xy def _calc_bilinear_weights(self, xi, yi, grid_xy): """ Calculate the bilinear interpolation weights for a given (xi, yi) coordinate and the four bounding grid points. Parameters ---------- xi, yi : float The (x_0, y_0) position of the model. grid_xy : `~numpy.ndarray` The x and y coordinates of the four bounding points. The order is left, right, bottom, top. Returns ------- weights : `~numpy.ndarray` The bilinear interpolation weights for the four bounding points. The order is lower-left, lower-right, upper-left, upper-right. """ x0, x1, y0, y1 = grid_xy xi = np.clip(xi, x0, x1) yi = np.clip(yi, y0, y1) norm = (x1 - x0) * (y1 - y0) # lower-left, lower-right, upper-left, upper-right return np.array([(x1 - xi) * (y1 - yi), (xi - x0) * (y1 - yi), (x1 - xi) * (yi - y0), (xi - x0) * (yi - y0)]) / norm def _calc_model_values(self, x_0, y_0, xi, yi): """ Calculate the ePSF model at a given (x_0, y_0) model coordinate and the input (xi, yi) coordinate. Parameters ---------- x_0, y_0 : float The (x, y) position of the model. xi, yi : float The input (x, y) coordinates at which the model is evaluated. Returns ------- result : float or `~numpy.ndarray` The interpolated ePSF model at the input (x_0, y_0) coordinate. """ grid_idx, grid_xy = self._find_bounding_points(x_0, y_0) interpolators = np.array([self._calc_interpolator(gidx) for gidx in grid_idx]) weights = self._calc_bilinear_weights(x_0, y_0, grid_xy) idx = np.where(weights != 0) interpolators = interpolators[idx] weights = weights[idx] result = 0 for interp, weight in zip(interpolators, weights, strict=True): result += interp(xi, yi, grid=False) * weight return result def evaluate(self, x, y, flux, x_0, y_0): """ Calculate the ePSF model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or `~numpy.ndarray` The x and y positions at which to evaluate the model. flux : float The flux scaling factor for the model. x_0, y_0 : float The (x, y) position of the model. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ if x.ndim > 2: msg = 'x and y must be 1D or 2D' raise ValueError(msg) # the base Model.__call__() method converts scalar inputs to # size-1 arrays before calling evaluate(), but we need scalar # values for the interpolator if not np.isscalar(x_0): x_0 = x_0[0] if not np.isscalar(y_0): y_0 = y_0[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) xi += self.origin[0] yi += self.origin[1] if self.data.shape[0] == 1: # if there is only one ePSF, we do not need to perform # the bilinear interpolation evaluated_model = flux * self._calc_interpolator(0)(xi, yi, grid=False) else: evaluated_model = flux * self._calc_model_values(x_0, y_0, xi, yi) if self.fill_value is not None: # set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation; these bounds match the # RegularGridInterpolator bounds ny, nx = self.data.shape[1:] invalid = (xi < 0) | (xi > nx - 1) | (yi < 0) | (yi > ny - 1) evaluated_model[invalid] = self.fill_value return evaluated_model @_plot_grid_docstring def plot_grid(self, *, ax=None, vmax_scale=None, peak_norm=False, deltas=False, cmap='viridis', dividers=True, divider_color='darkgray', divider_ls='-', figsize=None): plotter = _ModelGridPlotter(self) return plotter.plot_grid(ax=ax, vmax_scale=vmax_scale, peak_norm=peak_norm, deltas=deltas, cmap=cmap, dividers=dividers, divider_color=divider_color, divider_ls=divider_ls, figsize=figsize) class STDPSFGrid: """ 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 -------- >>> from photutils.psf import STDPSFGrid >>> psfgrid = STDPSFGrid('STDPSF_ACSWFC_F814W.fits') >>> fig = 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, 0)) 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 @_plot_grid_docstring def plot_grid(self, *, ax=None, vmax_scale=None, peak_norm=False, deltas=False, cmap='viridis', dividers=True, divider_color='darkgray', divider_ls='-', figsize=None): plotter = _ModelGridPlotter(self) return plotter.plot_grid(ax=ax, vmax_scale=vmax_scale, peak_norm=peak_norm, deltas=deltas, cmap=cmap, dividers=dividers, divider_color=divider_color, divider_ls=divider_ls, figsize=figsize) 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 PSFs', len(self.grid_xypos)), ('PSF 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) astropy-photutils-3322558/photutils/psf/groupers.py000066400000000000000000000330101517052111400224760ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing grouping of stars. """ from collections import defaultdict import numpy as np from astropy.utils import lazyproperty from scipy.cluster.hierarchy import fclusterdata from photutils.aperture import CircularAperture from photutils.utils import make_random_cmap from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._repr import make_repr __all__ = ['SourceGrouper', 'SourceGroups'] class SourceGroups: """ Container for source grouping results with analysis methods. This class stores the results of grouping sources and provides methods to analyze and query the grouping. Parameters ---------- x, y : 1D float `~numpy.ndarray` The 1D arrays of the x and y coordinates of the sources. groups : 1D int `~numpy.ndarray` A 1D array of the group IDs, in the same order as the input x and y coordinates. Attributes ---------- x : `~numpy.ndarray` The x coordinates of the sources. y : `~numpy.ndarray` The y coordinates of the sources. groups : `~numpy.ndarray` The group IDs for each source. n_sources : int Total number of sources. n_groups : int Total number of groups. See Also -------- SourceGrouper Examples -------- Create a SourceGroups object: >>> from photutils.psf import SourceGroups >>> import numpy as np >>> x = np.array([10, 15, 50, 55, 100]) >>> y = np.array([20, 25, 60, 65, 90]) >>> groups = np.array([1, 1, 2, 2, 3]) >>> source_groups = SourceGroups(x, y, groups) >>> print(source_groups) Access properties of the SourceGroups object: >>> print(f'Number of groups: {source_groups.n_groups}') Number of groups: 3 >>> source_groups.size_map # doctest: +SKIP {1: 2, 2: 2, 3: 1} >>> source_groups.sizes array([2, 2, 2, 2, 1]) >>> source_groups.group_centers # doctest: +SKIP {1: (12.5, 22.5), 2: (52.5, 62.5), 3: (100.0, 90.0)} >>> x_group1, y_group1 = source_groups.get_group_sources(1) >>> print(x_group1, y_group1) [10 15] [20 25] """ def __init__(self, x, y, groups): self.x = np.asarray(x) self.y = np.asarray(y) self.groups = np.asarray(groups) if self.x.shape != self.y.shape or self.x.shape != self.groups.shape: msg = 'x, y, and groups must have the same shape' raise ValueError(msg) self.n_sources = len(self.groups) unique_groups, counts = np.unique(self.groups, return_counts=True) self._unique_groups = unique_groups self._group_counts = counts self.n_groups = len(unique_groups) def __repr__(self): params = ['n_sources', 'n_groups'] return make_repr(self, params, brackets=True) def __len__(self): """ Return the number of sources. """ return self.n_sources @lazyproperty def size_map(self): """ Mapping of group ID to group size. Returns ------- size_map : dict A dictionary where keys are group IDs and values are the corresponding group sizes. """ return dict(zip(self._unique_groups.tolist(), self._group_counts.tolist(), strict=True)) @lazyproperty def sizes(self): """ Size of each group for each source. Returns ------- group_sizes : 1D int `~numpy.ndarray` A 1D array of the group sizes, in the same order as the sources. Each element indicates how many sources are in the same group as the corresponding source. """ return np.array([self.size_map[group] for group in self.groups]) @lazyproperty def group_centers(self): """ Centroid coordinates of each group. Returns ------- group_centers : dict A dictionary where keys are group IDs and values are tuples of (x_center, y_center) representing the centroid of each group. """ group_centers = {} for group_id in self._unique_groups.tolist(): mask = self.groups == group_id x_center = np.mean(self.x[mask]).item() y_center = np.mean(self.y[mask]).item() group_centers[group_id] = (x_center, y_center) return group_centers def get_group_sources(self, group_id): """ Get the coordinates of all sources in a specific group. Parameters ---------- group_id : int The group ID to retrieve sources for. Returns ------- x, y : `~numpy.ndarray` Arrays of x and y coordinates for all sources in the specified group. """ if group_id not in self.groups: msg = f'Group ID {group_id} not found in groups' raise ValueError(msg) mask = self.groups == group_id return self.x[mask], self.y[mask] def plot(self, radius, *, ax=None, cmap=None, seed=0, label_groups=False, label_kwargs=None, label_offset=(0, 0), **kwargs): """ Plot circular apertures around sources, color-coded by group. Parameters ---------- radius : float The radius of the circles to plot around each source (in pixels). ax : `~matplotlib.axes.Axes`, optional The matplotlib axes on which to plot. If None, uses the current axes. cmap : `~matplotlib.colors.Colormap` or str, optional The colormap to use for group colors. If None, a random colormap is generated. seed : int, optional Random seed for generating the colormap if ``cmap`` is None. label_groups : bool, optional Whether to label each group with its group ID at the group center. label_kwargs : dict, optional Keyword arguments passed to ``ax.text`` for plotting group ID labels. label_offset : tuple of float, optional Offset (dx, dy) in pixels for positioning labels relative to group centers. Positive values move right/up, negative values move left/down. Default is (0, 0). **kwargs Additional keyword arguments passed to `~photutils.aperture.CircularAperture.plot`. Returns ------- ax : `~matplotlib.axes.Axes` The matplotlib axes object. """ import matplotlib.pyplot as plt from matplotlib import colormaps if ax is None: ax = plt.gca() if cmap is None: cmap = make_random_cmap(n_colors=self.n_groups, seed=seed) elif isinstance(cmap, str): cmap = colormaps[cmap] # Set default label kwargs if label_kwargs is None: label_kwargs = {'ha': 'center', 'va': 'center', 'zorder': 10} # Get label offset label_dx, label_dy = label_offset for i, group_id in enumerate(self._unique_groups): mask = self.groups == group_id xypos = zip(self.x[mask], self.y[mask], strict=True) ap = CircularAperture(xypos, r=radius) color = cmap.colors[i] if hasattr(cmap, 'colors') else cmap(i) ap.plot(ax=ax, color=color, **kwargs) if label_groups: # Add group ID label with offset x_center, y_center = self.group_centers[group_id] label_x = x_center + label_dx label_y = y_center + label_dy ax.text(label_x, label_y, f'{group_id}', color=color, **label_kwargs) return ax 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. See Also -------- SourceGroups Examples -------- Create a SourceGrouper with a minimum separation of 10 pixels: >>> from photutils.psf import SourceGrouper >>> import numpy as np >>> grouper = SourceGrouper(min_separation=10) Group sources and get group IDs as an array (default behavior): >>> x = np.array([10, 15, 50, 55, 100]) >>> y = np.array([20, 25, 60, 65, 90]) >>> group_ids = grouper(x, y) >>> print(group_ids) [1 1 2 2 3] Optionally, get a SourceGroups object with additional analysis methods: >>> groups = grouper(x, y, return_groups_object=True) >>> print(groups) Access properties of the SourceGroups object: >>> print(f'Number of groups: {groups.n_groups}') Number of groups: 3 >>> groups.size_map # doctest: +SKIP {1: 2, 2: 2, 3: 1} Retrieve the (x, y) positions of sources from a specific group: >>> x_group1, y_group1 = groups.get_group_sources(1) >>> print(x_group1, y_group1) [10 15] [20 25] """ def __init__(self, min_separation): self.min_separation = min_separation def __repr__(self): return make_repr(self, 'min_separation') def _compute_groups(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 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. """ x = np.atleast_1d(x) y = np.atleast_1d(y) if x.shape != y.shape: msg = (f'x and y must have the same shape, got x.shape={x.shape} ' f'and y.shape={y.shape}') raise ValueError(msg) if x.shape == (0,): msg = 'x and y must not be empty' raise ValueError(msg) if not np.isfinite(x).all(): msg = 'x coordinates must be finite (no NaN or inf values)' raise ValueError(msg) if not np.isfinite(y).all(): msg = 'y coordinates must be finite (no NaN or inf values)' raise ValueError(msg) # single source forms its own group if x.shape == (1,): return np.array([1]) # Prepare coordinate pairs for hierarchical clustering coordinates = np.transpose((x, y)) cluster_labels = fclusterdata(coordinates, t=self.min_separation, criterion='distance') # Reorder cluster labels to start from 1 and increase # sequentially (this matches the behavior of DBSCAN and other # algorithms). Use defaultdict for efficient mapping by order of # first appearance. mapping = defaultdict(lambda: len(mapping) + 1) return np.array([mapping[label] for label in cluster_labels]) @deprecated_positional_kwargs(since='3.0', until='4.0') def __call__(self, x, y, return_groups_object=False): """ 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 coordinates of the sources. return_groups_object : bool, optional If `False` (default), return a 1D array of group IDs. If `True`, return a `SourceGroups` object containing the grouping results along with analysis methods. Returns ------- result : `~numpy.ndarray` or `SourceGroups` If ``return_groups_object=False`` (default), returns a 1D integer array of group IDs for each source, in the same order as the input coordinates. If ``return_groups_object=True``, returns a `SourceGroups` object containing the grouping results. The object provides: - ``groups`` : array of group IDs for each source - ``n_sources`` : total number of sources - ``n_groups`` : total number of groups - ``sizes`` : group size for each source - ``group_centers`` : centroid coordinates for each group - ``get_group_sources(group_id)`` : retrieve sources in a specific group - ``plot()`` : visualize the grouping with color-coded apertures Examples -------- Get group IDs as an array (default behavior): >>> from photutils.psf import SourceGrouper >>> import numpy as np >>> x = np.array([10, 15, 50]) >>> y = np.array([20, 25, 60]) >>> grouper = SourceGrouper(min_separation=10) >>> group_ids = grouper(x, y) >>> print(group_ids) [1 1 2] Get a SourceGroups object with additional analysis methods: >>> groups = grouper(x, y, return_groups_object=True) >>> print(groups.n_groups) 2 >>> print(groups.groups) [1 1 2] """ groups = self._compute_groups(x, y) if return_groups_object: return SourceGroups(x, y, groups) return groups astropy-photutils-3322558/photutils/psf/image_models.py000066400000000000000000000327261517052111400232720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Image-based PSF models. """ import copy import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.utils.decorators import lazyproperty from scipy.interpolate import RectBivariateSpline from photutils.utils._parameters import as_pair __all__ = ['ImagePSF'] class ImagePSF(Fittable2DModel): """ A model for a 2D image PSF. This class takes 2D image data and computes the values of the model at arbitrary locations, including fractional pixel positions, within the image using spline interpolation provided by :py:class:`~scipy.interpolate.RectBivariateSpline`. The model has three model parameters: an image intensity scaling factor (``flux``) which is applied to the input 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 evaluated. Parameters ---------- data : 2D `~numpy.ndarray` Array containing the 2D image. The length of the x and y axes must both be at least 4. All elements of the input image data must be finite. By default, the PSF peak is assumed to be located at the center of the input image (see the ``origin`` keyword). Please see the Notes section below for details on the normalization of the input image data. flux : float, optional The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of a feature in the image in the output coordinate grid on which the model is evaluated. Typically, this refers to the position of the PSF peak, which is assumed to be located at the center of the input image (see the ``origin`` keyword). origin : tuple of 2 float or None, optional The ``(x, y)`` coordinate with respect to the input image data array that represents the reference pixel of the input data. The reference ``origin`` pixel will be placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate system on which the model is evaluated. Most typically, the input PSF should be centered in the input image, and thus the origin should be set to the central pixel of the ``data`` array. If the origin is set to `None`, then the origin will be set to the center of the ``data`` array (``(npix - 1) / 2.0``). oversampling : int or array_like (int), optional The integer oversampling factor(s) of the input PSF image. 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. fill_value : float, optional The value to use for points outside the input pixel grid. The default is 0.0. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GriddedPSFModel : A model for a grid of ePSF models. Notes ----- The fitted PSF model flux represents the total flux of the source, assuming the input image was properly normalized. This flux is determined as a multiplicative scale factor applied to the input image PSF, after accounting for any oversampling. Theoretically, the sum of all values in the PSF image over an infinite grid should equal 1.0 (assuming no oversampling). However, when the PSF is represented over a finite region, the sum of the values may be less than 1.0. For oversampled PSF images, the normalization should be adjusted so that the sum of the array values equals the product of the oversampling factors (e.g., oversampling squared if the oversampling is the same along both axes). If the input image only covers a finite region of the PSF, the sum may again be less than the product of the oversampling factors. Correction factors based on the encircled or ensquared energy of the PSF can be used to estimate the proper scaling for the finite region of the input PSF image and ensure correct flux normalization. Examples -------- In this simple example, we create a PSF image model from a Circular Gaussian PSF. In this case, one should use the `CircularGaussianPSF` model directly as a PSF model. However, this example demonstrates how to create an image PSF model from an input image. .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPSF, ImagePSF gaussian_psf = CircularGaussianPSF(x_0=12, y_0=12, fwhm=3.2) yy, xx = np.mgrid[:25, :25] psf_data = gaussian_psf(xx, yy) psf_model = ImagePSF(psf_data, x_0=12, y_0=12, flux=10) data = psf_model(xx, yy) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ flux = Parameter(default=1, description='Intensity scaling factor of the image.') x_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the x axis')) y_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the y axis')) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, origin=None, oversampling=1, fill_value=0.0, **kwargs): self._validate_data(data) self.data = data self.origin = origin self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 0)) self.fill_value = fill_value super().__init__(flux, x_0, y_0, **kwargs) @staticmethod def _validate_data(data): if not isinstance(data, np.ndarray): msg = 'Input data must be a 2D numpy array' raise TypeError(msg) if data.ndim != 2: msg = 'Input data must be a 2D numpy array' raise ValueError(msg) if not np.all(np.isfinite(data)): msg = 'All elements of input data must be finite' raise ValueError(msg) # this is required by RectBivariateSpline for kx=3, ky=3 if np.any(np.array(data.shape) < 4): msg = 'The length of the x and y axes must both be at least 4' raise ValueError(msg) def __str__(self): keywords = [('PSF shape (oversampled pixels)', self.data.shape), ('Origin', self.origin.tolist()), ('Oversampling', self.oversampling.tolist()), ('Fill Value', self.fill_value), ] return self._format_str(keywords=keywords) def __repr__(self): kwargs = {'origin': self.origin.tolist(), 'oversampling': self.oversampling.tolist(), 'fill_value': self.fill_value} return self._format_repr(kwargs=kwargs) 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 image data, which may be 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 the model attributes, including the PSF image data. Returns ------- result : `ImagePSF` A copy of this model with only the model parameters copied. """ 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. Returns ------- result : `ImagePSF` A deep copy of this model. """ return copy.deepcopy(self) @property def shape(self): """ The shape of the (oversampled) PSF data array. Returns ------- shape : tuple The shape of the (oversampled) PSF data array. """ return self.data.shape @property def origin(self): """ A 1D `~numpy.ndarray` (x, y) pixel coordinates within the model's 2D image of the origin of the coordinate system. The reference ``origin`` pixel will be placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate system on which the model is evaluated. Most typically, the input PSF should be centered in the input image, and thus the origin should be set to the central pixel of the ``data`` array. If the origin is set to `None`, then the origin will be set to the center of the ``data`` array (``(npix - 1) / 2.0``). """ return self._origin @origin.setter def origin(self, origin): if origin is None: origin = (np.array(self.data.shape) - 1.0) / 2.0 origin = origin[::-1] # flip to (x, y) order else: origin = np.asarray(origin) if origin.ndim != 1 or len(origin) != 2: msg = 'origin must be 1D and have 2-elements' raise ValueError(msg) if not np.all(np.isfinite(origin)): msg = 'All elements of origin must be finite' raise ValueError(msg) self._origin = origin @lazyproperty def interpolator(self): """ The interpolating spline function. The interpolator is computed with a 3rd-degree `~scipy.interpolate.RectBivariateSpline` (kx=3, ky=3, s=0) using the input image data. The interpolator is used to evaluate the model at arbitrary locations, including fractional pixel positions. Notes ----- This property can be overridden in a subclass to define custom interpolators. """ x = np.arange(self.data.shape[1]) y = np.arange(self.data.shape[0]) # RectBivariateSpline expects the data to be in (x, y) axis order return RectBivariateSpline(x, y, self.data.T, kx=3, ky=3, s=0) def _calc_bounding_box(self): """ Set a bounding box defining the limits of the model. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ dy, dx = np.array(self.data.shape) / 2 / self.oversampling # apply the origin shift # if origin is None, the origin is set to the center of the # image and the shift is 0 xshift = np.array(self.data.shape[1] - 1) / 2 - self.origin[0] yshift = np.array(self.data.shape[0] - 1) / 2 - self.origin[1] xshift /= self.oversampling[1] yshift /= self.oversampling[0] return ((self.y_0 - dy + yshift, self.y_0 + dy + yshift), (self.x_0 - dx + xshift, self.x_0 + dx + xshift)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import ImagePSF >>> psf_data = np.arange(30, dtype=float).reshape(5, 6) >>> psf_data /= np.sum(psf_data) >>> model = ImagePSF(psf_data, flux=1, x_0=0, y_0=0) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-3.0, upper=3.0) y: Interval(lower=-2.5, upper=2.5) } model=ImagePSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box() def evaluate(self, x, y, flux, x_0, y_0): """ Calculate the value of the image model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of the feature in the image in the output coordinate grid on which the model is evaluated. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ xi = self.oversampling[1] * (np.asarray(x, dtype=float) - x_0) yi = self.oversampling[0] * (np.asarray(y, dtype=float) - y_0) xi += self._origin[0] yi += self._origin[1] evaluated_model = flux * self.interpolator(xi, yi, grid=False) if self.fill_value is not None: # set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation; these bounds match the # RegularGridInterpolator bounds ny, nx = self.data.shape invalid = (xi < 0) | (xi > nx - 1) | (yi < 0) | (yi > ny - 1) evaluated_model[invalid] = self.fill_value return evaluated_model astropy-photutils-3322558/photutils/psf/iterative.py000066400000000000000000001000561517052111400226310ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing iterative PSF-fitting photometry. """ import warnings from copy import deepcopy import numpy as np from astropy.nddata import NDData from astropy.table import QTable, vstack from photutils.psf._components import (_make_model_image_docstring, _make_residual_image_docstring, _ModelImageMaker) from photutils.psf.flags import decode_psf_flags from photutils.psf.photometry import PSFPhotometry from photutils.psf.utils import _create_call_docstring from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._repr import make_repr from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['IterativePSFPhotometry'] class IterativePSFPhotometry: """ 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 sources are very close to bright sources 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 initial center of a source 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 greater than or equal to 3. 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 sources in an image. This is a required input for `IterativePSFPhotometry`. 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 sources. Typically, grouped sources are those that overlap with their neighbors. Sources 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 ``group_warning_threshold`` sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. If `None`, then the default `astropy.modeling.fitting.TRFLSQFitter` is used. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. The value can be increased if the fit is not converging for sources. This parameter is passed to the ``fitter`` if it supports the ``maxiter`` parameter and ignored otherwise. xy_bounds : `None`, float, or 2-tuple of float, optional The maximum distance in pixels that a fitted source can be from the initial (x, y) position. If a single float, then the same maximum distance is used for both x and y. If a 2-tuple of floats, then the distances are in ``(x, y)`` order. If `None`, then no bounds are applied. Either value can also be `None` to indicate no bound along that axis. 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. aperture_radius : float, optional The radius of the circular aperture used to estimate the initial flux of each source. This is a required input for `IterativePSFPhotometry`. If `None`, then the initial flux values must be provided in the ``init_params`` table. The aperture radius must be a strictly positive scalar. If initial flux values are present in the ``init_params`` table, they will override this keyword *only for the first iteration*. local_bkg_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). group_warning_threshold : int, optional The maximum number of sources in a group before a warning is raised. If the number of sources in a group exceeds this value, a warning is raised to inform the user that fitting such large groups may take a long time and be error-prone. The default is 25 sources. sub_shape : `None`, int, or length-2 array_like The rectangular shape around the fitted center of a source 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`` will be defined by the model bounding box. This keyword must be specified if the model does not have a ``bounding_box`` attribute. 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. 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 rerun the `IterativePSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After running `IterativePSFPhotometry`, you can use the `results_to_init_params` method to generate a table of initial parameters that can be used in a subsequent call to `IterativePSFPhotometry`. This table will contain the fitted (x, y) positions, fluxes, and any other model parameters that were fit. If the fitted model parameters are NaN, then the source was not valid, likely due to not enough valid data pixels in the ``fit_shape`` region. The ``flags`` column in the output ``results`` table indicates the reason why a source was not valid. If the fitted 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 Astropy fitter that returns parameter errors. The local background value around each source is optionally estimated using the ``local_bkg_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 ``local_bkg_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. Care should be taken in defining the source groups. Simultaneously fitting very large source groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds the ``group_warning_threshold`` value. """ @deprecated_renamed_argument('localbkg_estimator', 'local_bkg_estimator', '3.0', until='4.0') def __init__(self, psf_model, fit_shape, finder, *, grouper=None, fitter=None, fitter_maxiters=100, xy_bounds=None, maxiters=3, mode='new', aperture_radius=None, local_bkg_estimator=None, group_warning_threshold=25, sub_shape=None, progress_bar=False): if finder is None: msg = 'finder cannot be None for IterativePSFPhotometry' raise ValueError(msg) if aperture_radius is None: msg = 'aperture_radius cannot be None for IterativePSFPhotometry' raise ValueError(msg) threshold = group_warning_threshold self._psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, fitter=fitter, fitter_maxiters=fitter_maxiters, xy_bounds=xy_bounds, aperture_radius=aperture_radius, local_bkg_estimator=local_bkg_estimator, group_warning_threshold=threshold, progress_bar=progress_bar) self.maxiters = self._validate_maxiters(maxiters) if mode not in ['new', 'all']: msg = "mode must be 'new' or 'all'" raise ValueError(msg) if mode == 'all' and grouper is None: msg = "grouper must be input for the 'all' mode" raise ValueError(msg) self.mode = mode self.sub_shape = sub_shape self._reset_results() def _reset_results(self): """ Reset these attributes for each __call__. """ self.fit_results = [] self.results = None def __repr__(self): params = ('psf_model', 'fit_shape', 'finder', 'grouper', 'fitter', 'fitter_maxiters', 'xy_bounds', 'maxiters', 'mode', 'local_bkg_estimator', 'aperture_radius', 'sub_shape', 'progress_bar') overrides = { 'psf_model': self._psfphot.psf_model, 'fit_shape': self._psfphot.fit_shape, 'finder': self._psfphot.finder, 'grouper': self._psfphot.grouper, 'fitter': self._psfphot.fitter, 'fitter_maxiters': self._psfphot.fitter_maxiters, 'xy_bounds': self._psfphot.xy_bounds, 'local_bkg_estimator': self._psfphot.local_bkg_estimator, 'aperture_radius': self._psfphot.aperture_radius, 'progress_bar': self._psfphot.progress_bar, } return make_repr(self, params, overrides=overrides) @staticmethod def _validate_maxiters(maxiters): if (not np.isscalar(maxiters) or maxiters <= 0 or ~np.isfinite(maxiters)): msg = 'maxiters must be a strictly-positive scalar' raise ValueError(msg) if maxiters != int(maxiters): msg = 'maxiters must be an integer' raise ValueError(msg) 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) @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 ---------- table : `~astropy.table.Table` The input table. colname : str The column name to move. colname_after : str The column name after which to place the moved column. 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)) return table[colnames] 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._data_processor.get_aper_fluxes(data, mask, sources) flux_col = self._psfphot._param_mapper.init_colnames['flux'] sources[flux_col] = flux return sources def _prepare_next_iteration_sources(self, residual_data, mask, new_sources, orig_sources): """ Create an initial parameters table for the next iteration. This method combines the results from the previous iteration with newly found sources, ensuring all sources have unique IDs and correctly named '_init' columns for the next run of PSFPhotometry. Parameters ---------- residual_data : 2D `~numpy.ndarray` The residual image from the previous iteration, used to measure initial fluxes for new sources. mask : 2D `~numpy.ndarray` or `None` The mask for the data. new_sources : `~astropy.table.Table` A table with '_init' columns for the x and y positions of newly detected sources. orig_sources : `~astropy.table.Table` The results table (from the previous iteration's fit) for the original sources. Returns ------- init_params : `~astropy.table.Table` A table ready to be used as `init_params` for the next photometry iteration. """ param_mapper = self._psfphot._param_mapper # build a new table constructively, converting _fit columns to # _init columns prepared_orig = QTable() prepared_orig['id'] = orig_sources['id'] for alias in param_mapper.alias_to_model_param: init_col = param_mapper.init_colnames.get(alias) if init_col and init_col in orig_sources.colnames: # use the previous fit result as the initial guess for # the next iteration prepared_orig[init_col] = orig_sources[init_col] # prepare the newly found sources max_id = np.max(orig_sources['id']) if len(orig_sources) > 0 else 0 new_sources['id'] = np.arange(len(new_sources)) + max_id + 1 # measure initial fluxes and add default values for other model # parameters new_sources = self._measure_init_fluxes(residual_data, mask, new_sources) model_param_mapper = param_mapper.alias_to_model_param for alias, model_param_name in model_param_mapper.items(): init_col = param_mapper.init_colnames.get(alias) if init_col and init_col not in new_sources.colnames: default_value = getattr(self._psfphot.psf_model, model_param_name) new_sources[init_col] = default_value # combine tables new_sources.meta.pop('date', None) # prevent merge conflicts return vstack([prepared_orig, new_sources]) @_create_call_docstring(iterative=True) def __call__(self, data, *, mask=None, error=None, init_params=None): if isinstance(data, NDData): data_, mask, error = PSFPhotometry._coerce_nddata(data) 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 the context manager to be able # to reemit 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, psf_shape=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() # Convert finder results to init params format data_processor = self._psfphot._data_processor new_sources = data_processor._convert_finder_to_init( new_sources) if self.mode == 'all': init_params = self._prepare_next_iteration_sources( residual_data, mask, new_sources, self._psfphot.results_to_init_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 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._move_column(phot_tbl, 'iter_detected', 'group_size') # add table metadata not already set by PSFPhotometry phot_tbl.meta['psf_class'] = self.__class__.__name__ phot_tbl.meta['maxiters'] = self.maxiters phot_tbl.meta['mode'] = self.mode phot_tbl.meta['sub_shape'] = self.sub_shape # emit unique warnings recorded_warnings = rwarn0 + rwarn1 self._emit_warnings(recorded_warnings) self.results = phot_tbl return phot_tbl def results_to_init_params(self, *, remove_invalid=True, reset_ids=True): """ Create a table of the fitted model parameters from the results. The table columns are named according to those expected for the initial parameters table. It can be used as the ``init_params`` for subsequent `PSFPhotometry` fits. Parameters ---------- remove_invalid : bool, optional If `True`, rows that contain non-finite fitted values are removed. reset_ids : bool, optional If `True`, the 'id' column will be reset to a sequential numbering starting from 1. If `False`, the 'id' column will remain unchanged from the results table. This option is ignored if ``remove_invalid`` is `False`. """ return self._psfphot._results_to_init_params( self.results, remove_invalid=remove_invalid, reset_ids=reset_ids) def results_to_model_params(self, *, remove_invalid=True, reset_ids=True): """ Create a table of the fitted model parameters from the results. The table columns are named according to the PSF model parameter names. It can also be used to reconstruct the fitted PSF models for visualization or further analysis. Parameters ---------- remove_invalid : bool, optional If `True`, rows that contain non-finite fitted values are removed. reset_ids : bool, optional If `True`, the 'id' column will be reset to a sequential numbering starting from 1. If `False`, the 'id' column will remain unchanged from the results table. This option is ignored if ``remove_invalid`` is `False`. """ return self._psfphot._results_to_model_params( self.results, self._psfphot._param_mapper, remove_invalid=remove_invalid, reset_ids=reset_ids) @deprecated_positional_kwargs(since='3.0', until='4.0') def decode_flags(self, return_bit_values=False): """ Decode the PSF photometry flags from the results table. This is a convenience method that calls `~photutils.psf.decode_psf_flags` with the 'flags' column from the results table. Parameters ---------- return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag descriptions (strings). Default is `False`. Returns ------- decoded : list of list of str or list of list of int List of lists where each inner list contains the active flag names (or bit values) for the corresponding source in the results table. If no flags are set for a source, an empty list is returned for that source. Raises ------ ValueError If no results are available. Please run the IterativePSFPhotometry instance first. See Also -------- photutils.psf.decode_psf_flags Examples -------- Decode flags from iterative PSF photometry results: >>> import numpy as np >>> from astropy.table import Table >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import (CircularGaussianPRF, ... IterativePSFPhotometry) >>> yy, xx = np.mgrid[:21, :21] >>> psf_model = CircularGaussianPRF(flux=1, x_0=10, y_0=10, fwhm=2) >>> # Create sources with one having negative flux >>> m1 = CircularGaussianPRF(flux=100, x_0=10, y_0=10, fwhm=2) >>> m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) >>> data = m1(xx, yy) + m2(xx, yy) >>> init_params = Table({'x': [10, 5], 'y': [10, 5], ... 'flux': [100, 100]}) >>> finder = DAOStarFinder(6.0, 2.0) >>> photometry = IterativePSFPhotometry(psf_model, (3, 3), ... finder=finder, ... aperture_radius=4, ... maxiters=1) >>> results = photometry(data, init_params=init_params) >>> decoded_flags = photometry.decode_flags() >>> for i, flags in enumerate(decoded_flags): ... print(f'Source {i+1}: {flags}') # doctest: +SKIP Source 1: [] Source 2: ['negative_flux'] """ if self.results is None: msg = ('No results available. Please run the ' 'IterativePSFPhotometry instance first.') raise ValueError(msg) return decode_psf_flags(self.results['flags'], return_bit_values=return_bit_values) def _get_model_image_params(self): # Convert fitted parameters to model parameter names without # filtering, so the row indices align with self.results model_params = self.results_to_model_params(remove_invalid=False) # Filter out invalid sources (those with NaN fitted values) keep = np.all([np.isfinite(model_params[col]) for col in model_params.colnames], axis=0) model_params = model_params[keep] # Extract local_bkg for the same valid sources local_bkg = self.results['local_bkg'][keep] return model_params, local_bkg @deprecated_renamed_argument('include_localbkg', 'include_local_bkg', '3.0', until='4.0') @_make_model_image_docstring def make_model_image(self, shape, *, psf_shape=None, include_local_bkg=False): if not self.fit_results: msg = ('No results available. Please run the ' 'IterativePSFPhotometry instance first.') raise ValueError(msg) model_params, local_bkg = self._get_model_image_params() maker = _ModelImageMaker(self._psfphot.psf_model, model_params, local_bkg=local_bkg, progress_bar=self._psfphot.progress_bar) return maker.make_model_image(shape, psf_shape=psf_shape, include_local_bkg=include_local_bkg) @deprecated_renamed_argument('include_localbkg', 'include_local_bkg', '3.0', until='4.0') @_make_residual_image_docstring def make_residual_image(self, data, *, psf_shape=None, include_local_bkg=False): if not self.fit_results: msg = ('No results available. Please run the ' 'IterativePSFPhotometry instance first.') raise ValueError(msg) model_params, local_bkg = self._get_model_image_params() maker = _ModelImageMaker(self._psfphot.psf_model, model_params, local_bkg=local_bkg, progress_bar=self._psfphot.progress_bar) return maker.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=include_local_bkg) astropy-photutils-3322558/photutils/psf/matching/000077500000000000000000000000001517052111400220535ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf/matching/__init__.py000066400000000000000000000020151517052111400241620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Deprecated subpackage. Use ``photutils.psf_matching`` instead. """ import warnings from astropy.utils.exceptions import AstropyDeprecationWarning import photutils.psf_matching as _psf_matching from photutils.psf_matching.fourier import __all__ as _fourier_all from photutils.psf_matching.windows import __all__ as _windows_all __all__ = list(_fourier_all) + list(_windows_all) _deprecation_msg = ('photutils.psf.matching is deprecated (since version ' '3.0) and will be removed in a future version. Use ' 'photutils.psf_matching instead. Please update your ' 'imports accordingly.') def __getattr__(name): if name in __all__: warnings.warn(_deprecation_msg, AstropyDeprecationWarning, stacklevel=2) return getattr(_psf_matching, name) msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) def __dir__(): return __all__ astropy-photutils-3322558/photutils/psf/model_helpers.py000066400000000000000000000426551517052111400234710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for making PSF models. """ import contextlib import re import numpy as np from astropy.modeling import CompoundModel from astropy.modeling.models import Const2D, Identity, Shift from astropy.nddata import NDData from astropy.units import Quantity from astropy.utils.decorators import deprecated from scipy.integrate import dblquad, trapezoid from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['grid_from_epsfs', 'make_psf_model'] 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. 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: msg = f'{x_name!r} parameter name not found in the input model' raise ValueError(msg) 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: msg = f'{y_name!r} parameter name not found in the input model' raise ValueError(msg) 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 (prior to # normalization) 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: msg = ('Cannot normalize the model because the integrated flux ' 'is zero') raise ValueError(msg) 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: msg = f'{name!r} parameter name not found in the output model' raise ValueError(msg) # 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 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, offset): """ One dimensional Shift model derivative with respect to parameter. """ d_offset = -np.ones_like(x) + offset * 0.0 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: return dblquad(model, -np.inf, np.inf, -np.inf, np.inf)[0] if dx <= 0 or dy <= 0: msg = 'dx and dy must be > 0' raise ValueError(msg) if subsample < 1: msg = 'subsample must be >= 1' raise ValueError(msg) xc = getattr(model, x_name) yc = getattr(model, y_name) if np.any(~np.isfinite((xc.value, yc.value))): msg = 'model x and y positions must be finite' raise ValueError(msg) 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)) if isinstance(data, Quantity): data = data.value # 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 @deprecated_positional_kwargs(since='3.0', until='4.0') @deprecated(since='3.0', alternative='`GriddedPSFModel`') def grid_from_epsfs(epsfs, grid_xypos=None, meta=None): # pragma: no cover """ Create a GriddedPSFModel from a list of ImagePSF models. Given a list of `~photutils.psf.ImagePSF` models, this function will return a `~photutils.psf.GriddedPSFModel`. The fiducial points for each input ImagePSF 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 ImagePSF (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.ImagePSF` A list of ImagePSF models 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 GriddedPSFModel, ImagePSF # 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 and len(grid_xypos) != len(epsfs): msg = 'grid_xypos must be the same length as epsfs' raise ValueError(msg) # loop over input once for i, epsf in enumerate(epsfs): # check input type if not isinstance(epsf, ImagePSF): msg = 'All input epsfs must be of type ImagePSF' raise TypeError(msg) # 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 with contextlib.suppress(AttributeError): dat_unit = epsf.data.unit else: if np.any(epsf.oversampling != oversampling): msg = ('All input ImagePSF models must have the same value ' 'for oversampling') raise ValueError(msg) if epsf.fill_value != fill_value: msg = ('All input ImagePSF models must have the same value ' 'for fill_value') raise ValueError(msg) if epsf.data.ndim != data_arrs[0].ndim: msg = ('All input ImagePSF models must have data with the ' 'same dimensions') raise ValueError(msg) try: unitt = epsf.data_unit if unitt != dat_unit: msg = 'All input data must have the same unit' raise ValueError(msg) except AttributeError as exc: if dat_unit is not None: msg = 'All input data must have the same unit' raise ValueError(msg) from exc if epsf.flux != flux: msg = ('All input ImagePSF models must have the same value ' 'for flux') raise ValueError(msg) 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 np.all(epsf.origin != origin): msg = ('If using (x_0, y_0) as fiducial point, origin must ' 'match for each input EPSF') raise ValueError(msg) # 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, strict=True)) 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) return GriddedPSFModel(data, fill_value=fill_value) astropy-photutils-3322558/photutils/psf/model_io.py000066400000000000000000000475761517052111400224450ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for reading and writing PSF models. """ import io import itertools import os import warnings import numpy as np from astropy.io import fits, registry from astropy.io.fits.verify import VerifyWarning from astropy.nddata import NDData, reshape_as_blocks from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['GriddedPSFModelRead', 'stdpsf_reader', 'webbpsf_reader'] __doctest_skip__ = ['GriddedPSFModelRead'] class GriddedPSFModelRead(registry.UnifiedReadWrite): """ Read and parse a FITS file into a `GriddedPSFModel` instance. This class enables the astropy unified I/O layer for `~photutils.psf.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 `~photutils.psf.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 ---------- instance : object Descriptor calling instance or `None` if no instance. cls : type Descriptor calling class (either owner class or instance class). """ def __init__(self, instance, cls): # uses default global registry super().__init__(instance, cls, 'read', registry=None) def __call__(self, *args, **kwargs): """ Read and parse a FITS file into a `GriddedPSFModel` instance using the registered "read" function. Parameters ---------- *args : tuple Positional arguments passed through to data reader. The first argument is typically the input filename. **kwargs : dict, optional Keyword arguments passed through to data reader. This includes the ``format`` keyword argument. Returns ------- out : `~photutils.psf.GriddedPSFModel` A gridded ePSF model corresponding to FITS file contents. """ return self.registry.read(self._cls, *args, **kwargs) def _read_stdpsf(filename): """ Read a STScI standard-format ePSF (STDPSF) FITS file. Parameters ---------- filename : str The name of the STDPDF FITS file. Returns ------- data : dict A dictionary containing the ePSF data and metadata. """ 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: msg = 'Invalid STDPDF FITS file' raise ValueError(msg) 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: msg = 'Unknown STDPSF FITS file' raise ValueError(msg) # STDPDF FITS positions are 1-indexed xgrid = np.array(xgrid) - 1 ygrid = np.array(ygrid) - 1 # nypsfs, nxpsfs, detector # 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 return {'data': data, 'npsfs': npsfs, 'nxpsfs': nxpsfs, 'nypsfs': nypsfs, 'xgrid': xgrid, 'ygrid': ygrid} def _split_detectors(grid_data, detector_data, detector_id): """ Split an ePSF array into individual detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_data : dict A dictionary containing the detector data. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. Notes ----- 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 ygrid = ygrid[:nypsfs] if det_idx < nxdet else ygrid[nypsfs:] - det_size return data, xgrid, ygrid def _split_wfc_uvis(grid_data, detector_id): """ Split an ePSF array into individual WFC/UVIS detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. """ if detector_id is None: msg = 'detector_id must be specified for ACS/WFC and WFC3/UVIS ePSFs' raise ValueError(msg) if detector_id not in (1, 2): msg = 'detector_id must be 1 or 2' raise ValueError(msg) # 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): """ Split an ePSF array into individual WFPC2 detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. """ if detector_id is None: msg = 'detector_id must be specified for WFPC2 ePSFs' raise ValueError(msg) if detector_id not in range(1, 5): msg = 'detector_id must be between 1 and 4, inclusive' raise ValueError(msg) 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): """ Split an ePSF array into individual NIRCam SW detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. """ if detector_id is None: msg = 'detector_id must be specified for NRCSW ePSFs' raise ValueError(msg) if detector_id not in range(1, 9): msg = 'detector_id must be between 1 and 8, inclusive' raise ValueError(msg) 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``. Parameters ---------- filename : str The name of the STDPDF FITS file. detector_id : int The detector ID. Returns ------- meta : dict or `None` A dictionary containing the metadata. """ 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: msg = f'Unknown detector {detector}' raise ValueError(msg) 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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 `~photutils.psf.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. """ from photutils.psf import GriddedPSFModel # prevent circular import 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: msg = 'Unknown detector or STDPSF format' raise ValueError(msg) 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 `~photutils.psf.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. """ from photutils.psf import GriddedPSFModel # prevent circular import 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): msg = 'Invalid WebbPSF FITS file; missing "DET_YX{}" header keys' raise ValueError(msg) if 'OVERSAMP' not in header: msg = 'Invalid WebbPSF FITS file; missing "OVERSAMP" header key' raise ValueError(msg) # 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: 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 a file is a STDPSF FITS file. Parameters ---------- origin : {'read', 'write'} A string indicating whether the file is to be opened for reading or writing. filepath : str The file path of the FITS file. fileobj : file-like object An open file object to read the file's contents, or `None` if the file could not be opened. *args, **kwargs Any additional positional or keyword arguments for the read or write function. Returns ------- result : 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') return all(key in header for key in keys) return False def is_webbpsf(origin, filepath, fileobj, *args, **kwargs): """ Determine whether a file is a WebbPSF FITS file. Parameters ---------- origin : {'read', 'write'} A string indicating whether the file is to be opened for reading or writing. filepath : str The file path of the FITS file. fileobj : file-like object An open file object to read the file's contents, or `None` if the file could not be opened. *args, **kwargs Any additional positional or keyword arguments for the read or write function. Returns ------- result : 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') return all(key in header for key in keys) return False astropy-photutils-3322558/photutils/psf/model_plotting.py000066400000000000000000000214461517052111400236620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for plotting Gridded PSF models. """ import numpy as np from astropy.visualization import simple_norm __all__ = [] def _plot_grid_docstring(func): func.__doc__ = """ 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 'viridis'. 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. Notes ----- This method returns a figure object. If you are using this method in a script, you will need to call ``fig.show()`` to display the figure. If you are using this method in a Jupyter notebook, the figure will be displayed automatically. When 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. """ return func class _ModelGridPlotter: """ Class to plot a grid of ePSF models. """ def __init__(self, model): self.model = model def _reshape_grid(self, data): """ Reshape the 3D ePSF grid as a 2D array of horizontally and vertically stacked ePSFs. Parameters ---------- data : `numpy.ndarray` The 3D array of ePSF data. Returns ------- reshaped_data : `numpy.ndarray` The 2D array of ePSF data. """ nypsfs = self.model._ygrid.shape[0] nxpsfs = self.model._xgrid.shape[0] ny, nx = self.model.data.shape[1:] return (data.reshape(nypsfs, nxpsfs, ny, nx) .transpose([0, 2, 1, 3]) .reshape(nypsfs * ny, nxpsfs * nx)) @_plot_grid_docstring def plot_grid(self, *, ax=None, vmax_scale=None, peak_norm=False, deltas=False, cmap='viridis', dividers=True, divider_color='darkgray', divider_ls='-', figsize=None): import matplotlib.pyplot as plt from mpl_toolkits.axes_grid1 import make_axes_locatable data = self.model.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.model.meta.get('detector', '') == 'NRCSW'): figsize = (20, 8) fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() if peak_norm and data.max() != 0: # normalize relative to peak data /= data.max() if deltas: if vmax_scale is None: vmax_scale = 0.03 vmax = data.max() * vmax_scale vmin = -vmax norm = simple_norm(data, 'linear', vmin=vmin, vmax=vmax) else: if vmax_scale is None: vmax_scale = 1.0 vmax = data.max() * vmax_scale vmin = vmax / 1.0e4 norm = simple_norm(data, 'log', vmin=vmin, vmax=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 order: left, right, bottom, top nypsfs = self.model._ygrid.shape[0] nxpsfs = self.model._xgrid.shape[0] extent = [-0.5, nxpsfs - 0.5, -0.5, nypsfs - 0.5] axim = ax.imshow(data, extent=extent, norm=norm, cmap=cmap, origin='lower') # Use the axes set up above to set appropriate tick labels xticklabels = self.model._xgrid.astype(int) yticklabels = self.model._ygrid.astype(int) if self.model.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 - 1): ax.axvline(ix + 0.5, color=divider_color, ls=divider_ls) for iy in range(nypsfs - 1): ax.axhline(iy + 0.5, color=divider_color, ls=divider_ls) instrument = self.model.meta.get('instrument', '') if not instrument: # WebbPSF output instrument = self.model.meta.get('instrume', '') detector = self.model.meta.get('detector', '') filtername = self.model.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: minus = '\u2212' ax.set_title(f'{title}(ePSFs {minus} )') 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' divider = make_axes_locatable(ax) cax_cbar = divider.append_axes('right', size='3%', pad='3%') cbar = fig.colorbar(axim, cax=cax_cbar, label=label) if not deltas: cbar.ax.set_yscale('log') if self.model.meta.get('detector', '') == 'NRCSW': # NIRCam NRCSW STDPSF files contain all detectors. # The plot gets extra divider lines and SCA name labels. nxpsfs = len(self.model._xgrid) nypsfs = len(self.model._ygrid) ax.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 astropy-photutils-3322558/photutils/psf/photometry.py000066400000000000000000002164541517052111400230610ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing PSF-fitting photometry. """ import contextlib import inspect import warnings from dataclasses import dataclass, field import astropy.units as u import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable from astropy.utils.decorators import deprecated from astropy.utils.exceptions import AstropyUserWarning from photutils.background import LocalBackground from photutils.psf._components import (PSFDataProcessor, PSFFitter, PSFResultsAssembler, _make_model_image_docstring, _make_residual_image_docstring, _ModelImageMaker) from photutils.psf.flags import decode_psf_flags from photutils.psf.utils import (_create_call_docstring, _get_psf_model_main_params, _make_mask, _validate_psf_model) from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) 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._repr import make_repr __all__ = ['PSFPhotometry'] @dataclass class _PSFParameterMapper: """ Helper class to map PSF model parameter names to table column names. """ psf_model: object alias_to_model_param: dict = field(init=False, repr=False) # Valid column names that can be used for initial (x, y, flux) # positions. Order matters: the first matched name in each tuple will # be used. VALID_INIT_COLNAMES = { # noqa: RUF012 'x': ( 'x_init', 'xinit', 'x', 'x_0', 'x0', 'xcentroid', 'x_centroid', 'x_peak', 'xcen', 'x_cen', 'xpos', 'x_pos', 'x_fit', 'xfit', ), 'y': ( 'y_init', 'yinit', 'y', 'y_0', 'y0', 'ycentroid', 'y_centroid', 'y_peak', 'ycen', 'y_cen', 'ypos', 'y_pos', 'y_fit', 'yfit', ), 'flux': ( 'flux_init', 'fluxinit', 'flux', 'flux_0', 'flux0', 'flux_fit', 'fluxfit', 'source_sum', 'segment_flux', 'kron_flux', ), } MAIN_ALIASES = ('x', 'y', 'flux') def __post_init__(self): self.alias_to_model_param = self._get_model_params_map() def _get_model_params_map(self): """ Get the mapping of aliases ('x', 'y', 'flux', etc.) to the actual parameter names in the PSF model. Returns ------- params_map : dict A dictionary mapping parameter aliases to their actual names in the PSF model. The keys are 'x', 'y', 'flux', and any additional parameters defined in the model. """ # the order of the main parameters is important; it defines # the order of table outputs main_params = _get_psf_model_main_params(self.psf_model) params_map = dict(zip(self.MAIN_ALIASES, main_params, strict=True)) # extra parameters that are not 'x', 'y', or 'flux', but # are free to be fit (fixed = False), are added to the map # with their own aliases fitted_params = [ param for param in self.psf_model.param_names if not self.psf_model.fixed[param] ] extra_params = [param for param in fitted_params if param not in main_params] params_map.update({key: key for key in extra_params}) return params_map @property def fitted_param_names(self): """ Get list of model parameter names that will be fitted. """ return [param for param in self.psf_model.param_names if not self.psf_model.fixed[param]] def get_init_colname(self, alias): """ Get initialization column name for parameter alias. """ return f'{alias}_init' def get_fit_colname(self, alias): """ Get fitted parameter column name for parameter alias. """ return f'{alias}_fit' def get_err_colname(self, alias): """ Get error column name for parameter alias. """ return f'{alias}_err' @property def init_colnames(self): """ Dictionary mapping aliases to initialization column names. """ return {alias: self.get_init_colname(alias) for alias in self.alias_to_model_param} @property def fit_colnames(self): """ Dictionary mapping aliases to fitted parameter column names. """ return {alias: self.get_fit_colname(alias) for alias in self.alias_to_model_param} @property def err_colnames(self): """ Dictionary mapping aliases to error column names. """ return {alias: self.get_err_colname(alias) for alias in self.alias_to_model_param} @property def model_param_to_alias(self): """ Dictionary mapping model parameter names to aliases. """ return {v: k for k, v in self.alias_to_model_param.items()} def find_column(self, table, param_alias): """ Find the first valid column name in a table for a given parameter alias. Parameters ---------- table : `~astropy.table.Table` The input table to search for the column. param_alias : str The alias for the parameter (e.g., 'x', 'y', 'flux'). Returns ------- result : str or `None` The first valid column name found in the table for the parameter alias, or `None` if no valid column is found. """ try: valid_names = self.VALID_INIT_COLNAMES[param_alias] except KeyError: # valid names for extra parameters are more limited valid_names = (f'{param_alias}_init', param_alias, f'{param_alias}_fit') for name in valid_names: if name in table.colnames: return name return None def rename_table_columns(self, table): """ Rename columns in-place in an input table to the ``_init`` format. Parameters ---------- table : `~astropy.table.Table` The input table with columns to be renamed. Returns ------- table : `~astropy.table.Table` The input table with columns renamed to the `_init` format based on the parameter aliases. """ for param_alias in self.alias_to_model_param: found_col = self.find_column(table, param_alias) if found_col: target_col = self.init_colnames[param_alias] if found_col != target_col: table.rename_column(found_col, target_col) return table class PSFPhotometry: """ 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. 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 initial source position 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 greater than or equal to 3. 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 sources 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 sources. Typically, grouped sources are those that overlap with their neighbors. Sources 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 ``group_warning_threshold`` sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. If `None`, then the default `astropy.modeling.fitting.TRFLSQFitter` is used. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. The value can be increased if the fit is not converging for sources. This parameter is passed to the ``fitter`` if it supports the ``maxiter`` parameter and ignored otherwise. xy_bounds : `None`, float, or 2-tuple of float, optional The maximum distance in pixels that a fitted source can be from the initial (x, y) position. If a single float, then the same maximum distance is used for both x and y. If a 2-tuple of floats, then the distances are in ``(x, y)`` order. If `None`, then no bounds are applied. Either value can also be `None` to indicate no bound along that axis. aperture_radius : float or `None`, optional The radius of the circular aperture used to estimate the initial flux of each source. If `None`, then the initial flux values must be provided in the ``init_params`` table. The aperture radius must be a strictly positive scalar. If initial flux values are present in the ``init_params`` table, they will override this keyword. local_bkg_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). group_warning_threshold : int, optional The maximum number of sources in a group before a warning is raised. If the number of sources in a group exceeds this value, a warning is raised to inform the user that fitting such large groups may take a long time and be error-prone. The default is 25 sources. 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. 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 rerun the `PSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After running `PSFPhotometry`, you can use the `results_to_init_params` method to generate a table of initial parameters that can be used in a subsequent call to `PSFPhotometry`. This table will contain the fitted (x, y) positions, fluxes, and any other model parameters that were fit. If the fitted model parameters are NaN, then the source was not valid, likely due to not enough valid data pixels in the ``fit_shape`` region. The ``flags`` column in the output ``results`` table indicates the reason why a source was not valid. If the fitted 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 Astropy fitter that returns parameter errors. The local background value around each source is optionally estimated using the ``local_bkg_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 ``local_bkg_estimator`` (or ``local_bkg`` column in ``init_params``) with care. Care should be taken in defining the source groups. Simultaneously fitting very large source groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds the ``group_warning_threshold`` value. """ # Default value for parameter initialization (invalid sources) _DEFAULT_PARAM_VALUE = np.nan @deprecated_renamed_argument('localbkg_estimator', 'local_bkg_estimator', '3.0', until='4.0') def __init__(self, psf_model, fit_shape, *, finder=None, grouper=None, fitter=None, fitter_maxiters=100, xy_bounds=None, aperture_radius=None, local_bkg_estimator=None, group_warning_threshold=25, progress_bar=False): self.psf_model = _validate_psf_model(psf_model) self._param_mapper = _PSFParameterMapper(self.psf_model) self.fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(1, 1), check_odd=True) self.finder = self._validate_callable(finder, 'finder') self.grouper = self._validate_callable(grouper, 'grouper') if fitter is None: fitter = TRFLSQFitter() self.fitter = self._validate_callable(fitter, 'fitter') self.fitter_maxiters = self._validate_maxiters(fitter_maxiters) self.xy_bounds = self._validate_bounds(xy_bounds) self.aperture_radius = self._validate_radius(aperture_radius) self.local_bkg_estimator = self._validate_localbkg( local_bkg_estimator, 'local_bkg_estimator') self.group_warning_threshold = group_warning_threshold self.progress_bar = progress_bar self._data_processor = PSFDataProcessor( self._param_mapper, self.fit_shape, finder=self.finder, aperture_radius=self.aperture_radius, local_bkg_estimator=self.local_bkg_estimator, ) self._psf_fitter = PSFFitter( self.psf_model, self._param_mapper, fitter=self.fitter, fitter_maxiters=self.fitter_maxiters, xy_bounds=self.xy_bounds, group_warning_threshold=self.group_warning_threshold, ) self._results_assembler = PSFResultsAssembler( self._param_mapper, self.fit_shape, xy_bounds=self.xy_bounds, ) # used by the __repr__ method and the output table metadata self._attrs = ('psf_model', 'fit_shape', 'finder', 'grouper', 'fitter', 'fitter_maxiters', 'xy_bounds', 'aperture_radius', 'local_bkg_estimator', 'group_warning_threshold', 'progress_bar') self._reset_results() def _reset_results(self): """ Reset internal state attributes for each __call__. """ self.data_unit = None self.finder_results = None self.init_params = None self.results = None self.fit_info = [] # sync data_unit with components if hasattr(self, '_data_processor'): self._data_processor.data_unit = None # internal state container self._state = { 'valid_mask_by_id': None, 'fit_param_errs': None, 'fit_error_indices': None, 'fitted_models_table': None, 'n_pixels_fit': None, 'group_size': None, 'invalid_reasons': None, 'sum_abs_residuals': None, 'cen_residuals': None, 'reduced_chi2': None, } def _initialize_source_state_storage(self, n_sources): """ Initialize the per-source arrays used to store the fit results in the state container. Parameters ---------- n_sources : int The number of sources to initialize the arrays for. """ nfitparam = len(self._param_mapper.fitted_param_names) self._state.update({ 'fit_param_errs': np.full((n_sources, nfitparam), np.nan), 'n_pixels_fit': np.zeros(n_sources, dtype=int), 'invalid_reasons': [''] * n_sources, 'sum_abs_residuals': np.full(n_sources, np.nan, dtype=float), 'cen_residuals': np.full(n_sources, np.nan, dtype=float), 'reduced_chi2': np.full(n_sources, np.nan, dtype=float), 'group_size': np.ones(n_sources, dtype=int), 'valid_mask_by_id': np.full(n_sources, fill_value=False, dtype=bool), }) # Initialize model parameter storage directly self._init_model_param_storage(n_sources) self.fit_info = [{} for _ in range(n_sources)] def _init_model_param_storage(self, n_sources): """ Initialize storage for model parameters directly in state. This avoids storing the full model objects and instead stores only the parameter values, fixed flags, and bounds that are needed for the results table. """ # get all parameter names from the PSF model model_params = list(self.psf_model.param_names) # initialize parameter value storage param_data = {} for model_param in model_params: # initialize all parameters with np.nan (for invalid sources) param_data[model_param] = np.full(n_sources, self._DEFAULT_PARAM_VALUE) param_data[f'{model_param}_fixed'] = [None] * n_sources param_data[f'{model_param}_bounds'] = [None] * n_sources # add placehold IDs column -- this will be updated later to # match IDs in init_params param_data['id'] = np.arange(1, n_sources + 1) self._state['model_param_data'] = param_data def _cache_fitted_parameters(self, row_index, model): """ Extract and store model parameters directly instead of storing the full model object. This method updates the internal state container with model parameter values, fixed flags, and bounds for a specific source. Parameters ---------- row_index : int The index of the source in the results arrays. model : astropy.modeling.Model or None The fitted model for this source, or None for invalid sources. """ param_data = self._state['model_param_data'] if model is None: # For invalid sources, use default template from psf_model template_model = self.psf_model for param_name in template_model.param_names: # Set all parameters to np.nan for invalid sources param_data[param_name][row_index] = self._DEFAULT_PARAM_VALUE template_param = getattr(template_model, param_name) param_data[f'{param_name}_fixed'][row_index] = ( template_param.fixed) param_data[f'{param_name}_bounds'][row_index] = ( template_param.bounds) else: # For valid sources, extract actual fitted values for param_name in model.param_names: param = getattr(model, param_name) param_data[param_name][row_index] = param.value param_data[f'{param_name}_fixed'][row_index] = param.fixed param_data[f'{param_name}_bounds'][row_index] = param.bounds def _build_fitted_models_table(self): """ Build the fitted models table from stored parameter data. Returns ------- table : `~astropy.table.QTable` The table of all model parameters for each source. """ param_data = self._state['model_param_data'] flux_param = self._param_mapper.alias_to_model_param['flux'] # Apply data unit to flux parameter if needed if self.data_unit is not None: param_data[flux_param] = param_data[flux_param] * self.data_unit # Create table from parameter data table = QTable(param_data) # Set id column to match init_params for clean merging if hasattr(self, 'init_params') and self.init_params is not None: ids = self.init_params['id'] table['id'] = ids return table def __repr__(self): return make_repr(self, self._attrs) @staticmethod def _validate_type(obj, name, expected_type): """ Validate that object is of expected type. Parameters ---------- obj : object or None Object to validate. name : str Name of the parameter for error messages. expected_type : type or tuple of types Expected type(s) for the object. Returns ------- obj : object or None The validated object. Raises ------ error_type If obj is not None and not an instance of expected_type. """ if obj is not None and not isinstance(obj, expected_type): type_name = expected_type.__name__ msg = f'{name} must be a {type_name} instance' raise TypeError(msg) return obj @staticmethod def _validate_callable(obj, name): """ Validate that the input object is callable. Parameters ---------- obj : object or None Object to validate. name : str Name of the parameter for error messages. Returns ------- obj : object or None The validated callable object. Raises ------ TypeError If obj is not None and not callable. """ if obj is not None and not callable(obj): msg = f'{name!r} must be a callable object' raise TypeError(msg) return obj def _validate_bounds(self, xy_bounds): """ Validate the input ``xy_bounds`` value. Parameters ---------- xy_bounds : float, tuple of float, or None The maximum distance(s) in pixels that fitted sources can be from initial positions. Returns ------- xy_bounds : ndarray or None The validated xy_bounds as a 2-element array, or None if input was None. Raises ------ ValueError If xy_bounds has incorrect shape, dimension, or contains invalid values (non-positive or non-finite). """ if xy_bounds is None: return xy_bounds xy_bounds = np.atleast_1d(xy_bounds) if len(xy_bounds) == 1: xy_bounds = np.array((xy_bounds[0], xy_bounds[0])) if len(xy_bounds) != 2: msg = 'xy_bounds must have 1 or 2 elements' raise ValueError(msg) if xy_bounds.ndim != 1: msg = 'xy_bounds must be a 1D array' raise ValueError(msg) for bound in xy_bounds: if bound is not None: if bound <= 0: msg = 'xy_bounds must be strictly positive' raise ValueError(msg) if not np.isfinite(bound): msg = 'xy_bounds must be finite' raise ValueError(msg) return xy_bounds @staticmethod def _validate_radius(radius): """ Validate the input ``aperture_radius`` value. Parameters ---------- radius : float or None The aperture radius value to validate. Returns ------- radius : float or None The validated aperture radius. Raises ------ ValueError If radius is not None and is not a strictly positive finite scalar. """ if radius is not None and (not np.isscalar(radius) or radius <= 0 or not np.isfinite(radius)): msg = 'aperture_radius must be a strictly-positive scalar' raise ValueError(msg) return radius def _validate_localbkg(self, value, name): """ Validate the input ``local_bkg_estimator`` value. Parameters ---------- value : LocalBackground or None The local background estimator to validate. name : str Name of the parameter for error messages. Returns ------- value : LocalBackground or None The validated local background estimator. Raises ------ TypeError If value is not None and not a LocalBackground instance. """ value = self._validate_type(value, 'local_bkg_estimator', LocalBackground) return self._validate_callable(value, name) def _validate_maxiters(self, maxiters): """ Validate the input ``maxiters`` value. Parameters ---------- maxiters : int or None Maximum number of fitter iterations to validate. Returns ------- maxiters : int or None The validated maxiters value, or None if the fitter doesn't support this parameter. """ spec = inspect.signature(self.fitter.__call__) if 'maxiter' not in spec.parameters: msg = ("'maxiters' will be ignored because it is not accepted " 'by the input fitter __call__ method.') warnings.warn(msg, AstropyUserWarning) maxiters = None return maxiters def _sync_data_unit(self): """ Synchronize data_unit between main class and components. This method ensures that the data_unit attribute is consistent between the PSFPhotometry instance and its internal component objects (e.g., _data_processor). This method modifies the internal state in-place. """ if hasattr(self, '_data_processor'): self._data_processor.data_unit = self.data_unit def _find_sources_if_needed(self, data, mask, init_params): """ Find sources using the finder if initial positions are not provided. This method delegates to the data processor component and syncs results. """ result = self._data_processor.find_sources_if_needed( data, mask, init_params) self.finder_results = self._data_processor.finder_results return result def _group_sources(self, init_params): """ Group sources using the grouper or the user-provided 'group_id' column. Parameters ---------- init_params : `~astropy.table.Table` The table of initial parameters. Returns ------- init_params : `~astropy.table.Table` The table of initial parameters with a 'group_id' column. """ if 'group_id' in init_params.colnames: # user-provided group_id takes precedence self.grouper = None elif self.grouper is not None: # use the grouper to group sources x_col = self._param_mapper.init_colnames['x'] y_col = self._param_mapper.init_colnames['y'] init_params['group_id'] = self.grouper(init_params[x_col], init_params[y_col]) else: # no grouper provided, so each source is its own group init_params['group_id'] = init_params['id'].copy() # ensure group_id contains only positive (> 0) integers group_id = init_params['group_id'] if np.any(~np.isfinite(group_id)): msg = 'group_id must be finite' raise ValueError(msg) if not np.issubdtype(group_id.dtype, np.integer): msg = 'group_id must be an integer array' raise TypeError(msg) if np.any(group_id <= 0): msg = 'group_id must contain only positive (> 0) integers' raise ValueError(msg) return init_params def _build_initial_parameters(self, data, mask, init_params): """ Build the table of initial parameters for fitting. This method orchestrates finding sources, estimating initial fluxes and backgrounds, and grouping sources. Parameters ---------- data : 2D `numpy.ndarray` The input image data. mask : 2D `numpy.ndarray` or `None` A boolean mask where `True` values are masked. init_params : `~astropy.table.Table` or `None` The input table of initial parameters. Returns ------- init_params : `~astropy.table.Table` or `None` The table of initial parameters ready for fitting, or `None` if no sources were found. """ init_params = self._find_sources_if_needed(data, mask, init_params) if init_params is None: return None # strip any units from the x/y position columns for axis in ('x', 'y'): colname = self._param_mapper.init_colnames[axis] if isinstance(init_params[colname], u.Quantity): init_params[colname] = init_params[colname].value add_flux_bkg = self._data_processor.estimate_flux_and_bkg_if_needed init_params = add_flux_bkg(data, mask, init_params) init_params = self._group_sources(init_params) # check for large group sizes after grouping is complete warn_size = self.group_warning_threshold if 'group_id' in init_params.colnames: _, counts = np.unique(init_params['group_id'], return_counts=True) if len(counts) > 0 and max(counts) > warn_size: msg = (f'Some groups have more than {warn_size} ' '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".') warnings.warn(msg, AstropyUserWarning) # Add columns for any additional model parameters that are # fit using the model's default value, if not already present. for alias, col_name in self._param_mapper.init_colnames.items(): if col_name not in init_params.colnames: alias_map = self._param_mapper.alias_to_model_param model_param_name = alias_map[alias] init_params[col_name] = getattr(self.psf_model, model_param_name) # Define the final column order of the init_params table. # Extra aliases are those that are not in the main_aliases. # The alias and model_param names are the same for # extra parameters, so we can use the alias_to_model_param map # to get the extra aliases. main_aliases = self._param_mapper.MAIN_ALIASES extra_aliases = [param for param in self._param_mapper.alias_to_model_param if param not in main_aliases] main_cols = [self._param_mapper.init_colnames[alias] for alias in main_aliases] extra_cols = [self._param_mapper.init_colnames[alias] for alias in extra_aliases] col_order = ['id', 'group_id', 'local_bkg', *main_cols, *extra_cols] return init_params[col_order] def _prepare_fit_inputs(self, data, *, mask=None, error=None, init_params=None): """ Prepare all inputs for the PSF fitting. This method handles data validation, unit processing, source finding, initial parameter estimation, and grouping. It returns the processed inputs ready for the `_fit_sources` method. Parameters ---------- data : 2D array_like The input image data. mask : 2D array_like or `None`, optional A boolean mask where `True` values are masked (ignored). error : 2D array_like or `None`, optional The 1-sigma uncertainties of the input data. init_params : `~astropy.table.Table` or `None`, optional The input table of initial parameters. Returns ------- data : 2D `numpy.ndarray` The validated input image data. mask : 2D `numpy.ndarray` or `None` The validated boolean mask where `True` values are masked. If no mask was input, then `None` is returned. error : 2D `numpy.ndarray` or `None` The validated 1-sigma uncertainties of the input data. If no error was input, then `None` is returned. init_params : `~astropy.table.Table` or `None` The table of initial parameters ready for fitting, or `None` if no sources were found. """ (data, error), unit = process_quantities((data, error), ('data', 'error')) self.data_unit = unit self._sync_data_unit() # Sync with components data = self._data_processor.validate_array(data, 'data') error = self._data_processor.validate_array(error, 'error', data_shape=data.shape) mask = self._data_processor.validate_array(mask, 'mask', data_shape=data.shape) mask = _make_mask(data, mask) init_params = self._data_processor.validate_init_params(init_params) init_params = self._build_initial_parameters(data, mask, init_params) if init_params is None: # no sources found return None, None, None, None return data, mask, error, init_params def _ungroup_fit_results(self, row_indices, valid_mask, group_model, group_fit_info): """ Ungroup fitted results and store per-source data. This method extracts individual source parameters, errors, and covariance information directly from the group fit results and stores them in the state container. This avoids storing large group (flat) model objects and covariance matrices. The results for each valid source in the group are stored directly in the state container, including the fitted model parameters, parameter errors, and fit_info dictionary. ``row_indices`` is used to ensure that the order of the sources in the state container matches the source ID order in the input ``init_params`` table. Parameters ---------- row_indices : list The row indices for sources in this group. valid_mask : ndarray Boolean mask indicating which sources in the group are valid. group_model : `astropy.modeling.Model` The fitted model for a single group. This can be a compound model. group_fit_info : dict The fit_info dictionary corresponding to the group fit. """ nfitparam = len(self._param_mapper.fitted_param_names) num_valid = int(np.count_nonzero(valid_mask)) # Extract parameter errors from the group covariance matrix param_cov = group_fit_info.get('param_cov') if param_cov is None: source_param_errs = np.full((num_valid, nfitparam), np.nan) source_covs = [None] * num_valid else: param_err_1d = np.sqrt(np.diag(param_cov)) # For grouped (flat) models, parameters are arranged as, # e.g., [flux_0, x_0_0, y_0_0, fwhm_0, flux_1, x_0_1, ...] source_param_errs = param_err_1d.reshape(num_valid, nfitparam) # Extract individual covariance matrices for each source source_covs = self._psf_fitter.extract_source_covariances( param_cov, num_valid, nfitparam) # Split models and extract parameters if num_valid == 1: source_models = [group_model] else: # For grouped (flat) models, create individual models from # params source_models = self._psf_fitter.split_flat_model(group_model, num_valid) # Store results for each valid source valid_idx = 0 for i, row_index in enumerate(row_indices): if not valid_mask[i]: continue model = source_models[valid_idx] param_errs = source_param_errs[valid_idx] source_cov = source_covs[valid_idx] # Extract and store model parameters self._cache_fitted_parameters(row_index, model) self._state['fit_param_errs'][row_index] = param_errs # Create individual fit_info with source-specific covariance source_fit_info = dict(group_fit_info) if source_cov is not None: source_fit_info['param_cov'] = source_cov self.fit_info[row_index] = source_fit_info self._state['valid_mask_by_id'][row_index] = True valid_idx += 1 def _calculate_residual_metrics(self, row_indices, valid_mask, npixfit_full, cen_index_full, *, error=None, xi_all=None, yi_all=None): """ Calculate residual-based fit metrics for valid sources. Parameters ---------- row_indices : array-like Source row indices. valid_mask : array-like Boolean mask for valid sources. npixfit_full : array-like Number of pixels used in fit for each source. cen_index_full : array-like Center pixel indices for each source. error : 2D array or None, optional The 1-sigma uncertainties of the input data. Used for calculating reduced chi-squared. xi_all : list or None, optional List of x-coordinates for each valid source's cutout pixels. yi_all : list or None, optional List of y-coordinates for each valid source's cutout pixels. Returns ------- sum_abs_residuals : ndarray Sum of absolute residuals for each source, or np.nan for invalid sources. cen_residuals : ndarray Center residuals for each source, or np.nan for invalid sources. reduced_chi2 : ndarray Reduced chi-squared values for each source, or np.nan for invalid sources. """ # Extract residuals from fit_info residual_key = None with contextlib.suppress(AttributeError): fit_info = self.fitter.fit_info if isinstance(fit_info, dict): if 'fun' in fit_info: residual_key = 'fun' if 'fvec' in fit_info: # LevMarLSQFitter residual_key = 'fvec' if residual_key is not None: residuals = self.fitter.fit_info[residual_key] else: residuals = None n_sources = len(row_indices) sum_abs_residuals = np.full(n_sources, np.nan, dtype=float) cen_residuals = np.full(n_sources, np.nan, dtype=float) reduced_chi2 = np.full(n_sources, np.nan, dtype=float) if residuals is not None: # convert to numpy arrays for vectorized operations valid_mask_arr = np.array(valid_mask, dtype=bool) npixfit_arr = np.array(npixfit_full) cen_index_arr = np.array(cen_index_full) # get valid source indices valid_indices = np.where(valid_mask_arr)[0] if len(valid_indices) > 0: npix_valid = npixfit_arr[valid_indices] # calculate cumulative pixel positions cumsum_npix = np.concatenate(([0], np.cumsum(npix_valid))) # get the number of fitted parameters nfitparam = len(self._param_mapper.fitted_param_names) # process all valid sources for idx, valid_idx in enumerate(valid_indices): start_pos = cumsum_npix[idx] end_pos = cumsum_npix[idx + 1] source_residuals = residuals[start_pos:end_pos] # For qfit and cfit calculations, we need raw residuals # (data - model), not weighted residuals # (data - model)/error. If errors were provided, convert # weighted residuals back to raw residuals. raw_residuals = source_residuals if (error is not None and xi_all is not None and yi_all is not None): # Extract error values for this source's pixels xi_source = xi_all[idx] yi_source = yi_all[idx] error_vals = error[yi_source, xi_source] # Convert weighted residuals to raw residuals: # multiply by error if (np.all(error_vals > 0) and np.all(np.isfinite(error_vals))): raw_residuals = source_residuals * error_vals # sum of absolute residuals sum_abs_residuals[valid_idx] = float( np.abs(raw_residuals).sum()) # center residual cen_idx = cen_index_arr[valid_idx] if np.isfinite(cen_idx): cen_residuals[valid_idx] = float( -raw_residuals[int(cen_idx)]) else: cen_residuals[valid_idx] = np.nan # Calculate chi-squared. The residuals have already # been weighted by (1 / error). If errors are not # input, then reduced_chi2 will be NaN. dof = float(npix_valid[idx] - nfitparam) if (error is not None and xi_all is not None and yi_all is not None): # Extract error values for this source's pixels xi_source = xi_all[idx] yi_source = yi_all[idx] error_vals = error[yi_source, xi_source] if (np.all(error_vals > 0) and np.all(np.isfinite(error_vals))): chi2 = np.sum(source_residuals**2) reduced_chi2[valid_idx] = chi2 / dof row_indices_arr = np.array(row_indices) self._state['sum_abs_residuals'][row_indices_arr] = sum_abs_residuals self._state['cen_residuals'][row_indices_arr] = cen_residuals self._state['reduced_chi2'][row_indices_arr] = reduced_chi2 return sum_abs_residuals, cen_residuals, reduced_chi2 def _fit_source_groups(self, source_groups, data, mask, error): """ Fit PSF models to groups of sources in the input data. This method processes each group of sources, fits PSF models, and stores the results. Individual source results are extracted and stored as soon as each group is fitted. Parameters ---------- source_groups : iterable Groups of sources to fit, where each group contains sources that should be fit simultaneously. data : 2D ndarray The input image data. mask : 2D ndarray or None Boolean mask for the input data. error : 2D ndarray or None The 1-sigma uncertainties of the input data. """ if self.progress_bar: # pragma: no cover source_groups = add_progress_bar(source_groups, desc='Fit source/group') y_offsets, x_offsets = self._data_processor.get_fit_offsets() nfitparam_per_source = len(self._param_mapper.fitted_param_names) # sources are fit by groups in group ID order for source_group in source_groups: group_size = len(source_group) xi_all = [] yi_all = [] cutout_all = [] npixfit_full = [] cen_index_full = [] valid_mask_list = [] invalid_reasons = [] row_indices = [] # Process all sources with pre-filtering optimization for row in source_group: # Always use pre-filtering for all group sizes should_skip_source = self._data_processor.should_skip_source should_skip, reason = should_skip_source(row, data.shape) if should_skip: res = { 'valid': False, 'reason': reason, 'xx': None, 'yy': None, 'cutout': None, 'npix': 0, 'cen_index': np.nan, } else: res = self._data_processor.get_source_cutout_data( row, data, mask, y_offsets, x_offsets) # Common processing for all sources npixfit_full.append(res['npix']) cen_index_full.append(res['cen_index']) invalid_reasons.append(res['reason']) row_indices.append(row['_row_index']) if res['valid'] and res['npix'] >= nfitparam_per_source: valid_mask_list.append(True) xi_all.append(res['xx']) yi_all.append(res['yy']) cutout_all.append(res['cutout']) else: if res['valid'] and res['npix'] < nfitparam_per_source: invalid_reasons[-1] = 'too_few_pixels' valid_mask_list.append(False) valid_mask = np.array(valid_mask_list, dtype=bool) num_valid = int(np.count_nonzero(valid_mask)) # Store basic info for all sources in group. # row_indices is used to store results in the original # source ID order given by init_params. row_indices_arr = np.array(row_indices) self._state['group_size'][row_indices_arr] = group_size self._state['n_pixels_fit'][row_indices_arr] = np.array( npixfit_full, dtype=int) for i, row_index in enumerate(row_indices): reason = invalid_reasons[i] self._state['invalid_reasons'][row_index] = ( '' if reason is None else reason ) if num_valid == 0: # Handle all-invalid group for row_index in row_indices: self._state['valid_mask_by_id'][row_index] = False self._cache_fitted_parameters(row_index, None) continue # Fit the group xi_concat = np.concatenate(xi_all) yi_concat = np.concatenate(yi_all) cutout_concat = np.concatenate(cutout_all) valid_sources = source_group[valid_mask] psf_model = self._psf_fitter.make_psf_model(valid_sources) fit_model, fit_info = self._psf_fitter.run_fitter( psf_model, xi_concat, yi_concat, cutout_concat, error) # Ungroup and store per-source results. row_indices is used # to ensure that results are stored in the original source # ID order given by init_params. self._ungroup_fit_results(row_indices, valid_mask, fit_model, fit_info) # Calculate residual metrics for valid sources self._calculate_residual_metrics( row_indices, valid_mask, npixfit_full, cen_index_full, error=error, xi_all=xi_all, yi_all=yi_all) def _get_fit_error_indices(self): """ Get the indices of fits that did not converge. This method delegates to the results assembler component. """ return self._results_assembler.get_fit_error_indices(self.fit_info) def _create_fit_results(self, fit_model_all_params): """ Create the table of fitted parameter values and errors. This method delegates to the results assembler component. """ fit_param_errs = self._state['fit_param_errs'] valid_mask = self._state.get('valid_mask_by_id') return self._results_assembler.create_fit_results( fit_model_all_params, fit_param_errs, valid_mask, self.data_unit) def _assemble_fit_results(self): """ Assemble the final fitted results tables and parameters. This method creates the fitted models table and fit parameters table from the per-source data that was stored during the fitting process. It also computes fit error indices. Returns ------- fit_params : `~astropy.table.Table` Table containing the fitted parameters and their errors. """ fit_error_indices = self._get_fit_error_indices() fitted_models_table = self._build_fitted_models_table() fit_params = self._create_fit_results(fitted_models_table) # store results in state for other methods that need them self._state['fit_error_indices'] = fit_error_indices self._state['fitted_models_table'] = fitted_models_table self._state['fit_params'] = fit_params return fit_params def _fit_sources(self, data, init_params, *, error=None, mask=None): """ Fit PSF models to sources in the input data. Parameters ---------- data : 2D ndarray The input image data. init_params : `~astropy.table.Table` The table of initial parameters for each source. error : 2D ndarray or `None`, optional The 1-sigma uncertainties of the input data. mask : 2D ndarray or `None`, optional A boolean mask where `True` values are masked (ignored). Returns ------- fit_params : `~astropy.table.Table` The table of fitted parameters and fit quality metrics for each source. """ # add row index for stable mapping if '_row_index' not in init_params.colnames: init_params['_row_index'] = np.arange(len(init_params)) self._initialize_source_state_storage(len(init_params)) source_groups = init_params.group_by('group_id').groups self._fit_source_groups(source_groups, data, mask, error) # clean up temporary row index column if '_row_index' in init_params.colnames: init_params.remove_column('_row_index') return self._assemble_fit_results() def _calc_fit_metrics(self, results_tbl): """ Calculate fit quality metrics qfit, cfit, and reduced_chi2. This method delegates to the results assembler component. """ sum_abs_residuals = self._state['sum_abs_residuals'] cen_residuals = self._state['cen_residuals'] reduced_chi2 = self._state['reduced_chi2'] return self._results_assembler.calc_fit_metrics( results_tbl, sum_abs_residuals, cen_residuals, reduced_chi2) def _define_flags(self, results_tbl, shape, init_params): """ Define per-source bitwise flags summarizing fit conditions. This method delegates to the results assembler component. """ fit_error_indices = self._state.get('fit_error_indices') fitted_models_table = self._state.get('fitted_models_table') valid_mask = self._state.get('valid_mask_by_id') invalid_reasons = self._state.get('invalid_reasons') return self._results_assembler.define_flags( results_tbl, shape, fit_error_indices, self.fit_info, fitted_models_table, valid_mask, invalid_reasons, init_params) def _assemble_results_table(self, init_params, fit_params, data_shape): """ Assemble the final results table. This method delegates to the results assembler component. """ # prepare metadata attributes class_attrs = {'psf_model', 'finder', 'grouper', 'fitter', 'local_bkg_estimator'} metadata_attrs = {} for attr in self._attrs: value = getattr(self, attr) if attr in class_attrs and value is not None: metadata_attrs[attr] = repr(value) else: metadata_attrs[attr] = value return self._results_assembler.assemble_results_table( init_params, fit_params, data_shape, self._state, self._calc_fit_metrics, self._define_flags, self.__class__.__name__, metadata_attrs) @staticmethod def _coerce_nddata(data): """ Return normalized (data, mask, error) if ``data`` is NDData. This helper extracts ``data.data``, propagates units, and derives an error array from an attached ``StdDevUncertainty`` (or compatible uncertainty) if present. Parameters ---------- data : `~astropy.nddata.NDData` The input data. Returns ------- data_array : 2D `~numpy.ndarray` or `~astropy.units.Quantity` The 2D data array. mask : 2D bool `~numpy.ndarray` or `None` The boolean mask array. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity` or `None` The 1-sigma error array. """ data_array = data.data if data.unit is not None: data_array = data_array << data.unit mask = data.mask unc = data.uncertainty error = None if unc is not None: err = unc.represent_as(StdDevUncertainty).quantity if getattr(err, 'unit', None) == u.dimensionless_unscaled: err = err.value elif data.unit is not None: err = err.to(data.unit) error = err return data_array, mask, error @_create_call_docstring(iterative=False) def __call__(self, data, *, mask=None, error=None, init_params=None): # reset state from previous runs self._reset_results() try: # handle NDData input if isinstance(data, NDData): data, mask, error = self._coerce_nddata(data) # prepare all inputs for sources to be fit data, mask, error, init_params = self._prepare_fit_inputs( data, mask=mask, error=error, init_params=init_params, ) # handle the case where no sources were found if init_params is None: return None self.init_params = init_params # fit sources defined in init_params fit_params = self._fit_sources(data, init_params, error=error, mask=mask) # assemble the final results table # Note: _assemble_results_table handles _state cleanup self.results = self._assemble_results_table( init_params, fit_params, data.shape) except Exception: # ensure state cleanup even if an exception occurs self._reset_state() raise self._reset_state() return self.results def _reset_state(self): """ Reset _state dictionary in case of exceptions. This ensures memory is freed even if the normal cleanup path is not reached due to an exception during processing. """ if hasattr(self, '_state') and self._state: self._state.clear() @property @deprecated('2.3.0', alternative='results') def fit_params(self): """ The table of fit parameters and their errors. This table is a subset of the ``results`` table, containing only the fit parameters and their errors. It can be used as the ``init_params`` for subsequent `PSFPhotometry` fits. """ if self.results is None: return None tbl = QTable() for col_name in self.results.colnames: if col_name == 'id' or '_fit' in col_name or '_err' in col_name: tbl[col_name] = self.results[col_name] return tbl @staticmethod def _results_to_init_params(results_tbl, *, remove_invalid=True, reset_ids=True): """ Convert PSF photometry results to initial parameters format. This method extracts fitted parameters (columns ending with '_fit') from the results table and renames them to initial parameter format (ending with '_init'). This output can be used as ``init_params`` for subsequent `PSFPhotometry` runs, allowing iterative refinement of source positions and fluxes. This is a static helper method to allow it to be called by `~photutils.psf.IterativePSFPhotometry`. Parameters ---------- results_tbl : `~astropy.table.QTable` or None The table of fit results from a previous `PSFPhotometry` run. If None, returns None. remove_invalid : bool, optional If `True`, rows containing non-finite fitted values are removed. Default is `True`. reset_ids : bool, optional If `True`, the 'id' column is reset to sequential numbering starting from 1. If `False`, the 'id' values are preserved from ``results_tbl``. This option is ignored if ``remove_invalid`` is `False`. Default is `True`. Returns ------- init_params_tbl : `~astropy.table.QTable` or None A table with columns renamed from '*_fit' to '*_init', suitable for use as ``init_params`` in a subsequent `PSFPhotometry` call. Returns None if ``results_tbl`` is None. Notes ----- Only the 'id' column and columns with '_fit' suffix are included in the output. All other columns from the results table (e.g., quality metrics, flags) are excluded. """ # PSF photometry not yet run if results_tbl is None: return None tbl = QTable() for col_name in results_tbl.colnames: if col_name == 'id' or '_fit' in col_name: init_name = col_name.replace('_fit', '_init') tbl[init_name] = results_tbl[col_name] if remove_invalid: # Remove rows with any non-finite fitted values keep = np.all([np.isfinite(tbl[col]) for col in tbl.colnames], axis=0) tbl = tbl[keep] if reset_ids: tbl['id'] = np.arange(1, len(tbl) + 1) return tbl @staticmethod def _results_to_model_params(results_tbl, param_mapper, *, remove_invalid=True, reset_ids=True): """ Convert PSF photometry results to PSF model parameters format. This method extracts fitted parameters (columns ending with '_fit') from the results table and renames them to match the PSF model's parameter names (e.g., 'x_fit' → 'x_0', 'flux_fit' → 'flux'). This output can be used to reconstruct fitted PSF models for visualization or further analysis. This is a static helper method to allow it to be called by `~photutils.psf.IterativePSFPhotometry`. Parameters ---------- results_tbl : `~astropy.table.QTable` or None The table of fit results from a previous `PSFPhotometry` run. If None, returns None. param_mapper : `_PSFParameterMapper` The helper class that manages the mapping between aliases (e.g., 'x', 'flux') and PSF model parameter names (e.g., 'x_0', 'flux'). remove_invalid : bool, optional If `True`, rows containing non-finite fitted values are removed. Default is `True`. reset_ids : bool, optional If `True`, the 'id' column is reset to sequential numbering starting from 1. If `False`, the 'id' values are preserved from ``results_tbl``. This option is ignored if ``remove_invalid`` is `False`. Default is `True`. Returns ------- model_params_tbl : `~astropy.table.QTable` or None A table with columns renamed to match the PSF model's parameter names, suitable for model reconstruction. Returns None if ``results_tbl`` is None. Notes ----- Only the 'id' column and columns with '_fit' suffix are included in the output. All other columns from the results table (e.g., initial parameters, quality metrics, flags) are excluded. """ # PSF photometry not yet run if results_tbl is None: return None tbl = QTable() for col_name in results_tbl.colnames: if col_name == 'id' or '_fit' in col_name: alias = col_name.replace('_fit', '') model_param_name = param_mapper.alias_to_model_param.get( alias, alias) tbl[model_param_name] = results_tbl[col_name] if remove_invalid: # Remove rows with any non-finite fitted values keep = np.all([np.isfinite(tbl[col]) for col in tbl.colnames], axis=0) tbl = tbl[keep] if reset_ids: tbl['id'] = np.arange(1, len(tbl) + 1) return tbl def results_to_init_params(self, *, remove_invalid=True, reset_ids=True): """ Create a table of the fitted model parameters from the results. The table columns are named according to those expected for the initial parameters table. It can be used as the ``init_params`` for subsequent `PSFPhotometry` fits. Parameters ---------- remove_invalid : bool, optional If `True`, rows that contain non-finite fitted values are removed. reset_ids : bool, optional If `True`, the 'id' column will be reset to a sequential numbering starting from 1. If `False`, the 'id' column will remain unchanged from the results table. This option is ignored if ``remove_invalid`` is `False`. """ return self._results_to_init_params(self.results, remove_invalid=remove_invalid, reset_ids=reset_ids) def results_to_model_params(self, *, remove_invalid=True, reset_ids=True): """ Create a table of the fitted model parameters from the results. The table columns are named according to the PSF model parameter names. It can also be used to reconstruct the fitted PSF models for visualization or further analysis. Parameters ---------- remove_invalid : bool, optional If `True`, rows that contain non-finite fitted values are removed. reset_ids : bool, optional If `True`, the 'id' column will be reset to a sequential numbering starting from 1. If `False`, the 'id' column will remain unchanged from the results table. This option is ignored if ``remove_invalid`` is `False`. """ return self._results_to_model_params(self.results, self._param_mapper, remove_invalid=remove_invalid, reset_ids=reset_ids) @deprecated_positional_kwargs(since='3.0', until='4.0') def decode_flags(self, return_bit_values=False): """ Decode the PSF photometry flags from the results table. This is a convenience method that calls `~photutils.psf.decode_psf_flags` with the 'flags' column from the results table. Parameters ---------- return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag descriptions (strings). Default is `False`. Returns ------- decoded : list of list of str or list of list of int List of lists where each inner list contains the active flag names (or bit values) for the corresponding source in the results table. If no flags are set for a source, an empty list is returned for that source. Raises ------ ValueError If no results are available. Please run the PSFPhotometry instance first. See Also -------- photutils.psf.decode_psf_flags Examples -------- Decode flags from PSF photometry results: >>> import numpy as np >>> from astropy.table import Table >>> from photutils.psf import CircularGaussianPRF, PSFPhotometry >>> yy, xx = np.mgrid[:21, :21] >>> psf_model = CircularGaussianPRF(flux=1, x_0=10, y_0=10, fwhm=2) >>> # Create a source with negative flux to trigger a flag >>> m1 = CircularGaussianPRF(flux=100, x_0=10, y_0=10, fwhm=2) >>> m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) >>> data = m1(xx, yy) + m2(xx, yy) >>> init_params = Table({'x': [10, 5], 'y': [10, 5], ... 'flux': [100, 100]}) >>> photometry = PSFPhotometry(psf_model, (3, 3)) >>> results = photometry(data, init_params=init_params) >>> decoded_flags = photometry.decode_flags() >>> for i, flags in enumerate(decoded_flags): ... print(f'Source {i+1}: {flags}') # doctest: +SKIP Source 1: [] Source 2: ['negative_flux'] """ if self.results is None: msg = ('No results available. Please run the PSFPhotometry ' 'instance first.') raise ValueError(msg) return decode_psf_flags(self.results['flags'], return_bit_values=return_bit_values) def _get_model_image_params(self): # Convert fitted parameters to model parameter names without # filtering, so the row indices align with self.results model_params = self.results_to_model_params(remove_invalid=False) # Filter out invalid sources (those with NaN fitted values) keep = np.all([np.isfinite(model_params[col]) for col in model_params.colnames], axis=0) model_params = model_params[keep] # Extract local_bkg for the same valid sources local_bkg = self.results['local_bkg'][keep] return model_params, local_bkg @deprecated_renamed_argument('include_localbkg', 'include_local_bkg', '3.0', until='4.0') @_make_model_image_docstring def make_model_image(self, shape, *, psf_shape=None, include_local_bkg=False): if self.results is None: msg = ('No results available. Please run the PSFPhotometry ' 'instance first.') raise ValueError(msg) model_params, local_bkg = self._get_model_image_params() maker = _ModelImageMaker(self.psf_model, model_params, local_bkg=local_bkg, progress_bar=self.progress_bar) return maker.make_model_image(shape, psf_shape=psf_shape, include_local_bkg=include_local_bkg) @deprecated_renamed_argument('include_localbkg', 'include_local_bkg', '3.0', until='4.0') @_make_residual_image_docstring def make_residual_image(self, data, *, psf_shape=None, include_local_bkg=False): if self.results is None: msg = ('No results available. Please run the PSFPhotometry ' 'instance first.') raise ValueError(msg) model_params, local_bkg = self._get_model_image_params() maker = _ModelImageMaker(self.psf_model, model_params, local_bkg=local_bkg, progress_bar=self.progress_bar) return maker.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=include_local_bkg) astropy-photutils-3322558/photutils/psf/simulation.py000066400000000000000000000164051517052111400230250ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for creating images from PSF models. """ import numpy as np from photutils.datasets import make_model_image, make_model_params from photutils.datasets.images import _model_shape_from_bbox from photutils.psf.utils import _get_psf_model_main_params from photutils.utils._parameters import as_pair __all__ = ['make_psf_model_image'] 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. **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 CircularGaussianPRF, make_psf_model_image >>> shape = (150, 200) >>> psf_model = CircularGaussianPRF(fwhm=3.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.2010 72.3184 147.9522 2 57.6408 39.1380 128.1262 3 15.5391 115.4520 200.8790 4 11.0411 131.7530 129.2661 5 157.6417 43.6615 186.6532 6 175.9470 80.2172 190.3359 7 142.2274 132.7563 244.3635 8 108.0270 13.4284 110.8398 9 180.0533 106.0888 174.9959 10 158.1171 90.3260 211.6146 .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CircularGaussianPRF, make_psf_model_image shape = (150, 200) psf_model = CircularGaussianPRF(fwhm=3.5) n_sources = 10 data, params = make_psf_model_image(shape, psf_model, n_sources, flux=(100, 250), min_separation=10, seed=0) fig, ax = plt.subplots() ax.imshow(data, origin='lower') .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CircularGaussianPRF, make_psf_model_image shape = (150, 200) psf_model = CircularGaussianPRF(fwhm=3.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)) fig, ax = plt.subplots() ax.imshow(data, origin='lower') """ main_params = _get_psf_model_main_params(psf_model) if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 0)) else: try: model_shape = _model_shape_from_bbox(psf_model) except ValueError as exc: msg = ('model_shape must be specified if the model does not ' 'have a bounding_box attribute') raise ValueError(msg) from exc 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 (main params) for key, val in kwargs.items(): if key not in psf_model.param_names or key in main_params[0:2]: continue # skip the x, y parameters other_params[key] = val x_name, y_name = main_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 astropy-photutils-3322558/photutils/psf/tests/000077500000000000000000000000001517052111400214235ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf/tests/__init__.py000066400000000000000000000000001517052111400235220ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf/tests/data/000077500000000000000000000000001517052111400223345ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits000066400000000000000000000341001517052111400271140ustar00rootroot00000000000000SIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 5 NAXIS2 = 5 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@>[Ŧ>JV=ķƒ>>ûĨ>%&­>1–Ā>(Z‚>7U>B>0Č/><M>0„ļ>ŗå>O>(CÆ>1=$>%h> =ō{ņ> ‚>fr>&=āœŅ=ãÛR> C>˜š>đ=øl8> ¤Š>+`7>8ļĻ>/cŅ>Ŋ/>~€>7ũ_>D]/>8ƒæ>ÅŠ>ļ>>.Íp>8zø>+äÜ> y=ø/ >ß>¤å> Xô=æķž=æˆ˙> Ø)>]u>hí=ú ņ>ß>-ą<>: Ä>1 *>ß->É >< >Gø><–>ē>¨>1ēe>;*Ŋ>.jK>@Ų=ųģv>Ŋė>M>ŪE=ë+=îúÍ>.>H„>~ø=˙ZB>)w>4>A…~>6‚š>\Ö> Ē.>B6>N|÷>A@ã> >>7‡Y>BK/>4ˇ„>a]=ūŠũ>šâ>÷#>Ł=đ?ą=ņ3B>€Õ> ÖÉ>B‚=ūĶ>>7Ķ<>EĘø>;ã>úƒ>#Š>GOG>TįP>Ht¨>$ē>9>;ŧ >FŦ!>9]>>X‹>C>"üˇ>Đđ=ô!Ņ=õkĄ>ŊĻ>&”*>ũø>Î >hO>;ŪĐ>JŸ*>=Đ÷>!¤>&?Í>Kb>XÖ>Iíą>%>I>Ļr>?ķ>J¯>;Ée>ēb> Ä>žé>%mp>ĐÕ=÷Ú=õKĄ>:Ō>#ĐÉ>ŖÜ>=•>k(>:g~>GS†>;4a> >$}Å>HĀ…>Uf >GC‰>#ņÔ>n>=Ä >Hģ‹>:Ļ5>˛ >d>ĒĘ>#čP>–H=÷D=øŽ1>SM>%ō”>„o>EÎ> >;qš>HĻ>=râ>Ÿ0>%=>J‹Š>W—>JŒ>%\Œ>Ít>>Á>J] >= =>ga>Ô>Ūí>%~I>e=÷ *=ö+>‚ >$Ôm>Ëė>fÂ>,>;Š.>IĄ>=˛ø>uJ>$pE>J>X5#>Jdĸ>$â(>Ģ:>>¨M>JöŸ><ã9>ú(>6‘>‚›>&“L>÷t=ønĸ=ä¸t> ôļ> >b6=øž4> Ši>*>H>7ĩ>.…ī>ČĮ>ĸø>7’>C!L>8>ē>Ą >$‘>-œ•>7Gę>+i†> •`=øk>ē>1l> áy=æĐõ=đ>§>‰đ>!Ė>Ûe>Yģ>ĸ1>4§Š>BWR>8o“>ĐY> >AōB>Ođ>Bôp>!já>¯`>8s>C/Ļ>6A3>Ąŧ>/ >’ >! ,>˙X=ō—5=õŅ>#>%r>(›>sž>g>9§û>IVL>>ä–>Ŗa>%ŌN>HŪ,>WŨ|>K >%Ģŧ>â >=°ˆ>IōĐ>
iĖ>üy>×>%Đ~>†°=ö„r=÷úĘ>ŋ>'H1>ĩß>F´>HĶ>=ī.>L;>A å>ß}>'t3>LjŦ>Z˛Ī>MÄ>'ī4>ŧĖ>@w]>L3>>‰> v> >ũt>'Č8>q=ú¯=ųęŦ>ŌW>(5>ÜI>Ũ¨>¨˛>> \>LÔ>AD—>œ>(x>LđN>[C¨>M•ļ>'ßi>A>@ų>M@Ö>?2h> E>b>o>(­E>‹=û4Ž=÷Ų:>Fõ>'¯'>+­>}!>%>>z>MĢ>@Á›>ã€>'G>MD.>\0+>Lũ>&ļG>\M>AFÉ>Mۊ>>h‰>õ¤>|Ŋ>Hų>(–>=ú&ž=ø7Ą>Vú>(ã>u,>¸9>Lk>?0đ>Mˇ>AKL>]T>'Ą)>MÜ.>\!v>MÁD>'p!>āĢ>Aē”>MŌs>?I4>•>F]> w>)*>Iō=ûŦõ=úˇÎ>'E>(´Y>‡Ž>ŽĨ>ËŨ>>Ya>LŽ>?iF>ģV>&ƒ >LČi>Z>é>K6ˆ>%„™>kđ>AąH>Mh >>2‚>*E>ž>>ũ›>'Č(>`Õ=øņ~=øÉ>¨Ë>'vĄ>ƒŒ>N@>6Ę>;ŧš>IĨī><÷x>Í>#ؤ>H‡Ŗ>VV>GŌy>"É>Xî>>N{>JC*><0e>ŧ˛>ˇ> Ũ>&Z9>!a=ų/=æž>> Ü|>D>Ž=ûéŲ> 9e>+ŗ>8gF>/w>÷z>;‚>8ą>C°)>8wÁ>hĸ>if>/E\>8„ŋ>,A™>ú=ų•#>0Ī>ˇƒ> ęC=éBT=ôMô>>>$t}>ÕC>´>Č >8Ļ3>GB><\i>Ô>#m‡>FNƒ>Sô6>G,w>$>ÛY>; u>GF>9Á.>ųC>Žd>Ū„>$M>ū=öĪy=ųŗO>ŧ>(tč> D>Q]>=ÖĨ>LeC>@îi>D4>'Š>Køë>YđI>L=ŗ>'ŗŸ>7>@û >LˇZ>>‡g>zP>†N>ĢS>(\¨>¯%=ü{‘=û•>n˙>)‹Ą> ˇE>\>:ō>>Ά>N{Ŧ>BŠë> ?Ŗ>(<>M=>\ĸ>N•>)=.>ÂA>A•>N°w>@%ķ>2Ģ>c> Mū>*r>ŋ"=ũ%=úy›>­>(t>Ÿ>ܛ>~>=”>>Ltũ>@ˆ >SE>&ôn>LŅ>Zgx>LĄ>&ß>ˇ,>@Ū>M#k>>i]>Ğ>´0>S'>(™.>k_=û`G=ųŲ‚>ƒC>(¤">J>V>ėĸ>>Š>LŒ7>@ Š>X>&…›>L+!>ZLb>Kâ#>&5A>Wz>@Íz>Lė->>qË>ZZ>R>$>(LÔ>”Ô=úá%=û8i>;.>)¯> Uļ>Ŋ0>e>>Š>M,i>@vË>7 >'Ĩh>MRė>[ĸÁ>L¯7>&l>î—>Ašc>NHR>?;(>ĒÍ>Vh>p}>)-t>Ûŧ=úą5=öpŅ>é>'‘l>V_>šÕ>Äü><:X>Jä3>>i>“ž>$‚>J>WãŽ>Ih„>#Ė[>€e>?ŧ>JrD>;‰‰>Ąƒ>]ö>iŽ>'ĶY>kš=øVx=ōąË>o›>#‘Ë>æH=ūÖÚ>;J>7V>Dŗŋ>8F>BĒ>]p>D7(>QŗP>CS>ū>R5>9ų<>F2>7Ké>um=˙—ã>#,>%g>°—=ķšĪ=æy>ĸY>^ô>X=ü> ‰>,™7>:>>0Đ>ų>Ÿ˜>9Ŗ >F7>:[Î>Ĩc>ģx>0Ŋ>:Šĸ>-áË>öo=ú‡Ŗ>xĪ>Ō> !=čę =ôëö>Œ>%,“>ŠÔ>}–>gã>8Đü>GDw><Ø)>ŋs>"Č">Eöū>Sŋ>G0°>$nķ>#Ŋ>;A>Fc|>9"Z>Ēŗ>ŲO>#>$§Ü>9$=÷>Z=ô{Ô>¤>#M“>eģ>­­>Ļu>6üL>DķŖ>:kX>Ŧ(>"4H>D€Ÿ>R4>EŠ!>"É]>¸–>9Îz>E8i>89€>Šö>¸˜>’ô>#&>?=õ4=đZ>ĩy>Æä>Ģ=ū>ų>ތ>2ø÷>@1y>5Ķø>o>)Ē>?×>L9>?ķ!>˜I>ÉĻ>5Kf>@ē>3gÁ>c¯=ū—Ã>á<>ŪØ>tT=ōõē=õ=ū>ƒ>#Ąô>%‡> Ō>1õ>7æĨ>EŌÜ>:™Đ>X >"°>Dōö>RY)>E1>"'p>“>:˜Ŧ>Eō;>8†>Ũđ>OÕ>8Ú>#Ė0>ûč=÷dB=ųc>ĮŲ>'Ë>o >´†>öN><ž¨>K5s>>ˇË>T>$ũŋ>IôŸ>X`ā>Iķx>$ õ>™g>>å>K'é><ä'>b˜>ßŲ>.>'‰›>Ķ =úŨ=ô[I>,c>%Q >)›>H{>>9˛m>H/đ>;üŠ>Ó>"˛ã>G¯Ą>U_ŗ>FĐT>"Öø>Ũđ><‘>G¤~>8­>V)>t>Ģ?>%s> Š=öĖÖ=ö.Ä>B^>%>nW>tI>NŸ>9æ|>H’ >ģ%>"í">FäF>U\S>G0c>!Ãü>ŅÉ>;ãF>Hyņ>:Jv>–G>¯.>@}>%҆>ą=ö]3=ņë>§L>#T>6=ũX&>{Å>5iü>Cė>7h9>ø>‚S>A Ë>O”->@ĘÜ>…`>)>7é>D0 >5)ú>Jo=ū‰ˆ>53>#¨n>-=ņ=ėLÕ>K&>ŊZ>Ō3>Sš>P4>/Üū>=S+>4ÃÎ>„u>UĨ><Ēí>IWņ>>s§>4î>â>3†e>=ŗŌ>1ļ‰>H=ū˛4>õ>é#>dø=íîw=đs(>=>"ŽŦ>Ē>;ž>{2>5>CˆÛ>9áF>šĪ> sá>B §>Oä$>CęL>!û„>7¤>9=>CšÂ>6Č>ųĢ>˛$>9ę>!Ą>āÃ=ôG=ė->i<>‰o>Ķ=ũļˇ>ũ>/ŽN><Ûî>2Ļõ>Ā>d–>;ĐŖ>Hq¸>ļZ>ŽX>2â><ĩŠ>0G‘>Ût=üe>Č>ĸÂ>g9=ņ=éA>ąÚ>¯‘>^6=öũT> 6Ë>+­ >7ö >.NĀ>øā>ŧ>8 …>D>8ļ†>“Ų>Â{>.7~>86Į>,}>k™=÷lą>tß>ŗƒ>û=ëŠ=đõF>˜+>Ņū>U=üī‘>i >21Ī>?v>4yJ>ūB>ē>>Œ–>KK§>>¤1>¸">\N>4‡Ė>?e}>2¨&>ã=ũ?!><>TO> ÷=ōÜō>ēÂ>!Mœ>/…î>%›S>Æq>€>EĢĀ>U”B>H^ >#Yl>,Û˙>U 6>d™Š>U@>,X>$5‡>HÁî>V:ŧ>Fœb>ާ>YQ>%ŖR>0d>"Y×>ĸ=õķ^>t¨>%)Â>&2>ZÔ>%N>7ëi>Eá™>9đ^>oļ> zō>Dëų>R_‰>D\4>!^ņ>)ē>:ôŽ>FHÅ>7Îå>.}>ŖÃ>>$Š >yD=öļ=õŅų>:Ŗ>%R}>$×>Ãc>õģ>8[>FãĮ>:´> °>!˙•>EîP>S˙o>Eŧ\>!E+>T~>;‚Ų>G–ę>9,>î>nb>:>%\Û>4•=õ*N=ø5€>Ĩ8>(Ŧ>¯;>ę#>>>;´˙>Jš>>Q>Kę>#Œ >Hmņ>Vė>H¯:>"z>Ą>>ǃ>KC5>?)> >˙>)$>N=öē-=åĮ> ׂ>īĄ>wī=ųę> >*°¤>7^ú>/yÁ>U>>‹>62G>B>8 >dą>ėį>,ĘM>6›>*Ûë> ˜ę=úĶ>ĢĪ>Ü> Ō`=æw&=>/> ;g>Ę=˙´Í>â>2p>?ôī>6q>n¸>Ą>?X>LÜ>@9> K>;0>5f>?Îč>3ye>Œ =ū&g>æN>Ļ>nû=đÔ0=뙈>Ĩ>m>6:=ų0e>(Ž>-ÉK>:Üą>0ė2>›B>ļ§>9$ļ>EĒy>9¸#> Å>oÜ>.Í%>90>>,ŗ>}Ų=ųô>ĶË>™‹>9 =í,ß=įmq> °î>įæ>Ë=ķ^> Ū>'Ֆ>3b>*Ū>ø–>W >3-p>>,L>3ĀL>K›>ņQ>*Ož>3Ŗx>(ís> P`=ķFÆ>°&>“W> ėh=čåã=î–Ë>QÛ>ÁÖ>Ÿ?=ún}>K#>/ôp>1Öõ>&ë>kƒ>;’>Fņã>:¨)>Ɔ>Z>1đ>;Ân>/"g>|.=ų> >M> ^>;Q=đeˆ=ūJ3>]>-XŨ>$ ,>„0>$Ü>Bņâ>RÜ>Ej/>! Ų>*u?>Qq›>_úS>Q9Č>)ãį>!žų>Eoé>Qåģ>Búy>M >Ļ{>#lG>,āî>§Ø=˙Øī=ķ',>úŠ>$G>R_=˙Ļq>Š >7tR>F á>9·>ī+>!č>Eaâ>S„>Døæ>  >>R>:‹>Fn°>7’>Ę>zĘ>ō >#š >c=ņRņ=ô„–>qį>$ē>“‚>'|>S>8o”>EŸŅ>9Žj>N!> ŨČ>E€>Q‚ī>CŌ¤> 0Š>˜Ë>:ík>E]ú>7^B>Lļ>ˆĖ>ËX>#Ĩu>l=ô|X=đІ>=>"›¨>[^=ũüĀ>+:>3˜>Aöĸ>6ÉD>R~>äģ>?s…>M29>?Ę@>€>6Ô>6hÅ>B\û>4‹Á>†Š=ũ†~>Ú>"()>›—=īÚa=âŠ> |i>>Ā6=ø… > ­e>(…t>5Žü>-a:>wŽ>.ņ>4FĄ>@'5>5•>>[>+jM>4Ī>)j> ę=öžV>x>u[> ũ=äķ=îũ>×/>ĨÁ>ޝ>Hæ>…>1o>? ũ>5Ž->ŒĘ>– ><ōø>Ių>>XU>Ė+>A?>3rÚ>>š>1Ž%>Ė=˙Bí>Ũ>¯ë>=īŗD=čŨ{>cĐ>Īl>ÎM=÷$›> `—>+€>>8 >.šÔ>~>8>6é?>Bũ>7Õ)>Ūũ>ō>-q >7•h>+ī >C=ö÷>˜t>"G>”e=ęڔ=į!b> û>ø>ĸ=õ-ƒ> Ũŧ>)î>4š>*ĶQ>ģœ>Št>4ņ.>?ßÖ>4<‡>§Ô>ē˜>+“Ĩ>4΂>(éÜ> _ņ=ōŠn>Â×>‘ˆ> כ=é'į=ī(Ņ>>>[š>™H=üY4>É>0ĨF>=ÜD>3>@>÷>§'>=w>JK{>=–Ô>†R>i>3›>=û>1Â>gä=ûf}>™ >Ŋ>ML=īŌP=ü4Ķ>žl>*IL>!Z>Ųž>ט>=ۇ>M­>A>•6>&Ŋ…>LL>ZåÅ>LØ*>'f?> >AAZ>M(>>÷>Õˇ>"j> €Ã>)‹„>=”=û§Ą=ôą;>X5>$K>•Đ>N{>ãé>7ö>E4÷>9Œķ>?$> r`>D;>QˇŠ>CōĒ>  >úB>9Ņt>E^Î>78Æ>û>˛y>a>#ŗÎ>ûž=ôĶE=ī‡>ė> Î>¯B=ûˇ>ũ$>4o2>A7(>5_C>˜å>Qh>@Ĩ•>L˙ö>?”Ú>pŠ>'I>5kä>?Ûs>2ƒ°>ÚH=ûĢ>—>ų°>#^=đĘĨ=éŊm>5V>Ė>ß#=õ˜m> ¨’>-CĢ>:ÅA>/ĸė>œ|>›Ē>9Ĩē>G Į>9î>H>Čæ>0Ic>;ãĖ>.Mĩ> ˛=÷Ā˙>|<> ‰>™=č}4=⇨> !o>5>i=÷×Ļ>û$>&Ģ>32ę>+SË>é>äģ>1ˇE>= ‚>2ėĘ>Jč> •a>(/š>0Ük>%Ž> Ļ­=ôŗ>ŦJ>?ą> ÖÍ=ân@=đf>õå>"Z3>l0>‹Â>ÖZ>4@¨>Bt,>8}>Íæ> c>B\n>OKé>BÃ>!ĩ>rī>7Ũ6>BQ>4™ž>Į>Š‹>‰Ģ> _ģ>ōC=đâđ=īt¯><â> cH>Uš>å^>Šž>1Ą>?áī>5øR>äâ>P–>?GF>LiB>@å>2ŗ>TY>5>?Ōķ>2ģĮ>÷å=˙øB>Ē+>GŌ>$=īŪÚ=뎺>Kē>ė>u–=ú˙f>.>-q>:Oq>1@Y>ûZ>3—>9>7>Egj>:cˆ>­ >“?>/>9\¸>-"> =ü>DK>-A>[a=ėĐ =ōū#>m\>"#F>§>Ôä>¨>5rc>C5ė>8Įc>ų>  >BG‰>Oh‹>BũÆ> zķ>Ž>7ū>AøZ>5*>˙>č>ŽS>!´8>>=ķęß=ö >ÕŊ>&Ė>ļÖ>Ō5>•s>:—>I">=ĩM>J^>#T&>GŒ‡>U‹>H”>#ŋ2>nB><…(>HÜ>9é>Ų>i>ã‰>&N>‚¸=øut=ôU6>+j>$øT>‹ß>Úå>E>8LY>Fk§>:Iz>¨ > G>CŋŅ>QfÚ>C™%> ¤I>NÕ>9¸l>EIŽ>71Î>•6>kŸ> Ž>$p>ÍÄ=ö§=ņBv>‚'>#ku>ģö>ļ>‘d>6]Œ>D?t>8 ė>Ѓ> sh>D6É>Q9~>Cy[>í•>ÉŦ>9‚^>D>Å>6$ŧ>¨>“>œD>#_ģ>‰č=ķ2ū=ä'å>Ī9>Ā > J=õz> ųJ>,[>8Ž:>-Ô7>p>‘>70ļ>Bȝ>5úŗ> ō>đb>-Lķ>6Žô>) ˙> !>=ô{ß>ÅŽ>k×> ī=âŅá=Ûęã>Øß>ŗ>á=÷Rf>˛H>#áZ>10J>)Ÿß>O$>…%>/‚‰>;$Ų>0Û)>ēg> ¨M>'EŒ>/ę >$Ã>„=ôI>?>kî>~;=Üã =ë">S:>ķä>ké>ė>iÜ>/øQ>=ų†>5vŠ>6>A[>=–>I›Ą>>jG>}•>\p>3Ã>=§Ü>1&š>ŊX>˙>r1>(ŗ>ë=íO=ô= >á˛>& ņ>x>‡><>8’Ŗ>G‹î>=h>­a>#P§>Fã>SÆ?>G/8>$„'>ņ>;€^>Fa5>8Įˇ>*|>ƒô>ę>$cV>›m=õšŒ=õĩ> J>&cˇ>Ũĩ>G>ÕŌ>8â>GJX><œ>ŧ—>#iŌ>Fhä>TŗY>Gx>$…ē>¤ė>Hã>9ķS>į>Ã>&>%Q>ūŪ=öD¤=ųû>ĐŲ>(‡ƒ> v÷>oø>õ><.ú>Jíú>?ÆK>>%žė>IĀ*>W|ë>Iķj>& >xž>?ī>J/I>;Ö°>mŊ>@r>m >'Xķ>)>=ú3ų=÷Kz>öŧ>'Æĸ>WY><Ã>{$>:ĸ›>IuH>>/ŗ>y>$š>Géŗ>Uø§>HĄœ>$¨€>ņV>=ã>IbI>;’‡>đ>ąi>ÜĻ>&‰_>ۈ=ų ‚=õ–<>F>%‡x>[ļ>z>ũ>7sˇ>EŨX>:ŅT>×Ĩ>" –>EƒD>SÁ>Eģ–>!Ėu>æL>;>Ô>FdĻ>8œ/>ž>úL>äã>#ĶĨ>Q}=ôe†=īš">ŽÛ>!đ >>Vp>â>4@k>Bļs>7¸é>P>@Ģ>@Vĸ>N 4>@Օ>Ív>ĸā>6v€>Aœē>3ˆ >žŨ>`T>;Ŋ>!Tˆ>gE=ī/§=ãvĖ> ž0>Ė…>tæ=ķ[|>ŗ:>&|§>4D>*> Į>ÚP>3“g>@bŗ>3š{>Ī„>dÂ>+ē>6‹>(]k>`=ņ×+>n4>Õ> d˜=āŠŪ=Ķ”Ŋ>Žs>P^> ˒=đÆ5>‚>W‰>&ߜ> ¤!> ž‘> Îė>&˜¸>0=û>'UÛ>D>¤Ä>ā>%f<>۟>õÄ=ėåí>öģ> õ=˙ņä=Ķüå=âë˛> â2>3Ã>„P>> ÉÁ>*X>7ĐÜ>/ņõ>ÉK>•˙>6ɤ>BÆ4>7ÜÃ>žP>nn>-Âi>6}>*ķ> œ7=û$a>ˇa>âŨ> ’•=ä].=éjG>ŖÅ>ŧ>„‚>ņ0>gĀ>/Â>>Į>5… >/p>‚Č>Iä>>…>4Ŋ>NW>3‘F>=üĸ>0ũ;>JA>æĀ>Į>9Ā>23=ëW=îæÅ>å>!o>ü>Ø×>îx>2sđ>@QX>7Ļj>Û&>īˆ>?¨č>LGK>A3,> †n>O>6<>@§>3ÎE>ū‹>m‡>‚Ü>Ī>w’=đ™V=îáŸ>Ųj> —2>ū>įũ>ŸÉ>3c!>@ņ‰>7Nc>[›>â>@vú>M\>@Ön> —>!…>6JÕ>@q >3cÄ>›Ķ>øŋ>ę>ęŲ>Ļß=đéû=ņ >ŒM>!tŽ>‹>Ŗ€>õE>3‡5>AE8>6ä~>ž´>ؖ>>Õ7>KÄ >?{j>â>]>5>?~ķ>2Ŋ>w}>ĐI>˜/>Ôj>s+=īü|=ėČĮ>Č­>ö¯>iü=ū3 >^G>2Ks>?I=>4ŒĢ>n>đ†>=†>IÔR>= ĩ>øQ>čë>3 >=œ>/š >Ā?=ūĪĖ>Q5>"æ>q˙=ė@“=éåč>’>8™>%=˙sf>”ø>/1o>=Â">3Õ">1;>÷Ÿ>Iđ>=Ė€>âĻ>Äļ>4ÍC>?\Í>1΍>n4=˙—3>‘>‹ū>÷Ō=ëžã=ŪÛq> 5>;*>l=ōŒ7>­<>"ūn>0—N>& s> ūæ>\>.ī>:Ã>.Iė>ŨÉ> ē“>(¯>2ˆą>$ōÁ>2ģ=ņ\ũ>ļ>5>P==ŨĀîastropy-photutils-3322558/photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits000066400000000000000000000132001517052111400267410ustar00rootroot00000000000000SIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 5 NAXIS2 = 5 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 >4Ŧ>4ģo>>|×>4ēø>r>5Ō_>T%>_Qū>T.Õ>5§>?>_ũË>laČ>`Ã>?Ŋ+>4Ã>Tą_>aLš>Vƒ>6ŠÉ>ć>4īV>@o>7 l>üÃ>Â˙>5ûD>?üĀ>6C>ŪA>5ûĀ>UyÂ>`Ս>Tôī>55>>Ęl>_ô>lė>_}ķ>>°>3ĢŠ>SŦą>_B>Sï>3Ŋ>MŪ>4>?U>50h>)ō>”>5ƒĮ>?Ģ{>5ōÖ>­˜>4ĢŨ>T†>_‰•>Tã>4n0>=ĩ6>^‹ä>j|Į>^V]>=4R>2ö>>Rv*>^ˆ>RxŊ>2Ū0>“>3UĖ>=ÉŪ>3å‰>nˇ>W$>4æã>>oR>4o8>Ĩš>4Č'>S•ö>^€Ĩ>Rį >3ņ’>= >]™–>iJ‚>]C°><ÁX>1ĸ’>Q}>\˜}>Q;'>2Z>aÉ>2HĮ><ĘÜ>2ō>ŒĒ>RÔ>6>›>?ĸ,>5m'>ŸÎ>5}J>Tq9>_]b>Sß>4ķ°>=y>]ʗ>ibÖ>]ķ¨>=Íŋ>2€›>Qq>\ÍŠ>R@>3}{>WÜ>3­>=ęĄ>4™ā>UA>” >31R><˙Í>3d>Ūs>2ü">Qˆc>\˜>Qc4>2œp><˜Ē>\‘^>h%ī>\O/>;Ų^>2Õ¯>Qlé>\’>Q:Ô>1ūō>Iļ>3›><Ęl>2įÆ>ŧ>ų5>3ŖĒ>=f4>3c‡>cî>3 ë>Rqé>]šŽ>Qŋ->2Nx>=]>]Œ>iY >\Û>;ŋ%>2Ņi>R(>]ŗį>Qņ0>2OØ>ë%>3hį>=æ¤>3ø>ŒT>íō>2‡ƒ><éÄ>3Ā_> >2†<>PîT>\˜>QÅ/>2ÎŌ><&h>[õH>gũË>\gI>;ãÆ>2-m>PȤ>\UŦ>Q21/>†T>2Ÿđ>=Á>3ĸ`>Ÿ\>Ŧ>32”><ú>>3ˇø>›&>3|~>QāÁ>]`>Qúŋ>3l"><›€>\Ũ>hĒi>\Š>2ŋ>QN˙>\Ũá>Q6š>1âÛ>M>2Đ*>=`G>3Wŋ>įŲ>Å>5s >>Âę>4ģc>gļ>5ŠŠ>T2j>_c>Sc>4Æû>>Në>_˙>jģo>^nL>=đ->3Œ>Sj×>_b>S >3ž÷>p>4ČT>?O…>4õ3> >">1#>:¯>1z>tg>1+é>Nå_>Yŧč>Nķī>1s>;Lö>ZX.>eŠô>Zą>:ŖM>2^>OūB>Z†Ë>O~|>1jĒ>ŽŒ>2l”>;‰Ū>1æ?>ĪY>œ>2Gt>2Î[>åņ>1´ļ>P:p>[¸ņ>Pœ¯>1Ļh>;_O>[`>go$>[ÚÚ>;kī>1ėĩ>P‡’>\(>Q-Ũ>2i >Ķ>2€ž><šÅ>3m>Ü×>>">2ؘ><åg>38×> ž>2ŽÖ>Q1ō>\wî>Q ˛>1–X><+5>\S>gÖ>[šÚ>:­l>2:>PĐŖ>\00>P¸›>18æ>Ũ>2ď><ņÔ>3?‚>w>!>2ir>2ø*>s3>2HŪ>P™¤>[īm>P¯Ē>1Ü">;ÕE>[Ũö>g°:>[Ž>;Z>1õ§>Pø >\už>Pę˛>1Ą>y‚>2øâ>=`<>3oŊ>9>q>2œ.><‡>2| >ŗ>2†(>Pį™>[Ŗs>P>2 ģ><ķ>\ŒÅ>gô>[ĄS>;‡ >2§>Rã>]R5>Q\Í>2FU>Ú>3Čå>=ų;>3Ģ@>M,>Ķ8>2=9><¯>2ƒŊ>Žz>2q>PA>[;Ŗ>O÷4>10°>[ģr>f´ī>Zˆ¸>:#Á>3Wß>Q%>[F;>O<ŋ>0/ĩ>q >3{ ><.L>1šø>Šg>ŋE>1å>; &>1™3>,đ>/øĶ>NĢ>Y{>NÂC>0•œ>9.8>X¤e>dЍ>Ypy>9ü;>0,ĩ>NM,>Y˛§>O>1m>)„>1ĄP>;¯t>2aĻ>÷Ģ>•>/ɔ>9¨i>0;?>į>/"´>M!ļ>XY’>Mƒˇ>/&ŧ>8šb>XČ>cɸ>XcŪ>8‹ę>/c>MŒ>XĶl>Mķ‡>/d€>ę>0Z¤>:[‡>0įL>Gé>ōĢ>-Ū>7Ã$>.Ŗš>Üs>-cš>K >V3â>KŒą>-Æî>7°>VW5>bO>V´ā>7.|>.§>LO>Wž”>LĘĶ>.R×>€Ä>/J>9’ā>0=>a>%C>/ ü>8lW>.>Ŋ>.Ļī>Ltļ>VĘ.>K>-R>8*ĸ>W­Ō>bžæ>V2Ú>6Ī>.×ŧ>M\n>X'Ė>LLŒ>.>õ>øÁ>/Ũ>9˜>/™7>Uƒ>[>0ƒ>9;k>/ėĪ>÷‡>1ų!>N›ļ>X¸Ā>MĖd>0‡j>;b\>Z>`>e\G>YŽ>: >1é>P<>[¨>O˜Š>0Ī>>†u>3<><ĸ’>2l#>VĪ>‰t>.§>8’>/Y>…B>.÷ļ>La>VæB>L=…>.—ú>8!X>Wk>bXv>VėŠ>7Œ­>/)m>Mß>Xĩ>LéA>.›p>\J>0Í^>:•T>0­ƒ>ō,>‚l>,Øô>6gƒ>-B>’>,I§>I?Í>T>I–[>,_>5x>SŠû>_J>Sęį>4ųø>+ŝ>I2n>TQŒ>Ižâ>+ŅL>L>-S÷>76u>-šß>sÕ>âÔ>)ũš>3ÃÚ>*øo>Í>)Ɉ>F­>Qԉ>G€ >*Wæ>2Ž>PøĀ>\‹>Q’>2ōŽ>(üW>F$0>QAŌ>FŊ>)‡>Dq>*>3ڊ>*Â3>BĘ>×>+mæ>3æ6>)nO>4Ō>+Q>HDĘ>QÔ >E”|>(^É>4\ų>S9P>]`š>PZ!>1´ž>+ ->HŽ >RĸX>F_>)A†>W>+Q>4C>*+ˆ>ßastropy-photutils-3322558/photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits000066400000000000000000000550001517052111400270350ustar00rootroot00000000000000SIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 5 NAXIS2 = 5 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 >4Ŧ>4ģo>>|×>4ēø>r>5Ō_>T%>_Qū>T.Õ>5§>?>_ũË>laČ>`Ã>?Ŋ+>4Ã>Tą_>aLš>Vƒ>6ŠÉ>ć>4īV>@o>7 l>üÃ>Â˙>5ûD>?üĀ>6C>ŪA>5ûĀ>UyÂ>`Ս>Tôī>55>>Ęl>_ô>lė>_}ķ>>°>3ĢŠ>SŦą>_B>Sï>3Ŋ>MŪ>4>?U>50h>)ō>”>5ƒĮ>?Ģ{>5ōÖ>­˜>4ĢŨ>T†>_‰•>Tã>4n0>=ĩ6>^‹ä>j|Į>^V]>=4R>2ö>>Rv*>^ˆ>RxŊ>2Ū0>“>3UĖ>=ÉŪ>3å‰>nˇ>W$>4æã>>oR>4o8>Ĩš>4Č'>S•ö>^€Ĩ>Rį >3ņ’>= >]™–>iJ‚>]C°><ÁX>1ĸ’>Q}>\˜}>Q;'>2Z>aÉ>2HĮ><ĘÜ>2ō>ŒĒ>RÔ>6>›>?ĸ,>5m'>ŸÎ>5}J>Tq9>_]b>Sß>4ķ°>=y>]ʗ>ibÖ>]ķ¨>=Íŋ>2€›>Qq>\ÍŠ>R@>3}{>WÜ>3­>=ęĄ>4™ā>UA>q>6¯–>?ËW>4žĘ>æ!>4ä}>Sōļ>^Ēū>RiF>2×>\Ķv>h}ė>\dz>;ÚŽ>0’ë>Oës>[Ŋ>Pü)>2?%>ŗđ>1d/>3>›>•ņ>Ī”>3ƒ>= >2mE>E>2šė>Qh>\ >P“|>1Fŧ>;”1>[Ąŧ>gdä>[$[>:aL>19!>Pš>[ī>PŊ>0ē>¯Ž>2 Ö><‹Û>2Įr>^'>Q>3´ž>=ä>2Ēĸ>›|>49Z>S 7>]Ũ[>Qē>2 ļ>=ČT>^~<>j&g>]u+>;ō×>3˜4>SWŋ>^ë}>S>2Ø>rã>4zē>?j>5‚>77>db>5,#>?žž>5Æš>—>3áé>Së>_í+>T€N>4āí>=I¸>^čē>kž,>_S>>HÜ>2öÕ>S1å>_ws>Ty>4TË>ŋŨ>3âŽ>>Üį>5FE>Þ>Ĩü>4Øū>?q>6‚>1_>53€>U6ō>am >VyÆ>7<Š>>V!>`jŠ>mu>aL>@ll>2…|>S8N>`Y>Tī0>5C~>'>2ĩË>>wą>5d“>)õ>F>5ێ>@EŅ>5]>û>3ín>U>`×ĩ>S§>1úˇ><ĶO>_;>k‹R>].{>9úb>1X>Rl’>]Ā}>Oë§>.ŠÄ>|>2k>0Ã>•š>€É>1”p>;>0Įi>0F›>O‹t>Zud>Mü>.,>8âp>Y„E>dČô>W›>6^>>. œ>M ×>Wå¯>KcS>+ŲŦ>2\>.p9>8/M>-œ>…M>øū>2ãË><“>1-ļ>ČŲ>2]‚>QQW>[œä>NŅ‘>/ā>:ûV>[q>f >X• >7ƒE>/Č>Nø„>Y<>L”9>-`ļ>ąá>0Ë>9ŽŦ>/ î>?>I\>3 h>=(>3ƒ4>2>2ąį>R*Ä>]Œû>QÅY>1˙<>;ĸû>\Ŧ€>hŒĀ>\ í>:ŽŊ>1=_>Q^>\oī>Pl>0n|>rõ>2‡><Ô>2Z>?ņ>aŗ>4õ?>>×>4Ē[>x*>3;ģ>S@ >^Ö/>Räú>2ûô>;Rš>\Ø`>iČ>\šM>;>0Ī>P+>\Fä>PhĒ>0›v>ĘD>2ė><Ąŋ>2OX>ĸ>Īú>4Čä>>Žš>3Z>Ī—>3žš>T1´>_F'>R>1R­><‚~>^ƒ)>i}ž>[…>9>1šŗ>Q§f>[Ė5>N=č>.ŦF>˜Á>3 4>;‡Ü>0 @>/>U>2!›>;đ>1Ē>Ņ>2{å>QĒŊ>\GÂ>OČ^>035>;ŽV>\ĩ>fīk>Yžc>8t.>0äŗ>Oߌ>Z >M^1>-ŋ>Ü@>1įŽ>:æL>/ÎZ>Yî>˙>0x\>:y>0d >3ô>00ũ>N€>Y.Ŋ>M}k>/+Ô>9B>Xã1>cčą>WcQ>7Fa>/¨>M">Wđû>KÔí>,éŦ>`>/ēÕ>8åī>.#>>'Ķ>´Ä>.+>7•>-æ6>Œ|>-Ö >Kĩˆ>V‡s>K>,šq>7:€>V¨>`ā¯>U#Z>5†Ā>->JnN>Tåģ>I÷ˇ>,Že>áū>+°į>4î7>,œ>Y>Ģ8>0Ī•>:~>/ķ>ēÃ>0€č>N^J>X˛r>Lú>.ÄĪ>9Āž>YG>cÁ†>WkŠ>7Âô>/ųÍ>MųF>XY*>Lœ>.Xí> >0i>9¸(>/Ąå>8ĸ>” >31R><˙Í>3d>Ūs>2ü">Qˆc>\˜>Qc4>2œp><˜Ē>\‘^>h%ī>\O/>;Ų^>2Õ¯>Qlé>\’>Q:Ô>1ūō>Iļ>3›><Ęl>2įÆ>ŧ>ų5>3ŖĒ>=f4>3c‡>cî>3 ë>Rqé>]šŽ>Qŋ->2Nx>=]>]Œ>iY >\Û>;ŋ%>2Ņi>R(>]ŗį>Qņ0>2OØ>ë%>3hį>=æ¤>3ø>ŒT>íō>2‡ƒ><éÄ>3Ā_> >2†<>PîT>\˜>QÅ/>2ÎŌ><&h>[õH>gũË>\gI>;ãÆ>2-m>PȤ>\UŦ>Q21/>†T>2Ÿđ>=Á>3ĸ`>Ÿ\>Ŧ>32”><ú>>3ˇø>›&>3|~>QāÁ>]`>Qúŋ>3l"><›€>\Ũ>hĒi>\Š>2ŋ>QN˙>\Ũá>Q6š>1âÛ>M>2Đ*>=`G>3Wŋ>įŲ>Å>5s >>Âę>4ģc>gļ>5ŠŠ>T2j>_c>Sc>4Æû>>Në>_˙>jģo>^nL>=đ->3Œ>Sj×>_b>S >3ž÷>p>4ČT>?O…>4õ3> >ßå>4É}>=ĻY>3Á°>ĒË>5}>Sŧ>] >Q|å>37>=›™>]RI>h@E>\ Ī>< >2°]>Q”š>\”—>Qr>24ņ> ˇ>3 >=ˇ0>3׹>ރ>\>3ØÁ>==!>2ü¯>MT>3 >Qĸ{>\€%>PŦm>1Ūj>;ņu>\)[>gļķ>[Z>;v>1֘>PųŸ>\;‹>P†ũ>1‚Š>•ę>3F>=CR>35˛>´>9\>5Ō>> ä>3˙Ę>{h>4†ä>S™¯>^­ë>R—‰>2đ>=ˆ>^"Z>iŨ<>]b>3)>Raí>]ĪŅ>RŒ>2Āö>F>3ęM>>.Á>41ž>ė#>ˆá>5ũ8>@˛>5Ë˙>ģ>5/i>TÃ%>`M¸>T…Č>4ĸR>>‹Ô>_ž&>kÁq>_e>>‘>47ã>Sįd>_T>Sģ°>3ė™>(!>4ėœ>?-ė>5 ü>uf>>ß>5ęX>?â8>5ģ>Œŋ>57Ē>U#ã>`dā>T^R>4ÍP>>l>` ŗ>l­>_a>>)>32">S˜>_~Š>Sļ:>3û>"Ą>3¨ģ>>•Ö>4ÃĀ>P—>Āâ>4‰˜><ɇ>1Ø>§>4‹–>SĻ•>]•>O]>.ÔU><ĩg>]`>gsP>YH¨>7€˜>0§>OĶC>YîF>Lāz>-  >Ÿŗ>/âN>9Ič>.w’>a´>˛>2īd>1Gw>˜š>2šŠ>QØ3>\0>ODö>/\*><4>\e¤>gš>Yvį>82Ī>1[s>P)ņ>Zzg>Mžū>.?ī>>10Č>:И>0 î>įƒ>`L>4iĸ>=Î.>3(č>>4Zx>SqG>]Čy>P˙Ã>1 >=§ü>^V>i >[pc>9æÉ>3F>RpŪ>\ā_>Oųš>0 .>Â->3eR><ęR>2:>¨ĩ>ßy>4“>=ĀÍ>3E\>x>3É%>Sž>^ >QĨc>1ōG>=9O>^Yk>iÚņ>\°ž>;->2Ķ>RŲÍ>^X>QƒĢ>1Gc>ÚĻ>4`>>FU>3qĢ>0Ú>Kv>3đ@>>\w>4rx>‰T>3V–>SD†>^Ũ,>R­)>3 t>< >^P>jä>\Ÿb>:ú™>26>RS>]T¤>Oû$>/›>n>3Uh><ØĮ>1*ũ>ĶË>1Ų>2ĻÕ>;ÄË>1nĶ>]ü>3Æ>Q×Q>\8ī>O g>0¤Å><)~>\ŌÄ>gz>YÚg>8Á >13Ŧ>P›U>[Í>Må˙>.ū>Iģ>1ģL>;#(>/ú‰>,>‹ >3ŧQ>=vŪ>3J> ķ>2ė->R#>]6Z>Q3y>1ī >;ą8>\-Ģ>gŸ>[Ō>:@>1+/>P j>ZÜe>NÆ~>/u>ák>1Įd>;0—>0§#>9ņ>˙\>4į>=а>3eÄ>xj>3p^>RXĻ>]ū>Pęx>1ßÛ><§c>\ø@>h´>[,Ņ>:—Z>2kĶ>QQv>[÷s>OÎâ>0——> Ž>3ų><B>2[>Ėģ>R3>3Ŧ~>=ôÖ>3ķÕ>ŽŅ>3ŠĨ>RÄ>]î‘>QÜ]>2Ē>>)>^Žf>j4>] >;œ1>4€Á>S7>^”š>R_â>2m >0>4\ >>_:>4S>vt>Ģ8>0Ī•>:~>/ķ>ēÃ>0€č>N^J>X˛r>Lú>.ÄĪ>9Āž>YG>cÁ†>WkŠ>7Âô>/ųÍ>MųF>XY*>Lœ>.Xí> >0i>9¸(>/Ąå>8ĸ>">1#>:¯>1z>tg>1+é>Nå_>Yŧč>Nķī>1s>;Lö>ZX.>eŠô>Zą>:ŖM>2^>OūB>Z†Ë>O~|>1jĒ>ŽŒ>2l”>;‰Ū>1æ?>ĪY>œ>2Gt>2Î[>åņ>1´ļ>P:p>[¸ņ>Pœ¯>1Ļh>;_O>[`>go$>[ÚÚ>;kī>1ėĩ>P‡’>\(>Q-Ũ>2i >Ķ>2€ž><šÅ>3m>Ü×>>">2ؘ><åg>38×> ž>2ŽÖ>Q1ō>\wî>Q ˛>1–X><+5>\S>gÖ>[šÚ>:­l>2:>PĐŖ>\00>P¸›>18æ>Ũ>2ď><ņÔ>3?‚>w>!>2ir>2ø*>s3>2HŪ>P™¤>[īm>P¯Ē>1Ü">;ÕE>[Ũö>g°:>[Ž>;Z>1õ§>Pø >\už>Pę˛>1Ą>y‚>2øâ>=`<>3oŊ>9>q>2œ.><‡>2| >ŗ>2†(>Pį™>[Ŗs>P>2 ģ><ķ>\ŒÅ>gô>[ĄS>;‡ >2§>Rã>]R5>Q\Í>2FU>Ú>3Čå>=ų;>3Ģ@>M,>ōá>3îX>=aĢ>4g>Mü>3üŌ>R r>\Å>Q™đ>3œ}>=Nß>]\(>h•>\gŒ>3.d>Ry>]k…>QpV>2‚`>u>49Ö>> >3š›>M >å>3=į>2kŊ>E >2ŸŽ>PĘl>[!5>O‰‘>1€,>;ĸĪ>[f9>fJ6>Z">:’>1Ē‹>Ph>[ö>O{7>1"Ë>j@>2§s><]5>2WË>¨+>m->3Ô|>=ß>2•¯>—‘>3N>Q¸>\r…>P˜`>1¯U>\A¯>g‘">[I4>;#Ô>2#'>Pž_>[Ã2>PM†>1šD> •>2‚â><…¯>2Ō_>?U>‹4>5į÷>?÷Ę>5>”‰>4ėė>TXD>_Ō_>TX>3˙˛>>~X>_Oō>kSŅ>_W>=šw>4´>Té>_ĨÖ>T¤>4fR>5L>5ŧ„>?úÛ>6e>ÆM>ĶK>6Z’>@Ŧf>6¤{>Ē?>5æƒ>UÉ>aÁ[>V)!>5˙™>@=>a¨Ī>nU=>bHƒ>@w>5ģŠ>Uˀ>b>V”ô>6‚Ÿ>ÜË>5öĄ>@Á>6æ7>GV>á>5B‡>>.S>2•^>x >5,&>T}đ>^^ĸ>PĖ>0ƒú>>Ķ`>_}Œ>i¤->[Šs>:#>4A¯>S<ä>]]>Oԙ>0AE>pĢ>3]s><%Ã>1_>đ™>ĸ–>5€'>?6r>3æŗ>ÁV>4Đë>TĒŠ>_{ŧ>RO;>1û2>>ķ>`Ŗ>k>M>]mž>;šį>5 >TĨÍ>_Ib>RC…>2Uû>܎>5D•>>Ŋ:>3Ŋ.>Aã>>5Ú>?´>4ĄŽ>ņ€>4é >TY >_Jī>Rå’>2Ô)>>ū´>_Īã>k˜>]ņß><97>5>Tlá>_Et>RÕv>2Åą>6>5hą>? M>4§|>ø}>ŌK>4ŖZ>>#ė>3ژ>ÁĻ>4Ųđ>S8â>]–˛>QKž>1Ķ>>\n>^!č>hĶm>[ŗė>:zX>4Ŧ>Rž­>]$b>P”X>0Ļæ>1ö>42>=œß>2ŪĄ>îV>âm>3/c>=y.>3¤Ü>ܟ>3Lj>RŧŠ>]ĐŨ>QÆĄ>2Uc>=đS>^I2>idå>\%>:Ōī>3Ëų>R¸ŋ>]ū>P!ā>0Î>Ŗ>3ļæ><Į>1š<>—J>—ã>1Ö(>:×>0Ī>ͤ>2cē>P}Ŧ>ZŽÚ>NœÚ>0^i>;×a>[fĪ>e¯Í>Yl>9>G>1‡&>Oŋ >YžP>M„+>/ Ĩ>ū>1{Ņ>:MŸ>/Čė>!>Ŋĩ>1 >:mÆ>0Œž>=ą>0›œ>Nņ>Yšã>N ü>/Ú÷>:H„>Z:Ö>e^C>YHĩ>9|f>0Ī%>O|ö>Z4<>N˜m>0Ø>ĨÎ>1m>:ĶQ>0Š*>.>qķ>32ú>2>%‰>2xy>PøĘ>[>#>O">0Qí>< ë>[÷–>fŖˆ>Yī >9ËV>2lę>Q ~>[rG>O|õ>0āŲ>ūk>2Ę><(F>1øã>¨>>>5œn>?œS>4áÜ>ŧ|>4˜‚>SŖ…>^Æ\>Rˆ‚>2r5>>p>^1B>iĒŠ>\ōœ>;Æ>4d>RŅŧ>]˛‚>QÅ>2ˆ>ƒa>4Oå>=ėÔ>3Ÿų>ŨQ>ī>. O>6“č>,ū>D>.Žļ>IōK>T >Iĸ/>-=>8˜G>V +>bÉ>Wr>93ĩ>/Ņ>Lķä>Y Ú>P#¯>2ņ:>ČV>.Á>:RÎ>3 Ņ>+ų>Ķ8>2=9><¯>2ƒŊ>Žz>2q>PA>[;Ŗ>O÷4>10°>[ģr>f´ī>Zˆ¸>:#Á>3Wß>Q%>[F;>O<ŋ>0/ĩ>q >3{ ><.L>1šø>Šg>ŋE>1å>; &>1™3>,đ>/øĶ>NĢ>Y{>NÂC>0•œ>9.8>X¤e>dЍ>Ypy>9ü;>0,ĩ>NM,>Y˛§>O>1m>)„>1ĄP>;¯t>2aĻ>÷Ģ>•>/ɔ>9¨i>0;?>į>/"´>M!ļ>XY’>Mƒˇ>/&ŧ>8šb>XČ>cɸ>XcŪ>8‹ę>/c>MŒ>XĶl>Mķ‡>/d€>ę>0Z¤>:[‡>0įL>Gé>ōĢ>-Ū>7Ã$>.Ŗš>Üs>-cš>K >V3â>KŒą>-Æî>7°>VW5>bO>V´ā>7.|>.§>LO>Wž”>LĘĶ>.R×>€Ä>/J>9’ā>0=>a>%C>/ ü>8lW>.>Ŋ>.Ļī>Ltļ>VĘ.>K>-R>8*ĸ>W­Ō>bžæ>V2Ú>6Ī>.×ŧ>M\n>X'Ė>LLŒ>.>õ>øÁ>/Ũ>9˜>/™7>Uƒ>ËÎ>-ü>>7ej>.cû>õ>.Z}>K‡q>V)×>KM.>-Ē>8 >VíR>aë2>Uũx>6PĢ>.ƒm>L€>W>K>Ą>,ļO>Æn>/Pd>8š>.ŽN>éŠ>Ō}>/fs>8‹e>.ŪŽ>x>/å>LJÕ>VsŦ>K*ô>-´>8Xō>W(W>aģÅ>UÂA>6Æ >.ŧ>LV>VĖ4>KbŖ>-ŗl>ˇ˛>.û]>8f>.ŋā> ß>‘§>0?é>8õ‘>.Ūû>ũR>/y>M9>Vīl>KpŽ>-ã¨>8ô>WÚ*>bR?>V?đ>7:q>/yĐ>M5÷>W‚|>L ?>.Ví>_Ō>/“/>9 >/yŊ>Á>@Š>3Ą><Ŋ>2ȃ>Øn>2]ë>Pįe>[î7>P>1˜)>;ęũ>[ßn>gh_>[`š>;æ>1đÃ>PĄ´>[ŪY>Pƒņ>1˛>>^>2C >2Ūŧ>K{>÷>5Ą>?ÕĀ>5Ņ>`f>4Ž@>TNE>`Ī>Tž>5Â>=ã>^­&>k!W>_’ŗ>>ŋ\>3 >R&ƒ>^Jo>Są™>4 á>ŠT>3­>=ãä>4äŸ>,{>\ŧ>4í\>>í_>4ŒX>įĸ>4´õ>Tœ>_øŌ>Sŗg>3¤>>÷ô>`SĐ>lŲ>^Ė’>=m>5kQ>U'Ë>`>Sa>3Ë>@Ë>5ĪÖ>?w¨>4‹<>ĸC>ū~>5Ĩ1>?zŖ>4z8>^i>4é%>Tww>_Ŋ>RĖ>2–Í>?¯>`z>ká>^M>5>Uu>` x>SRė>34ĩ>Ÿb>6 T>?Ģ->4žū>īH>*>5ZB>?3œ>4—ę>°ę>4Šĸ>SÆ >^æ’>RĄŠ>2yČ>>4ŗ>^Ŋļ>jbD>]’h><8>4{W>S~)>^¯å>RŠm>2Üņ>Ûô>4Áė>>>4OÛ>‚>y>3yĢ>=Yj>3Pr>f>2’×>Qc[>\ \>Q ´>1™ ><@W>\¤Æ>hyÁ>\Kr>;*ē>3e>R7t>]Ĩč>Qų’>2T>×S>4ü>>W>3ö›>'a>¨Ū>0vQ>:SG>0WÅ>ēš>/”ŧ>N<¸>Y>NOž>/ >9^>Ybņ>eM$>YŒĖ>9\8>0_>O 8>ZI—>Nę{>0 I>Ŋ†>1lē>;L>1*ú>>‰‚>-B‹>6ū>,˜Ņ>Š>-uN>J¯p>Tôš>I r>*‘f>7‰>VLÅ>`›)>SĒL>3cŸ>/O>L>V-6>Iˆå>*ƒ9>‘e>/m)>8* >-?0>Ģ>~ö>+ĩ•>4M]>*TÁ>Ŧ>,A>HÍë>R >FHę>)!’>6æ>Së÷>]j=>Pã¤>2C/>-=>Ié\>S!“>G36>)Í~>ļŲ>->5œx>+u'>ŧ¸>ˇV>.¯>7¸Ü>-ŧG>Ģŧ>-ä\>KJ>UŖ$>JK>,f>7ķ>UēŲ>`¨>T˜>55ī>-§Ŗ>K$(>UÎ>JœY>,ŗ€>ö:>-Ú÷>7¤s>.Lŧ>}Ļ>°é>2><C>2bŨ>k>18a>OE1>Zw>O*°>0A >:~a>YÎã>e„–>YŖå>9K*>1+ >OVŅ>ZĨË>Om>0…7>3Š>1ۘ>;õą>2^Ę>ąú> ę>6kĮ>@Z>5‡r>NA>6Ö>Tõ>`Õ>SĀW>3ĄG>=ĸĄ>]ŗ…>j/Ô>]Úä>=g>2zé>Pև>\P>QÜn>4/K>û>4L‚>=äß>4öļ>dÕ>[>0ƒ>9;k>/ėĪ>÷‡>1ų!>N›ļ>X¸Ā>MĖd>0‡j>;b\>Z>`>e\G>YŽ>: >1é>P<>[¨>O˜Š>0Ī>>†u>3<><ĸ’>2l#>VĪ>‰t>.§>8’>/Y>…B>.÷ļ>La>VæB>L=…>.—ú>8!X>Wk>bXv>VėŠ>7Œ­>/)m>Mß>Xĩ>LéA>.›p>\J>0Í^>:•T>0­ƒ>ō,>‚l>,Øô>6gƒ>-B>’>,I§>I?Í>T>I–[>,_>5x>SŠû>_J>Sęį>4ųø>+ŝ>I2n>TQŒ>Ižâ>+ŅL>L>-S÷>76u>-šß>sÕ>âÔ>)ũš>3ÃÚ>*øo>Í>)Ɉ>F­>Qԉ>G€ >*Wæ>2Ž>PøĀ>\‹>Q’>2ōŽ>(üW>F$0>QAŌ>FŊ>)‡>Dq>*>3ڊ>*Â3>BĘ>×>+mæ>3æ6>)nO>4Ō>+Q>HDĘ>QÔ >E”|>(^É>4\ų>S9P>]`š>PZ!>1´ž>+ ->HŽ >RĸX>F_>)A†>W>+Q>4C>*+ˆ>ß>á|>+[Œ>3VU>(e{>ŗ>+¤y>GÄË>PW>Cŧį>&Ķ>4~>QŖH>Zā~>Mā>/ÍĶ>)ņ>FĢŽ>P ŋ>DZ>'Y,>6u>*ô.>3ËI>)ä…>Čõ>Ö>,R >5„Į>+„ŗ>_ō>+ņ[>Iå>SŨ>H`É>*Ũ>5ˆĩ>T?É>_A.>SŖ>5 ‹>,Lh>IäE>TI’>I<Ģ>,<‚> >,Ņ\>6Ū>,ĀS>ąU>%o>.%œ>7>- ~>?=>-nÔ>K1œ>U]>Iú¯>,ŒŸ>7qÍ>VÍ>atĪ>U^æ>6Ė>.aā>Lģž>Väã>KJš>-Ûģ>˜ô>/“>89|>.mÃ>û>Ĩ‡>1˛‚>:ē(>0ē >>1î9>O“S>YŠ…>Mųũ>/7Ã>;•é>ZŲą>ekG>XīŖ>8ō>1tr>P{>Zv>N>/Ō”>E>1n'>;+4>1@C>ŧÕ>MČ>3ú´>=î>4 Ÿ>Æŧ>2Õ >Qķ'>\îv>QW#>2><(7>\Cv>gŒ¨>[}>:ėæ>2Ŋ>Q Ž>\Â>PҚ>1ę—>˜Û>3TÂ>=¨z>4.>IČ>č–>5Ũŗ>@R>5Ÿˆ>|H>63‰>VF=>aTO>Tĩ9>4Ø>@''>ao)>lö>^î-><ߜ>5Š>U$>_“>R›>2‹>9å>5ˇ?>?ã>3à >×>÷ļ>5 ä>>y>3žö>/>4ŋ^>SķŽ>^æ(>RŽ>2X>>>_wŅ>jöZ>]ús><á>4ÍÍ>T5p>_$z>Rļį>2Ä#>2Ö>5[ŧ>>ԅ>3ų'>Â>ŋ >4dĶ>=˙‡>3žĻ>Ju>3ÅĐ>R‰œ>]Ôö>Rv>2oC>=EŖ>]ŧ¸>iņ>]PQ><(Ā>3Ļ >RŸį>]ß<>R0m>2¤é>‰ >4f^>>§>3įO>ŠĢ>ˇ~>0Æ>: Ü>0ôå>ųĸ>0H|>NŸé>ZNc>O2)>/įŨ>9đŗ>Z;Æ>fÉ>[a>9Ę*>0×R>P”>\‰>P” >0gj>č>2do><ģ>2aD>5‰> ĸ>-Ąũ>7'ķ>-¨ļ>›>-j_>Kî>Uō>JôZ>->6ļÅ>V'L>a_č>U„Ú>6 >-T¨>KĘĖ>V)ē>JAĒ>+ö>Ōģ>.p>7 >,DO>Ķ'>ōÁ>*Ũ>3ú>)d>Ņ0>*0}>Fâŗ>PųĨ>EŽ0>( >3…û>QÕT>\XÍ>Pk >1“Ž>*•Q>Gį>Râ>F _>)A:>{Ë>+NÍ>4~ >*›1> >ü>(ûå>1ĸÕ>'ˇŦ>ŒĘ>)7ī>E\É>NÔō>C >%öƒ>2Ņn>Pŗ>Yj…>LŊh>.5Ŗ>*7+>F, >Ol>BĀŌ>%”Í>&>)Ī‚>2 @>'Ā>d„>ŋ>-_">6´>+ĀĪ>úl>-˜˛>JŠ´>T|Ã>Hy>)ŅŲ>6_Ė>Tßų>_,Ė>RŲ>2!f>,C„>I‡‡>Sĩ§>Gˆ>)>†c>,Oä>5­Ú>+†Ē>sģ>¤Â>0Ԙ>9zö>/zŸ>N’>0čI>MüY>XČ>Lž>.9>9­×>Xy¤>cĒ>WÔO>7äģ>/…š>Mx_>XÄĒ>MĐŦ>/1D>Ūã>/\ō>9Ö8>0Ĩœ>%_>l>.bN>7<÷>-…„>> >,Ëü>Hb`>RĄ>GČt>*V>5Ō–>TLB>`*”>UUū>7ež>18>PĸĻ>\k>QlØ>3Ú>ė°><1U>E=¯>:[>m`>F[>5oí>>ņš>49 >m>4of>S˛Đ>^˙>S.G>3Îo>=aÅ>]áW>i”t>]uĄ>=>3Oļ>R-ļ>]!Į>Q‰Ę>2ĢA>õ÷>3đ0>=ˆ >3] >[˙>á@>2n÷>;™m>0ūĸ>8W>0î}>O3y>YĪæ>NY>/Ã>9Đģ>YyĶ>dÂī>XÉ>9VO>0Cī>N×l>YÖČ>Nxe>0G¨>Š?>1˛´>;Ąâ>1Ą?>äĒ>ķë>-ƒ“>7;ë>.Ú>CN>,‘Q>Iûî>U9>JÂĄ>-Ą>5‰>>T…đ>`@‡>U#j>6p>,\ã>JGĢ>U>JŌ‘>,ßÔ>PS>-¯$>7”>.~Ų>>…{>+øã>5ŽČ>,a„>`N>*ģ >G÷&>S[¯>HúË>+ D>2ËT>Qń>^W>Sl^>4>(Ëī>Fėw>S5›>I7>*ô>מ>*‹>5‚ë>,Ė>bx>ō>,œ>5ē}>+äa>CÅ>+˙Î>IĖå>TŽ‘>IH>+¤Ī>4ʰ>T•z>`>T2\>5 Å>*“Q>I‰ >TēŽ>I[ä>+ˆE>ũJ>,J>6[—>,w>`ö>w&>-Ü>4—ë>)Āį>7>,Ņę>Ib2>R">E`>'cŪ>4PI>R~Ú>\,&>Oƒ>/‡Á>)gX>Ff’>P9V>D\1>&­Š>đų>)K3>2DK>(”><æ>đ>.ÉÆ>6ūD>,ą´>‰‘>./N>JZS>S”>GĖ$>* )>6Ox>SúZ>]ĀC>Q¤3>2Āq>,ŽČ>IK(>Rķ>Gu>)đ‡>øņ>,e >54Ĩ>+JH>aü>\&>/¤š>8TĒ>.BŪ>õ›>0kz>MMK>W?û>K´ >-‰ü>9ƒ>Xģ>bĘÖ>WŽ>7œ^>/+Ų>Lč'>WÆf>LŨŲ>.Ũˆ>„ >.đn>8å2>/Ļ>u(>gÕ>2=Ė>; >1‘š>Rv>2Į>P0Ĩ>Z|˙>NÅr>/Ŋ><ū>[ Y>eá™>Yyį>9 >1¸ū>Oëŧ>Zē>O–>0 Ų>‚č>1yo>;–Ž>1Ų>Â>ĩĄ>4Ļ}>>œ>4Ŗ†>;Ø>4āÎ>Sōļ>_FŨ>Sâ:>4pƒ>?lĖ>`8H>l'ī>_Ķ•>>ˆ+>5‘&>UDĒ>`æÖ>TÜ,>4Ÿ—>Ī>5Ø'>@~v>69x>(ō>ņi>3_<>2\s>Lš>3rĐ>QB>Zø]>O•>1" ><×å>[×>e›>XŧP>9a†>3@N>PՂ>Yâū>M7á>.ōÛ>G_>3b>; G>/˛…>Ķ>¤ę>4xA>=ėđ>3ģL>*Ę>4P>S->]Ĩ3>Qž&>3&Ŋ>=´>^ Ŗ>hņF>\Rp>3 Ž>Rw>\Ú>>P˛î>2‘>(>3Ũf>=ũ>2^l>VĘ>†>4ōí>>ŋt>4w>I¸>3ļŠ>R˙æ>^T>Rĸ)>3F•><ŧ•>]_>ir >]÷><@W>2–>QūĄ>]Gü>Q^ō>1ÉS>>Á>3Ā5>=Ą¯>3 Ņ>QŨ>|å>/öæ>9ķų>0‚č>už>.G>LL>W“ß>LĮ`>.ä>6ø->V8%>aöŧ>Vts>6ķĖ>-Š>Ka>VJä>K*Ī>,ĘP>Ŋ>-ôB>7Ņ[>.Đ>‹ß>šU>+>f>3ų >*dQ>yą>+á>HwŲ>R y>FŠÍ>)]o>4Ni>S]-=>P€P>1;P>*l`>H.ĩ>QŊ>EˆÁ>'3(>z€>*‡˛>3%>(œŽ>/‡>ā÷>2O›>9ŪQ>.!;>ôė>2r>N;|>W Ü>I Ņ>*.‘>919>WĻ`>aŌĻ>T„x>3Ž>+>IĖ0>UŗŽ>J_>+ƒ;> Ų>(Ņ>5­M>-e>RR>–>+ķ>2B>&šĖ>xØ>+gf>Hë->P| >Brä>%˙>4f3>Tz>\ä÷>M¯ >.‡R>*~A>J ģ>S ÷>Dœq>&'š>Ü0>-#Á>6P>*Qy>ŽĀ>­˜>/Ŋ>8Čk>.Ą+>Ļr>.Õĸ>LŽ:>W"į>KžL>-‹p>6ëû>U˙\>a >U$->5š >,kį>J„>T֐>IĶ}>+ŋR>tÚ>-‹Ą>7R›>.ß>āģ>=y>0/Š>:2ē>1%ņ>eđ>/÷é>MĮŪ>Xđ)>N ô>/âļ>8ũũ>X8 >cčp>XA>8<>/-Z>Mvų>Xˇ>M~ų>.°›>ƒŒ>0]>:¨ä>1V>c=>7…>.Ėf>9F>0áû>ŅÔ>0Ë>M‡>YŸ´>O Ę>0Áp>;Œö>ZuÁ>gĄ>[ĖĢ>:¯ü>1åo>P1ā>]í>QŲō>1Í>oÍ>1O>=C>3VÖ>†j>@€>0Ķū>:V¨>1+­>¤u>1>N•0>Y5 >NA >0‹Ī>:K/>YKˇ>dN$>X~î>8ü¸>0Út>NČõ>YeŊ>MÕ>/.>”:>2 x>;o9>1-™> />§>/ ˜>8fé>/ ‰>–=>.ĮÔ>Lí>VČ~>Kņ>.ƒ>7Ī‚>VĩÖ>aūá>Vyn>7iG>.AO>L$„>W°>L>-öd>đĢ>/cö>98‡>/† >úu>7D>,!„>5– >,¨Ī>`ŗ>+”¨>HHĢ>S-á>HđS>+ėę>4sH>Rב>^QL>Sy@>4ôM>+>–>Hēu>SŅķ>IV@>+ú>‘Ē>,¤î>6…€>-\]>‹R><Į>,/>5éÔ>,{D>¯Ë>+`>HŠŅ>S ˛>Hūķ>+Y>3°™>RĄE>^iU>Sfæ>4mī>*„>Gû>S‚ŧ>I#™>+ŠŽ>]C>+ŠL>6 û>-/f>_">Q=>,ņ˜>6F€>,íé>×>+Ä×>Ivŋ>T!ū>I'Ķ>+ÃU>4,>Röõ>^,§>R¤H>3ä>)úŨ>G~˛>RDq>GkŪ>*a>œ>+Q>4ĄĀ>+HØ>Ē>%>,›ö>5Ø>,“>đQ>, ö>I>SOt>GØ[>)Áž>4ĮŽ>RԈ>]j>Qlo>2)k>+}>GŊ>QĶI>F–Ė>) >L˜>+>Ú>4ö>*v}>õw>{‡>-ęë>7>-næ>…6>--a>JAų>TŒÖ>IP>+~Č>5ôķ>Tv’>_=Ī>S_g>4&>,u~>IÖd>TEŪ>H˙>>+G>@$>-3>6t5>,š.>Ą^>„h>.Ē%>7ÅX>.3>LN>.ĘU>Kŋ;>V$ >Jō)>,âô>7ęˇ>V‰6>ažõ>UĪl>6W>.iî>L&i>WÅ>KØŲ>-Y>åú>/NŲ>9&>/r >ŌØ>Œī>1W>;bú>2'Ė>cT>1_C>OŪ>Z^ą>Or¤>0€ũ>:<>Yv`>eYŊ>YĀø>9G'>/õ>MūÂ>Yŧ>Nk>/…°>z>0Ū>:S„>0čG>~>mq>4˜>>•V>4IĪ>ŋä>3Š1>Räš>^Fú>ROÃ>2_˜>=0Ö>^K>jĒ>]\÷>;Ë÷>3Ĩ>R¯W>^†­>RŖ|>2§—>Ō>4f>?Ĩ>5O˙>¤ģ>ÜÅ>0ĒÂ>9Ķí>0GY>ÜŦ>1¯ >O 0>Yl>Mˆ >/Ũį>;Ŋ•>ZlG>dQļ>W—>8ų>2\č>OÉĖ>Xķ>L7ë>-¤°>0>1ũ{>: l>.ŅĢ>Ģ>Í{>1“>:žŒ>1œ>œ€>1ĩŌ>O`Ē>YN>N5 >0•ƒ>:Ī5>Z 0>d—*>XPG>8í–>0Įí>Nēį>XË>LŲÁ>.˜A>š>>0ôĻ>9øą>/†Í>Đs>ƒ>0Į]>9ú@>0t<>č>0"w>M¤>Xü>Mš>/Œ×>8ēč>WŌé>bĮx>W Ē>7æ >.×ū>LáY>Woã>KöT>-ę >Sŧ>0X>9Ū>/X#>Ēu>†L>/F•>8Ŗ8>.҃>cč>.2W>Kw<>Uûą>J´å>,ˆ´>6SJ>T÷R>` ´>TL;>4ĪÎ>,;t>IÅņ>TĨļ>I„Æ>+g >ø>-"Â>6īZ>-cX> \>‡Ü>-!T>6wD>,æü>õ>, >IjG>SŦĸ>H9Ø>*C‰>4"Õ>RŪī>]—.>Q >2 >>)Üu>G7­>Qŗ>Fkķ>(hF>Y>)ÍĘ>3VU>)îÔ>ô˛> õ>*Ø<>2ŊŲ>(žŗ>ÍÍ>*‰>F€i>OSB>CŊ˙>'H >2‘ø>OŦB>Y >MhŪ>0>(ZÚ>DÄ.>N8Ô>C°ŗ>(mS>n>(Ė6>1Ā'>)(+>­Z>{ >+ė[>4¤Ģ>*ŧŪ> ę>+ q>H >Rh{>FŠŊ>)ũ>4—>S#æ>]>PyG>2-ŋ>*ęŖ>Hnĩ>Rh>EđÍ>(û•>,>,k#>55—>*ÆŲ>XÉ>.Ė>..>7.{>. ˆ>PŌ>.\ũ>K­>U>JJ>-Ų>7C>U‡>_čž>Så>4îģ>-ÛH>K,ņ>U4M>Ikb>+T‘>Ę>/{>8u>-ĢĒ>˙u>6Ú>/j>8ĮS>0/å>UÖ>/+=>KØt>Vßn>Lø>/‹R>8ķ>VuĢ>aņ=>Vĸ}>7}Œ>. x>LD@>WC/>Kņ>-p2>kŗ>/Ī>9~T>/=>'=>UĶ>/īy>:í^>2åģ>MÜ>/¯˙>MC7>Z,>PĮÛ>3¯>9#ú>X>eæX>[‚k>;ž€>/Ķ)>NKv>[)[>PĀ>1ŋá>ęĐ>0Û˙><.ī>2÷$>|W>aZ>/Éä>9$>0ģ>t>/Ųä>LéD>Wē>M,>/˛>8¯ >WWƒ>b§e>WV>8Q>.ų;>LĀN>WĪû>LÍ>.—¸>ŪĄ>0^}>:?¯>0Ą>×;>_]>.=>7lū>.ŪV>?Ë>.†M>Jä™>UX‘>KN>.{`>7„Ž>UŠ>`y>Uh›>6äf>-÷v>K;•>Uũĸ>K6´>-‰s>žĮ>.×1>8Ŧb>/J(>ú’>*>-Ē>6’ƒ>.į> r>,z->HĪ|>SĢ >IËÜ>-¯>5SŖ>S:Ŗ>^ŸŊ>T$>5ܨ>,OØ>Ixú>TĢŪ>J€*>--Č>ĩ˜>-ãÎ>8u>/&ū>2&>&>.ƒc>7į^>.‡Ô>ɨ>-ÜË>Jæû>UąI>JėÔ>-W$>6’Ę>U8C>`|>U[ā>6[ē>,ę‡>Jƒn>U°ō>J˙Î>-_*>ē>-ę÷>8>/>0Ę>5ã>.ŋĶ>7‘§>.@/>3>.Ũ>KL:>U5~>J$T>-Ę>6å¤>U‘>_ų3>T,ŋ>5ƒ>-Xķ>JÖĩ>Tūƒ>IËŌ>,g>Aņ>.AŲ>7m†>-áÔ>^ō>9|>1S“>:{>0v>‚>0Vŋ>Mɜ>Wį >K˙b>-|>9ķ>W›ū>bˇ>U×=>6ŽĒ>/då>Lf>VdĀ>K é>-ˇQ>,>/TŠ>7õ%>.1Ž>Ņđ>$^>1l˜>:í>0ҏ><î>0`a>N? >XŌÕ>M8…>.֋>98Å>XRÚ>cUî>W;>7Ŗc>/Ž>MPĘ>WŨŸ>LtD>.^j>Å>0>9ˆi>/š‹>ƒĄ>%–>2?Y>;$n>0Œ>>Đ>1qß>O7œ>Y?Û>MŒ>.Aã>:3>Y4‹>cĀx>WÍ>6õ >0Kˆ>N:’>X}Đ>LqI>-Ë?>đŠ>1RÃ>:Žx>0Q>eË>t>3žĒ>=w/>32R>.đ>2”,>Q({>\=;>PRå>1'¤>;rģ>[Š>gAk>Zˇt>9ņy>1˛Â>PČ\>[ú~>Oę#>0‚>Ú>32Ä>='d>2ŋ3>T*>ß>5†0>@6>6Hü>ž>3ņî>S˛*>_¤b>SŨ>3ü >=>^JŅ>j‹Ę>]ė5>2ũO>RÚ§>^“F>RŠ>2‘>;>43c>>Đ->4ŗá>I>U–>/žŽ>8Ž6>/’>Ö>1(‚>NhF>XMÕ>LŠķ>.ņå>;Bū>Z)×>d€>WԚ>7ũ”>1Ũ>OØ.>YĶ\>MÁ>.„{>ü—>2„{>;ļ>0Œ2> ~>&­>0­ô>9Íâ>0s>_ĸ>1fk>N“I>X’Û>M6Å>/ۄ>;:‡>Z¨>dI>WĪ>8l~>1¸V>OŸ>Yuž>M)Ŧ>.˜>>2 O>:īô>0~> >ĸ>0>9ķ‡>0ml>õæ>0Ųn>NJ>Xœ;>MEQ>/’ˆ>:?€>Y;>cæ•>W­X>8.Æ>0ĸ>N”^>Xč >MŦ>.˛†>Џ>1$">:–Ņ>0rJ>ČŅ>>1Oˇ>:s^>0o>nŅ>0q>NyÄ>XÆW>Lžķ>.$,>9Ų>Y{>d3>W‘•>7ŽĄ>/ė¤>NPž>Yū>MJ>.ūˆ>¯A>0‰A>:q%>0ŧP>RM>ÔĖ>0õB>:{ģ>0¨I> |>0"Ģ>MÔC>X} >MŽ>.Žņ>9->X:I>c^y>Wk>7͍>/WŽ>MZŒ>X9l>M[>.ū)>}M>/ͤ>9Ēá>0)Š>*Ŧ>II>/÷¯>8‘ŧ>.PÅ>>/!>LŨI>Vēˆ>Jí >,ôį>8ô>W“Ų>ađĖ>UÍ_>6ŖĻ>/R>Lī,>W>K§i>-÷q> >/ķî>9ã>/0Ū>IŽ>ԝ>/NN>8Yw>.îé>Îâ>.›í>Kß]>VEC>Kkw>.z–>7ķ:>VË>a°i>V>7pA>/&>Lå‡>Wb">L Ĩ>.K~>VĘ>0Ļ>9Ū&>/ĖĒ>x >2{>/fr>8ãc>/ũ5>ĩ>/8o>LY >W<ƒ>LÅP>/×[>8V>W5>b˜{>WL >8mÉ>/I>M7>>X#>LëŌ>.ˍ>'>1ˇ>:Ĩ >0uČ>’Â>pÔ>.ô>8ø>/+j>hL>.ÄŪ>KBú>V ĩ>KÛf>/ +>7˜Ô>U÷z>aŒķ>V‹ņ>7—E>._v>Lå>WQ>L^>>.G>OU>/Ęũ>9ۈ>0^>P>O6>-ĩ>7Ä9>/8¸>Ÿt>.?M>Km>W m>Mo>/;Õ>7fé>V”û>cˆë>Xˁ>91œ>-~f>LP>XūĒ>NņŖ>0ä>-Î>.Lē>9ũ>1Đ?> D>vĘ>.˙|>7s[>.7X>Ŧ\>/­>KВ>Ufė>Je„>-ŗį>8ŋH>VƒJ>`ž >U/>6Ŧ>/h>L[×>V–š>K›į>. ˙>ö|>/ØĘ>9d>/ã >Úæ>Ôf>.Ũš>7ßæ>.Œõ>€Ô>.üĐ>K™z>U å>JÄ>-eƒ>7û­>V!S>`ŗö>Tæö>6 >.}Õ>Kˇ>Ví>Jāë>-0Đ>ō;>.öŧ>8¨>/;> ų>°ŗ>.„>7‡@>.‡E>Så>-š">Jfq>TßĨ>J)ë>->6Ŧ.>Tė>_Č>TP˜>5ŽØ>-o>JÍg>Ue<>Jpx>,ųe>$š>.Sú>8Ö>.Í >˙‚>,Ā>.Ō8>7ęV>.s>>7>.Yu>K9W>UĻ>JÛ>,ë5>7žđ>UĐ>`>TJ…>5ŲÅ>.Vö>K“ >UĨd>J€>-]`>ƒ>.ķø>8Z9>/ģ>ą:>É>/ĪŌ>9Ė>/qR>ék>/?Ö>Lŗv>V°P>KI >-đ%>8Ę­>W{P>aĖH>UÂ>6îŊ>/ģ¯>M2l>WL>Kīĸ>.sî>8h>0\>9Xâ>/ÜÆ>XC>#C>2Ų>2W>ˆķ>2{>Pn>[ā>O• >1Ā>>[Ø>fØS>ZŒa>:‹Đ>3qI>Q¯<>[ķ>PŽ>1Z°>6N>3ĸA>< ‰>20z> C>Ē7>2Û>2x>—>2'>Oęž>ZČ>OmË>0ą…>;é>[9€>fŽ´>Z™L>:Z˛>37g>QP×>\đ>P“ų>1Ŧw>u‡>3Ūa>=`—>3S‹>mÜ>Í>3ƒĩ>1˙“>\ž>2īÅ>P]>Z^ >Ną>0SE><(>[v>e›d>YdŲ>9ą8>2ã>PĒm>Z§>NŲÚ>0l >cŊ>3oi>1Õc>ņ>på>4äÍ>>…Ÿ>4{Y>‰>4w>RÎ^>]Í^>RRâ>3f >=đš>]ņ|>iš>]}8><×>4 >R÷[>^G`>RŒí>3ž>›>4žÜ>>ē‚>4uL>ĪĪ>žĻ>4õá>?„˛>5æ˛>ƒ˜>4čŦ>T/×>_öģ>Tœ>5=>>×+>_¯ę>ké‡>_ŲĀ>>ŊŸ>4 >TÖ>_Å>T?>4ŠL>"p>4šˇ>?1í>5ZÍ>ī>¯1>.Øž>7•}>-Á4>Đ$>0•T>Mu\>V°Ä>J‰>,÷Ī>:ô>YB>bõ>UM>5gÂ>1‘{>NÜÕ>Wšš>Jš˜>+Ķ>&>0íĪ>9 Š>-Ŧũ>ϧ>‚Ö>0 d>8ėę>/Qt>P°>0Ķ–>MË&>Wkę>KŲ÷>.˛?>:Ô5>YUÄ>c@˜>Vĸŗ>7˜Õ>1P6>OÕ>XŊ$>LŸ>.u‡>ņ>1H›>:WG>/î>>oī>Ģ>0đĻ>9~č>/Ÿ×>Ā>1W>Mî•>WeĀ>KÄC>.Fž>;ƒ>Y%">böt>V‘–>7Zé>1ƃ>NōĐ>X´–>Lë>.ŧ÷>m1>1eč>:Ž~>0•ö> ¨>ä>1>:Î>0ô>’>0 >MŠŽ>W¸—>L9 >.™>9Îc>XĒū>cW2ģ>8-œ>0î>N˜A>XÚŖ>Muy>/Ķ/>âū>0ô7>::G>0´>°>5‚>3-b>=S2>3‰˜>ÔE>1¯>P37>[’ų>PMU>1āÖ>;ŠU>[p}>g$>[ d>;ƒ‡>2ã‰>Q2>\Œ>P­F>2¨f>íž>3]ũ><Ūũ>3ž>>w>0Ûņ>9×Õ>/ę?>*>0J§>M Á>WÆ >LD9>.Ēü>:BD>Y3>cŖ6>W€Í>8‰>1Õ>Oj>Y‰Ŋ>Mč~>0!—>uā>2O/>;UÄ>1R>>XÉ>ĶÎ>0ĩd>9Û&>0$>°6>/íh>M†>Wæ>Lšˆ>/K>9ë>XĮ>c¤m>WĸŽ>8 b>0•?>N¯>YJÕ>Mģ>/׋>Tš>1ÅE>;:Ã>1G>ū>zČ>/Cį>8D)>/C> >/=->Kņ1>Vf >KĮv>.Å1>80G>VŗY>aĖŽ>VĻš>7č@>/K>L–ō>WYK>LŽÚ>.Üč> Ÿ>0B>9¯Z>0]>ė˛>×N>,đå>5Ąâ>,Ŗd>>,Úū>Hü+>S]?>Hũ>,…v>5–e>S˛ĩ>^ū\>THš>5ķ>,p°>IĮ>U>Jäĸ>-‹>Ē>-Ō>8o>/B„>e>į‘>':—>0ax>'æ>Â>%éL>@ĀÆ>Këū>AÄē>%b´>+üC>Iĸš>V\Ē>K†ō>-†­>"Ÿė>@|ē>M›->CzĘ>&=> õ=>'Gô>3 |>*F >yđ>Á>0/ >9 >/l(>üō>0[ē>M2>Wfī>Lh÷>.ķ >9df>WÕ>bÁ)>Wƒ{>8–—>/ģd>M\,>X?„>MĒS>0 í>R„>0…z>:š_>1uˇ>lr>ķ0>/eä>8yi>.ŪÛ>šā>.Ũh>Kûo>Vo>K‚Š>.b+>84Ķ>VÖ´>aÍą>V7”>7|ƒ>/4š>LĪŅ>Wc>L ņ>.sí>Ŧ¸>/ņ:>9Œ‰>/åH>äí>&›>.¯Ô>7ķ•>.Ŧ>f>.bđ>K€ >UŽp>JĢt>-ŗģ>7Œ¨>V\ķ>al>UEĶ>6Ēl>.>LÂ>VÆB>KsV>-×v> 0>.ķ2>8Õ>/9ļ>TÅ>Ä>/@R>8( >.˛q>ē>/Ŧ>K˝>UĀČ>JÂq>-â>8A¤>V}ī>aq>U˜Z>7U>/b>LxT>W>L7™>.؋>å†>06 >9˙ž>0ØĘ>Š>F?>.Îî>9 v>04>Ļp>.%Ų>KĢ>VÜ >L“6>/dą>7ū>VJ >až->VÜj>8Jg>.ë§>KėU>VėĢ>L„F>/ë>•ę>/Yd>9Võ>0-p>8Ņ>)2>2m>;‚4>1˙—> N>2.ļ>OUm>Y°N>Nĩũ>0÷ä><š>Zn›>eA>Y¨U>:j‡>2bT>OÃz>Z`Ú>O”Û>1§5>Ł>1Ų‚>;°s>2”w>܆>„§>3ā>2ōw>Į\>3^d>PŒÖ>[ß>P$8>2+°>=jå>\>g6¯>[Ÿü>;â„>4’c>RI>\õÜ>QĶŊ>3, >ˆũ>4 ū>>i >4Ûŗ>>Ô>4š>>‚l>4ē>Ū(>4y‰>RŨ*>]ˇN>R8O>3“E>>ž>^`y>ia >]!Ę>=M>51h>S >^m>RP;>3ˆ>Ŗ>58[>>vr>4Eá>pã>¯‡>5œÎ>?f´>5>Čķ>5é>S”ģ>^sā>RŒō>3†l>>ĶW>^˛‹>iî–>]^ē><Ö¨>4˙#>SŸ->^uĄ>Rf‡>3ú>ks>52’>>ŗ>4C>éS>‡ę>4=Ž>>; >4Oh>J>4fé>Sl>^qÚ>SG>4č>>,ŧ>^]|>jdũ>^ŦR>>+§>3‚^>R8}>]õ}>Sl>4 Y>Cē>3$‘>=ˇ1>4Ąë>Ŗ>Îŧ>.d>6fX>,āŖ>Lü>/Āü>Kģ,>Tį;>ID$>,gÜ>8˙ >Vä>_Q‘>RÃ>4Q>.ņ}>JS4>SM>GŸl>*Ÿ>š@>-ē>62ŋ>,iX>s>īh>.Œ<>7ŌĨ>.™”>¤Ø>.ë>LZ>UũŽ>Jâ*>-Īļ>8š>WH>a)g>Tāl>6)ô>/hã>L™y>VPr>J Ļ>->š'>/œF>8Īt>/™>m>Œ>/I.>8Dp>.Rą>Į>.ķ(>Kû>UÕī>JEĩ>,ÛB>8h^>V´Î>a¤>Tę2>5ö >.ØÂ>L@å>Vžš>Kd5>-†Í>ú)>/+[>8Úß>/E > >āf>-˛Ũ>6f6>->Į>-X>Iė¤>SîÛ>IJ:>- W>7ëS>U˛i>`û>T¸Ž>6 ?>/÷p>LÉķ>VĨ>Ka3>.Á>Ė6>0YP>9>/Á>>O>Ųũ>2 Z><Ë>4">øį>0X>OQ>Zú6>Q >3cá>; Ã>[ˇ>g¯>\@S><ÅP>3%Đ>QĶC>\ėų>Qāņ>3b°>ļÂ>4›A>>“>4V">Æx>PČ>-ėģ>6ę>-b/>c>-”Á>I{H>S€Ļ>Hˆ~>,p>7ĸü>U^y>`k>T6M>5Ō>/ƒF>LŖø>WgĨ>Kū÷>.>+>02ŧ>:u>0It>å>9Ą>,Â7>5āŧ>,šņ>ĻŽ>,ãę>HéČ>S>Hû>+a¯>6É>T>_>SBŌ>4Ã˙>./1>KAr>U°+>JNË>,Î'> j>.ØY>8n >.Û˛> Ķ>‘>*—ķ>3]…>*ŋ>ŖĪ>)}k>DÆ>NÎ@>E c>)‚F>2I{>O;Õ>Y˛ã>OfŽ>2)Ū>*/>Fu@>PŅ>FÁ‰>*JŨ>q>+ŋ>>59>,–;>S.>í>*øÉ>2†ä>(%a>!P>*9>EŌy>Nˇ>CS]>&¯0>2ā>Pë>Y¨W>N =>/üŦ>*PŒ>FoH>Pƒ>E3E>(OŦ>r4>*‰5>3w>*Oō>ã|>–Ö>*‰}>4ˇB>.1Ø>ÅĢ>)žV>B‹F>M\Ž>FŦM>+7>2IÅ>KĐx>Vė!>PtÖ>4€>)SÉ>B=(>M# >GŸ“>,?>ŲJ>(-?>2@C>,¨ķ>’”astropy-photutils-3322558/photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits000066400000000000000000000207001517052111400271240ustar00rootroot00000000000000SIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 5 NAXIS2 = 5 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 =áDë>X> ‘>B=ė}Č>đî>U>cŧ>9>÷N> žÚ>÷8>&Έ>œÛ> ŋ>eÂ>6Ę>2×>ųX>82=ę|Â>&> Ļ/>Ëŧ=ä$Å=áūE>ķg> |é>¯=î*˙>Ų>Íß> Œ>yE>„5> û"> ēū>'ûč> Äô> j†> >q¯> ļ’>+‰>&Í=특>4â> 5é>t=åÃņ=âÆÁ>—ã> ™Ū>: =î—>>Ļ,> ë>$ŋ>Hė> 5ō>!o>(3>!]˙> Æ>‚1>7ļ> øŗ>šf>ŋ{=îq>ŲY> mØ>…=æÍ‰=ë> ?š>æ> @â=ö ‰> N>"hk>* >#÷â>:>w >*ú8>2Ę>+/Ã>™> 2>#âe>*Ôo>"įę> Ģ=÷ėĪ>tĀ>īƒ> }=đE=ķ#Ō>Ŋģ>0+>Ø>Bš>V?>)ú>3÷Ē>,åU>įë>ø>2ėV><žd>4+§>bt>š>+áB>4@>+É>Ϟ>+Ė>Š>§ā>Į.=øĢ=é Ļ>ĀĘ>ŦR> ĐŊ=õjÔ> z‡> Λ>)Ö%>"đƒ> ->‚Ø>)0>1Ë>)HĘ> ū> x>"‚Ú>)ãâ>!Â> ĩe=öÜ[> ZŖ>YÄ> !ä=ėFˆ=âĸ">ŠM> 6>š =>m>Ų5>$€>ā$>đ><õ>#Ęõ>+ŅÚ>#į!> k÷> >.n>$5ų>õ×>Ãí=đ…Č> d>8 >xĢ=æŅ=â’é>M˙> ˜Ė=ī=&>bę>/,>!=(>üų>ęv> @Ģ>!ļ>(Ŋš>!ƒĸ> ķ>n3>Éū>!yP>û>÷8=íŊ> J> Zd>^Ķ=æœä=ã=>Ģ&> ‰W>ۏ=đû>č3>Õ>!Üõ>œ>ēĶ> öÁ>!ũY>)`>"'X> Æ>=m>Če>"A*>ˆ„>cK=ī b>ü,> Œ>ˇˇ=įš=â >Ē> o¤>čŊ=îĻš>[Č>cō> ×f>ר>5*> N&>!pÚ>(fo>!MÜ> r>Í>k*>!F>ÃŖ>¯{=î0>ŠÛ> 7Ø>œ=åĶŠ=į%>×>)Ų> ąĄ=ô ß>üœ>īT>%œK>b¤> zx>2>&_>-¨´>&L>ʤ> bÔ> %6>&ĄÁ>õ×> ŗH=ķ*â> *>^> ß=ëņŸ=îûÁ> ķG>ēx>´ô=ûŨ}> øĢ>%ŧ >.ÉÍ>'Ėđ>-$>ŦG>.¸°>7gé>/>¸+>V>'Ņ>/|Û>&Âé>š‚=ü‚Ą>TĪ>û>3ũ=ķ€é=ęS> Ē˙>›Ī> Āh=öŲA> ĐČ>!fW>*c3>#‰k> }><>)ÛJ>2Kö>*->b´> ēA>#æ>*{&>!ķ> ›Á=öÖX> — >ÎY> ęŖ=î)|=æĀS>ä3>īž> lė=ōäS>ĻN>3W>'.`> ¤> ŋ>yÁ>&&á>.‚´>&˜›>`›> üÖ>MQ>&°ˆ>eĶ>›×=ōÉą> ŋĖ>/> [=éäe=áōS>Ôl> Ũļ>sH=đb8>^/>øZ>!Ķ>@>Ŋc> >¯> ĶĻ>(]ķ>!=> \b>a>Đ>!f>—´>˙§=îĻ2>0X> ,>Ž…=åŗö=ãøŌ>> vĪ>Ô˛=ōîN>ģ—>¯&>#5:>úM> }”> âŒ>"×Ä>*Ę.>#\Á>I> ]X>¯k>#\o>tĒ>x<=đÅ­>‡¤> ŸF> Ķ=įâÜ=ã\>)> ZŸ>Īæ=ō?ŗ>‚s>ôM>#?I>œ> !Õ> š‰>#$ĩ>*Õw>#vÁ>ÄØ> Ū> Æ>#vē>›O>,=īĶ >”ī> a>Íî=æ‡˛=ä?>ę >;> ßZ=ōš)>÷Ã>§K>#ãę>õÅ> PÚ> úÉ>#Âė>+\P>$I >6ą> HŪ>n>#ØĮ>[E>­Ŧ=>ŗ>> Ĩų>}œ=į÷Á=é,š> >0> rĖ=õ×å> 2š> Ÿ>'ŨĻ>!Uļ> •ˆ>f>(ŧ>/°)>(q>ÂĨ> 0>!X|>'ū>>˙?> )ŋ=ôZû> ÎJ>*8> ē%=ėf×=ë@W> ų>tY> Œ?=ö‘> }Ŋ> ß.>)‘í>"äĘ> 2>ĩ˛>)6›>1o>)›Ú>J> dG>"_š>)ŗ†>!‘đ> ˇq=ö¨> ׯ>Đo> FF=íßü=ėĮ¯> s<>95>-Ö=öĸ> 8>>!*5Ü>#l™> (P>k>)<ø>1ɂ>)áĻ>"u> `—>"Ø>)Ŋ4>!Ą1> „(=ö[m> ÃI>3> Ĩ,=î^C=ßÅL>Wg> Lũ>Id=ī{Y>‚>0ß>@>ŠĶ> ]> Ęã>ĮU>&9>0> A>ęU>-]>>,>lĢ>šĄ=í¸>É˙> ]•>ÔĐ=ã÷=ãå> ¤> ‰> ƒ=ķËF>î¤>Æŧ>#fœ>Lå> ×> ˙Q>"ĸĘ>*Ąĩ>#S7>‰ > ŗĸ>Ąg>#F,>Z˙>™¤=ņ´ą>¤0> –_>âˆ=įÎØ=åR“> >^> ķ@=õ…>īS>ķČ>%Č->›ú> ãë>ęĮ>$ôŖ>-H>%™a>#ß> ;š>¤ >%r‡>j> b=ņš+> Ü>å•> ?=įĮ”=ä‹ß>Z€>á> €Z=ōû{>.>>$Ŧ6>Œˇ> _¯>3‚>$D >,F>$ĖU>B > ^˙>ŸŸ>$q>c$>jŲ=đ%d> ¨> Ũģ>‘%=įƒ=æÅD>}ū>‡f> Ā=ķäÆ>m›>pß>%›Á>wŧ> TĘ>§ų>%Đ->-?Ą>%øĻ>_Á> ą4>¨>%hī>žū>s=ņĒ^> Ü>ļ>Ğ=é|ē=ę 0> mŌ>P> Ôz=öŧ> —`>žĪ>(Cé>!ų> úš>ļ>'ī›>0ĸ>(Ŧ>X> b!>!5>(t^> œ]> Wø=ô\ > Ás>†ä> #%=ė.=īx0> š>K>ę=÷sĩ> ÷Û>"]o>+l>$*É> Ęæ>‡Ü>*–>2qG>*Šē>(> ˇS>"Ąt>*Ķ>"B> qē=öš> āc>Û> šw=ī^=ā¨#>{y> 'å>˜S=īʼn>ē >?6>žĀ>l>ŽÄ> ķt> CF>'s> ā> e%>uV>˙M>Ā™>ŧD>ßę=î S>ƒĢ> ë{> =ä<|=ã‡U>>í> > Ë=ōŲô>ŗ>>!đI>S> Ļá> Iˆ>"5<>)uŠ>"Y>Ô>ã'>EI>"r÷>â÷>|_=đz>šw> Oz>ī`=æ¸P=ã˙8>įˇ> ŋk> Ļô=ōÉ-> Ü>°b>"œ¯> Õ> Į‰> F/>"ņ>*M>#{D>|$>ã'>ŪÎ>"õĀ>õ>ĮÅ=đf>ö$> ` >*<=æãž=ã×u> *>> ’=ņúš>á>æ>"ëü>úG> \ž> ķT>#I>*F#>#5">>Hō>Ÿ>"•\> Ĩ>kŌ=īÁ>—!> ÜR>ņ=æeA=æņ>‘–>Į> Iė=ķé>ö>>$üÆ>'W> N¨>Bô>%P>,¨˜>$Æu>j‹> ũŊ>\M>$ģ<>˞>į!=đÕĒ> ŖT>ĨŲ>`Ü=é`š=čĮ>g‹>dÛ> &å=ķKˆ>@_>,>&@ũ>|Ę> ŅØ>!>&.2>-ŌE>&^>> >uü>&n>GÁ>ÉĶ=ō > gĮ>ēš>D=ęá|=ékč>Č>8 > ÄÅ=ķâŌ>#>2w>&ŨŊ>ía> Ô)>zā>%ÃV>.!>&MŸ>ŋ‹> ˇO>cY>&ŒN>ˆE>w1=ņæM> A>;>Ø8=ęy9=ā1>[Ø> KĢ>Čę=đúh>Ņ>ŗŖ>e'>!„>Å> ]Ķ>ÛP>&Đŗ> B > c>,Á>x>>‘g>ÁĘ=>Ø> ˜U>ŠŦ=å ė=â—Ļ>ą(> ›>Í=ōh>nũ>W>!!'>ĩ`> ]> W>!S>(l >!´c> ƒ4>*Ô>ZP>!M >üŌ>Đ=īÃ{>> Ŧ”>ˆō=æIŊ=ãf >D›> Kv> 5=ō%ã>¯%>į>!Ūl>4> /> ”‡>!ĀC>)ß>"Fß> 3>Í>””>!ģĒ>ké>Š=ī“> S> Ģ>S=æ =ãû>ŪH> û'> ¨ =ō-š>ā>Cō>"c˜>…n> Ô> É@>"dM>)ë„>"úS>>õ>Ü>"d<>ũĘ>kž=ī6*>„Ą> DR> Ŧ=æāH=æ@!>==>Ī> Í=ō“€>V”>8>$ū>Ÿ> šg>B >$IŸ>+´č>$,U>ã> <Œ>Ÿ>$w>Tŧ>{š=đ0=> ;=>NČ>=čđ=įÂ>žÅ>„> …9=ō°>[`>ū]>$øj>_Ų> g> ˆ>$Ū >,l†>$Õ+>+Ô> B>|>%w>FL>Ā=đęÜ> ”Ũ>ũ >ŨŒ=ę@ü=éõ’>ŧ>˛…> ´Ē=ô>=Ä>ĩ>&Į>šU> ÖĶ>ÍÖ>%„“>-t\>&õ>ãÎ> .>ea>&H">“Â>Öu=ōw8> z>>ūM=ëcu=ÜÄü>§„>˜ >˛=ëí%>Đ>u->7ŗ>ãÚ>ļû>2ō>ÛÚ>"ÖĶ>@„>ę>Đ¯>ëĒ>S>E°>Ēp=éį¨>K>uđ>_L=ŪÖF=ßūü>Û> Ū;>¯=īM)>ÂB>¸>åQ>aš>> @ß>y>%¤1>÷Ú> -7>ŧë>™1>…—>K>~b=ėSĻ>7Ž> Ö¤>˃=ãN=ã|ĩ>x> 4“>Ôĩ=ō%E>ōÜ>ÂX>!ÆK>šĶ>į1> t>!Ä>(w6>!oô> 8>ŦË>ĩę>!$> >ž =îžN>‚Ķ> |–>__=æ<Ģ=å5L>>1Š> ͎=ķ,>Ŋŋ>´/>"Ļ>Œž> OÕ> }ē>"o>)ĐĀ>"ĸV> ö>¯9>™>"Y>ŋŨ>ių=đzÍ>Ą~> Šá>,Ė=į{Œ=æ4Ę>áu>°ö> ķ´=ō‘>8>€û>#mI>‰> H > œá>#>å>*Ę >#V6>'į>é˙>đ5>#v%>¨ī>í-=đfđ> š>I>įĖ=čÃs=įŧO>Kp><]> Uį=ķ ŧ>1ų>x>$Į>ęų> õˇ>Āh>$”>+ģ€>$+[>ÆØ> •>ã<>$”¯>ž>ȓ=ņTį> y’>õ¯>Šķ=éũ=ę™>kā>OŒ> žë=ôķ’>¯Ķ>%>%{š>ņk> >8ņ>%1@>,Ã9>%A¸>Žë> PĢ>ÛÔ>%Õn>.e> Ā=ķ ž> DF>ˆ>ų=ėI›=Öv>=ø 2>é >¯x=âņ`=úMa>n_>1>>Ģ=˙Đ >0C>ģ“>žî>ļ>äW=ũ–Š>z>ŠÆ>ęĶ=öČI=ᘔ=ūÅ>>øķ=øä=ÕyÛ=Ū†L>cš> >ŽÅ=ė>A> :>&­>Vn>åx>:ą>đœ>#:ö>Xė>’æ>ŠÂ>õZ>&>Ÿā>ņĖ=č°h> c>†Ã>^?=ŪŲC=ä¤(>•É> ʇ> U=ķ>ģ{>Ž>"Š>¯> l™> å>!Ä­>)›>!ŗØ> G>ZK>TŖ>!ˆŠ>Ņm>Íđ=đN>T> ũ0>Ģp=枨=æJ•>œŌ>¨P> 1=ôÎÕ>ßM>Õ{>#‘ >; > 4¤>Xi>#.”>*5>"Á>A•> x•> „>"ž‰>×>Ąi=ņLB>ãm> ĩ#>mÛ=č? =įŒö>Q×>D—> D­=ķœn>ÜÍ>W>#ū¸>8”> œb>ŠJ>#Öä>+BT>#Iö>-…> ŌĪ>Ąí>$h>ˇl>û2=ō ¨> ũē>.÷>P}=éOæ=ę3>˜G>Ļ > yr=õ#”>>yÔ>%§Ü>ūA> øč>Ģ”>%{>,āS>%S>>ėž> §>ß0>%‚ü>‚%>ki=ķŽ> ڗ>N>?=ꓖ=ęŪX>y‡>/č> Z=õĄc>÷>Û>%ēŲ>ÄO> N(>ûv>%$ų>,ļč>%Ę>é¤> —>=a>%hR>+>ö2=ō˜> #Z>æ>Ū\=ëcŪastropy-photutils-3322558/photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits000066400000000000000000000207001517052111400267700ustar00rootroot00000000000000SIMPLE = T BITPIX = -32 NAXIS = 3 NAXIS1 = 5 NAXIS2 = 5 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 =î‚&>Ja>Į>ÎÄ=Ū\> ’×>(9ũ>.¯1>"Ē1>=c> >.”Á>6dļ>+jY>U>ĸL>"-¨>+Ũ>"ÉŊ> GM=āÁ> 6E>ŋ> Š=ķ‘=蘈> ˛P>Žä> c =åÄN> ē>'Cž>0]>%ž~> ˆ¤>‡í>0Í4>:UŲ>/9k>:> ~@>&7>/?č>$ķ°> •=æ.> Ãü>ÖĄ> =ė#Ė=ĪĶ=÷Æ&>=úŪŧ=Ôf¨=ôĄė>čÕ>Qb>5x=øf1>Ƌ>…â>$Ķ>Ŗ>īŋ=ųNÕ>Æ^>>į>’“=õh=Õû=ú˙ä>A§=÷Ÿ€=ĪÅ_>‰Ä>6j÷>Kn>@ÛK>!&[>0ū;>tÆN>‡W>v]x>?úä>Bw>…mį>‘Ŧš>Š†>Aŋ>:Šŧ>x×>…5c>iCē>+ >"č>FyP>M§_>1}4>¨s> wÆ>4ĄÚ>Dyũ>3ĄĮ> FĪ>0(>py>ƒØ8>nGė>4%>@SĀ>ƒĸ> ŋ>€ø2>?Ŋģ>4Yō>rãā>ƒ`ô>jŲ>0 > šį>8c>DSĒ>0ģü> ~>‹7>6/%>EˆZ>7|> œ>4ģ.>lA>‚Š>o[>2ä]><Ás>~Â>ĻJ>€s><ōũ>,Ę>jė >‚œo>k o>-á?>„/>6z>Jĸ,>8> ĶÜ=éá'> c> > ×Z=äšV> Ķ >&ŽĨ>.ų:>$œF> –H>z>1šb>:σ>0Q|>šŌ> ›>&Ę^>/¯>%–ļ> =įØģ> +>5> ֍=íH†=åã$>ōP>x>¨Â=Ū!I>‚Z>!˙‚>+ä;>Ë}>> ƒ>*>5[ę>){"> âa>1>!ē>,Ķ>"ĀN>‰č=ŨcÚ>¤†> L> J=įPæ=Ũp(>˄> 3…>Čŧ=×Ŗ1>Ÿ‚>?Ö>&0x>§đ> &> pÛ>%ÄC>/_>%¸*> UĘ>`>Ŗ™>%3>Fë>Īc=×d;>^1> Ô}>úF=ŨŪ#> ˛{>8'Ų>J‹ ><ĩ˛>Ņ$>/ĢÅ>s=g>…cÎ>p<Į>2›3>>|>…K#>‘”Ÿ>?>;ŧ>1g™>uūČ>†+?>n'7>-Ø> ÔØ>;ø >MPe>9š(> Õ > ī>4î^>Dĸ—>5u}>eã>3f,>pį<>…-`>püD>5$>=aˇ>‚™‹>‘ce>în>=û >- W>n!Ä>„›>jĘ >+R^> .¤>8&K>H‰c>2ĮŌ>Dá>6É><­ô>JĄ†>9aĻ>—b>5îž>s}>…ÂÂ>sč>6õN>@á\>ƒØk>‘ãá>„­Ģ>DTŧ>1ī”>r,”>…+>qo>2Ä|>|—>>j)>LŽ7>8y&> aS=áûc>‰g>`>-ú=ā ~>ąî>ˇŠ>)‹)>ž2>ø=> uE>)\>4@>(']> ‚><ø>ģ_>)L >!h> ĩ=âh8>(N>š+> '=ã&=ۗ>k>U>˛Ė=ÖĒÍ>ķ>ôĶ>!sŪ>ŌN=˙>^>!>* j>"Ũ >…6=˙đ>}F>" >†>™Q=ÕV>$´>ÂL>ËV=Ų™ö=ŨĖ >ŸŦ>: =ô…ķ=˄g=˙U>zû>Ęŋ>‹Å=õĢž>čđ>Ãą>' Ū>Â>˙u=ķZ>=‰>“7>øˆ=ü–=ɁE=÷‰:>÷> į=Øîß>1I>6M$>Ehk>.ö>4ˆ>l”Ē>ƒÎ‹>l3Ą>.”ŧ>>N>€Âŋ>ÃŊ>ü{>AY->)ûë>gÉ}>‚QĀ>jSĖ>1A”>0 >1›Î>F–w>3Î'> ˇ^>įĪ>5C#>A;‘>7Î>SÍ>;Û->qõû>ƒeģ>rūø>>ĩŅ>F/>ƒlÛ>ë>ƒ4ī>F;L>5¨ß>q˙Ų>„mÂ>pŖ>35G> Ųŋ>8Ņë>GíN>5Ūŗ> Ļ=ú_G>)‚=>@ >5äā>Ŧü>&xķ>`ß8>{̰>fm>/Ŋō>7šĮ>uāį>ˆnr>v I>6 ž>-˙–>b“‘>x ĩ>^2>#Üü>I>3›{>?WĶ>*Īũ>jĐ> ö¸>21ø>C5>:oĀ>ĸÉ>/Ãô>jlĻ>€ģ?>m Ŗ>8`į>>>ü>€qļ>ŒÉŽ>}Ŋˇ>=ôš>2 Ķ>n>Öa>gÂ>*k1>Ŗß><Ôō>I!Ā>0>ũ>‰î>§¨>4 >ItÅ>7•å>y•>-č>l-†>…“ž>qū!>-é>ī>’0^>„…ä>>‰˜>0ד>l×ķ>„ĪĐ>q(á>0ļ> ɰ>5`y>GS\>5mõ>r>#Ÿ>4g>D(>4–Ž>œ>.īū>gĩÅ>€ã >kšÉ>-šŸ>;Ŋî>~ĸ>l>áQ>;#c>-Cx>kŖm>ōÖ>kD>.}_>]~>2ū¨>EÁš>6> ÜÍ=ņ0&>&āL>7)×>)ÖĶ> “^>!§J>^ß >qĘI>X—Û>(ÆC>6Žų>x˜]>…ŗŌ>mÛ>4@|>-}…>eZ>t¸H>Xsâ>$ĪS> _>.a$>6’¨>"Ũ=ųĘē>—\>*ôļ>;mČ>+°>€>&8>aéb>zš>auũ>%Ɖ>1ÆB>s´ī>‡ŧ>s.ü>22¤>&Œ >_†û>uûn>^\s>&žĢ>jä>+—ē>9pŨ>)VŖ>ģ0> Ëú>+6>7}¯>&‘œ=ų-H>%T;>W´>lßP>W¤”>Č­>/Ø>k–š>‚›’>m˙ļ>0c•>đÍ>Xö>pû}>\~Ģ>&&Ú=ôGë>'.“>9+ũ>+ÂE>°Ÿ>Ŋõ>-… >AUž>86>˛…>)K|>c ū>{9ë>gD>+Âb>5tÜ>wH->ˆj>w Œ>2Æw>)<Å>f3>}ô>d¯Ã>%Ŋ>uŪ>3CJ>CÕ_>3)>n,>@}>*”O>?O9>/Č×>á…> >ZeA>vË>^Ą—>"pĪ>*Ø >m¨‰>…īĄ>o>*܈>"´Ĩ>^•ā>wņ—>\„Ũ>¯ŋ>ŧ0>/‡?>?ä>>+ =ũ‰i>•ũ>-D>=Ļ–>.<>´>!S>W{ę>rK>_Žŧ>$-3>)áë>fúh>‚Ŧ>pû?>.ôƒ>"ģ>YAb>rĒĢ>^!o> ō}> T>0Ā>>ļŒ>,+“=ûD‹=üĖĢ>%Û9>4c>#[‰>>48>V˜Ŋ>hÖ÷>MC>ŌM>*ˇĖ>jp>An>`>&d§>Ē1>V5Ų>jQ6>P‡Ô>áŗ=ôûˇ>$p>4VĖ>%Nž>/Ä=üŠ>æä>+÷> ˇ=ũR>y>N P>cí†>PÕ>_ū>%Ë6>a´î>zœæ>aō˜>%Ũd>ûü>QŦ˛>gĪ’>PsR>â`=ôŦ> ">/­>X@=đQ-> U>-B›>4vu>$åk>å’>&ęc>[+­>nūd>\ >'Ú>-ÉP>kÜb>‚ŧ>q$P>3ŋ†>!i>ZGâ>p,p>[Žˆ>"¸>2Ģ>,ēÃ>8˛‹>&c=ųŊ>ąĶ>:P×>E'ō>1kô>`B>3’>m°:>€ė_>h`>+jZ>>üē>Đ~>Ž€,>ƒ>?ĝ>-$ >lâ|>‚žJ>nW>5\”>Hŗ>3‡Ũ>EėĮ>8p(>->Ŗž>% Ā>6+>,÷> M>%ÁŖ>[„>u!÷>cË>+øˆ>2ŋ>pķā>‡3->vô>2ĸž>&ˆW>_X>xíb>aqĻ> š¨>›Ķ>*W…>;0>)ŧ)=õÜ>=Đ×x>Įâ>+{>+ĩ>Nž> d>? >[Ö>Pí>"/*>#j–>Uļ€>oÕ°>[°ĸ>#^x>$C´>KLā>\ˆ>DÔV>ŊD>Œ>&w¸>+9î>Pį=Ձâ> í>$!Ė>+žl>=īņÜ>%Û4>R>c¸C>Nšę>J…>) Ô>a`>yEî>bųc>*—Ė>Ŋ¨>Nŋ'>g‰ļ>T< > ë/=▤>ûÚ>0Da>%î“>)Ž> >x0>%Î>hˇ>:>>#A÷>Iz¸>[UĮ>KĐæ>".'>'…>Xģ>o(Œ>\ú>)û>Ļ>Jœˇ>`ŋH>N#>:}=ō˛>‘>+¸~>Ŗ=đvŅ=î{}>nR>+iĪ>'X>Å1>če>Mš>d†h>VQ>>,NĀ>3.>fņ‚>~ē>hô#>3@>,Î=>W. >hŖ>RMC>  >ā>' š>-Ee>å÷=į„1astropy-photutils-3322558/photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits000066400000000000000000000341001517052111400332620ustar00rootroot00000000000000SIMPLE = T / conforms to FITS standard BITPIX = -64 / array data type NAXIS = 3 / number of array dimensions NAXIS1 = 5 NAXIS2 = 5 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 ?ē{`ōG§?ŧË9[úŧh?Ŋ6vRiu?ģĒË}āY?¸oIAŌ)g?ŧPe_Ÿ†€?žš~‘Ü:í?ŋ$M°š~?Ŋ€Pî@ũ?ēęŠ?ŧRßęåâĀ?žąĶå´øƒ?ŋĸ‰îŅV?Ŋq˙Eņ(?ē Ü'Ú-?灃Z]dŸ?ŧ´ĢJˇō?Ŋ\‰W¤!?ģA@?¸Q¯!UPˇ?ˇ*q„ķŽ?šJzO ?ša'åpü?ˇ˙Ä]õY„?ĩ/Šž8¯?ēȌ%{v?Ŋ •„J(?Ŋj¨gÔà ?ģŌx ƒ¨—?¸Bî ĩR?ŧ¯áÍ bü?ŋå­šĘ?ŋu;öP?ŊÆ+w E?ēRÎA`?ŧĮ3”@öÛ?ŋ'(°P1k?ŋ†T>qÅ1?Ŋ×q|“VÁ?ēenކĩ&?ģ df<o?ŊCÃq&J3?ŊšĄŸūëČ?ŧęĻ’ƒÉ?¸Ã|ũŸé3?ˇÂžV—,?š¸‹C$6ø?ē;ČIĨ?¸•zŽc!?ĩ˛Ļžv?ēũŨ~Î# ?Ŋ%4úg˛—?ŊdSœ\ÜĐ?ģŗ†qė?¸^։wŦ?ŧ˙–™ŗš?ŋI"ĸŦŦ?ŋîī éŲ?ŊÅĻ_Đpĩ?ē?…Ȩ´ú?Ŋ4Ą­ ã?ŋ~ęÂ>ŋ?ŋÄ•ojÔ?ŊûĘ3;Ũ?ētÎØpė?ģ“t‡Z6?Ŋŧí4øĘ?Ŋũ+؋ōO?ŧLaÄģ ?¸õI4!KL?¸_xmS8æ?ēKaßuƒ™?ē‚Uiq:?¸ũœd%?ļŒlgĐ?ģ7KŧˆÛh?ŊSđŧL•f?Ŋˆą]@äL?ģĐxÎŧĶ?¸x§nē?Ŋ%Ã#,?ŋfAĶŽ•Į?ŋĸđ ˜a?ŊÕ¯MJu=?ēNа øŦ?ŊJƒæī:?ŋ3[yūC?ŋĖ8 ԓĀ?ž›éõ‡?ēzî˛Ņi?ģžôŦ[ö?ŊÂ%&ëÁ‹?Ŋũž }į-?ŧJ!n`ûî?¸ķH'•0?¸f^˜i?ēN‚ŲN¤?ēs–ø7t?¸ųžgäŖÂ?ĩûyæ<Í?ģ^=ou2!?ŊbÃļ4K€?Ŋt+F("?ģ+øWû(?¸ v=­?Ŋ-eđWžĢ?ŋIÂāoí.?ŋYÕ#Ii3?Ŋ[Ž ņÚ?šĻ“ĖūÜ?Ŋ Hdį?ŋ0}ë>0"?ŋ>'XŽŌņ?ŊB’D{wÔ?𔆠ē?ģ+3o˙äâ?Ŋ˙}JŸ?Ŋ&z‰ĩ€5?ģI´â9M†?ˇÖØĄo&?ˇąÉ*eG?šegrĄL…?šlPRsžl?ˇÆôkÎ?´ģ ]Ēg?ģ°}:ˆ™?Ŋ§‚YMQ?ŊŠTĄ’ÖĻ?ģĩŸ@N&~?¸!\ųKøÅ?Ŋ|ŧ,Pi?ŋŠģžÄŸĻ?ŋ‹Ķē~Ri?ŊßԞzŲ?šžÄxwk?ŊiB-ĖÜí?ŋq#9î_?ŋq›į??Ŋj¯å?o?š˛Ö!\k ?ģwjĐˆZ?Ŋ\֙›o?Ŋ\ؔL?ģwĻ;ĸŦ?ˇũÔtîåb?ˇ÷Ā͜a´?šŖH3å°?šĸÆUģŽ?ˇ÷đÎ?´į"ūš?ģŸ €'C5?Ŋ€DM¸Õ?Ŋp,CŠiå?ģqpįx?ˇÚ……$ņ?Ŋ  {“0ŧ?ŋ|™#ÉĘ?ŋāŲ Ö?Ŋti •?šĒšZL ?ŊĘ$AZ—?ŋÆÉĄ†Ŗ?ŋ¸eÁtˆô?ŊĄWG‰‡?šÚ„Dęû?ŧNjŸÕ.?ŊōĮį0?Ŋæ%€Ȕ?ģîqžßŨĪ?¸^ߏz¯?¸žĖö†=?ējōōs?ē``?aTk?¸ ˇé|á?ĩv\Đ.ãų?ģŗhÄ(Ą­?ŊŽæ€ĖP?Ŋ{yˇæ´?ģ|R`…+ü?ˇįFā/ÔŽ?ŊŊ:HÄ&§?ŋļ( sģč?ŋŖėÛ2´9?Ŋ‰‡7\AÖ?šĀBƒ‡ĩ†?Ŋō‘ĩœ?ŋíž*Čv˜?ŋŨi”g§ ?ŊğŗĀ‹?šûĮ5Hså?ŧHļ"bĪr?ž*%rΖÂ?ž|z´$h?ŧ!üƒˆ˙~?¸ŽA1ße?šû@ö×b?瞄"Ɂ?秔Ǟ&h?¸ãõöA?ĩ˛Æˆí>|?ģbqŠC§“?Ŋb—/ģĢ?ŊpyĨG÷?ģŠŠyáŧ?¸JŽķđ´?Ŋ9rÁ =?ŋV<Ē‘d’?ŋeÂQ‚X?ŊfRVi.?š¯éîob?Ŋ-ŧZÍ?ŋHD5m?ŋX õ1ųö?Ŋ[„v‡Ä?šŠ& ąô?ģD)–;Ž?Ŋ=÷WwßZ?ŊLɘ×â?ģnÚNŸ2?ˇõÖÄ\Ž=?ˇŅ†ž,Ë?šŒ@á—3?šô(ôÂ?ˇõ ļ?šÜvû…‹ö?Ŋ.glž?ŋráDŅ?ŋ’š Ūāé?Ŋ‰ų)õĪ_?šĪĘđ°2?ģ„ŋ¨ōxš?Ŋt–vüF?ŊwīĒC…?ģ’4âë?¸ōô&?ˇüIĸÎYœ?š˛Hæ–xč?šļĒ‚đėƒ?¸ ÂB*Ö.?´øˆō͈Ę?ģĒŽj~­?Ŋ¯ĄCbŸ$?ŊÁ\œItō?ģß^cl)‚?¸]FlãŗĻ?Ŋ|ۜäÆ?ŋ ‹ ?ŋļzŸˇb?Ŋŋ>Vn?ēÆŽāS?ŊvÄÚ#Â2?ŋ™Á HQĮ?ŋŗk•`o?ŊÂjˆtqi?ēÂ\Cæ?ģ•*€Dˇ™?Ŋ™hrü‹?¸o¨ŒĨ.?ĩj§ŗSf!?ģĢ9?é?Ŋœ;Øŗ-?ŊžHՊf?ģŗM&ĩ†?¸/§yÅą?Ŋ‘Ąë>ęŲ?ŋĄØĮ¸(z?ŋ§ąiÉR!?Ŋ¤ņ×Ŋ×)?šņ]ˆ ˇ•?ŊŖÂį^ î?ŋļNu<’Ĩ?ŋŋšH?ŊÁ[hc?ē:Æõ?ģÛ*ØĢíÖ?ŊŌÆË%Į?ŊŪúÔņJ?ŧž§wdˇ?¸‰ZŽ—Ž?¸*~!ŅęŲ?ŋĄØĮ¸(z?ŋ§ąiÉR!?Ŋ¤ņ×Ŋ×)?šņ]ˆ ˇ•?ŊŖÂį^ î?ŋļNu<’Ĩ?ŋŋšH?ŊÁ[hc?ē:Æõ?ģÛ*ØĢíÖ?ŊŌÆË%Į?ŊŪúÔņJ?ŧž§wdˇ?¸‰ZŽ—Ž?¸*~!Ņ ö߇?¸™îs ?šŽdĮšō?šĻo‚AC#?¸^xËĀ3?ĩä7Í_¯˜?ĩ¨&Ģ?ļūĩ4ÂZ?ˇÉ‹?ĩîVû?ŗ¯Ŧ[w?ˇŠÖē(€|?¸˙~…˛¨œ?š F`Aõ?ˇ°gō‹´T?ĩ {āh ?¸Öa eQ ?ēbâ-S&x?ēvúCČ5O?šĪōļ‘ž?ļeąÛßKQ?¸ĮÖÂÉĢ?ēXķüø?ēr†Ģ“ ?šī‹ NŒ?ļn(s?ˇ`â:DÚÛ?¸á’ždi ?¸˙$ ō?ˇļũßw°?ĩ7„ ü†[?´ÔœgîĄ ?ļ3Į­ņ;?ļQ– šOz?ĩ,ctW;r?˛ī‰-AO-?ˇĘĮ÷S¤t?š,Ī+æ*?š3´“¯ZŦ?ˇß ¨"&’?ĩ^čæ6ú×?šųap_ž?ēŠwB„n§?ē‘° lX?š+NK‚i?ļ‹…‰ˇ7 ?š3ãũx]?ēŠ€B3ķ)?ēŌŽ2O¨?š(øH|fÎ?ļˆOīk‡†?ˇÎ3˙Ę{?š,¨:×ģ÷?š/Ųģaŋ ?ˇ×Ø<( Z?ĩU%Ē~Â?ĩcĀ(ŸWP?ļŸā¯ų?ļ @r÷&ą?ĩeIųĐt]?ŗÕ‚‘7astropy-photutils-3322558/photutils/psf/tests/data/nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits000066400000000000000000000264001517052111400332100ustar00rootroot00000000000000SIMPLE = T / conforms to FITS standard BITPIX = -64 / array data type NAXIS = 3 / number of array dimensions NAXIS1 = 5 NAXIS2 = 5 NAXIS3 = 1 EXTEND = T COMMENT / PSF Library Information INSTRUME= 'NIRCam ' / Instrument name DETECTOR= 'NRCB4 ' / Detector name FILTER = 'F150W ' / 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.164045685 / 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 = '(1023.0, 1023.0)' / The #0 PSF's (y,x) detector pixel position NUM_PSFS= 1 / 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.005376952 / SIAF distortion coefficient for COEF_X10 COEF_X11= 2.168404345E-19 / SIAF distortion coefficient for COEF_X11 COEF_X20= 0.0021339856177 / SIAF distortion coefficient for COEF_X20 COEF_X21= 0.0069468156377 / SIAF distortion coefficient for COEF_X21 COEF_X22= -0.00039184889037 / SIAF distortion coefficient for COEF_X22 COEF_X30= -1.0972920012E-05 / SIAF distortion coefficient for COEF_X30 COEF_X31= 5.769391306E-07 / SIAF distortion coefficient for COEF_X31 COEF_X32= -9.7128174867E-06 / SIAF distortion coefficient for COEF_X32 COEF_X33= -1.2134167106E-06 / SIAF distortion coefficient for COEF_X33 COEF_X40= 2.552344626E-08 / SIAF distortion coefficient for COEF_X40 COEF_X41= -1.1434720184E-08 / SIAF distortion coefficient for COEF_X41 COEF_X42= -6.6736195709E-09 / SIAF distortion coefficient for COEF_X42 COEF_X43= -1.5483937823E-08 / SIAF distortion coefficient for COEF_X43 COEF_X44= -1.1761501506E-09 / SIAF distortion coefficient for COEF_X44 COEF_X50= -1.7853547677E-10 / SIAF distortion coefficient for COEF_X50 COEF_X51= -3.6147216885E-10 / SIAF distortion coefficient for COEF_X51 COEF_X52= 6.1542887766E-10 / SIAF distortion coefficient for COEF_X52 COEF_X53= 4.9543547916E-10 / SIAF distortion coefficient for COEF_X53 COEF_X54= -9.5533592928E-10 / SIAF distortion coefficient for COEF_X54 COEF_X55= 9.672617721E-10 / SIAF distortion coefficient for COEF_X55 COEF_Y00= 0.0 / SIAF distortion coefficient for COEF_Y00 COEF_Y10= -0.14368033435 / SIAF distortion coefficient for COEF_Y10 COEF_Y11= 31.82732373 / SIAF distortion coefficient for COEF_Y11 COEF_Y20= -0.0020977336129 / SIAF distortion coefficient for COEF_Y20 COEF_Y21= 0.0026371067036 / SIAF distortion coefficient for COEF_Y21 COEF_Y22= 0.0048232423644 / SIAF distortion coefficient for COEF_Y22 COEF_Y30= -2.4210626426E-07 / SIAF distortion coefficient for COEF_Y30 COEF_Y31= -1.439133603E-05 / SIAF distortion coefficient for COEF_Y31 COEF_Y32= 9.4030987897E-08 / SIAF distortion coefficient for COEF_Y32 COEF_Y33= -7.7144363263E-06 / SIAF distortion coefficient for COEF_Y33 COEF_Y40= -3.1980523933999E-08 / SIAF distortion coefficient for COEF_Y40 COEF_Y41= -3.0090915533E-08 / SIAF distortion coefficient for COEF_Y41 COEF_Y42= 4.4788427697E-09 / SIAF distortion coefficient for COEF_Y42 COEF_Y43= -2.4191633057E-08 / SIAF distortion coefficient for COEF_Y43 COEF_Y44= 1.1977859423E-08 / SIAF distortion coefficient for COEF_Y44 COEF_Y50= -7.4770944672E-12 / SIAF distortion coefficient for COEF_Y50 COEF_Y51= 2.303737261E-09 / SIAF distortion coefficient for COEF_Y51 COEF_Y52= 1.5652636556E-09 / SIAF distortion coefficient for COEF_Y52 COEF_Y53= 1.3274998682E-10 / SIAF distortion coefficient for COEF_Y53 COEF_Y54= 5.0404258562E-10 / SIAF distortion coefficient for COEF_Y54 COEF_Y55= -1.5991839324E-09 / SIAF distortion coefficient for COEF_Y55 ROTATION= -0.3330226 / PSF rotated to match detector rotation WAVELEN = 1.49233068811064E-06 / Weighted mean wavelength in meters DIFFLMT = 0.04152912997132518 / 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 = 74.70689555411363 / [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 = '489 ' / NRC SCA num used for IPC and PPC model IPCFILE = 'KERNEL_IPC_CUBE.fits' / IPC model source file DATE = '2023-11-10T01:32:16' / 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 ?Áã4/d?ǜ%C?Å 2eĄ?ÃlĮ(ÍØ;?ŋō[¨;áĐ?ÃÛ]@žãb?ÆČÛx–”?ĮhŸˆ™?Řiį‰PÍ?Áß.ž(c?ÃØĄ‘Čõå?ÆÃ õ´GŅ?ĮiĢS*Ã?ÅŠvu$ō?Â6—üQL?Á՛q˚@?Ä|lŧ€‘k?ÅđŪŌ‡a?Ô÷œ°`?ĀTė!Sü?ŧŦōˇk5?‡ž¸wžX?ÁĀ5ŸĨ?ŋÄבÎl:?ē‹’É+_›astropy-photutils-3322558/photutils/psf/tests/test_components.py000066400000000000000000000505271517052111400252320ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _components module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import LevMarLSQFitter, TRFLSQFitter from astropy.table import QTable, Table from numpy.testing import assert_equal from photutils.detection import DAOStarFinder from photutils.psf import CircularGaussianPRF from photutils.psf._components import (PSFDataProcessor, PSFFitter, PSFResultsAssembler) from photutils.psf.photometry import _PSFParameterMapper @pytest.fixture(name='basic_data') def fixture_basic_data(): """ Create basic test data for component testing. """ # Create simple test image shape = (25, 25) yy, xx = np.mgrid[:shape[0], :shape[1]] # Add a simple Gaussian source model = CircularGaussianPRF(flux=100, x_0=12, y_0=12, fwhm=2.0) data = model(xx, yy) rng = np.random.default_rng(0) data += rng.normal(0, 1, shape) error = np.ones_like(data) mask = np.zeros_like(data, dtype=bool) return data, error, mask @pytest.fixture(name='psf_model') def fixture_psf_model(): """ Create a basic PSF model for testing. """ return CircularGaussianPRF(flux=1, fwhm=2.7) @pytest.fixture(name='param_mapper') def fixture_param_mapper(psf_model): """ Create a parameter mapper for testing. """ return _PSFParameterMapper(psf_model) @pytest.fixture(name='init_params') def fixture_init_params(): """ Create initial parameters table for testing. """ return Table({ 'x_init': [12.0, 8.0, 16.0], 'y_init': [12.0, 8.0, 16.0], 'flux_init': [100.0, 50.0, 75.0], }) class TestPSFDataProcessor: """ Test the PSFDataProcessor class. """ def test_init(self, param_mapper): """ Test PSFDataProcessor initialization. """ fit_shape = (7, 7) processor = PSFDataProcessor( param_mapper=param_mapper, fit_shape=fit_shape, finder=None, aperture_radius=3.0, local_bkg_estimator=None, ) assert processor.param_mapper is param_mapper assert processor.fit_shape == fit_shape assert processor.finder is None assert processor.aperture_radius == 3.0 assert processor.local_bkg_estimator is None assert processor.data_unit is None assert processor.finder_results is None assert processor._cached_offsets is None assert processor._cache_key is None def test_validate_array_valid_input(self, param_mapper): """ Test validate_array with valid inputs. """ processor = PSFDataProcessor(param_mapper, (7, 7)) # Test valid 2D array data = np.ones((10, 10)) result = processor.validate_array(data, 'data') assert_equal(result, data) # Test None input result = processor.validate_array(None, 'mask') assert result is None # Test np.ma.nomask result = processor.validate_array(np.ma.nomask, 'mask') assert result is None def test_validate_array_invalid_input(self, param_mapper): """ Test validate_array with invalid inputs. """ processor = PSFDataProcessor(param_mapper, (7, 7)) # Test 1D array match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): processor.validate_array(np.ones(10), 'data') # Test 3D array match = 'error must be a 2D array' with pytest.raises(ValueError, match=match): processor.validate_array(np.ones((5, 5, 5)), 'error') # Test shape mismatch data = np.ones((10, 10)) error = np.ones((5, 5)) match_str = 'data and error must have the same shape' with pytest.raises(ValueError, match=match_str): processor.validate_array(error, 'error', data_shape=data.shape) def test_normalize_init_units_no_units(self, param_mapper): """ Test normalize_init_units when neither have units. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = None init_params = Table({'flux_init': [100.0, 200.0]}) result = processor.normalize_init_units(init_params, 'flux_init') assert result is init_params assert result['flux_init'].unit is None def test_normalize_init_units_both_have_units(self, param_mapper): """ Test normalize_init_units when both have compatible units. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = u.count init_params = QTable({'flux_init': [100.0, 200.0] * u.count}) result = processor.normalize_init_units(init_params, 'flux_init') assert result is init_params assert result['flux_init'].unit == u.count def test_normalize_init_units_incompatible_units(self, param_mapper): """ Test normalize_init_units with incompatible units. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = u.count init_params = QTable({'flux_init': [100.0, 200.0] * u.meter}) match_str = 'incompatible with the input data units' with pytest.raises(ValueError, match=match_str): processor.normalize_init_units(init_params, 'flux_init') def test_normalize_init_units_data_has_units_init_does_not( self, param_mapper): """ Test normalize_init_units when data has units but init doesn't. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = u.count init_params = Table({'flux_init': [100.0, 200.0]}) match_str = 'input data has units.*does not have units' with pytest.raises(ValueError, match=match_str): processor.normalize_init_units(init_params, 'flux_init') def test_normalize_init_units_init_has_units_data_does_not( self, param_mapper): """ Test normalize_init_units when init has units, but data doesn't. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = None init_params = QTable({'flux_init': [100.0, 200.0] * u.count}) match_str = 'has units.*input data does not have units' with pytest.raises(ValueError, match=match_str): processor.normalize_init_units(init_params, 'flux_init') def test_validate_init_params_valid(self, param_mapper, init_params): """ Test validate_init_params with valid input. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = None result = processor.validate_init_params(init_params) assert isinstance(result, Table) assert 'x_init' in result.colnames assert 'y_init' in result.colnames assert 'flux_init' in result.colnames def test_validate_init_params_none(self, param_mapper): """ Test validate_init_params with None input. """ processor = PSFDataProcessor(param_mapper, (7, 7)) result = processor.validate_init_params(None) assert result is None def test_validate_init_params_invalid_type(self, param_mapper): """ Test validate_init_params with invalid type. """ processor = PSFDataProcessor(param_mapper, (7, 7)) match_str = 'init_params must be an astropy Table' with pytest.raises(TypeError, match=match_str): processor.validate_init_params([1, 2, 3]) def test_validate_init_params_missing_positions(self, param_mapper): """ Test validate_init_params with missing position columns. """ processor = PSFDataProcessor(param_mapper, (7, 7)) # Missing x column init_params = Table({'y_init': [12.0], 'flux_init': [100.0]}) match_str = 'must contain valid column names.*x and y' with pytest.raises(ValueError, match=match_str): processor.validate_init_params(init_params) # Missing y column init_params = Table({'x_init': [12.0], 'flux_init': [100.0]}) with pytest.raises(ValueError, match=match_str): processor.validate_init_params(init_params) def test_validate_init_params_nonfinite_local_bkg(self, param_mapper): """ Test validate_init_params allows non-finite local_bkg values. Non-finite local_bkg values should be allowed and will be flagged later during processing. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = None init_params = Table({ 'x_init': [12.0], 'y_init': [12.0], 'local_bkg': [np.nan], }) # Should not raise an error - non-finite local_bkg is allowed result = processor.validate_init_params(init_params) assert result is not None assert 'local_bkg' in result.colnames assert np.isnan(result['local_bkg'][0]) def test_get_aper_fluxes(self, param_mapper, basic_data, init_params): """ Test get_aper_fluxes method. """ data, _, mask = basic_data processor = PSFDataProcessor(param_mapper, (7, 7), aperture_radius=3.0) fluxes = processor.get_aper_fluxes(data, mask, init_params) assert isinstance(fluxes, np.ndarray) assert len(fluxes) == len(init_params) assert np.all(np.isfinite(fluxes)) def test_find_sources_if_needed_with_init_params(self, param_mapper, basic_data, init_params): """ Test find_sources_if_needed when init_params is provided. """ data, _, mask = basic_data processor = PSFDataProcessor(param_mapper, (7, 7)) result = processor.find_sources_if_needed(data, mask, init_params) assert result is not None assert 'id' in result.colnames assert len(result) == len(init_params) def test_find_sources_if_needed_no_init_no_finder(self, param_mapper, basic_data): """ Test find_sources_if_needed when neither is provided. """ data, _, mask = basic_data processor = PSFDataProcessor(param_mapper, (7, 7), finder=None) match_str = 'finder must be defined if init_params is not input' with pytest.raises(ValueError, match=match_str): processor.find_sources_if_needed(data, mask, None) def test_find_sources_if_needed_with_finder(self, param_mapper, basic_data): """ Test find_sources_if_needed with a finder. """ data, _, mask = basic_data finder = DAOStarFinder(threshold=5.0, fwhm=2.0) processor = PSFDataProcessor(param_mapper, (7, 7), finder=finder) result = processor.find_sources_if_needed(data, mask, None) # Should find at least one source in our test data assert result is not None assert len(result) >= 1 class TestPSFFitter: """ Test the PSFFitter class. """ def test_init(self, psf_model, param_mapper): """ Test PSFFitter initialization. """ fitter = PSFFitter( psf_model=psf_model, param_mapper=param_mapper, fitter=None, fitter_maxiters=100, xy_bounds=None, group_warning_threshold=25, ) assert fitter.psf_model is psf_model assert fitter.param_mapper is param_mapper assert isinstance(fitter.fitter, TRFLSQFitter) # Default fitter assert fitter.fitter_maxiters == 100 assert fitter.xy_bounds is None assert fitter.group_warning_threshold == 25 def test_init_custom_fitter(self, psf_model, param_mapper): """ Test PSFFitter initialization with custom fitter. """ custom_fitter = LevMarLSQFitter() fitter = PSFFitter( psf_model=psf_model, param_mapper=param_mapper, fitter=custom_fitter, xy_bounds=(1.0, 1.0), ) assert fitter.fitter is custom_fitter assert fitter.xy_bounds == (1.0, 1.0) def test_make_psf_model_single_source(self, psf_model, param_mapper): """ Test make_psf_model with a single source. """ fitter = PSFFitter(psf_model, param_mapper) sources = Table({ 'id': [1], 'x_init': [10.0], 'y_init': [15.0], 'flux_init': [100.0], }) model = fitter.make_psf_model(sources) assert model.name == 1 assert model.x_0.value == 10.0 assert model.y_0.value == 15.0 assert model.flux.value == 100.0 def test_make_psf_model_multiple_sources(self, psf_model, param_mapper): """ Test make_psf_model with multiple sources. """ fitter = PSFFitter(psf_model, param_mapper) sources = Table({ 'id': [1, 2], 'x_init': [10.0, 20.0], 'y_init': [15.0, 25.0], 'flux_init': [100.0, 200.0], }) model = fitter.make_psf_model(sources) # Should be a flat model with parameters for each source assert hasattr(model, 'flux_0') assert hasattr(model, 'x_0_0') assert hasattr(model, 'y_0_0') assert hasattr(model, 'flux_1') assert hasattr(model, 'x_0_1') assert hasattr(model, 'y_0_1') # Check parameter values assert model.flux_0.value == 100.0 assert model.x_0_0.value == 10.0 assert model.y_0_0.value == 15.0 assert model.flux_1.value == 200.0 assert model.x_0_1.value == 20.0 assert model.y_0_1.value == 25.0 def test_make_psf_model_with_xy_bounds(self, psf_model, param_mapper): """ Test make_psf_model with xy bounds. """ fitter = PSFFitter(psf_model, param_mapper, xy_bounds=(2.0, 3.0)) sources = Table({ 'id': [1], 'x_init': [10.0], 'y_init': [15.0], 'flux_init': [100.0], }) model = fitter.make_psf_model(sources) # Check bounds were set assert model.x_0.bounds == (8.0, 12.0) # 10.0 Âą 2.0 assert model.y_0.bounds == (12.0, 18.0) # 15.0 Âą 3.0 def test_make_psf_model_with_units(self, psf_model, param_mapper): """ Test make_psf_model with quantity columns. """ fitter = PSFFitter(psf_model, param_mapper) sources = QTable({ 'id': [1], 'x_init': [10.0] * u.pixel, 'y_init': [15.0] * u.pixel, 'flux_init': [100.0] * u.count, }) model = fitter.make_psf_model(sources) # Units should be stripped for fitting assert model.x_0.value == 10.0 assert model.y_0.value == 15.0 assert model.flux.value == 100.0 class TestPSFResultsAssembler: """ Test the PSFResultsAssembler class. """ def test_init(self, param_mapper): """ Test PSFResultsAssembler initialization. """ fit_shape = (7, 7) assembler = PSFResultsAssembler( param_mapper=param_mapper, fit_shape=fit_shape, xy_bounds=(1.0, 1.0), ) assert assembler.param_mapper is param_mapper assert assembler.fit_shape == fit_shape assert assembler.xy_bounds == (1.0, 1.0) def test_get_fit_error_indices_all_converged(self, param_mapper): """ Test get_fit_error_indices when all fits converged. """ assembler = PSFResultsAssembler(param_mapper, (7, 7)) # Good convergence status codes fit_info = [ {'ierr': 1}, # Converged {'status': 2}, # Converged {'ierr': 3}, # Converged ] bad_indices = assembler.get_fit_error_indices(fit_info) assert len(bad_indices) == 0 def test_get_fit_error_indices_some_failed(self, param_mapper): """ Test get_fit_error_indices when some fits failed. """ assembler = PSFResultsAssembler(param_mapper, (7, 7)) fit_info = [ {'ierr': 1}, # Converged {'ierr': 0}, # Failed {'status': 2}, # Converged {'status': 0}, # Failed ] bad_indices = assembler.get_fit_error_indices(fit_info) assert_equal(bad_indices, [1, 3]) def test_param_errors_to_table_no_units(self, param_mapper): """ Test param_errors_to_table without units. """ assembler = PSFResultsAssembler(param_mapper, (7, 7)) # Assuming 3 fitted parameters (x, y, flux) fit_param_errs = np.array([ [0.1, 0.1, 1.0], # errors for source 1 [0.2, 0.2, 2.0], # errors for source 2 ]) table = assembler.param_errors_to_table(fit_param_errs, data_unit=None) assert isinstance(table, QTable) assert 'x_err' in table.colnames assert 'y_err' in table.colnames assert 'flux_err' in table.colnames assert len(table) == 2 def test_param_errors_to_table_with_units(self, param_mapper): """ Test param_errors_to_table with units. """ assembler = PSFResultsAssembler(param_mapper, (7, 7)) fit_param_errs = np.array([ [0.1, 0.1, 1.0], [0.2, 0.2, 2.0], ]) table = assembler.param_errors_to_table(fit_param_errs, data_unit=u.count) assert isinstance(table, QTable) assert table['flux_err'].unit == u.count # Position errors should not have units assert (not hasattr(table['x_err'], 'unit') or table['x_err'].unit is None) class TestComponentIntegration: """ Test integration between components. """ def test_components_work_together(self, psf_model, basic_data, init_params): """ Test that all components can work together in sequence. """ data, _, mask = basic_data # Create components param_mapper = _PSFParameterMapper(psf_model) processor = PSFDataProcessor(param_mapper, (7, 7), aperture_radius=3.0) fitter = PSFFitter(psf_model, param_mapper) assembler = PSFResultsAssembler(param_mapper, (7, 7)) # Process data processor.data_unit = None validated_params = processor.validate_init_params(init_params) sources = processor.find_sources_if_needed(data, mask, validated_params) assert sources is not None assert len(sources) > 0 # Create PSF model for fitting (test with first source) psf_model_fit = fitter.make_psf_model(sources[:1]) assert psf_model_fit is not None assert hasattr(psf_model_fit, 'x_0') assert hasattr(psf_model_fit, 'y_0') # Test error handling for fit results fit_info = [{'ierr': 1}] # Successful fit bad_indices = assembler.get_fit_error_indices(fit_info) assert len(bad_indices) == 0 def test_components_preserve_units(self, psf_model): """ Test that components properly handle astropy units. """ # Create data with units data = np.ones((25, 25)) * u.count mask = np.zeros((25, 25), dtype=bool) init_params = QTable({ 'x_init': [12.0] * u.pixel, 'y_init': [12.0] * u.pixel, 'flux_init': [100.0] * u.count, }) # Create components param_mapper = _PSFParameterMapper(psf_model) processor = PSFDataProcessor(param_mapper, (7, 7)) # Set data unit and validate processor.data_unit = u.count validated_params = processor.validate_init_params(init_params) sources_with_id = processor.find_sources_if_needed( data, mask, validated_params) assert sources_with_id['flux_init'].unit == u.count # Test that PSF fitting strips units fitter = PSFFitter(psf_model, param_mapper) psf_model_fit = fitter.make_psf_model(sources_with_id) # Model parameters should be unitless for fitting assert not hasattr(psf_model_fit.x_0.value, 'unit') assert not hasattr(psf_model_fit.y_0.value, 'unit') assert not hasattr(psf_model_fit.flux.value, 'unit') astropy-photutils-3322558/photutils/psf/tests/test_epsf_builder.py000066400000000000000000002661221517052111400255100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf_builder module. """ import itertools import warnings from unittest.mock import patch import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NDData from astropy.table import Table from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose, assert_array_equal from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic) from photutils.datasets import make_model_image from photutils.psf import (CircularGaussianPRF, EPSFBuilder, EPSFBuildResult, EPSFFitter, EPSFStar, EPSFStars, ImagePSF, extract_stars, make_psf_model_image) from photutils.psf.epsf_builder import (_CoordinateTransformer, _EPSFValidator, _ProgressReporter, _SmoothingKernel) from photutils.psf.epsf_stars import LinkedEPSFStar from photutils.utils._optional_deps import HAS_TQDM @pytest.fixture def epsf_test_data(): """ Create a simulated image for testing. """ fwhm = 2.7 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) model_shape = (9, 9) n_sources = 100 shape = (750, 750) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=25, border_size=25, seed=0) nddata = NDData(data) init_stars = Table() init_stars['x'] = true_params['x_0'] init_stars['y'] = true_params['y_0'] return { 'fwhm': fwhm, 'data': data, 'nddata': nddata, 'init_stars': init_stars, } @pytest.fixture def epsf_fitter_data(epsf_test_data): """ Create extracted stars and an ePSF for testing EPSFFitter. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf, _ = builder(stars) return {'stars': stars, 'epsf': epsf} def _make_epsf_fitter(**kwargs): """ Helper to create EPSFFitter suppressing the deprecation warning. Remove this helper and the catch_warnings block when EPSFFitter is removed. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyDeprecationWarning) return EPSFFitter(**kwargs) class TestSmoothingKernel: """ Tests for the _SmoothingKernel class. """ @pytest.mark.parametrize('kernel_type', ['quartic', 'quadratic']) def test_get_kernel(self, kernel_type): """ Test quartic kernel retrieval. """ kernel = _SmoothingKernel.get_kernel(kernel_type) assert isinstance(kernel, np.ndarray) assert kernel.shape == (5, 5) if kernel_type == 'quartic': expected_sum = _SmoothingKernel.QUARTIC_KERNEL.sum() else: expected_sum = _SmoothingKernel.QUADRATIC_KERNEL.sum() assert np.isclose(kernel.sum(), expected_sum) def test_get_kernel_custom_array(self): """ Test custom array kernel retrieval. """ custom_kernel = np.ones((3, 3)) / 9.0 kernel = _SmoothingKernel.get_kernel(custom_kernel) assert isinstance(kernel, np.ndarray) assert kernel.shape == (3, 3) assert np.allclose(kernel, custom_kernel) def test_get_kernel_invalid_type(self): """ Test invalid kernel type raises TypeError. """ match = 'Unsupported kernel type' with pytest.raises(TypeError, match=match): _SmoothingKernel.get_kernel('invalid') @pytest.mark.parametrize('kernel_type', ['quartic', 'quadratic']) def test_apply_smoothing(self, kernel_type): """ Test smoothing with quartic kernel. """ data = np.ones((10, 10)) smoothed = _SmoothingKernel.apply_smoothing(data, kernel_type) assert isinstance(smoothed, np.ndarray) assert smoothed.shape == data.shape assert_allclose(smoothed.sum(), data.sum()) def test_apply_smoothing_custom_kernel(self): """ Test smoothing with custom kernel. """ data = np.ones((10, 10)) kernel = np.array([[0, 0.1, 0], [0.1, 0.6, 0.1], [0, 0.1, 0]]) smoothed = _SmoothingKernel.apply_smoothing(data, kernel) assert isinstance(smoothed, np.ndarray) assert smoothed.shape == data.shape assert_allclose(smoothed.sum(), data.sum()) def test_apply_smoothing_none(self): """ Test smoothing with None returns original data. """ data = np.ones((10, 10)) result = _SmoothingKernel.apply_smoothing(data, None) assert result is data # Should return same object class TestEPSFValidator: """ Tests for the _EPSFValidator class. """ def test_validate_oversampling_valid(self): """ Test valid oversampling validation. """ result = _EPSFValidator.validate_oversampling(2) assert np.array_equal(result, (2, 2)) result = _EPSFValidator.validate_oversampling((3, 4)) assert np.array_equal(result, (3, 4)) def test_validate_oversampling_none(self): """ Test validate_oversampling with None input. """ match = "'oversampling' must be specified" with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling(None) def test_validate_oversampling_invalid_exception(self): """ Test oversampling validation with invalid input. """ # Test with invalid input that should raise exception from # as_pair match = 'Invalid oversampling parameter' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling('invalid') def test_validate_oversampling_invalid_exception_with_context(self): """ Test oversampling validation with context and invalid input. """ match = 'test_context: Invalid oversampling parameter' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling('invalid', context='test_context') def test_validate_oversampling_zero_values(self): """ Test oversampling validation with zero values. """ match = 'oversampling must be > 0' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling((0, 2)) match = ('test_context: Invalid oversampling parameter - ' 'oversampling must be > 0') with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling((0, 2), context='test_context') def test_validate_oversampling_as_pair_exception_with_context(self): """ Test oversampling validation when as_pair raises exception. """ # Use a tuple with wrong number of elements to trigger as_pair error match = 'test_ctx: Invalid oversampling parameter' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_oversampling((1, 2, 3), context='test_ctx') def test_validate_shape_compatibility(self, epsf_test_data): """ Test shape compatibility validation. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) # Should not raise an exception for compatible shapes _EPSFValidator.validate_shape_compatibility(stars, (1, 1)) def test_validate_shape_compatibility_custom_shape(self, epsf_test_data): """ Test shape compatibility with custom shape. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) # Test with specific shape _EPSFValidator.validate_shape_compatibility(stars, (1, 1), shape=(21, 21)) def test_validate_shape_compatibility_empty_stars(self): """ Test shape compatibility with empty star list. """ empty_stars = EPSFStars([]) match = 'Cannot validate shape compatibility with empty star list' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(empty_stars, (1, 1)) def test_validate_shape_compatibility_small_stars(self): """ Test shape compatibility with very small star cutouts. """ # Create very small star (2x2 pixels) small_data = np.ones((2, 2)) small_star = EPSFStar(small_data, cutout_center=(1, 1)) small_stars = EPSFStars([small_star]) match = r'Found .* star.*with very small dimensions' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(small_stars, (1, 1)) def test_validate_shape_compatibility_invalid_shape_type(self): """ Test shape compatibility with invalid shape type. """ data = np.ones((5, 5)) star = EPSFStar(data, cutout_center=(2, 2)) stars = EPSFStars([star]) match = 'Shape must be a 2-element sequence' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(stars, (1, 1), shape=(10, 10, 10)) with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(stars, (1, 1), shape='invalid') def test_validate_shape_compatibility_incompatible_shape(self): """ Test shape compatibility with incompatible shape. """ data = np.ones((5, 5)) star = EPSFStar(data, cutout_center=(2, 2)) stars = EPSFStars([star]) # Request shape that's too small match = r'Requested ePSF shape .* is incompatible' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(stars, (2, 2), shape=(5, 5)) def test_validate_shape_compatibility_even_dimensions_warning(self): """ Test shape compatibility with even dimensions warning. """ data = np.ones((5, 5)) star = EPSFStar(data, cutout_center=(2, 2)) stars = EPSFStars([star]) # Test even dimensions trigger warning match = 'ePSF shape .* has even dimensions' with pytest.warns(UserWarning, match=match): _EPSFValidator.validate_shape_compatibility(stars, (1, 1), shape=(20, 20)) def test_validate_stars_empty_list(self): """ Test validate_stars with empty star list. """ empty_stars = EPSFStars([]) match = 'EPSFStars object must contain at least one star' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(empty_stars) match = 'test_context: EPSFStars object must contain at least one star' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(empty_stars, context='test_context') def test_validate_stars_non_finite_data(self): """ Test validate_stars with non-finite data. """ # Create star with all NaN data - need to provide explicit flux # since flux estimation would fail with all NaN data data = np.full((5, 5), np.nan) match = 'Input data array contains invalid data that will be masked' with pytest.warns(AstropyUserWarning, match=match): star = EPSFStar(data, cutout_center=(2, 2), flux=1.0) match = r'Found [\s\S]* invalid stars [\s\S]* no finite data values' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars([star]) def test_validate_stars_too_small(self): """ Test validate_stars with very small stars. """ # Create very small star (2x2 pixels) data = np.ones((2, 2)) star = EPSFStar(data, cutout_center=(1, 1)) match = r'Found [\s\S]* invalid stars [\s\S]* too small' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars([star]) def test_validate_stars_missing_cutout_center(self): """ Test validate_stars with star missing cutout_center. """ # Create mock star without cutout_center class MockStar: def __init__(self): self.data = np.ones((5, 5)) self.shape = (5, 5) mock_stars = [MockStar()] match = r'Found .* invalid stars [\s\S]* missing cutout_center' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(mock_stars) def test_validate_stars_validation_error(self): """ Test validate_stars with validation error during processing. """ # Create mock star that raises error during validation class MockStar: def __init__(self): self.data = np.ones((5, 5)) @property def shape(self): msg = 'Test error' raise ValueError(msg) mock_stars = [MockStar()] match = r'Found .* invalid stars [\s\S]* validation error' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(mock_stars) def test_validate_stars_multiple_invalid(self): """ Test validate_stars with multiple invalid stars. """ # Create multiple mock stars with different issues class MockStar1: def __init__(self): self.data = None class MockStar2: def __init__(self): self.data = np.ones((2, 2)) # Too small self.shape = (2, 2) mock_stars = [MockStar1(), MockStar2()] match = r'Found 2 invalid stars [\s\S]* too small' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(mock_stars) def test_validate_stars_more_than_5_invalid(self): """ Test validate_stars with more than 5 invalid stars. """ # Create 7 mock stars with missing data class MockStar: def __init__(self): self.data = None mock_stars = [MockStar() for _ in range(7)] match = r'Found 7 invalid stars [\s\S]* missing data' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(mock_stars) def test_validate_stars_context_with_invalid(self): """ Test validate_stars with context and invalid stars. """ class MockStar: def __init__(self): self.data = None mock_stars = [MockStar()] match = 'my_context: Found 1 invalid stars' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_stars(mock_stars, context='my_context') def test_validate_stars_valid(self): """ Test validate_stars with valid stars. """ # Create valid stars data1 = np.ones((5, 5)) data2 = np.ones((6, 6)) star1 = EPSFStar(data1, cutout_center=(2, 2)) star2 = EPSFStar(data2, cutout_center=(3, 3)) # Should not raise any exception _EPSFValidator.validate_stars([star1, star2]) def test_validate_center_accuracy_valid(self): """ Test validate_center_accuracy with valid inputs. """ # Test valid values _EPSFValidator.validate_center_accuracy(0.001) _EPSFValidator.validate_center_accuracy(0.01) _EPSFValidator.validate_center_accuracy(0.1) _EPSFValidator.validate_center_accuracy(1.0) def test_validate_center_accuracy_invalid_type(self): """ Test validate_center_accuracy with invalid type. """ match = 'center_accuracy must be a number' with pytest.raises(TypeError, match=match): _EPSFValidator.validate_center_accuracy('0.001') def test_validate_center_accuracy_non_positive(self): """ Test validate_center_accuracy with non-positive values. """ match = 'center_accuracy must be positive' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_center_accuracy(0.0) with pytest.raises(ValueError, match=match): _EPSFValidator.validate_center_accuracy(-0.001) def test_validate_center_accuracy_too_large(self): """ Test validate_center_accuracy with values too large. """ match = r'center_accuracy .* seems unusually large' with pytest.warns(AstropyUserWarning, match=match): _EPSFValidator.validate_center_accuracy(1.1) def test_validate_maxiters_valid(self): """ Test validate_maxiters with valid inputs. """ # Test valid values (these should not raise or warn) _EPSFValidator.validate_maxiters(1) _EPSFValidator.validate_maxiters(10) _EPSFValidator.validate_maxiters(100) def test_validate_maxiters_invalid_type(self): """ Test validate_maxiters with invalid type. """ match = 'maxiters must be an integer' with pytest.raises(TypeError, match=match): _EPSFValidator.validate_maxiters(10.5) with pytest.raises(TypeError, match=match): _EPSFValidator.validate_maxiters('10') def test_validate_maxiters_non_positive(self): """ Test validate_maxiters with non-positive values. """ match = 'maxiters must be a positive number' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_maxiters(0) with pytest.raises(ValueError, match=match): _EPSFValidator.validate_maxiters(-5) def test_validate_maxiters_too_large(self): """ Test validate_maxiters with values too large triggers warning. """ match = r'maxiters .* seems unusually large' with pytest.warns(AstropyUserWarning, match=match): _EPSFValidator.validate_maxiters(101) class TestCoordinateTransformer: """ Tests for the _CoordinateTransformer class. """ @pytest.mark.parametrize('oversampling', [(2, 2), (3, 4), (5, 1)]) def test_basic(self, oversampling): """ Test basic coordinate transformation. """ # Create transformer transformer = _CoordinateTransformer(oversampling=oversampling) assert np.array_equal(transformer.oversampling, oversampling) assert transformer.oversampling[0] == oversampling[0] assert transformer.oversampling[1] == oversampling[1] def test_empty_star_shapes(self): """ Test compute_epsf_shape with empty star_shapes list. """ transformer = _CoordinateTransformer(oversampling=(2, 2)) match = 'Need at least one star to compute ePSF shape' with pytest.raises(ValueError, match=match): transformer.compute_epsf_shape([]) def test_oversampled_to_undersampled(self): """ Test oversampled_to_undersampled conversion. """ transformer = _CoordinateTransformer(oversampling=(4, 2)) x_under, y_under = transformer.oversampled_to_undersampled(8.0, 16.0) assert x_under == 4.0 # 8 / 2 assert y_under == 4.0 # 16 / 4 def test_undersampled_to_oversampled(self): """ Test undersampled_to_oversampled conversion. """ transformer = _CoordinateTransformer(oversampling=(4, 2)) x_over, y_over = transformer.undersampled_to_oversampled(4.0, 4.0) assert x_over == 8.0 # 4 * 2 assert y_over == 16.0 # 4 * 4 def test_star_to_epsf_coords(self): """ Test star_to_epsf_coords method of _CoordinateTransformer. """ transformer = _CoordinateTransformer(oversampling=(2, 2)) # Test coordinate transformation star_x = np.array([0.0, 1.0, 2.0]) star_y = np.array([0.0, 1.0, 2.0]) epsf_origin = (10.0, 10.0) epsf_x, epsf_y = transformer.star_to_epsf_coords( star_x, star_y, epsf_origin) # Check output shape assert epsf_x.shape == star_x.shape assert epsf_y.shape == star_y.shape # Check values: with oversampling=2 and origin=(10, 10), # the formula computes round(oversampling * star_x + origin_x) expected_x = np.array([10, 12, 14]) expected_y = np.array([10, 12, 14]) assert np.array_equal(epsf_x, expected_x) assert np.array_equal(epsf_y, expected_y) def test_compute_epsf_origin(self): """ Test compute_epsf_origin method of _CoordinateTransformer. """ transformer = _CoordinateTransformer(oversampling=(2, 2)) # Test with odd shape origin = transformer.compute_epsf_origin((11, 11)) assert origin == (5.0, 5.0) # Test with different shape origin = transformer.compute_epsf_origin((21, 31)) assert origin == (15.0, 10.0) class TestProgressReporter: """ Tests for the _ProgressReporter class. """ def test_progress_reporter(self): """ Test basic functionality of _ProgressReporter. """ # Test with enabled=True reporter = _ProgressReporter(enabled=True, maxiters=10) assert reporter.enabled is True assert reporter.maxiters == 10 reporter.setup() reporter.update() reporter.write_convergence_message(5) reporter.close() # Test with enabled=False reporter = _ProgressReporter(enabled=False, maxiters=5) assert reporter.enabled is False result = reporter.setup() assert result is reporter assert reporter._pbar is None class TestEPSFBuildResult: """ Tests for the EPSFBuildResult class. """ def test_creation(self): """ Test EPSFBuildResult creation. """ # Create a simple PSF model for testing data = np.ones((5, 5)) psf = ImagePSF(data) # Create stars list (can be empty for this test) stars = [] result = EPSFBuildResult( epsf=psf, fitted_stars=stars, iterations=5, converged=True, final_center_accuracy=0.01, n_excluded_stars=0, excluded_star_indices=[], ) assert result.epsf is psf assert result.fitted_stars == stars assert result.iterations == 5 assert result.converged is True def test_with_data(self, epsf_test_data): """ Test EPSFBuildResult with actual data. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf, fitted_stars = builder(stars) result = EPSFBuildResult( epsf=epsf, fitted_stars=fitted_stars, iterations=2, converged=False, final_center_accuracy=0.1, n_excluded_stars=0, excluded_star_indices=[], ) assert result.epsf is not None assert result.epsf.data.shape == (11, 11) assert result.fitted_stars is not None assert len(result.fitted_stars) == len(stars) def test_getitem_invalid_index(self): """ Test EPSFBuildResult.__getitem__ with invalid index. """ data = np.ones((5, 5)) psf = ImagePSF(data) stars = EPSFStars([]) result = EPSFBuildResult( epsf=psf, fitted_stars=stars, iterations=5, converged=True, final_center_accuracy=0.01, n_excluded_stars=0, excluded_star_indices=[], ) # Valid indices assert result[0] is psf assert result[1] is stars # Invalid index match = 'EPSFBuildResult index must be 0' with pytest.raises(IndexError, match=match): result[2] with pytest.raises(IndexError, match=match): result[-1] def test_iteration(self): """ Test EPSFBuildResult iteration (tuple unpacking). """ data = np.ones((5, 5)) psf = ImagePSF(data) stars = EPSFStars([]) result = EPSFBuildResult( epsf=psf, fitted_stars=stars, iterations=5, converged=True, final_center_accuracy=0.01, n_excluded_stars=0, excluded_star_indices=[], ) # Test tuple unpacking via iteration epsf_out, stars_out = result assert epsf_out is psf assert stars_out is stars # Test list conversion result_list = list(result) assert len(result_list) == 2 assert result_list[0] is psf assert result_list[1] is stars def test_attributes(self, epsf_test_data): """ Test EPSFBuildResult has all expected attributes. """ builder = EPSFBuilder(oversampling=1, maxiters=3, progress_bar=False) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) result = builder(stars) # Check all attributes exist assert result.epsf is not None assert result.fitted_stars is not None assert isinstance(result.iterations, int) assert isinstance(result.converged, (bool, np.bool_)) assert isinstance(result.final_center_accuracy, (float, np.floating)) assert isinstance(result.n_excluded_stars, int) assert isinstance(result.excluded_star_indices, list) class TestEPSFFitter: """ Tests for the EPSFFitter class. EPSFFitter is deprecated since 3.0.0. These tests verify that it still functions correctly while emitting a deprecation warning. """ def test_deprecation_warning(self): """ Test that EPSFFitter emits a deprecation warning. """ match = 'EPSFFitter is deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): EPSFFitter() def test_fit_stars(self, epsf_fitter_data): """ Test EPSFFitter __call__ method. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] fitter = _make_epsf_fitter() fitted_stars = fitter(epsf, stars) assert fitted_stars is not None assert len(fitted_stars) == len(stars) def test_empty_stars(self): """ Test EPSFFitter with empty stars. """ data = np.ones((11, 11)) epsf = ImagePSF(data) fitter = _make_epsf_fitter() empty_stars = EPSFStars([]) result = fitter(epsf, empty_stars) assert len(result) == 0 def test_invalid_epsf_type(self): """ Test EPSFFitter with invalid epsf type. """ data = np.ones((5, 5)) star = EPSFStar(data, cutout_center=(2, 2)) stars = EPSFStars([star]) fitter = _make_epsf_fitter() match = 'The input epsf must be an ImagePSF' with pytest.raises(TypeError, match=match): fitter('not_an_epsf', stars) def test_fit_boxsize_none(self, epsf_fitter_data): """ Test EPSFFitter with fit_boxsize=None. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Test fitter with fit_boxsize=None (use entire star image) fitter = _make_epsf_fitter(fit_boxsize=None) fitted_stars = fitter(epsf, stars) assert len(fitted_stars) == len(stars) def test_invalid_star_type(self, epsf_fitter_data): """ Test EPSFFitter with invalid star type. """ epsf = epsf_fitter_data['epsf'] # Create mock invalid star type class InvalidStar: pass # Create an EPSFStars-like object with invalid star invalid_stars = [InvalidStar()] fitter = _make_epsf_fitter() match = 'stars must contain only EPSFStar' with pytest.raises(TypeError, match=match): fitter(epsf, invalid_stars) def test_fit_info_ierr(self, epsf_fitter_data): """ Test EPSFFitter handling of fit_info with ierr. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Test fitter - the fit_info handling is automatic fitter = _make_epsf_fitter() assert fitter.fitter_has_fit_info is True fitted_stars = fitter(epsf, stars) # Check that fit_error_status is set for star in fitted_stars.all_stars: assert hasattr(star, '_fit_error_status') def test_fitter_without_fit_info(self, epsf_fitter_data): """ Test EPSFFitter with a fitter that doesn't have fit_info. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create a mock fitter without fit_info attribute class MockFitter: def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): return model mock_fitter = MockFitter() fitter = _make_epsf_fitter(fitter=mock_fitter) # Verify that fitter_has_fit_info is False assert fitter.fitter_has_fit_info is False # Fit the stars fitted_stars = fitter(epsf, stars) assert len(fitted_stars) == len(stars) # Check that _fit_info is None for stars fit without fit_info for star in fitted_stars.all_stars: assert star._fit_info is None def test_weights_not_supported(self, epsf_fitter_data): """ Test EPSFFitter when fitter raises TypeError for weights. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create a fitter that raises TypeError when weights is passed class NoWeightsFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__(self, model, *_args, **kwargs): if 'weights' in kwargs: msg = 'weights not supported' raise TypeError(msg) return model no_weights_fitter = NoWeightsFitter() fitter = _make_epsf_fitter(fitter=no_weights_fitter) # Fit the stars - should handle TypeError gracefully fitted_stars = fitter(epsf, stars) assert len(fitted_stars) == len(stars) def test_invalid_ierr(self, epsf_fitter_data): """ Test EPSFFitter when fitter returns invalid ierr value. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create a fitter that returns invalid ierr (not in [1, 2, 3, 4]) class BadIerrFitter: def __init__(self): self.fit_info = {'ierr': 0} # Invalid ierr value def __call__(self, model, *_args, **_kwargs): return model bad_ierr_fitter = BadIerrFitter() fitter = _make_epsf_fitter(fitter=bad_ierr_fitter) # Fit the stars - should set fit_error_status = 2 fitted_stars = fitter(epsf, stars) assert len(fitted_stars) == len(stars) # Check that fit_error_status is set to 2 for all stars for star in fitted_stars.all_stars: assert star._fit_error_status == 2 def test_removes_fitter_kwargs(self): """ Test that EPSFFitter removes reserved kwargs. """ # Pass kwargs that should be removed fitter = _make_epsf_fitter(x=1, y=2, z=3, weights=4, calc_uncertainties=False) # These should be removed from fitter_kwargs assert 'x' not in fitter.fitter_kwargs assert 'y' not in fitter.fitter_kwargs assert 'z' not in fitter.fitter_kwargs assert 'weights' not in fitter.fitter_kwargs # Other kwargs should be preserved assert fitter.fitter_kwargs.get('calc_uncertainties') is False def test_with_linked_star_mock_wcs(self, epsf_fitter_data): """ Test EPSFFitter with LinkedEPSFStar using mock WCS. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create mock WCS that returns identity transform class MockWCS: def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec mock_wcs = MockWCS() # Create EPSFStar objects with mock WCS linked_stars_list = [] for i in range(2): star_data = stars.all_stars[i].data.copy() center = stars.all_stars[i].cutout_center # Use origin that places star in a reasonable position origin = (0, 0) star = EPSFStar(star_data, cutout_center=center, origin=origin, wcs_large=mock_wcs) linked_stars_list.append(star) # Create LinkedEPSFStar linked_star = LinkedEPSFStar(linked_stars_list) # Create EPSFStars with the LinkedEPSFStar stars_with_linked = EPSFStars([linked_star]) # Fit the linked stars fitter = _make_epsf_fitter() fitted_stars = fitter(epsf, stars_with_linked) assert len(fitted_stars) == 1 # fitted_stars is an EPSFStars; the first item wraps LinkedEPSFStar assert len(fitted_stars.all_stars) == 2 # 2 stars in the linked star def test_fit_boxsize_none_with_excluded_star(self, epsf_fitter_data): """ Test EPSFFitter with fit_boxsize=None and excluded star. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Mark first star as excluded stars.all_stars[0]._excluded_from_fit = True # Create fitter with fit_boxsize=None fitter = _make_epsf_fitter(fit_boxsize=None) fitted_stars = fitter(epsf, stars) # Check that excluded star is returned unchanged assert fitted_stars.all_stars[0] is stars.all_stars[0] assert len(fitted_stars) == len(stars) def test_linked_star_with_excluded_star(self, epsf_fitter_data): """ Test EPSFFitter with LinkedEPSFStar containing excluded star. This tests lines 825, 856-860 (excluded star in LinkedEPSFStar). """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create mock WCS class MockWCS: def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec mock_wcs = MockWCS() # Create LinkedEPSFStar with two stars, one excluded linked_stars_list = [] for i in range(2): star_data = stars.all_stars[i].data.copy() center = stars.all_stars[i].cutout_center origin = (0, 0) star = EPSFStar(star_data, cutout_center=center, origin=origin, wcs_large=mock_wcs) linked_stars_list.append(star) # Mark second star as excluded linked_stars_list[1]._excluded_from_fit = True linked_star = LinkedEPSFStar(linked_stars_list) stars_with_linked = EPSFStars([linked_star]) # Fit with fit_boxsize=None fitter = _make_epsf_fitter(fit_boxsize=None) fitted_stars = fitter(epsf, stars_with_linked) # Check that excluded star is in the result assert len(fitted_stars) == 1 assert len(fitted_stars.all_stars) == 2 def test_fit_star_partial_overlap_error(self, epsf_fitter_data): """ Test EPSFFitter._fit_star with PartialOverlapError. """ epsf = epsf_fitter_data['epsf'] # Create a star with cutout_center at edge to cause overlap error star_data = np.ones((11, 11)) # Place center very close to edge - this should cause overlap error # when fit_boxsize tries to extract a region star = EPSFStar(star_data, cutout_center=(0.5, 0.5)) stars_with_edge = EPSFStars([star]) # Use fit_boxsize that will cause overlap error fitter = _make_epsf_fitter(fit_boxsize=9) fitted_stars = fitter(epsf, stars_with_edge) # Check that star has fit_error_status set assert fitted_stars.all_stars[0]._fit_error_status == 1 class TestEPSFBuilder: """ Tests for the EPSFBuilder class. """ @pytest.mark.parametrize('extract_shape', [(25, 25), (19, 25), (25, 19)]) def test_build(self, epsf_test_data, extract_shape): """ Test EPSFBuilder build process on a simulated image. """ oversampling = 2 stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:10], size=extract_shape) epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=5, progress_bar=False, recentering_maxiters=5) epsf, fitted_stars = epsf_builder(stars) # Verify EPSF properties with default settings assert isinstance(epsf, ImagePSF) assert epsf.x_0 == 0.0 assert epsf.y_0 == 0.0 assert epsf.flux == 1.0 # Shape is star_shape * oversampling, then ensure odd ref_size = np.array(extract_shape) * oversampling ref_size = np.where(ref_size % 2 == 0, ref_size + 1, ref_size) assert epsf.data.shape == tuple(ref_size) # Verify basic EPSF properties assert len(fitted_stars) == 10 # ePSF should sum to ~oversamp^2 for properly normalized # oversampled PSF expected_sum = oversampling ** 2 assert 0.9 * expected_sum < epsf.data.sum() < 1.1 * expected_sum assert epsf.data.max() > 0.01 # Should have a peak # Check that the center region has higher values than edges center_y, center_x = np.array(ref_size) // 2 center_val = epsf.data[center_y, center_x] edge_val = epsf.data[0, 0] assert center_val > edge_val # Center should be brighter than edge # Test that residual computation works (basic functionality test) resid_star = fitted_stars[0].compute_residual_image(epsf) assert isinstance(resid_star, np.ndarray) assert resid_star.shape == fitted_stars[0].data.shape def test_invalid_inputs(self): """ Test EPSFBuilder with various invalid inputs. """ match = "'oversampling' must be specified" with pytest.raises(ValueError, match=match): EPSFBuilder(oversampling=None) match = 'oversampling must be > 0' with pytest.raises(ValueError, match=match): EPSFBuilder(oversampling=-1) match = 'maxiters must be a positive number' with pytest.raises(ValueError, match=match): EPSFBuilder(maxiters=-1) match = 'oversampling must be > 0' with pytest.raises(ValueError, match=match): EPSFBuilder(oversampling=[-1, 4]) for sigma_clip in [None, [], 'a']: match = 'sigma_clip must be an astropy.stats.SigmaClip instance' with pytest.raises(TypeError, match=match): EPSFBuilder(sigma_clip=sigma_clip) def test_fitter_options(self): """ Test EPSFBuilder with different fitter options. """ # Test with default fitter (TRFLSQFitter) builder1 = EPSFBuilder(maxiters=3) assert isinstance(builder1.fitter, TRFLSQFitter) # Default fit_shape is 5 assert_array_equal(builder1.fit_shape, (5, 5)) assert builder1.fitter_maxiters == 100 # Test with explicit astropy fitter fitter = TRFLSQFitter() builder2 = EPSFBuilder(fitter=fitter, maxiters=3) assert builder2.fitter is fitter # Test with custom fit_shape (scalar) builder3 = EPSFBuilder(fit_shape=7, maxiters=3) assert_array_equal(builder3.fit_shape, (7, 7)) # Test with tuple fit_shape builder4 = EPSFBuilder(fit_shape=(5, 7), maxiters=3) assert_array_equal(builder4.fit_shape, (5, 7)) # Test with None fit_shape (use entire star image) builder5 = EPSFBuilder(fit_shape=None, maxiters=3) assert builder5.fit_shape is None # Test with custom fitter_maxiters builder6 = EPSFBuilder(fitter_maxiters=200, maxiters=3) assert builder6.fitter_maxiters == 200 # Test with invalid fitter type (should fail) match = 'fitter must be a callable' with pytest.raises(TypeError, match=match): EPSFBuilder(fitter='invalid_fitter', maxiters=3) def test_fitter_options_deprecated_epsf_fitter(self): """ Test that passing an EPSFFitter to EPSFBuilder works but emits a deprecation warning. """ epsf_fitter = _make_epsf_fitter(fit_boxsize=7) match = 'Passing an EPSFFitter instance' with pytest.warns(AstropyDeprecationWarning, match=match): builder = EPSFBuilder(fitter=epsf_fitter, maxiters=3) # The astropy fitter should be extracted assert isinstance(builder.fitter, TRFLSQFitter) # The fit_shape should be extracted from EPSFFitter assert_array_equal(builder.fit_shape, (7, 7)) def test_fitter_maxiters_ignored(self): """ Test that fitter_maxiters is ignored if fitter doesn't support maxiter. """ # Create a mock fitter without maxiter support class SimpleFitter: def __call__(self, model, x, y, z): # noqa: ARG002 return model match = 'fitter_maxiters.*will be ignored' with pytest.warns(AstropyUserWarning, match=match): builder = EPSFBuilder(fitter=SimpleFitter(), fitter_maxiters=200, maxiters=3) assert builder.fitter_maxiters is None def test_fitting_bounds(self, epsf_test_data): """ Test EPSFBuilder with fit_shape larger than star cutouts. """ size = 25 oversampling = 4 stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'], size=size) # Use fit_shape larger than cutout epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=8, progress_bar=True, recentering_maxiters=5, fit_shape=31, smoothing_kernel='quadratic') # With a fit_shape larger than the cutout we expect the fitting # to fail for all stars. The ValueError is raised before any # star can be excluded (exclusion only happens after iter > 3). match = 'The ePSF fitting failed for all stars' with pytest.raises(ValueError, match=match): epsf_builder(stars) @pytest.mark.parametrize(('oversamp', 'star_size', 'expected_shape'), [ # oversampling=1: shape should be odd (add 1 to even product) (1, 25, (25, 25)), # 25*1 = 25 (odd) -> 25 (1, 24, (25, 25)), # 24*1 = 24 (even) -> 25 (1, 26, (27, 27)), # 26*1 = 26 (even) -> 27 # oversampling=2: product is even, add 1 (2, 25, (51, 51)), # 25*2 = 50 (even) -> 51 (2, 24, (49, 49)), # 24*2 = 48 (even) -> 49 # oversampling=3: product is odd for odd star size (3, 25, (75, 75)), # 25*3 = 75 (odd) -> 75 (3, 24, (73, 73)), # 24*3 = 72 (even) -> 73 # oversampling=4: product is even, add 1 (4, 25, (101, 101)), # 25*4 = 100 (even) -> 101 (4, 24, (97, 97)), # 24*4 = 96 (even) -> 97 # oversampling=5: product is odd for odd star size (5, 25, (125, 125)), # 25*5 = 125 (odd) -> 125 (5, 24, (121, 121)), # 24*5 = 120 (even) -> 121 ]) def test_shape_calculation(self, oversamp, star_size, expected_shape): """ Test that the ePSF shape is correctly calculated for various oversampling factors. The ePSF shape should be: - star_size * oversampling for each dimension - Then ensure odd dimensions (add 1 if even) """ # Test the shape calculation directly via _CoordinateTransformer transformer = _CoordinateTransformer(oversampling=(oversamp, oversamp)) star_shapes = [(star_size, star_size)] computed_shape = transformer.compute_epsf_shape(star_shapes) assert computed_shape == expected_shape, ( f'For oversamp={oversamp}, star_size={star_size}: ' f'expected {expected_shape}, got {computed_shape}' ) @pytest.mark.parametrize('kernel_type', ['quadratic', 'quartic', 'custom']) def test_smoothing_kernel(self, epsf_test_data, kernel_type): """ Test EPSFBuilder with smoothing kernel. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) if kernel_type == 'custom': kernel = np.ones((3, 3)) / 9.0 else: kernel = kernel_type builder = EPSFBuilder( smoothing_kernel=kernel, maxiters=1, progress_bar=False, ) epsf, _ = builder(stars) assert epsf is not None assert epsf.data.shape == (45, 45) @pytest.mark.parametrize('centering_func', [centroid_com, centroid_1dg, centroid_2dg, centroid_quadratic, ]) def test_recentering(self, epsf_test_data, centering_func): """ Test EPSFBuilder with different recentering function. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) # Setting oversampling=1 is required for centroid_quadratic to # work because its default fit_boxsize=5 is in native pixels and # we cannot adjust it here builder = EPSFBuilder( oversampling=1, recentering_func=centering_func, maxiters=5, progress_bar=False, ) epsf, _ = builder(stars) assert epsf is not None assert epsf.data.shape == (11, 11) @pytest.mark.parametrize('shape', [(25, 25), (19, 25), (25, 19)]) def test_shape_parameters(self, epsf_test_data, shape): """ Test EPSFBuilder with explicit shape parameters. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) # Test with explicit shape builder = EPSFBuilder( shape=shape, oversampling=1, maxiters=1, progress_bar=False, ) epsf, _ = builder(stars) assert epsf is not None assert epsf.data.shape == shape def test_check_convergence_no_good_stars(self): """ Test EPSFBuilder._check_convergence with no good stars. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) # Create stars and mark all as fit_failed data = np.ones((5, 5)) star = EPSFStar(data, cutout_center=(2, 2)) stars = EPSFStars([star]) centers = np.array([[2.0, 2.0]]) fit_failed = np.array([True]) # All stars failed converged, center_dist_sq, _ = builder._check_convergence( stars, centers, fit_failed) # Should return False (not converged) when no good stars assert converged is False # center_dist_sq should be high to prevent false convergence assert center_dist_sq[0] > builder.center_accuracy_sq def test_resample_residuals_no_good_stars(self, epsf_test_data): """ Test EPSFBuilder._resample_residuals with no good stars. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create an initial ePSF epsf = builder._create_initial_epsf(stars) # Mark all stars as excluded for star in stars.all_stars: star._excluded_from_fit = True # Now resample residuals should handle no good stars result = builder._resample_residuals(stars, epsf) assert result.shape[0] == 0 # No good stars def test_resample_residual_output(self, epsf_test_data): """ Test EPSFBuilder._resample_residual creates output image if None is passed. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create an initial ePSF epsf = builder._create_initial_epsf(stars) # Call _resample_residual without out_image (should create one) star = stars.all_stars[0] result = builder._resample_residual(star, epsf, out_image=None) assert result is not None assert result.shape == epsf.data.shape def test_build_step_with_epsf(self, epsf_test_data): """ Test EPSFBuilder._build_epsf_step with existing ePSF. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) # Create an initial ePSF epsf = builder._create_initial_epsf(stars) # Now build with existing ePSF improved_epsf = builder._build_epsf_step(stars, epsf=epsf) assert improved_epsf is not None assert improved_epsf.data.shape == epsf.data.shape def test_star_exclusion(self, epsf_test_data): """ Test that stars are excluded after repeated fit failures. Here, we modify the first star's position such that star is centered near the corner of extracted cutout image. This will cause the fitting to fail for that star because its fitting region extends beyond the cutout boundaries, and it should be excluded from subsequent iterations. """ tbl = epsf_test_data['init_stars'][:5].copy() tbl['x'][0] = 465 tbl['y'][0] = 30 stars = extract_stars(epsf_test_data['nddata'], tbl, size=11) builder = EPSFBuilder(oversampling=1, maxiters=5, progress_bar=False) match = ('has been excluded from ePSF fitting because its fitting ' 'region extends') with pytest.warns(AstropyUserWarning, match=match): result = builder(stars) assert result.n_excluded_stars == 1 assert result.excluded_star_indices == [0] assert result.epsf is not None assert result.epsf.data.shape == (11, 11) assert result.fitted_stars.n_good_stars == 4 assert result.fitted_stars.n_all_stars == 5 def test_star_exclusion_single_warning(self, epsf_test_data): """ Test that only a single warning is emitted per excluded star. When a star repeatedly fails fitting across iterations, the warning should only be emitted when the star is actually excluded (after more than 3 iterations of failure). """ tbl = epsf_test_data['init_stars'][:5].copy() tbl['x'][0] = 465 tbl['y'][0] = 30 stars = extract_stars(epsf_test_data['nddata'], tbl, size=11) builder = EPSFBuilder(oversampling=1, maxiters=6, progress_bar=False) # Capture all warnings with warnings.catch_warnings(record=True) as warning_list: warnings.simplefilter('always') builder(stars) # Filter for the specific warning about exclusion fit_warnings = [w for w in warning_list if 'has been excluded from ePSF fitting' in str(w.message)] # Should only have 1 warning despite multiple iterations assert len(fit_warnings) == 1 def test_excluded_star_no_copy(self, epsf_test_data): """ Test that excluded stars are returned without copying. When a star is excluded from fitting, the fitter should return the same star object directly, not a copy. This is more efficient than creating unnecessary copies. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) # Mark one star as excluded original_star = stars.all_stars[0] original_star._excluded_from_fit = True # Create an ePSF for fitting builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) epsf = builder._create_initial_epsf(stars) # Fit the stars using the builder's internal method fitted_stars = builder._fit_stars(epsf, stars) # The excluded star should be the exact same object (identity) assert fitted_stars.all_stars[0] is original_star def test_process_iteration_with_fit_failures(self, epsf_test_data): """ Test _process_iteration marks stars excluded after iter > 3. This test covers both types of fit failures: 1. Fitting region extends beyond cutout (status=1) 2. Fit did not converge due to invalid ierr (status=2) """ # Create stars with one positioned near corner to cause overlap # error tbl = epsf_test_data['init_stars'][:5].copy() tbl['x'][0] = 465 # Position near corner to cause overlap error tbl['y'][0] = 30 stars = extract_stars(epsf_test_data['nddata'], tbl, size=11) # Build initial ePSF. This will fit the stars and move their # centers. Star 0 will have its center moved near the edge of # the cutout, which will cause overlap errors in subsequent # iterations. builder_init = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) with warnings.catch_warnings(): warnings.simplefilter('ignore') epsf, fitted_stars = builder_init(stars) # Create a fitter that returns invalid ierr for the first call # only. # Star 0 will fail due to overlap error (status=1) before the # fitter is called (because its center moved near edge). # Star 1 is the first to reach the fitter, and will fail with # invalid ierr (status=2). # Subsequent stars will get valid ierr. class FirstCallFailingFitter: def __init__(self): self.call_count = 0 self.fit_info = {'ierr': 1} # Valid by default def __call__(self, model, *_args, **_kwargs): self.call_count += 1 # Fail only on the first fitter call (which is star 1, # since star 0 fails with overlap error before reaching # fitter) if self.call_count == 1: self.fit_info = {'ierr': 0} # Invalid ierr else: self.fit_info = {'ierr': 1} # Valid ierr return model failing_fitter = FirstCallFailingFitter() builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False, fitter=failing_fitter) # Process iteration with iter_num > 3 to trigger exclusion. Use # fitted_stars (which has moved centers) to trigger overlap error. # Capture warnings to verify both types are emitted. with warnings.catch_warnings(record=True) as warning_list: warnings.simplefilter('always') _, stars_new, fit_failed = builder._process_iteration( fitted_stars, epsf, iter_num=4) # Check that stars 0 and 1 failed assert fit_failed[0] # Overlap error (status=1) assert fit_failed[1] # Invalid ierr (status=2) # Verify both stars are marked for exclusion assert stars_new.all_stars[0]._excluded_from_fit assert stars_new.all_stars[1]._excluded_from_fit # Verify correct error status for each failure type assert stars_new.all_stars[0]._fit_error_status == 1 # Overlap error assert stars_new.all_stars[1]._fit_error_status == 2 # Fit failure # Verify both warning types were emitted warning_messages = [str(w.message) for w in warning_list] overlap_warnings = [m for m in warning_messages if 'fitting region extends beyond' in m] converge_warnings = [m for m in warning_messages if 'fit did not converge' in m] assert len(overlap_warnings) == 1 assert len(converge_warnings) == 1 def test_star_exclusion_fit_failure(self, epsf_test_data): """ Test that stars are excluded with appropriate message when fit does not converge (ierr error). This tests exclusion due to fit failure (status=2), as opposed to the fitting region extending beyond the cutout (status=1). """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) n_stars = len(stars.all_stars) # Create a fitter that fails for the first star (invalid ierr) # but succeeds for others. Add small offsets to x_0/y_0 to # prevent early convergence, ensuring we reach iteration > 3. class PartialFailingFitter: def __init__(self): self.call_count = 0 self.fit_info = {'ierr': 1} # Valid by default def __call__(self, model, *_args, **_kwargs): self.call_count += 1 star_idx = (self.call_count - 1) % n_stars # Fail only the first star if star_idx == 0: self.fit_info = {'ierr': 0} # Invalid ierr else: self.fit_info = {'ierr': 1} # Valid ierr # Add small offset to prevent early convergence model.x_0 = model.x_0 + 0.01 model.y_0 = model.y_0 + 0.01 return model failing_fitter = PartialFailingFitter() # Use maxiters=5 so we reach iter > 3 to trigger exclusion builder = EPSFBuilder(oversampling=1, maxiters=5, progress_bar=False, fitter=failing_fitter) # Should warn about fit not converging match = ('has been excluded from ePSF fitting because the fit did ' 'not converge') with pytest.warns(AstropyUserWarning, match=match): result = builder(stars) # At least the first star (with ierr=0) should be excluded assert result.n_excluded_stars >= 1 assert 0 in result.excluded_star_indices # Check that the first star has fit_error_status=2 (fit failure) assert result.fitted_stars.all_stars[0]._fit_error_status == 2 assert result.fitted_stars.all_stars[0]._excluded_from_fit def test_build_tracks_excluded_indices(self, epsf_test_data): """ Test that build_epsf properly tracks excluded star indices. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:10], size=11) # Create a fitter that: # 1. Adds noise to centers to prevent convergence # 2. Fails first 3 stars on iteration 4+ n_stars = len(stars.all_stars) class NoConvergeFitter: def __init__(self): self.star_count = 0 self.fit_info = {'ierr': 1} def __call__(self, model, *_args, **_kwargs): self.star_count += 1 iteration = self.star_count // n_stars + 1 star_idx = (self.star_count - 1) % n_stars # On iteration 4+, fail first 3 stars if iteration > 4 and star_idx < 3: self.fit_info = {'ierr': 0} # Invalid else: self.fit_info = {'ierr': 1} # Valid # Add slight offset to x_0 to prevent convergence model.x_0 = model.x_0 + 0.01 * (iteration % 2) return model fitter_obj = NoConvergeFitter() builder = EPSFBuilder(oversampling=1, maxiters=7, progress_bar=False, fitter=fitter_obj, center_accuracy=1e-6) # Build - this should trigger exclusion tracking with warnings.catch_warnings(): warnings.simplefilter('ignore') result = builder(stars) # Check that excluded_star_indices was populated assert hasattr(result, 'excluded_star_indices') assert isinstance(result.excluded_star_indices, list) # We may or may not have excluded stars depending on exact timing assert result.n_excluded_stars >= 0 def test_build_step_origin_is_none_branch(self, epsf_test_data): """ Test _build_epsf_step else branch when origin is None. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) # Create ePSF and verify origin condition epsf = builder._create_initial_epsf(stars) # Verify the branch condition logic has_valid_origin = hasattr(epsf, 'origin') and epsf.origin is not None assert has_valid_origin # Normal case, origin exists # The else branch is only reached when origin is None # This line calculates origin from shape expected_origin_y = (epsf.data.shape[0] - 1) / 2.0 expected_origin_x = (epsf.data.shape[1] - 1) / 2.0 assert_allclose(epsf.origin, (expected_origin_x, expected_origin_y)) @pytest.mark.skipif(not HAS_TQDM, reason='tqdm is required') def test_with_progress_bar(self, epsf_test_data): """ Test EPSFBuilder with progress_bar=True. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) # Build with progress bar enabled and high center_accuracy to # prevent convergence builder = EPSFBuilder(oversampling=1, maxiters=3, progress_bar=True, center_accuracy=1e-10) result = builder(stars) assert result.epsf is not None assert result.epsf.data.shape == (11, 11) def test_recenter_shift_increase(self, epsf_test_data): """ Test early exit in _recenter_epsf when shift increases. Uses mock to force the centroid function to return values that cause shift to increase on second iteration. """ builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False, recentering_maxiters=10) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) epsf, _ = builder(stars) # Create a mock centroid function that returns oscillating values # First call: shift by 0.5 pixels # Second call: shift back by 1.0 (larger shift, triggers break) call_count = [0] center = np.array(epsf.data.shape) / 2.0 def mock_centroid(data, *, mask=None): # noqa: ARG001 call_count[0] += 1 if call_count[0] == 1: # First iteration: small shift return (center[1] + 0.5, center[0] + 0.5) # Second iteration: shift back (larger distance) return (center[1] - 0.5, center[0] - 0.5) with patch.object(builder, 'recentering_func', mock_centroid): recentered = builder._recenter_epsf(epsf) assert recentered is not None assert recentered.shape == epsf.data.shape # The mock should have been called at least twice assert call_count[0] >= 2 @pytest.mark.parametrize(('oversampling', 'box_size', 'expected_box'), [(1, (5, 5), (5, 5)), (2, (5, 5), (11, 11)), (3, (5, 5), (15, 15)), (4, (5, 5), (21, 21)), (4, (7, 7), (29, 29)), (4, (3, 3), (13, 13)), ((2, 4), (5, 5), (11, 21)), ((3, 2), (5, 7), (15, 15)), ]) def test_recentering_boxsize_oversampling_scaling( self, epsf_test_data, oversampling, box_size, expected_box, ): """ Test that recentering_boxsize is in the input star pixel space and is correctly scaled to the oversampled ePSF grid. The recentering_boxsize parameter should be specified in the input star (undersampled) pixel space. When used internally, it must be multiplied by the oversampling factor and made odd so that the centroid cutout on the oversampled ePSF grid has the correct size. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) builder = EPSFBuilder(oversampling=oversampling, maxiters=2, progress_bar=False, recentering_boxsize=box_size) epsf, _ = builder(stars) # Use a mock centroid function to capture the actual cutout # shape passed to the centroid function cutout_shapes = [] def recording_centroid(data, *, mask=None): # noqa: ARG001 cutout_shapes.append(data.shape) cy, cx = np.array(data.shape) / 2.0 return (cx, cy) recentered = builder._recenter_epsf( epsf, centroid_func=recording_centroid, box_size=box_size, maxiters=1) assert recentered is not None # The cutout passed to the centroid function should have the # expected oversampled box size assert len(cutout_shapes) >= 1 assert cutout_shapes[0] == expected_box def test_recentering_boxsize_is_in_star_space(self, epsf_test_data): """ Test that recentering_boxsize operates in the input star pixel space, not the oversampled ePSF space. With oversampling=4 and recentering_boxsize=(5, 5), the centroid cutout on the oversampled ePSF grid should be approximately 5 * 4=20 -> 21 pixels (made odd), NOT 5 pixels. """ oversampling = 4 stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) builder = EPSFBuilder(oversampling=oversampling, maxiters=2, progress_bar=False, recentering_boxsize=(5, 5)) epsf, _ = builder(stars) cutout_shapes = [] def recording_centroid(data, *, mask=None): # noqa: ARG001 cutout_shapes.append(data.shape) cy, cx = np.array(data.shape) / 2.0 return (cx, cy) builder._recenter_epsf( epsf, centroid_func=recording_centroid, maxiters=1) assert len(cutout_shapes) >= 1 # 5 * 4 = 20 -> 21 (made odd) assert cutout_shapes[0] == (21, 21) # It should NOT be (5, 5) which would be wrong assert cutout_shapes[0] != (5, 5) def test_very_small_sources(self): """ Test EPSFBuilder with very small sources that may cause numerical issues. """ fwhm = 1.5 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) shape = (50, 50) sources = Table() sources['x_0'] = [25] sources['y_0'] = [25] sources['fwhm'] = [fwhm] 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=11) # Should handle numerical edge cases gracefully builder = EPSFBuilder(oversampling=1, maxiters=5, progress_bar=False) epsf, _ = builder(stars) assert epsf is not None assert epsf.data.shape == (11, 11) def test_fit_stars_with_linked_stars(self, epsf_test_data): """ Test EPSFBuilder._fit_stars with LinkedEPSFStar objects. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf, _ = builder(stars) # Create mock WCS class MockWCS: def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec mock_wcs = MockWCS() # Create LinkedEPSFStar from first two stars linked_stars_list = [] for i in range(2): star_data = stars.all_stars[i].data.copy() center = stars.all_stars[i].cutout_center origin = (0, 0) star = EPSFStar(star_data, cutout_center=center, origin=origin, wcs_large=mock_wcs) linked_stars_list.append(star) linked_star = LinkedEPSFStar(linked_stars_list) # Create EPSFStars with LinkedEPSFStar and regular stars stars_mixed = EPSFStars([linked_star, stars.all_stars[2]]) # Fit stars using builder's _fit_stars method fitted_stars = builder._fit_stars(epsf, stars_mixed) # Check structure: EPSFStars contains 2 items # (1 LinkedEPSFStar + 1 regular star) assert len(fitted_stars) == 2 # First item in the container should be LinkedEPSFStar assert isinstance(fitted_stars._data[0], LinkedEPSFStar) assert len(fitted_stars._data[0]) == 2 # Second is regular EPSFStar assert isinstance(fitted_stars._data[1], EPSFStar) # all_stars should have 3 stars (2 from linked + 1 regular) assert fitted_stars.n_all_stars == 3 def test_fit_stars_with_excluded_linked_stars(self, epsf_test_data): """ Test EPSFBuilder._fit_stars with excluded LinkedEPSFStar. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf, _ = builder(stars) # Create mock WCS class MockWCS: def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec mock_wcs = MockWCS() # Create LinkedEPSFStar with one excluded star linked_stars_list = [] for i in range(2): star_data = stars.all_stars[i].data.copy() center = stars.all_stars[i].cutout_center origin = (0, 0) star = EPSFStar(star_data, cutout_center=center, origin=origin, wcs_large=mock_wcs) if i == 1: star._excluded_from_fit = True linked_stars_list.append(star) linked_star = LinkedEPSFStar(linked_stars_list) stars_with_excluded = EPSFStars([linked_star]) # Fit stars fitted_stars = builder._fit_stars(epsf, stars_with_excluded) assert len(fitted_stars) == 1 # Check the fitted result is LinkedEPSFStar assert isinstance(fitted_stars._data[0], LinkedEPSFStar) # Check that excluded star was handled (second star should be excluded) assert fitted_stars._data[0][1]._excluded_from_fit def test_fit_stars_empty(self): """ Test EPSFBuilder._fit_stars with empty stars list. """ builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) # Create an ePSF for testing data = np.ones((11, 11)) epsf = ImagePSF(data) # Test with empty EPSFStars empty_stars = EPSFStars([]) fitted_stars = builder._fit_stars(epsf, empty_stars) # Should return empty stars unchanged assert len(fitted_stars) == 0 def test_fit_star_overlap_error(self, epsf_test_data): """ Test EPSFBuilder._fit_star with fit_shape causing overlap error. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create builder with fit_shape that works initially builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=5, progress_bar=False) epsf, _ = builder(stars) # Now create a new builder with fit_shape larger than star # to trigger overlap error builder_large = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=25, progress_bar=False) # Get a star star = stars.all_stars[0] # Fit the star with fit_shape=25 (larger than 11x11 star) # This should trigger PartialOverlapError or NoOverlapError # and set fit_error_status = 1 fitted_star = builder_large._fit_star(epsf, star) # Check that fit_error_status was set to 1 assert fitted_star._fit_error_status == 1 def test_fit_star_with_fit_shape_none(self, epsf_test_data): """ Test EPSFBuilder._fit_star with fit_shape=None (use entire star). """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create builder with fit_shape=None builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=None, progress_bar=False) epsf, _ = builder(stars) # Get a star star = stars.all_stars[0] # Fit the star - should use entire cutout fitted_star = builder._fit_star(epsf, star) # Check that fitting succeeded assert fitted_star._fit_error_status == 0 assert fitted_star.flux > 0 def test_normalize_epsf_zero_sum(self): """ Test EPSFBuilder._normalize_epsf with zero-sum data. """ builder = EPSFBuilder(oversampling=2, maxiters=2, progress_bar=False) # Create zero-sum ePSF data epsf_data = np.zeros((5, 5)) match = 'Cannot normalize ePSF: data sum is zero' with pytest.raises(ValueError, match=match): builder._normalize_epsf(epsf_data) @pytest.mark.parametrize('oversamp', [1, 2, 3, 4, 5]) def test_build_oversampling(self, oversamp): """ Test that the ePSF built with oversampling has the expected shape and properties. Sources are placed on a regular grid with exact subpixel offsets to ensure that the ePSF is properly sampled. The test checks that the resulting ePSF has the expected shape, that it sums to the expected value for an oversampled PSF, and that its shape matches the input PSF model when scaled by the sum of the ePSF data. """ 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 fwhm = 7.0 sources = Table() offset = 50 size = oversamp * offset + offset y, x = np.mgrid[0:oversamp, 0:oversamp] * offset + offset sources['x_0'] = x.ravel() + xdithers sources['y_0'] = y.ravel() + ydithers sources['fwhm'] = np.full((nstars,), fwhm) psf_model = CircularGaussianPRF(fwhm=fwhm) 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'] star_size = 25 stars = extract_stars(nddata, stars_tbl, size=star_size) epsf_builder = EPSFBuilder(oversampling=oversamp, maxiters=15, progress_bar=False, recentering_maxiters=20) epsf, results = epsf_builder(stars) # Verify EPSF properties with default settings assert isinstance(epsf, ImagePSF) assert epsf.x_0 == 0.0 assert epsf.y_0 == 0.0 assert epsf.flux == 1.0 # Check expected shape of ePSF data # The shape should be star_size * oversamp, then ensure odd # dimensions by adding 1 if even. expected_dim = star_size * oversamp if expected_dim % 2 == 0: expected_dim += 1 expected_shape = (expected_dim, expected_dim) assert epsf.data.shape == expected_shape # Check expected sum of ePSF data. # For an oversampled PSF, the sum of the array values should # equal the product of the oversampling factors (oversamp^2 for # symmetric oversampling). expected_sum = oversamp**2 assert_allclose(epsf.data.sum(), expected_sum, rtol=0.02) # Check that the shape of the ePSF matches the input PSF model # when scaled by the sum of the ePSF data. The input PSF model # is a circular Gaussian with the specified FWHM, and the ePSF # should approximate this shape when scaled by the total flux. # Calculate the expected PSF shape based on the input model and # the oversampling factor. The FWHM should be scaled by the # oversampling factor to match the ePSF sampling. size = epsf.data.shape[0] cen = (size - 1) / 2 fwhm2 = oversamp * fwhm model = CircularGaussianPRF(flux=1, x_0=cen, y_0=cen, fwhm=fwhm2) yy, xx = np.mgrid[0:size, 0:size] psf = model(xx, yy) * oversamp**2 assert_allclose(epsf.data, psf, atol=2e-4) # Check that the fitted centers are close to the true source # positions assert_allclose(results.center_flat[:, 0], sources['x_0'], atol=0.005) assert_allclose(results.center_flat[:, 1], sources['y_0'], atol=0.005) def test_fit_stars_with_excluded_epsf_star(self, epsf_test_data): """ Test _fit_stars with excluded EPSFStar. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) # Build initial ePSF result = builder(stars) epsf = result.epsf # Mark first star as excluded stars.all_stars[0]._excluded_from_fit = True # Call _fit_stars fitted_stars = builder._fit_stars(epsf, stars) # Check that excluded star is returned unchanged assert fitted_stars.all_stars[0] is stars.all_stars[0] assert len(fitted_stars) == len(stars) def test_fit_stars_with_excluded_linked_star(self, epsf_test_data): """ Test _fit_stars with excluded star in LinkedEPSFStar. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) # Build initial ePSF result = builder(stars) epsf = result.epsf # Create mock WCS class MockWCS: def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec mock_wcs = MockWCS() # Create LinkedEPSFStar with two stars, one excluded linked_stars_list = [] for i in range(2): star_data = stars.all_stars[i].data.copy() center = stars.all_stars[i].cutout_center origin = (0, 0) star = EPSFStar(star_data, cutout_center=center, origin=origin, wcs_large=mock_wcs) linked_stars_list.append(star) # Mark second star as excluded linked_stars_list[1]._excluded_from_fit = True linked_star = LinkedEPSFStar(linked_stars_list) stars_with_linked = EPSFStars([linked_star]) # Call _fit_stars fitted_stars = builder._fit_stars(epsf, stars_with_linked) # Check that result has the linked stars assert len(fitted_stars) == 1 assert len(fitted_stars.all_stars) == 2 def test_fit_star_fitter_without_weights(self, epsf_test_data): """ Test _fit_star with fitter that doesn't support weights. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create a fitter that raises TypeError when weights is passed class NoWeightsFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__(self, model, *_args, **kwargs): if 'weights' in kwargs: msg = 'weights not supported' raise TypeError(msg) return model no_weights_fitter = NoWeightsFitter() builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False, fitter=no_weights_fitter) # Build initial ePSF result = builder(stars) epsf = result.epsf # Call _fit_star directly star = stars.all_stars[0] fitted_star = builder._fit_star(epsf, star) # Check that star was fitted (should have new center) assert fitted_star is not None assert hasattr(fitted_star, 'center') def test_fit_star_fitter_without_fit_info(self, epsf_test_data): """ Test _fit_star with fitter that doesn't have fit_info. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create a fitter without fit_info attribute class NoFitInfoFitter: def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): return model no_fit_info_fitter = NoFitInfoFitter() builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False, fitter=no_fit_info_fitter) # Build initial ePSF result = builder(stars) epsf = result.epsf # Call _fit_star directly star = stars.all_stars[0] fitted_star = builder._fit_star(epsf, star) # Check that star was fitted assert fitted_star is not None assert hasattr(fitted_star, 'center') # fit_info should be None assert fitted_star._fit_info is None def test_fit_stars_invalid_epsf_type(self, epsf_test_data): """ Test _fit_stars with invalid epsf type. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) # Pass non-ImagePSF object as epsf match = 'The input epsf must be an ImagePSF' with pytest.raises(TypeError, match=match): builder._fit_stars('not_an_epsf', stars) def test_fit_stars_invalid_star_type(self, epsf_test_data): """ Test _fit_stars with invalid star type. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) # Build initial ePSF result = builder(stars) epsf = result.epsf # Create EPSFStars with invalid star type class InvalidStar: pass invalid_stars = EPSFStars([InvalidStar()]) # Call _fit_stars with invalid star type match = 'stars must contain only EPSFStar and/or LinkedEPSFStar' with pytest.raises(TypeError, match=match): builder._fit_stars(epsf, invalid_stars) def test_fit_star_position_outside_cutout(self, epsf_test_data): """ Test that _fit_star sets fit_error_status=3 and does not update the star's cutout_center when the fitted position falls outside the data cutout. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=5, progress_bar=False) epsf, _ = builder(stars) star = stars.all_stars[0] original_center = star.cutout_center.copy() original_flux = star.flux # Create a fitter that returns a large shift pushing the # fitted position outside the cutout class ShiftingFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): # Set a large shift that will push the center outside model.x_0 = 100.0 # Way outside an 11x11 cutout model.y_0 = 0.0 return model builder_shift = EPSFBuilder(oversampling=1, maxiters=1, fit_shape=5, progress_bar=False, fitter=ShiftingFitter()) fitted_star = builder_shift._fit_star(epsf, star) # fit_error_status should be 3 assert fitted_star._fit_error_status == 3 # cutout_center should NOT have been updated assert_array_equal(fitted_star.cutout_center, original_center) # flux should NOT have been updated assert fitted_star.flux == original_flux def test_fit_star_position_negative_outside_cutout(self, epsf_test_data): """ Test that _fit_star sets fit_error_status=3 when the fitted position is negative (outside cutout on the other side). """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=5, progress_bar=False) epsf, _ = builder(stars) star = stars.all_stars[0] original_center = star.cutout_center.copy() # Create a fitter that shifts the center to a negative position class NegativeShiftFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): model.x_0 = -100.0 model.y_0 = 0.0 return model builder_shift = EPSFBuilder(oversampling=1, maxiters=1, fit_shape=5, progress_bar=False, fitter=NegativeShiftFitter()) fitted_star = builder_shift._fit_star(epsf, star) assert fitted_star._fit_error_status == 3 assert_array_equal(fitted_star.cutout_center, original_center) def test_fit_star_position_inside_cutout(self, epsf_test_data): """ Test that _fit_star updates the center when the fitted position is inside the data cutout (normal case). """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=5, progress_bar=False) epsf, _ = builder(stars) star = stars.all_stars[0] original_center = star.cutout_center.copy() # Create a fitter that applies a small valid shift class SmallShiftFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): model.x_0 = 0.1 model.y_0 = -0.1 return model builder_shift = EPSFBuilder(oversampling=1, maxiters=1, fit_shape=5, progress_bar=False, fitter=SmallShiftFitter()) fitted_star = builder_shift._fit_star(epsf, star) # fit_error_status should be 0 (success) assert fitted_star._fit_error_status == 0 # cutout_center should have been updated expected_x = original_center[0] + 0.1 expected_y = original_center[1] - 0.1 assert_allclose(fitted_star.cutout_center[0], expected_x) assert_allclose(fitted_star.cutout_center[1], expected_y) def test_process_iteration_excludes_outside_cutout(self, epsf_test_data): """ Test that _process_iteration excludes stars with fit_error_status=3 and emits the correct warning message. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, fit_shape=5, progress_bar=False) epsf, fitted_stars = builder(stars) # Create a fitter that makes the first star's fitted position # outside cutout class OutsideFitter: def __init__(self): self.fit_info = {'ierr': 1} self.call_count = 0 def __call__( self, model, x, y, z, *, # noqa: ARG002 weights=None, **kwargs, # noqa: ARG002 ): self.call_count += 1 if self.call_count == 1: model.x_0 = 100.0 # Outside cutout model.y_0 = 0.0 else: model.x_0 = 0.01 model.y_0 = 0.01 self.fit_info = {'ierr': 1} return model builder_outside = EPSFBuilder(oversampling=1, maxiters=1, fit_shape=5, progress_bar=False, fitter=OutsideFitter()) match = 'fitted position is outside the data cutout' with pytest.warns(AstropyUserWarning, match=match): _, stars_new, fit_failed = builder_outside._process_iteration( fitted_stars, epsf, iter_num=4) # First star should have failed assert fit_failed[0] assert stars_new.all_stars[0]._fit_error_status == 3 assert stars_new.all_stars[0]._excluded_from_fit astropy-photutils-3322558/photutils/psf/tests/test_epsf_stars.py000066400000000000000000001665741517052111400252300ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf_stars module. """ import warnings from multiprocessing.reduction import ForkingPickler import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.nddata import (InverseVariance, NDData, StdDevUncertainty, VarianceUncertainty) from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from astropy.wcs import WCS from numpy.testing import assert_allclose, assert_array_equal from photutils.psf import make_psf_model_image from photutils.psf.epsf_stars import (EPSFStar, EPSFStars, LinkedEPSFStar, _compute_mean_sky_coordinate, _create_weights_cutout, _prepare_uncertainty_info, extract_stars) from photutils.psf.functional_models import CircularGaussianPRF from photutils.psf.image_models import ImagePSF @pytest.fixture def epsf_test_data(): """ Create a simulated image for testing. """ fwhm = 2.7 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) model_shape = (9, 9) n_sources = 100 shape = (750, 750) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=25, border_size=25, seed=0) nddata = NDData(data) init_stars = Table() init_stars['x'] = true_params['x_0'].astype(int) init_stars['y'] = true_params['y_0'].astype(int) return { 'fwhm': fwhm, 'data': data, 'nddata': nddata, 'init_stars': init_stars, } @pytest.fixture def simple_wcs(): """ Create a simple WCS for testing. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [25, 25] wcs.wcs.crval = [0, 0] wcs.wcs.cdelt = [1, 1] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs @pytest.fixture def simple_data(): """ Create simple 50x50 array of ones for testing. """ return np.ones((50, 50)) @pytest.fixture def simple_nddata(simple_data): """ Create simple NDData object for testing. """ return NDData(simple_data) @pytest.fixture def simple_table(): """ Create simple table with single star at center. """ return Table({'x': [25], 'y': [25]}) @pytest.fixture def stars_table(): """ Create table with multiple star positions. """ table = Table() table['x'] = [15, 15, 35, 35] table['y'] = [15, 35, 40, 10] return table @pytest.fixture def stars_data(stars_table): """ Create image data with stars using CircularGaussianPRF model. """ yy, xx = np.mgrid[0:51, 0:55] data = np.zeros(xx.shape) model = CircularGaussianPRF(fwhm=3.5) for xi, yi in zip(stars_table['x'], stars_table['y'], strict=True): data += model.evaluate(xx, yy, 100, xi, yi, 3.5) return data @pytest.fixture def stars_nddata(stars_data): """ Create NDData object with star data. """ return NDData(data=stars_data) def test_compute_mean_sky_coordinate(): """ Test spherical coordinate averaging. """ delta = 0.5 / 3600.0 # 0.5 arcsec in degrees ra = 10.0 dec = 30.0 coords = np.array([ [ra - delta, dec - delta], [ra + delta, dec - delta], [ra - delta, dec + delta], [ra + delta, dec + delta], ]) mean_lon, mean_lat = _compute_mean_sky_coordinate(coords) assert_allclose(mean_lon, ra) assert_allclose(mean_lat, dec) def test_compute_mean_sky_coordinate_edge_cases(): """ Test mean sky coordinate calculation edge cases. """ # Test coordinates near poles coords = np.array([ [0.0, 89.0], [90.0, 89.0], [180.0, 89.0], [270.0, 89.0], ]) # Mean latitude should be close to 89 - relax tolerance for edge case _, mean_lat = _compute_mean_sky_coordinate(coords) assert abs(mean_lat - 89.0) < 1.1 # Test with single coordinate single_coord = np.array([[45.0, 30.0]]) mean_lon, mean_lat = _compute_mean_sky_coordinate(single_coord) assert abs(mean_lon - 45.0) < 1e-10 assert abs(mean_lat - 30.0) < 1e-10 def test_prepare_uncertainty_info(): """ Test uncertainty info preparation. """ # Test with no uncertainty data = NDData(np.ones((5, 5))) info = _prepare_uncertainty_info(data) assert info['type'] == 'none' # Test with weight-like uncertainty by creating custom uncertainty class WeightsUncertainty(StdDevUncertainty): @property def uncertainty_type(self): return 'weights' weights = np.ones((5, 5)) * 2 data.uncertainty = WeightsUncertainty(weights) info = _prepare_uncertainty_info(data) assert info['type'] == 'weights' assert_array_equal(info['array'], weights) def test_prepare_uncertainty_info_variants(): """ Test uncertainty preparation for different uncertainty types. """ # Test standard deviation uncertainty data = NDData(np.ones((5, 5))) data.uncertainty = StdDevUncertainty(np.ones((5, 5)) * 0.1) info = _prepare_uncertainty_info(data) assert info['type'] == 'uncertainty' assert 'uncertainty' in info def test_create_weights_cutout(): """ Test weights cutout creation. """ # Test with no uncertainty info = {'type': 'none'} slices = (slice(1, 4), slice(1, 4)) # 3x3 cutout mask = None weights, has_nonfinite = _create_weights_cutout(info, mask, slices) assert weights.shape == (3, 3) assert_array_equal(weights, np.ones((3, 3))) assert not has_nonfinite # Test with mask full_mask = np.zeros((5, 5), dtype=bool) full_mask[2, 2] = True # Mask center of cutout weights, has_nonfinite = _create_weights_cutout(info, full_mask, slices) assert weights[1, 1] == 0.0 # Should be masked assert not has_nonfinite def test_create_weights_cutout_with_uncertainty(): """ Test weights cutout creation with uncertainty. """ # Create uncertainty info uncertainty = StdDevUncertainty(np.ones((5, 5)) * 0.1) info = { 'type': 'uncertainty', 'uncertainty': uncertainty, } slices = (slice(1, 4), slice(1, 4)) mask = None weights, has_nonfinite = _create_weights_cutout(info, mask, slices) assert weights.shape == (3, 3) # Should be inverse of uncertainty values (1/0.1 = 10) assert_allclose(weights, np.ones((3, 3)) * 10) assert not has_nonfinite def test_create_weights_cutout_non_finite_warning(): """ Test detection of non-finite weights. """ # Create weights with non-finite values bad_weights = np.ones((5, 5)) bad_weights[2, 2] = np.inf info = { 'type': 'weights', 'array': bad_weights, } slices = (slice(1, 4), slice(1, 4)) mask = None # Function should return has_nonfinite=True (warning is now # emitted by caller) weights, has_nonfinite = _create_weights_cutout(info, mask, slices) assert has_nonfinite # Non-finite value should be set to zero assert weights[1, 1] == 0.0 class TestEPSFStar: """ Tests for EPSFStar class functionality. """ def test_basic_initialization(self): """ Test basic EPSFStar initialization. """ data = np.ones((11, 11)) star = EPSFStar(data) assert star.data.shape == (11, 11) assert star.cutout_center is not None assert star.weights.shape == data.shape assert star.flux > 0 def test_explicit_flux(self): """ Test EPSFStar initialization with explicit flux value. """ data = np.ones((5, 5)) explicit_flux = 100.0 star = EPSFStar(data, flux=explicit_flux) # Flux should be the explicitly provided value assert star.flux == explicit_flux def test_invalid_data_input(self): """ Test EPSFStar initialization with invalid data. """ # Test 1D data match = 'Input data must be 2-dimensional' with pytest.raises(ValueError, match=match): EPSFStar(np.ones(10)) # Test 3D data with pytest.raises(ValueError, match=match): EPSFStar(np.ones((5, 5, 5))) # Test empty data match = 'Input data cannot be empty' with pytest.raises(ValueError, match=match): EPSFStar(np.array([]).reshape(0, 0)) def test_weights_validation(self): """ Test weight validation in EPSFStar. """ data = np.ones((5, 5)) # Test mismatched weights shape wrong_weights = np.ones((3, 3)) match = 'Weights shape .* must match data shape' with pytest.raises(ValueError, match=match): EPSFStar(data, weights=wrong_weights) # Test non-finite weights (should generate warning) bad_weights = np.ones((5, 5)) bad_weights[2, 2] = np.inf bad_weights[1, 1] = np.nan with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star = EPSFStar(data, weights=bad_weights) assert len(w) == 1 assert issubclass(w[0].category, AstropyUserWarning) assert 'Non-finite weight values' in str(w[0].message) # Check that non-finite weights were set to zero assert star.weights[2, 2] == 0.0 assert star.weights[1, 1] == 0.0 def test_invalid_data_handling(self): """ Test handling of invalid pixel values. """ data = np.ones((5, 5)) data[1, 1] = np.nan data[2, 2] = np.inf data[3, 3] = np.nan data[4, 4] = np.inf with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star = EPSFStar(data) # Should mask invalid pixels assert star.mask[1, 1] assert star.mask[2, 2] assert star.mask[3, 3] assert star.mask[4, 4] assert star.weights[1, 1] == 0.0 assert star.weights[2, 2] == 0.0 assert star.weights[3, 3] == 0.0 assert star.weights[4, 4] == 0.0 # Check that warning was issued about invalid data assert len(w) > 0 def test_cutout_center_validation(self): """ Test cutout_center validation. """ data = np.ones((5, 5)) star = EPSFStar(data) # Test invalid shape match = 'cutout_center must have exactly two elements' with pytest.raises(ValueError, match=match): star.cutout_center = [1, 2, 3] # Test non-finite values match = 'must be finite' with pytest.raises(ValueError, match=match): star.cutout_center = [np.nan, 2.0] # Test bounds warnings (should warn but not raise) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star.cutout_center = [-1, 2] # Outside bounds assert len(w) >= 1 # Check that warning mentions coordinates outside bounds warning_messages = [str(warning.message) for warning in w] assert any('outside the cutout bounds' in msg for msg in warning_messages) def test_origin_validation(self): """ Test origin parameter validation. """ data = np.ones((5, 5)) # Test invalid origin shape match = 'Origin must have exactly 2 elements' with pytest.raises(ValueError, match=match): EPSFStar(data, origin=[1, 2, 3]) # Test non-finite origin match = 'Origin coordinates must be finite' with pytest.raises(ValueError, match=match): EPSFStar(data, origin=[np.inf, 2]) def test_estimate_flux_masked_data(self): """ Test flux estimation with masked data. """ data = np.ones((5, 5)) * 10 # Create weights that mask some pixels weights = np.ones((5, 5)) weights[1:3, 1:3] = 0 # Mask central 2x2 region star = EPSFStar(data, weights=weights) # Flux should be estimated via interpolation assert star.flux > 0 # Should be close to total flux despite masking assert star.flux == pytest.approx(250, rel=0.1) # 5*5*10 = 250 def test_data_shape_validation(self): """ Test EPSFStar validation for various data shapes. """ # Test zero-dimension data - this actually triggers "empty" # error match = 'Input data cannot be empty' with pytest.raises(ValueError, match=match): EPSFStar(np.zeros((0, 5))) with pytest.raises(ValueError, match=match): EPSFStar(np.zeros((5, 0))) def test_flux_estimation_failure(self): """ Test flux estimation behavior with all masked data. """ # Create data with all masked pixels - this should raise # ValueError because the star cutout is completely masked data = np.ones((5, 5)) weights = np.zeros((5, 5)) # All masked data # This should raise ValueError because all data is masked match = 'Star cutout is completely masked; no valid data available' with pytest.raises(ValueError, match=match): EPSFStar(data, weights=weights) def test_completely_masked_star(self): """ Test that completely masked stars are properly rejected. """ # Create star data with all weights zero (completely masked) data = np.ones((7, 7)) * 100.0 weights = np.zeros((7, 7)) # Should raise ValueError with appropriate message match = 'Star cutout is completely masked; no valid data available' with pytest.raises(ValueError, match=match): EPSFStar(data, weights=weights) def test_negative_flux_allowed(self): """ Test that negative flux is allowed for valid sources. Negative flux can occur legitimately with background oversubtraction or similar effects. """ # Create data with negative net flux data = np.ones((5, 5)) * -10.0 star = EPSFStar(data, flux=-50.0) # Should not raise an error assert star.flux == -50.0 # Also test with estimated flux star2 = EPSFStar(data) assert star2.flux == -250.0 # sum of 25 pixels * -10 def test_all_zero_data_warning(self): """ Test that all-zero data emits a warning when EPSFStar is called directly, but flag is set for extract_stars to handle. All-zero unmasked data is unusual and likely indicates a problem, but it's not technically invalid, so we allow star creation. """ data = np.zeros((5, 5)) # EPSFStar should emit warning when called directly with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star = EPSFStar(data) # Should have warning about all-zero data warning_messages = [str(warning.message) for warning in w] assert any('All unmasked data values' in msg and 'zero' in msg for msg in warning_messages) # Star should be created with flux=0 and flag set assert star.flux == 0.0 assert hasattr(star, '_has_all_zero_data') assert star._has_all_zero_data is True def test_array_method(self): """ Test the __array__ method. """ data = np.random.default_rng(42).random((5, 5)) star = EPSFStar(data) # Test that __array__ returns the data star_array = star.__array__() assert_array_equal(star_array, data) def test_properties(self): """ Test star properties. """ data = np.ones((7, 9)) origin = (10, 20) star = EPSFStar(data, origin=origin) # Test shape property assert star.shape == (7, 9) # Test center property (different from cutout_center) expected_center = star.cutout_center + np.array(origin) assert_array_equal(star.center, expected_center) # Test slices property # Implementation uses (origin_y to origin_y+shape[0], # origin_x to origin_x+shape[1]) expected_slices = (slice(20, 29), slice(10, 17)) assert star.slices == expected_slices # Test bbox property bbox = star.bbox assert bbox.ixmin == 10 assert bbox.ixmax == 17 assert bbox.iymin == 20 assert bbox.iymax == 29 def test_flux_estimation_interpolation_fallback(self): """ Test flux estimation with interpolation fallbacks. """ data = np.ones((5, 5)) * 10 weights = np.ones((5, 5)) weights[2, 2] = 0 # Mask center pixel star = EPSFStar(data, weights=weights) # Should estimate flux using interpolation # Flux should be close to total despite masked pixel assert star.flux == pytest.approx(250, rel=0.1) def test_register_epsf(self): """ Test ePSF registration and scaling. """ data = np.ones((11, 11)) star = EPSFStar(data) # Create a simple ePSF model epsf_data = np.zeros((5, 5)) epsf_data[2, 2] = 1 # Central peak epsf = ImagePSF(epsf_data) # Register the ePSF registered = star.register_epsf(epsf) assert registered.shape == data.shape assert isinstance(registered, np.ndarray) def test_private_properties(self): """ Test private properties. """ data = np.random.default_rng(42).random((5, 5)) weights = np.ones((5, 5)) weights[1, 1] = 0 # Mask one pixel star = EPSFStar(data, weights=weights) # Test _xyidx_centered x_centered, y_centered = star._xyidx_centered assert len(x_centered) == len(y_centered) assert len(x_centered) == np.sum(~star.mask) # Verify centering is correct yidx, xidx = np.indices(data.shape) expected_x = xidx[~star.mask].ravel() - star.cutout_center[0] expected_y = yidx[~star.mask].ravel() - star.cutout_center[1] assert_array_equal(x_centered, expected_x) assert_array_equal(y_centered, expected_y) # Test normalized data values expected_values = data[~star.mask].ravel() normalized = star._data_values_normalized expected_normalized = expected_values / star.flux assert_allclose(normalized, expected_normalized) def test_flux_estimation_exception_handling(self): """ Test flux estimation exception handling when estimate_flux returns invalid values. """ # Test with data that results in zero flux - this is now ALLOWED # since zero flux is a valid (though not useful) value data = np.zeros((3, 3)) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) star = EPSFStar(data) assert star.flux == 0.0 # Zero flux is allowed # Test that completely invalid (NaN) data is rejected # (NaN data gets masked, then completely masked raises error) data_nan = np.full((3, 3), np.nan) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) match = 'Star cutout is completely masked' with pytest.raises(ValueError, match=match): EPSFStar(data_nan) def test_cutout_center_out_of_bounds_y(self): """ Test cutout_center validation for y-coordinate out of bounds. """ data = np.ones((5, 5)) star = EPSFStar(data) # Test y-coordinate outside bounds with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star.cutout_center = (2.0, -1.0) # y < 0 assert len(w) >= 1 warning_messages = [str(warning.message) for warning in w] assert any('y-coordinate' in msg and 'outside' in msg for msg in warning_messages) # Test y-coordinate at upper bound with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') star.cutout_center = (2.0, 6.0) # y >= shape[0] assert len(w) >= 1 warning_messages = [str(warning.message) for warning in w] assert any('y-coordinate' in msg and 'outside' in msg for msg in warning_messages) def test_empty_data_validation(self): """ Test empty data validation. """ data = np.array([[]]) # Empty 2D array match = 'Input data cannot be empty' with pytest.raises(ValueError, match=match): EPSFStar(data) def test_residual_image(self): """ Test to ensure ``compute_residual_image`` gives correct residuals. """ size = 100 yy, xx, = np.mgrid[0:size + 1, 0:size + 1] / 4 gmodel = CircularGaussianPRF().evaluate(xx, yy, 1, 12.5, 12.5, 2.5) epsf = ImagePSF(gmodel, oversampling=4) _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) assert_allclose(np.sum(residual), 0.0) class TestEPSFStars: """ Tests for EPSFStars collection class functionality. """ def test_initialization_variants(self): """ Test different initialization methods. """ data1 = np.ones((5, 5)) data2 = np.ones((7, 7)) star1 = EPSFStar(data1) star2 = EPSFStar(data2) # Test single star initialization stars_single = EPSFStars(star1) assert len(stars_single) == 1 # Test list initialization stars_list = EPSFStars([star1, star2]) assert len(stars_list) == 2 # Test invalid initialization match = 'stars_list must be a list of EPSFStar' with pytest.raises(TypeError, match=match): EPSFStars('invalid') def test_indexing_operations(self): """ Test indexing and slicing operations. """ stars = [EPSFStar(np.ones((5, 5))) for _ in range(3)] stars_obj = EPSFStars(stars) # Test getitem first = stars_obj[0] assert isinstance(first, EPSFStars) assert len(first) == 1 # Test delitem del stars_obj[1] assert len(stars_obj) == 2 # Test iteration count = 0 for star in stars_obj: count += 1 assert isinstance(star, EPSFStar) assert count == 2 def test_pickle_operations(self): """ Test pickle state management. """ stars = [EPSFStar(np.ones((5, 5))) for _ in range(2)] stars_obj = EPSFStars(stars) # Test getstate/setstate state = stars_obj.__getstate__() new_obj = EPSFStars([]) new_obj.__setstate__(state) assert len(new_obj) == len(stars_obj) def test_attribute_access(self): """ Test dynamic attribute access. """ data1 = np.ones((5, 5)) data2 = np.ones((7, 7)) * 2 stars = EPSFStars([EPSFStar(data1), EPSFStar(data2)]) # Test accessing cutout_center attribute centers = stars.cutout_center assert len(centers) == 2 assert centers.shape == (2, 2) # Test accessing flux attribute fluxes = stars.flux assert len(fluxes) == 2 # Test accessing _excluded_from_fit attribute excluded = stars._excluded_from_fit assert len(excluded) == 2 assert not any(excluded) # Should all be False initially def test_flat_attributes(self): """ Test flat attribute access methods. """ stars = [EPSFStar(np.ones((5, 5))) for _ in range(2)] stars_obj = EPSFStars(stars) # Test cutout_center_flat centers_flat = stars_obj.cutout_center_flat assert centers_flat.shape == (2, 2) # Test center_flat centers_flat = stars_obj.center_flat assert centers_flat.shape == (2, 2) def test_star_counting(self): """ Test star counting properties. """ stars = [EPSFStar(np.ones((5, 5))) for _ in range(3)] stars_obj = EPSFStars(stars) # Test counting properties assert stars_obj.n_stars == 3 assert stars_obj.n_all_stars == 3 assert stars_obj.n_good_stars == 3 # Test all_stars and all_good_stars properties all_stars = stars_obj.all_stars assert len(all_stars) == 3 good_stars = stars_obj.all_good_stars assert len(good_stars) == 3 # Mark one star as excluded stars[1]._excluded_from_fit = True assert stars_obj.n_good_stars == 2 def test_shape_attribute(self): """ Test accessing shape attribute through EPSFStars. """ stars = [EPSFStar(np.ones((5, 5))), EPSFStar(np.ones((7, 9)))] stars_obj = EPSFStars(stars) # Access individual star shapes through the container shapes = stars_obj.shape assert len(shapes) == 2 assert shapes[0] == (5, 5) assert shapes[1] == (7, 9) def test_pickleable(self): """ Verify that EPSFStars can be successfully pickled/unpickled for multiprocessing. """ # This should not fail stars = EPSFStars([1]) ForkingPickler.loads(ForkingPickler.dumps(stars)) def test_cutout_center_flat_with_linked_stars(self, simple_wcs): """ Test cutout_center_flat property with LinkedEPSFStar objects. """ # Create regular stars star1 = EPSFStar(np.ones((5, 5))) star2 = EPSFStar(np.ones((7, 7))) # Create linked stars linked_star1 = EPSFStar(np.ones((6, 6)), wcs_large=simple_wcs) linked_star2 = EPSFStar(np.ones((8, 8)), wcs_large=simple_wcs) linked = LinkedEPSFStar([linked_star1, linked_star2]) # Create EPSFStars collection with mix of regular and linked stars stars = EPSFStars([star1, linked, star2]) # Test cutout_center_flat property centers_flat = stars.cutout_center_flat # Should have 4 centers: star1, linked_star1, linked_star2, star2 assert len(centers_flat) == 4 assert centers_flat.shape == (4, 2) def test_all_stars_with_linked_stars(self, simple_wcs): """ Test all_stars property with LinkedEPSFStar objects. """ # Create regular stars star1 = EPSFStar(np.ones((5, 5))) star2 = EPSFStar(np.ones((7, 7))) # Create linked stars linked_star1 = EPSFStar(np.ones((6, 6)), wcs_large=simple_wcs) linked_star2 = EPSFStar(np.ones((8, 8)), wcs_large=simple_wcs) linked = LinkedEPSFStar([linked_star1, linked_star2]) # Create EPSFStars collection with mix of regular and linked # stars stars = EPSFStars([star1, linked, star2]) # Test all_stars property all_stars_list = stars.all_stars # Should have 4 stars total: star1, linked_star1, linked_star2, # star2 assert len(all_stars_list) == 4 # Verify they are all EPSFStar instances for star in all_stars_list: assert isinstance(star, EPSFStar) class TestLinkedEPSFStar: """ Tests for LinkedEPSFStar functionality. """ def test_initialization_validation(self): """ Test LinkedEPSFStar initialization validation. """ # Test with non-EPSFStar objects match = 'stars_list must contain only EPSFStar objects' with pytest.raises(TypeError, match=match): LinkedEPSFStar(['not_a_star', 'also_not_a_star']) # Test with EPSFStar without WCS star_no_wcs = EPSFStar(np.ones((5, 5))) match = 'Each EPSFStar object must have a valid wcs_large attribute' with pytest.raises(ValueError, match=match): LinkedEPSFStar([star_no_wcs]) def test_constraint_no_good_stars(self, simple_wcs): """ Test constraining centers with no good stars. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) # Mark both as excluded star1._excluded_from_fit = True star2._excluded_from_fit = True linked = LinkedEPSFStar([star1, star2]) # Should warn about no good stars with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') linked.constrain_centers() assert len(w) >= 1 warning_messages = [str(warning.message) for warning in w] assert any('have all been excluded' in msg for msg in warning_messages) def test_constraint_single_star(self, simple_wcs): """ Test constraining centers with single star (no-op). """ star = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star]) # Should do nothing for single star original_center = star.cutout_center.copy() linked.constrain_centers() assert_array_equal(star.cutout_center, original_center) def test_all_excluded_property(self, simple_wcs): """ Test the all_excluded property. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2]) # Initially, no stars are excluded assert not linked.all_excluded # Exclude one star star1._excluded_from_fit = True assert not linked.all_excluded # Exclude both stars star2._excluded_from_fit = True assert linked.all_excluded def test_constrain_centers_with_good_stars(self, simple_wcs): """ Test constrain_centers method with good stars. """ # Create multiple stars with different positions (within bounds) star1 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs, cutout_center=(3.1, 3.1), origin=(20, 20)) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs, cutout_center=(2.9, 2.9), origin=(20, 20)) star3 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs, cutout_center=(3.0, 3.2), origin=(20, 20)) # Make sure none are excluded star1._excluded_from_fit = False star2._excluded_from_fit = False star3._excluded_from_fit = False linked = LinkedEPSFStar([star1, star2, star3]) # Test constrain_centers (should execute without error) linked.constrain_centers() def test_constrain_centers_with_some_excluded_stars(self, simple_wcs): """ Test constrain_centers with some excluded stars. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star3 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) # Exclude some stars but not all star1._excluded_from_fit = True # Excluded star2._excluded_from_fit = False # Good star3._excluded_from_fit = False # Good linked = LinkedEPSFStar([star1, star2, star3]) # This should process only the good stars; should not raise # warnings since there are good stars linked.constrain_centers() def test_constrain_all_excluded(self, simple_wcs): """ Test constrain_centers when all stars excluded. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) # Exclude all stars star1._excluded_from_fit = True star2._excluded_from_fit = True linked = LinkedEPSFStar([star1, star2]) # Should trigger early return and emit warning with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') linked.constrain_centers() # Should get warning about no good stars warning_messages = [str(warning.message) for warning in w] has_warning = any('Cannot constrain centers' in msg for msg in warning_messages) assert has_warning def test_len_getitem_iter(self, simple_wcs): """ Test __len__, __getitem__, and __iter__ methods. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2]) # Test __len__ assert len(linked) == 2 # Test __getitem__ assert linked[0] is star1 assert linked[1] is star2 # Test __iter__ stars_list = list(linked) assert len(stars_list) == 2 assert stars_list[0] is star1 assert stars_list[1] is star2 def test_getattr_delegation(self, simple_wcs): """ Test __getattr__ delegation for various attributes. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2]) # Test accessing flux attribute (should be array) fluxes = linked.flux assert len(fluxes) == 2 assert fluxes[0] == star1.flux assert fluxes[1] == star2.flux # Test accessing cutout_center (should be array) centers = linked.cutout_center assert centers.shape == (2, 2) # Test accessing center (should be array) centers = linked.center assert centers.shape == (2, 2) def test_getattr_single_star(self, simple_wcs): """ Test __getattr__ with single star (returns scalar not array). """ star = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star]) # With single star, should return single value not array flux = linked.flux assert flux == star.flux assert not isinstance(flux, np.ndarray) def test_getattr_private_attribute_error(self, simple_wcs): """ Test that accessing non-existent private attributes raises error. """ star = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star]) # Accessing non-existent private attribute should raise match = "'LinkedEPSFStar' object has no attribute" with pytest.raises(AttributeError, match=match): _ = linked._nonexistent_attribute def test_pickle_operations(self, simple_wcs): """ Test __getstate__ and __setstate__ for pickling. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2]) # Test getstate/setstate state = linked.__getstate__() new_linked = LinkedEPSFStar([EPSFStar(np.ones((3, 3)), wcs_large=simple_wcs)]) new_linked.__setstate__(state) assert len(new_linked) == 2 def test_flat_properties(self, simple_wcs): """ Test cutout_center_flat and center_flat properties. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, origin=(10, 20)) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs, origin=(30, 40)) linked = LinkedEPSFStar([star1, star2]) # Test cutout_center_flat centers_flat = linked.cutout_center_flat assert centers_flat.shape == (2, 2) assert_array_equal(centers_flat[0], star1.cutout_center) assert_array_equal(centers_flat[1], star2.cutout_center) # Test center_flat centers = linked.center_flat assert centers.shape == (2, 2) assert_array_equal(centers[0], star1.center) assert_array_equal(centers[1], star2.center) def test_counting_properties(self, simple_wcs): """ Test n_stars, n_all_stars, and n_good_stars properties. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) star3 = EPSFStar(np.ones((6, 6)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2, star3]) # Test n_stars and n_all_stars (should be same for # LinkedEPSFStar) assert linked.n_stars == 3 assert linked.n_all_stars == 3 # Test n_good_stars assert linked.n_good_stars == 3 # Exclude one star star2._excluded_from_fit = True assert linked.n_good_stars == 2 class TestExtractStars: """ Tests for extract_stars function. """ def test_extract_stars(self, stars_nddata, stars_table): """ Test basic star extraction functionality. """ size = 11 stars = extract_stars(stars_nddata, stars_table, 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, stars_nddata, stars_table): """ Test extract_stars input validation. """ match = 'data must be a single NDData object or list of NDData objects' with pytest.raises(TypeError, match=match): extract_stars(np.ones(3), stars_table) match = 'All catalog elements must be Table objects' with pytest.raises(TypeError, match=match): extract_stars(stars_nddata, [(1, 1), (2, 2), (3, 3)]) match = 'number of catalogs must match the number of input images' with pytest.raises(ValueError, match=match): extract_stars(stars_nddata, [stars_table, stars_table]) match = "the catalog must have a 'skycoord' column" with pytest.raises(ValueError, match=match): extract_stars([stars_nddata, stars_nddata], stars_table) def test_empty_catalog(self, simple_nddata): """ Test extraction with empty catalog. """ empty_table = Table() empty_table['x'] = [] empty_table['y'] = [] with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(simple_nddata, empty_table) assert len(stars) == 0 # Should warn about empty catalog assert len(w) >= 1 warning_messages = [str(warning.message) for warning in w] assert any('empty' in msg.lower() for msg in warning_messages) def test_stars_outside_image(self, simple_nddata): """ Test extraction with stars outside image bounds. """ table = Table() table['x'] = [-10, 100] # Outside image bounds table['y'] = [25, 25] with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 0 # Should warn about excluded stars assert len(w) >= 1 warning_messages = [str(warning.message) for warning in w] assert any('not extracted' in msg for msg in warning_messages) def test_invalid_input_types(self, simple_nddata): """ Test extraction with invalid input types. """ table = Table() table['x'] = [25] table['y'] = [25] # Test invalid data type match = 'must be a single NDData object or list of NDData objects' with pytest.raises(TypeError, match=match): extract_stars('not_nddata', table) # Test invalid catalog type match = 'must be a single Table object' with pytest.raises(TypeError, match=match): extract_stars(simple_nddata, 'not_table') def test_coordinate_validation(self, simple_nddata): """ Test coordinate system validation. """ table = Table() table['x'] = [25] table['y'] = [25] # Test missing skycoord for multiple images match = "must have a 'skycoord' column" with pytest.raises(ValueError, match=match): extract_stars([simple_nddata, simple_nddata], table) # Test missing coordinate columns bad_table = Table() bad_table['flux'] = [100] # No x, y, or skycoord match = "must have either 'x' and 'y' columns or a 'skycoord' column" with pytest.raises(ValueError, match=match): extract_stars(simple_nddata, bad_table) def test_data_validation(self, simple_table): """ Test data input validation. """ # Test invalid data types in list match = 'All data elements must be NDData objects' with pytest.raises(TypeError, match=match): extract_stars(['not_nddata'], simple_table) # Test NDData with no data array empty_nddata = NDData(np.array([])) # Provide empty array match = 'must contain 2D data' with pytest.raises(ValueError, match=match): extract_stars(empty_nddata, simple_table) # Test NDData with wrong dimensions nddata_1d = NDData(np.ones(50)) with pytest.raises(ValueError, match=match): extract_stars(nddata_1d, simple_table) def test_catalog_validation(self, simple_nddata): """ Test catalog input validation. """ # Test invalid catalog types in list match = 'All catalog elements must be Table objects' with pytest.raises(TypeError, match=match): extract_stars(simple_nddata, ['not_table']) def test_coordinate_system_validation(self, simple_nddata): """ Test coordinate system validation for complex cases. """ # Test skycoord-only catalog without WCS skycoord_table = Table() skycoord_table['skycoord'] = [SkyCoord(0, 0, unit='deg')] match = 'NDData object must have a wcs attribute' with pytest.raises(ValueError, match=match): extract_stars(simple_nddata, skycoord_table) # Test multiple catalogs with mismatched count table1 = Table({'x': [25], 'y': [25]}) table2 = Table({'x': [25], 'y': [25]}) match = 'number of catalogs must match the number of input images' with pytest.raises(ValueError, match=match): extract_stars(simple_nddata, [table1, table2]) def test_extract_stars_skycoord_and_wcs(self, simple_data, simple_wcs): """ Test extract_stars with skycoord input and WCS. """ nddata_with_wcs = NDData(simple_data) nddata_with_wcs.wcs = simple_wcs table = Table() table['skycoord'] = [SkyCoord(0, 0, unit='deg')] stars = extract_stars(nddata_with_wcs, table, size=(11, 11)) valid_stars = [s for s in stars.all_stars if s is not None] assert len(valid_stars) >= 1 def test_extract_stars_size_validation_coverage(self, simple_nddata): """ Test size validation paths in extract_stars. """ table = Table({'x': [25], 'y': [25]}) # Test various size configurations to hit validation paths. # This should exercise the as_pair validation. stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 1 # Test tuple size stars = extract_stars(simple_nddata, table, size=(11, 13)) assert len(stars) == 1 assert stars[0].data.shape == (11, 13) def test_extract_stars_coordinate_conversion_paths(self, simple_data, simple_wcs): """ Test coordinate conversion paths in extract_stars. """ nddata_with_wcs = NDData(simple_data) nddata_with_wcs.wcs = simple_wcs # Test with both x,y and skycoord present (should prefer x,y) table = Table() table['x'] = [25.0] table['y'] = [25.0] table['skycoord'] = [SkyCoord(0, 0, unit='deg')] stars = extract_stars(nddata_with_wcs, table, size=11) assert len(stars) == 1 def test_extract_stars_id_handling(self, simple_nddata): """ Test ID handling in extract_stars. """ # Test with explicit IDs table = Table() table['x'] = [25, 30] table['y'] = [25, 30] table['id'] = ['star_a', 'star_b'] stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 2 assert stars[0].id_label == 'star_a' assert stars[1].id_label == 'star_b' # Test without IDs (should auto-generate) table_no_id = Table() table_no_id['x'] = [25, 30] table_no_id['y'] = [25, 30] stars = extract_stars(simple_nddata, table_no_id, size=11) assert len(stars) == 2 assert stars[0].id_label == 1 # Auto-generated starting from 1 assert stars[1].id_label == 2 def test_extract_linked_stars_multiple_images(self, simple_wcs): """ Test extracting linked stars from multiple images with single catalog. """ # Create two images with WCS data1 = np.ones((50, 50)) * 10 data2 = np.ones((50, 50)) * 20 nddata1 = NDData(data1) nddata1.wcs = simple_wcs nddata2 = NDData(data2) nddata2.wcs = simple_wcs # Create catalog with skycoord at center of image table = Table() table['skycoord'] = [SkyCoord(0, 0, unit='deg')] # Extract linked stars (suppress warnings to avoid pytest error) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) stars = extract_stars([nddata1, nddata2], table, size=11) # Should have 1 linked star containing 2 EPSFStar objects assert len(stars) == 1 assert isinstance(stars._data[0], LinkedEPSFStar) assert len(stars._data[0]) == 2 def test_extract_unlinked_stars_multiple_catalogs(self): """ Test extracting stars with multiple catalogs (no linking). """ # Create two images data1 = np.ones((50, 50)) * 10 data2 = np.ones((50, 50)) * 20 nddata1 = NDData(data1) nddata2 = NDData(data2) # Create two catalogs with different stars table1 = Table({'x': [25], 'y': [25]}) table2 = Table({'x': [30], 'y': [30]}) # Extract stars stars = extract_stars([nddata1, nddata2], [table1, table2], size=11) # Should have 2 separate (not linked) stars assert len(stars) == 2 assert all(isinstance(s, EPSFStar) for s in stars._data) def test_extract_linked_stars_partial_extraction(self, simple_wcs): """ Test linked star extraction where star is valid in one image but not another (edge case). """ # Create two images - second one is smaller so star near edge # won't be extractable data1 = np.ones((50, 50)) * 10 data2 = np.ones((20, 20)) * 20 # Smaller image nddata1 = NDData(data1) nddata1.wcs = simple_wcs nddata2 = NDData(data2) nddata2.wcs = simple_wcs # Create catalog with star at position that's valid in first # but not second table = Table() table['skycoord'] = [SkyCoord(0, 0, unit='deg')] # Center with warnings.catch_warnings(record=True): warnings.simplefilter('always') stars = extract_stars([nddata1, nddata2], table, size=11) # Should have extracted at least 1 star (from first image) # The second image star is outside bounds so only 1 is extracted assert len(stars) >= 1 def test_extract_stars_flux_estimation_failure(self): """ Test that EPSFStar creation failure emits warning for completely masked stars. """ # Create data with explicit zero weights (completely masked) data = np.ones((50, 50)) * 100.0 nddata = NDData(data) # Use zero uncertainty which causes infinite weights, # which are then set to zero (completely masked) uncertainty = StdDevUncertainty(np.zeros((50, 50))) nddata.uncertainty = uncertainty table = Table({'x': [25], 'y': [25]}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(nddata, table, size=11) # Should warn about failed EPSFStar creation warning_messages = [str(warning.message) for warning in w] assert any('Failed to create EPSFStar' in msg for msg in warning_messages) # Should NOT have duplicate warnings about completely masked masked_warnings = [msg for msg in warning_messages if 'completely masked' in msg] # Should only have one warning per failed star assert len(masked_warnings) == 1 # No valid stars should be extracted assert len(stars) == 0 def test_extract_stars_completely_masked(self): """ Test extract_stars with completely masked cutouts. """ # Create data with zeros and zero weights data = np.zeros((50, 50)) uncertainty = StdDevUncertainty(np.zeros((50, 50))) nddata = NDData(data, uncertainty=uncertainty) table = Table({'x': [25, 30, 35], 'y': [25, 30, 35]}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(nddata, table, size=11) # Check warnings warning_messages = [str(warning.message) for warning in w] # Should have one warning about non-finite weights nonfinite_warnings = [msg for msg in warning_messages if 'non-finite weight values' in msg] assert len(nonfinite_warnings) == 1 # Should have warnings about failed EPSFStar creation failed_warnings = [msg for msg in warning_messages if 'Failed to create EPSFStar' in msg] assert len(failed_warnings) == 3 # One per star # Each warning should mention completely masked for msg in failed_warnings: assert 'completely masked' in msg # No valid stars should be extracted assert len(stars) == 0 def test_extract_stars_nonfinite_weights_warning(self): """ Test that non-finite weights in uncertainty emit warning. """ data = np.ones((50, 50)) * 100 nddata = NDData(data) # Create uncertainty with non-finite values uncertainty = np.ones((50, 50)) * 0.1 uncertainty[20:30, 20:30] = 0 # Will cause 1/0 = inf in weights nddata.uncertainty = StdDevUncertainty(uncertainty) table = Table({'x': [25], 'y': [25]}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(nddata, table, size=11) # Should warn about non-finite weights warning_messages = [str(warning.message) for warning in w] assert any('non-finite weight values' in msg for msg in warning_messages) # Star should still be extracted (non-finite weights set to 0) assert len(stars) == 1 def test_extract_stars_all_zero_data_warnings(self): """ Test that extract_stars emits individual warnings for stars with all-zero data, including their positions. """ # Create an all-zero image data = np.zeros((50, 50)) nddata = NDData(data) # Create a table with 3 stars table = Table({'x': [10.5, 25.0, 40.8], 'y': [15.2, 30.0, 35.6]}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') stars = extract_stars(nddata, table, size=11) # Should have 3 warnings about all-zero data warning_messages = [str(warning.message) for warning in w] zero_warnings = [ msg for msg in warning_messages if 'all unmasked data values equal to zero' in msg] assert len(zero_warnings) == 3 # Check that each warning includes the star position assert any('10.5' in msg and '15.2' in msg for msg in zero_warnings) assert any('25.0' in msg and '30.0' in msg for msg in zero_warnings) assert any('40.8' in msg and '35.6' in msg for msg in zero_warnings) # All stars should still be extracted with flux=0 assert len(stars) == 3 for star in stars: assert star.flux == 0.0 def test_validate_single_catalog_multiple_images_no_wcs(self): """ Test validation error when single catalog with multiple images but images lack WCS. """ # Create two images without WCS data1 = np.ones((50, 50)) data2 = np.ones((50, 50)) nddata1 = NDData(data1) nddata2 = NDData(data2) # Create catalog with skycoord table = Table() table['skycoord'] = [SkyCoord(0, 0, unit='deg')] # Should raise because images don't have WCS match = 'must have a wcs attribute' with pytest.raises(ValueError, match=match): extract_stars([nddata1, nddata2], table, size=11) def test_validate_skycoord_only_catalog_no_wcs(self): """ Test validation when catalog has only skycoord but NDData lacks WCS. """ # Create NDData without WCS nddata = NDData(np.ones((50, 50))) # Create catalog with only skycoord (no x, y columns) table = Table() table['skycoord'] = [SkyCoord(0, 0, unit='deg')] # Should raise because NDData does not have WCS match = 'NDData object must have a wcs attribute' with pytest.raises(ValueError, match=match): extract_stars(nddata, table, size=11) def test_validate_multiple_catalogs_skycoord_only_no_wcs(self, simple_wcs): """ Test validation when catalog has only skycoord and some NDData objects lack WCS. This tests the branch where the corresponding NDData has WCS, but another NDData in the list does not have WCS. """ nddata1 = NDData(np.ones((50, 50))) # nddata1 intentionally has no WCS nddata2 = NDData(np.ones((50, 50))) nddata2.wcs = simple_wcs # Second image has WCS # First catalog has x,y (does not need WCS), second has only # skycoord table1 = Table({'x': [25], 'y': [25]}) table2 = Table() table2['skycoord'] = [SkyCoord(0, 0, unit='deg')] # nddata2 has WCS, but nddata1 does not match = 'each NDData object must have a wcs' with pytest.raises(ValueError, match=match): extract_stars([nddata1, nddata2], [table1, table2], size=11) def test_extract_stars_uncertainties(self, epsf_test_data): """ Test extract_stars with various uncertainty types. """ rng = np.random.default_rng(seed=0) shape = epsf_test_data['nddata'].data.shape error = np.abs(rng.normal(loc=0, scale=1, size=shape)) uncertainty1 = StdDevUncertainty(error) uncertainty2 = uncertainty1.represent_as(VarianceUncertainty) uncertainty3 = uncertainty1.represent_as(InverseVariance) ndd1 = NDData(epsf_test_data['nddata'].data, uncertainty=uncertainty1) ndd2 = NDData(epsf_test_data['nddata'].data, uncertainty=uncertainty2) ndd3 = NDData(epsf_test_data['nddata'].data, uncertainty=uncertainty3) size = 25 ndd_inputs = (ndd1, ndd2, ndd3) outputs = [extract_stars(ndd_input, epsf_test_data['init_stars'], size=size) for ndd_input in ndd_inputs] for stars in outputs: assert len(stars) == len(epsf_test_data['init_stars']) assert isinstance(stars, EPSFStars) assert isinstance(stars[0], EPSFStars) assert stars[0].data.shape == (size, size) assert stars[0].weights.shape == (size, size) assert_allclose(outputs[0].weights, outputs[1].weights) assert_allclose(outputs[0].weights, outputs[2].weights) def test_extract_stars_nonfinite_weights(self, epsf_test_data): """ Test extract_stars with sparse zero uncertainty values that create non-finite weights at specific locations. The stars should still be extracted successfully, with only the expected warning about non-finite weights being set to zero. """ shape = epsf_test_data['nddata'].data.shape init = epsf_test_data['init_stars'] # Create an uncertainty array with mostly valid (non-zero) values, # but include some zero uncertainty values at specific locations # within the star cutout regions to trigger non-finite weights uncertainty_data = np.ones(shape) for i in range(min(3, len(init))): x_pix = int(init['x'][i]) y_pix = int(init['y'][i]) # Set a small region around the star center to zero uncertainty uncertainty_data[y_pix - 2:y_pix + 3, x_pix - 2:x_pix + 3] = 0.0 uncertainty = StdDevUncertainty(uncertainty_data) ndd = NDData(epsf_test_data['nddata'].data, uncertainty=uncertainty) size = 25 # Should only get the non-finite weights warning; stars should # still be extracted successfully match = 'non-finite weight values' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(ndd, init[0:3], size=size) # All 3 stars should be successfully extracted assert len(stars) == 3 for i in range(3): assert stars[i] is not None assert stars[i].data.shape == (size, size) def test_extract_stars_all_zero_uncertainty(self, epsf_test_data): """ Test extract_stars with all-zero uncertainty values. When all uncertainty values are zero, all weights become infinite and are then set to zero, resulting in fully-masked cutouts. This causes flux estimation to fail because there is no valid data. """ shape = epsf_test_data['nddata'].data.shape uncertainty = StdDevUncertainty(np.zeros(shape)) ndd = NDData(epsf_test_data['nddata'].data, uncertainty=uncertainty) size = 25 # With all-zero uncertainty, stars will fail with completely # masked errors because all weights are set to zero (fully # masked data). match1 = 'Star cutout is completely masked' match2 = 'non-finite weight values' with (pytest.warns(AstropyUserWarning, match=match1), pytest.warns(AstropyUserWarning, match=match2)): stars = extract_stars(ndd, epsf_test_data['init_stars'][0:3], size=size) # All stars should fail (None) because they are completely masked assert len(stars) == 0 astropy-photutils-3322558/photutils/psf/tests/test_flags.py000066400000000000000000000461501517052111400241360ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the flags module. """ import numpy as np import pytest from photutils.psf import IterativePSFPhotometry, PSFPhotometry from photutils.psf.flags import (PSF_FLAGS, _PSFFlagDefinition, _PSFFlags, _update_decode_docstring, decode_psf_flags) def test_decode_psf_flags(): """ Test the decode_psf_flags standalone function. """ # Test single flag value with no flags set decoded = decode_psf_flags(0) assert decoded == [] assert isinstance(decoded, list) # Test single flag value with one bit set decoded = decode_psf_flags(1) assert decoded == ['n_pixels_fit_partial'] decoded = decode_psf_flags(2) assert decoded == ['outside_bounds'] decoded = decode_psf_flags(4) assert decoded == ['negative_flux'] decoded = decode_psf_flags(8) assert decoded == ['no_convergence'] decoded = decode_psf_flags(16) assert decoded == ['no_covariance'] decoded = decode_psf_flags(32) assert decoded == ['near_bound'] decoded = decode_psf_flags(64) assert decoded == ['no_overlap'] decoded = decode_psf_flags(128) assert decoded == ['fully_masked'] decoded = decode_psf_flags(256) assert decoded == ['too_few_pixels'] decoded = decode_psf_flags(512) assert decoded == ['non_finite_position'] decoded = decode_psf_flags(1024) assert decoded == ['non_finite_flux'] decoded = decode_psf_flags(2048) assert decoded == ['non_finite_localbkg'] # Test combination of flags decoded = decode_psf_flags(5) # bits 1 and 4 assert set(decoded) == {'n_pixels_fit_partial', 'negative_flux'} assert len(decoded) == 2 decoded = decode_psf_flags(136) # bits 8 and 128 assert set(decoded) == {'no_convergence', 'fully_masked'} assert len(decoded) == 2 # Test with all flags set all_flags = (1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256 + 512 + 1024 + 2048) # 4095 decoded = decode_psf_flags(all_flags) expected_all = ['n_pixels_fit_partial', 'outside_bounds', 'negative_flux', 'no_convergence', 'no_covariance', 'near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_localbkg'] assert set(decoded) == set(expected_all) assert len(decoded) == 12 # Test with array input flags_array = [0, 1, 2, 5] decoded_list = decode_psf_flags(flags_array) assert len(decoded_list) == 4 assert isinstance(decoded_list, list) # Check individual results assert decoded_list[0] == [] assert decoded_list[1] == ['n_pixels_fit_partial'] assert decoded_list[2] == ['outside_bounds'] assert set(decoded_list[3]) == {'n_pixels_fit_partial', 'negative_flux'} # Test with numpy array flags_np = np.array([8, 16, 32]) decoded_list = decode_psf_flags(flags_np) assert len(decoded_list) == 3 assert decoded_list[0] == ['no_convergence'] assert decoded_list[1] == ['no_covariance'] assert decoded_list[2] == ['near_bound'] # Test with 0-d numpy array (scalar array) flag_scalar = np.array(64) decoded = decode_psf_flags(flag_scalar) assert isinstance(decoded, list) assert decoded == ['no_overlap'] # Test membership operations (common usage pattern) issues = decode_psf_flags(136) assert 'no_convergence' in issues assert 'fully_masked' in issues assert 'negative_flux' not in issues # Test error conditions match = 'Flag value must be an integer' with pytest.raises(TypeError, match=match): decode_psf_flags(3.14) with pytest.raises(TypeError, match=match): decode_psf_flags('invalid') with pytest.raises(TypeError, match=match): decode_psf_flags([1, 2.5, 3]) def test_decode_psf_flags_practical_usage(): """ Test practical usage patterns for decode_psf_flags. """ # Simulate some typical flag values typical_flags = [0, 1, 8, 64, 136, 256] # Test batch processing all_issues = decode_psf_flags(typical_flags) assert len(all_issues) == len(typical_flags) # Test filtering for specific conditions convergence_issues = [i for i, issues in enumerate(all_issues) if 'no_convergence' in issues] expected_conv_indices = [2, 4] # flags 8 and 136 have convergence issues assert convergence_issues == expected_conv_indices # Test counting issues issue_counts = {} for issues in all_issues: for issue in issues: issue_counts[issue] = issue_counts.get(issue, 0) + 1 # Verify expected counts assert issue_counts.get('no_convergence', 0) == 2 # flags 8 and 136 assert issue_counts.get('fully_masked', 0) == 1 # flag 136 assert issue_counts.get('n_pixels_fit_partial', 0) == 1 # flag 1 # Test boolean context (empty list is falsy) clean_sources = [i for i, issues in enumerate(all_issues) if not issues] assert 0 in clean_sources # flag 0 should have no issues # Test string formatting for reporting for i, issues in enumerate(all_issues): if issues: report = f"Source {i}: {', '.join(issues)}" assert isinstance(report, str) assert str(i) in report def test_decode_psf_flags_edge_cases(): """ Test edge cases for decode_psf_flags. """ # Test with very large flag value (all bits set + extra) large_flag = 2**16 - 1 # Much larger than our defined flags decoded = decode_psf_flags(large_flag) expected_all = ['n_pixels_fit_partial', 'outside_bounds', 'negative_flux', 'no_convergence', 'no_covariance', 'near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_localbkg'] assert set(decoded) == set(expected_all) match = 'Flag value must be a non-negative integer' with pytest.raises(ValueError, match=match): decode_psf_flags(-2) # Test with empty array empty_array = np.array([], dtype=int) decoded = decode_psf_flags(empty_array) assert decoded == [] # Test with 2D array (should flatten) flag_2d = np.array([[0, 1], [8, 136]]) decoded = decode_psf_flags(flag_2d) assert len(decoded) == 4 # Flattened to 4 elements assert decoded[0] == [] assert decoded[1] == ['n_pixels_fit_partial'] assert decoded[2] == ['no_convergence'] assert set(decoded[3]) == {'no_convergence', 'fully_masked'} def test_psf_flags_singleton(): """ Test _PSFFlags singleton behavior. """ # Test that PSF_FLAGS is accessible and is a _PSFFlags instance assert isinstance(PSF_FLAGS, _PSFFlags) # Test that multiple references point to the same object flags1 = PSF_FLAGS flags2 = PSF_FLAGS assert flags1 is flags2 # Test that creating a new instance works independently new_flags = _PSFFlags() assert isinstance(new_flags, _PSFFlags) assert new_flags is not PSF_FLAGS # Different instances def test_psf_flags_constants(): """ Test _PSFFlags constant access. """ # Test all flag constants exist and have correct values expected_constants = { 'N_PIXELS_FIT_PARTIAL': 1, 'OUTSIDE_BOUNDS': 2, 'NEGATIVE_FLUX': 4, 'NO_CONVERGENCE': 8, 'NO_COVARIANCE': 16, 'NEAR_BOUND': 32, 'NO_OVERLAP': 64, 'FULLY_MASKED': 128, 'TOO_FEW_PIXELS': 256, 'NON_FINITE_POSITION': 512, 'NON_FINITE_FLUX': 1024, 'NON_FINITE_LOCALBKG': 2048, } for const_name, expected_value in expected_constants.items(): assert hasattr(PSF_FLAGS, const_name) actual_value = getattr(PSF_FLAGS, const_name) assert actual_value == expected_value assert isinstance(actual_value, int) def test_psf_flags_properties(): """ Test _PSFFlags property access methods. """ # Test bit_values property bit_values = PSF_FLAGS.bit_values expected_bits = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] assert set(bit_values) == set(expected_bits) assert len(bit_values) == 12 # Test names property names = PSF_FLAGS.names expected_names = [ 'n_pixels_fit_partial', 'outside_bounds', 'negative_flux', 'no_convergence', 'no_covariance', 'near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_localbkg', ] assert set(names) == set(expected_names) assert len(names) == 12 # Test flag_dict property flag_dict = PSF_FLAGS.flag_dict assert isinstance(flag_dict, dict) assert len(flag_dict) == 12 for bit_val, name in flag_dict.items(): assert bit_val in expected_bits assert name in expected_names # Test all_flags property all_flags = PSF_FLAGS.all_flags assert isinstance(all_flags, list) assert len(all_flags) == 12 for flag_def in all_flags: assert isinstance(flag_def, _PSFFlagDefinition) def test_psf_flags_get_methods(): """ Test _PSFFlags getter methods. """ # Test get_name assert PSF_FLAGS.get_name(1) == 'n_pixels_fit_partial' assert PSF_FLAGS.get_name(8) == 'no_convergence' assert PSF_FLAGS.get_name(256) == 'too_few_pixels' assert PSF_FLAGS.get_name(512) == 'non_finite_position' assert PSF_FLAGS.get_name(1024) == 'non_finite_flux' assert PSF_FLAGS.get_name(2048) == 'non_finite_localbkg' # Test get_bit_value assert PSF_FLAGS.get_bit_value('n_pixels_fit_partial') == 1 assert PSF_FLAGS.get_bit_value('no_convergence') == 8 assert PSF_FLAGS.get_bit_value('too_few_pixels') == 256 # Test get_description desc1 = PSF_FLAGS.get_description(1) assert 'n_pixels_fit smaller than full fit_shape region' in desc1 desc8 = PSF_FLAGS.get_description(8) assert 'possible non-convergence' in desc8 # Test get_detailed_description detailed1 = PSF_FLAGS.get_detailed_description(1) assert 'number of fitted pixels' in detailed1 assert 'partial PSF fitting' in detailed1 detailed8 = PSF_FLAGS.get_detailed_description(8) assert 'algorithm may not have converged' in detailed8 def test_psf_flags_get_definition(): """ Test _PSFFlags get_definition method. """ # Test get_definition by bit value def_by_bit = PSF_FLAGS.get_definition(1) assert isinstance(def_by_bit, _PSFFlagDefinition) assert def_by_bit.bit_value == 1 assert def_by_bit.name == 'n_pixels_fit_partial' # Test get_definition by name def_by_name = PSF_FLAGS.get_definition('n_pixels_fit_partial') assert isinstance(def_by_name, _PSFFlagDefinition) assert def_by_name.bit_value == 1 assert def_by_name.name == 'n_pixels_fit_partial' # Test that both methods return the same object assert def_by_bit is def_by_name # Test error cases match = 'No flag with bit value 999' with pytest.raises(KeyError, match=match): PSF_FLAGS.get_definition(999) match = "No flag with name 'invalid'" with pytest.raises(KeyError, match=match): PSF_FLAGS.get_definition('invalid') match = 'identifier must be int' with pytest.raises(TypeError, match=match): PSF_FLAGS.get_definition(3.14) def test_psf_flag_definition(): """ Test _PSFFlagDefinition dataclass. """ # Create a flag definition flag_def = _PSFFlagDefinition( bit_value=1, name='test_flag', description='test description', detailed_description='detailed test description', ) # Test attributes assert flag_def.bit_value == 1 assert flag_def.name == 'test_flag' assert flag_def.description == 'test description' assert flag_def.detailed_description == 'detailed test description' # Test immutability (frozen dataclass) with pytest.raises(AttributeError): flag_def.bit_value = 2 # Test equality flag_def2 = _PSFFlagDefinition( bit_value=1, name='test_flag', description='test description', detailed_description='detailed test description', ) assert flag_def == flag_def2 # Test inequality flag_def3 = _PSFFlagDefinition( bit_value=2, name='test_flag', description='test description', detailed_description='detailed test description', ) assert flag_def != flag_def3 def test_psf_flags_integration_with_decode(): """ Test integration between _PSFFlags and decode_psf_flags. """ # Test that decode_psf_flags uses PSF_FLAGS internally test_flags = [PSF_FLAGS.N_PIXELS_FIT_PARTIAL, PSF_FLAGS.NO_CONVERGENCE, PSF_FLAGS.FULLY_MASKED] decoded = decode_psf_flags(test_flags) assert len(decoded) == 3 assert decoded[0] == ['n_pixels_fit_partial'] assert decoded[1] == ['no_convergence'] assert decoded[2] == ['fully_masked'] # Test combined flags combined = PSF_FLAGS.NO_CONVERGENCE | PSF_FLAGS.FULLY_MASKED decoded_combined = decode_psf_flags(combined) assert set(decoded_combined) == {'no_convergence', 'fully_masked'} # Test all constants work with decode for const_name in ['N_PIXELS_FIT_PARTIAL', 'OUTSIDE_BOUNDS', 'NEGATIVE_FLUX', 'NO_CONVERGENCE', 'NO_COVARIANCE', 'NEAR_BOUND', 'NO_OVERLAP', 'FULLY_MASKED', 'TOO_FEW_PIXELS']: const_value = getattr(PSF_FLAGS, const_name) decoded_const = decode_psf_flags(const_value) assert len(decoded_const) == 1 # The decoded name should match the constant name (lowercase) expected_name = const_name.lower() assert decoded_const[0] == expected_name def test_psf_flags_completeness(): """ Test that _PSFFlags covers all expected flag scenarios. """ # Test that we have the expected number of flags assert len(PSF_FLAGS.all_flags) == 12 # Test that bit values are powers of 2 for bit_val in PSF_FLAGS.bit_values: assert bit_val > 0 assert (bit_val & (bit_val - 1)) == 0 # Power of 2 check # Test that bit values are unique bit_values = PSF_FLAGS.bit_values assert len(bit_values) == len(set(bit_values)) # Test that names are unique names = PSF_FLAGS.names assert len(names) == len(set(names)) # Test that all names are valid Python identifiers (for compatibility) for name in names: assert name.isidentifier() assert '_' in name or name.islower() # Snake_case convention # Test that all flags can be combined without conflicts all_combined = 0 for bit_val in PSF_FLAGS.bit_values: all_combined |= bit_val decoded_all = decode_psf_flags(all_combined) assert len(decoded_all) == 12 assert set(decoded_all) == set(PSF_FLAGS.names) def test_psf_classes_docstrings(): """ Test that the PSF classes have dynamic flag documentation. """ classes_to_test = [PSFPhotometry, IterativePSFPhotometry] for cls in classes_to_test: docstring = cls.__call__.__doc__ # Should have flags section assert '* ``flags`` : bitwise flag values' in docstring # Should have all dynamic flag descriptions dynamic_flags = [ 'n_pixels_fit smaller than full fit_shape region', 'fitted position outside input image bounds', 'non-positive flux', 'possible non-convergence', 'missing parameter covariance', 'fitted parameter near a bound', 'no overlap with data', 'fully masked source', 'too few pixels for fitting', ] for flag_desc in dynamic_flags: msg = f'Missing flag description in {cls.__name__}: {flag_desc}' assert flag_desc in docstring, msg def test_decode_psf_flags_docstring(): """ Test that the decode_psf_flags function has dynamic flag documentation. """ docstring = decode_psf_flags.__doc__ # Should not have placeholder assert '' not in docstring # Should have all expected flag names in the expected format expected_flags = [ "``'n_pixels_fit_partial'`` : bit 1", "``'outside_bounds'`` : bit 2", "``'negative_flux'`` : bit 4", "``'no_convergence'`` : bit 8", "``'no_covariance'`` : bit 16", "``'near_bound'`` : bit 32", "``'no_overlap'`` : bit 64", "``'fully_masked'`` : bit 128", "``'too_few_pixels'`` : bit 256", ] for flag_desc in expected_flags: msg = f'Missing flag in docstring: {flag_desc}' assert flag_desc in docstring, msg # Should have flag descriptions expected_descriptions = [ 'n_pixels_fit smaller than full fit_shape region', 'fitted position outside input image bounds', 'non-positive flux', 'possible non-convergence', 'missing parameter covariance', 'fitted parameter near a bound', 'no overlap with data', 'fully masked source', 'too few pixels for fitting', ] for desc in expected_descriptions: assert desc in docstring, f'Missing description: {desc}' def test_update_decode_docstring_noop(): """ Test that the update_decode_docstring decorator is a no-op if no docstring exists. """ @_update_decode_docstring def test_func(data): pass docstring = test_func.__doc__ assert docstring is None def test_decode_psf_flags_return_bit_values(): """ Test the decode_psf_flags function with return_bit_values=True. """ # Test single flag value with no flags set decoded = decode_psf_flags(0, return_bit_values=True) assert decoded == [] assert isinstance(decoded, list) # Test single flag value with one bit set decoded = decode_psf_flags(1, return_bit_values=True) assert decoded == [1] decoded = decode_psf_flags(2, return_bit_values=True) assert decoded == [2] # Test combination of flags decoded = decode_psf_flags(5, return_bit_values=True) # bits 1 and 4 assert set(decoded) == {1, 4} assert len(decoded) == 2 decoded = decode_psf_flags(136, return_bit_values=True) # bits 8 and 128 assert set(decoded) == {8, 128} assert len(decoded) == 2 # Test with all flags set all_flags = (1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256 + 512 + 1024 + 2048) # 4095 decoded = decode_psf_flags(all_flags, return_bit_values=True) expected_all = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] assert set(decoded) == set(expected_all) assert len(decoded) == 12 # Test with array input flags_array = [0, 1, 2, 5] decoded_list = decode_psf_flags(flags_array, return_bit_values=True) assert len(decoded_list) == 4 assert isinstance(decoded_list, list) # Check individual results assert decoded_list[0] == [] assert decoded_list[1] == [1] assert decoded_list[2] == [2] assert set(decoded_list[3]) == {1, 4} # Test with numpy array flags_np = np.array([8, 16, 32]) decoded_list = decode_psf_flags(flags_np, return_bit_values=True) assert len(decoded_list) == 3 assert decoded_list[0] == [8] assert decoded_list[1] == [16] assert decoded_list[2] == [32] astropy-photutils-3322558/photutils/psf/tests/test_functional_models.py000066400000000000000000000217661517052111400265550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the functional_models module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.stats import gaussian_fwhm_to_sigma from numpy.testing import assert_allclose from photutils.psf import (AiryDiskPSF, CircularGaussianPRF, CircularGaussianPSF, CircularGaussianSigmaPRF, GaussianPRF, GaussianPSF, MoffatPSF) def make_gaussian_models(name): flux = 71.4 x_0 = 24.3 y_0 = 25.2 x_fwhm = 10.1 y_fwhm = 5.82 theta = 21.7 flux_i = 50 x_0_i = 20 y_0_i = 30 x_fwhm_i = 15 y_fwhm_i = 8 theta_i = 31 if name == 'GaussianPSF': model = GaussianPSF(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta) model_init = GaussianPSF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, x_fwhm=x_fwhm_i, y_fwhm=y_fwhm_i, theta=theta_i) elif name == 'GaussianPRF': model = GaussianPRF(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta) model_init = GaussianPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, x_fwhm=x_fwhm_i, y_fwhm=y_fwhm_i, theta=theta_i) elif name == 'CircularGaussianPSF': model = CircularGaussianPSF(flux=flux, x_0=x_0, y_0=y_0, fwhm=x_fwhm) model_init = CircularGaussianPSF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, fwhm=x_fwhm_i) elif name == 'CircularGaussianPRF': model = CircularGaussianPRF(flux=flux, x_0=x_0, y_0=y_0, fwhm=x_fwhm) model_init = CircularGaussianPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, fwhm=x_fwhm_i) elif name == 'CircularGaussianSigmaPRF': model = CircularGaussianSigmaPRF(flux=flux, x_0=x_0, y_0=y_0, sigma=x_fwhm / 2.35) model_init = CircularGaussianSigmaPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, sigma=x_fwhm_i / 2.35) else: msg = 'invalid model name' raise ValueError(msg) return model, model_init def gaussian_tests(name, use_units): model, model_init = make_gaussian_models(name) fixed_types = ('fwhm', 'sigma', 'theta') for param in model.param_names: for fixed_type in fixed_types: if fixed_type in param: tparam = getattr(model_init, param) tparam.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.m xx <<= unit yy <<= unit unit_params = ('x_0', 'y_0', 'x_fwhm', 'y_fwhm', 'fwhm', 'sigma') for param in model.param_names: if param in unit_params: tparam = getattr(model, param) tparam <<= unit setattr(model, param, tparam) data = model(xx, yy) if use_units: data = data.value assert_allclose(data.sum(), model.flux.value) try: assert_allclose(model.x_sigma, model.x_fwhm * gaussian_fwhm_to_sigma) assert_allclose(model.y_sigma, model.y_fwhm * gaussian_fwhm_to_sigma) except AttributeError: assert_allclose(model.sigma, model.fwhm * gaussian_fwhm_to_sigma) try: xsigma = model.x_sigma ysigma = model.y_sigma if isinstance(xsigma, u.Quantity): xsigma = xsigma.value ysigma = ysigma.value assert_allclose(model.amplitude * (2 * np.pi * xsigma * ysigma), model.flux) except AttributeError: sigma = model.sigma if isinstance(sigma, u.Quantity): sigma = sigma.value assert_allclose(model.amplitude * (2 * np.pi * sigma**2), model.flux) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value, rtol=1e-5) assert_allclose(fit_model.y_0.value, model.y_0.value, rtol=1e-5) try: assert_allclose(fit_model.x_fwhm.value, model.x_fwhm.value) assert_allclose(fit_model.y_fwhm.value, model.y_fwhm.value) assert_allclose(fit_model.theta.value, model.theta.value) except AttributeError: if name == 'CircularGaussianSigmaPRF': assert_allclose(fit_model.sigma.value, model.sigma.value) else: assert_allclose(fit_model.fwhm.value, model.fwhm.value) # test the model derivatives fit_model2 = fitter(model_init, xx, yy, data, estimate_jacobian=True) assert_allclose(fit_model2.x_0, fit_model.x_0) assert_allclose(fit_model2.y_0, fit_model.y_0) try: assert_allclose(fit_model2.x_fwhm, fit_model.x_fwhm) assert_allclose(fit_model2.y_fwhm, fit_model.y_fwhm) assert_allclose(fit_model2.theta, fit_model.theta) except AttributeError: assert_allclose(fit_model2.fwhm, fit_model.fwhm) if use_units and 'Circular' not in name: model.y_0 = model.y_0.value * u.s yy = yy.value * u.s match = 'Units .* inputs should match' with pytest.raises(u.UnitsError, match=match): fitter(model_init, xx, yy, data) @pytest.mark.parametrize('name', ['GaussianPSF', 'CircularGaussianPSF']) @pytest.mark.parametrize('use_units', [False, True]) def test_gaussian_psfs(name, use_units): gaussian_tests(name, use_units) @pytest.mark.parametrize('name', ['GaussianPRF', 'CircularGaussianPRF', 'CircularGaussianSigmaPRF']) @pytest.mark.parametrize('use_units', [False, True]) def test_gaussian_prfs(name, use_units): gaussian_tests(name, use_units) def test_gaussian_prf_sums(): """ Test that subpixel accuracy of Gaussian PRFs by checking the sum of pixels. """ model1 = GaussianPRF(x_0=0, y_0=0, x_fwhm=0.001, y_fwhm=0.001) model2 = CircularGaussianPRF(x_0=0, y_0=0, fwhm=0.001) model3 = CircularGaussianSigmaPRF(x_0=0, y_0=0, sigma=0.001) yy, xx = np.mgrid[-10:11, -10:11] for model in (model1, model2, model3): assert_allclose(model(xx, yy).sum(), 1.0) def test_gaussian_bounding_boxes(): model1 = GaussianPSF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) model2 = GaussianPRF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) xbbox = (-4.6712699, 4.6712699) ybbox = (-7.0069049, 7.0069048) for model in (model1, model2): assert_allclose(model.bounding_box, (xbbox, ybbox)) model3 = CircularGaussianPSF(x_0=0, y_0=0, fwhm=2) model4 = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) for model in (model3, model4): assert_allclose(model.bounding_box, (xbbox, xbbox)) model5 = CircularGaussianSigmaPRF(x_0=0, y_0=0, sigma=2) assert_allclose(model5.bounding_box, ((-11, 11), (-11, 11))) @pytest.mark.parametrize('use_units', [False, True]) def test_moffat_psf_model(use_units): model = MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=8.1, beta=7.2) model_init = MoffatPSF(flux=50, x_0=20, y_0=30, alpha=5, beta=4) model_init.alpha.fixed = False model_init.beta.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.cm xx <<= unit yy <<= unit model.x_0 <<= unit model.y_0 <<= unit model.alpha <<= unit data = model(xx, yy) assert_allclose(data.sum(), model.flux.value, rtol=5e-6) fwhm = 2 * model.alpha * np.sqrt(2**(1 / model.beta) - 1) assert_allclose(model.fwhm, fwhm) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value) assert_allclose(fit_model.y_0.value, model.y_0.value) assert_allclose(fit_model.alpha.value, model.alpha.value) assert_allclose(fit_model.beta.value, model.beta.value) # test bounding box model = MoffatPSF(x_0=0, y_0=0, alpha=1.0, beta=2.0) bbox = 12.871885058111655 assert_allclose(model.bounding_box, ((-bbox, bbox), (-bbox, bbox))) @pytest.mark.parametrize('use_units', [False, True]) def test_airydisk_psf_model(use_units): model = AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=2.1) model_init = AiryDiskPSF(flux=50, x_0=23, y_0=27, radius=2.5) model_init.radius.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.cm xx <<= unit yy <<= unit model.x_0 <<= unit model.y_0 <<= unit model.radius <<= unit data = model(xx, yy) assert_allclose(data.sum(), model.flux.value, rtol=0.015) fwhm = 0.8436659602162364 * model.radius assert_allclose(model.fwhm, fwhm) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value) assert_allclose(fit_model.y_0.value, model.y_0.value) assert_allclose(fit_model.radius.value, model.radius.value) # test bounding box model = AiryDiskPSF(x_0=0, y_0=0, radius=5) bbox = 42.18329801081182 assert_allclose(model.bounding_box, ((-bbox, bbox), (-bbox, bbox))) astropy-photutils-3322558/photutils/psf/tests/test_gridded_models.py000066400000000000000000000345031517052111400260060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gridded_models 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 astropy.table import QTable from numpy.testing import assert_allclose, assert_equal from photutils.datasets import make_model_image from photutils.psf import GriddedPSFModel, STDPSFGrid from photutils.segmentation import SourceCatalog, detect_sources from photutils.utils._optional_deps import HAS_MATPLOTLIB # 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 = [] yy, xx = np.mgrid[0:101, 0:101] for i in range(16): theta = np.deg2rad(i * 10.0) gmodel = Gaussian2D(1, 50, 50, 10, 5, theta=theta) psfs.append(gmodel(xx, yy)) xgrid = [0, 40, 160, 200] ygrid = [0, 60, 140, 200] meta = {} meta['grid_xypos'] = list(product(xgrid, ygrid)) meta['oversampling'] = 4 nddata = NDData(psfs, meta=meta) return GriddedPSFModel(nddata) 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) # check that data and grid_xypos attributes are read-only match = 'object has no setter' with pytest.raises(AttributeError, match=match): psfmodel.data = np.ones((4, 5, 5)) with pytest.raises(AttributeError, match=match): psfmodel.grid_xypos = [[0, 0], [1, 1]] def test_repr_str(self, psfmodel): repr_str = repr(psfmodel) assert 'GriddedPSFModel' in repr_str assert 'flux=1.' in repr_str assert 'x_0=0.' in repr_str assert 'y_0=0.' in repr_str assert 'oversampling=' in repr_str assert 'fill_value=0.0' in repr_str str_str = str(psfmodel) assert 'GriddedPSFModel' in str_str assert 'Number of PSFs: 16' in str_str assert 'PSF shape (oversampled pixels): (101, 101)' in str_str assert 'Oversampling: [4, 4]' in str_str assert 'Fill Value: 0.0' in str_str def test_gridded_psf_model_basic_eval(self, psfmodel): assert psfmodel(0, 0) == 1 assert psfmodel(100, 100) == 0 assert_allclose(psfmodel([0, 100], [0, 100]), [1, 0]) 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) _, 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) def test_gridded_psf_model_single_psf(self, psfmodel): psfmodel = psfmodel.copy() psfmodel._data = psfmodel.data[0:1, :, :] assert psfmodel(0, 0) == 1 assert psfmodel(100, 100) == 0 assert_allclose(psfmodel([0, 100], [0, 100]), [1, 0]) 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) _, 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) 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) def test_gridded_psf_model_invalid_inputs(self): data = np.ones((4, 5, 5)) # check if NDData match = 'data must be an NDData instance' with pytest.raises(TypeError, match=match): GriddedPSFModel(data) # check PSF data dimension match = 'The NDData data attribute must be a 3D numpy ndarray' with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(np.ones((3, 3)))) match = 'The length of the PSF x and y axes must both be at least 4' with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(np.ones((4, 3, 3)))) match = 'The number of ePSFs must not be 2 or 3' meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0]], 'oversampling': 4} nddata = NDData(np.ones((3, 4, 4)), meta=meta) with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) match = 'All elements of input data must be finite' data2 = np.ones((4, 5, 5)) data2[0, 2, 2] = np.nan with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(data2)) # check that grid_xypos is in meta meta = {'oversampling': 4} nddata = NDData(data, meta=meta) match = "'grid_xypos' must be in the nddata meta dictionary" with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # check grid_xypos length meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0]], 'oversampling': 4} nddata = NDData(data, meta=meta) match = 'length of grid_xypos must match the number of input ePSFs' with pytest.raises(ValueError, match=match): 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) match = 'grid_xypos must form a rectangular grid' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) meta = {'grid_xypos': [[0, 0], [0, 2], [0, 4], [0, 6]], 'oversampling': 4} nddata = NDData(data, meta=meta) match = 'grid_xypos must form a rectangular grid' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # check that oversampling is in meta meta = {'grid_xypos': [[0, 0], [0, 1], [1, 0], [1, 1]]} nddata = NDData(data, meta=meta) match = "'oversampling' must be in the nddata meta dictionary" with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) 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) params = QTable() params['x_0'] = [40, 50, 160, 160] params['y_0'] = [60, 150, 50, 140] params['flux'] = [100, 100, 100, 100] data = make_model_image(shape, psfmodel, params) 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], 280.0, rtol=1.0e-5) assert 88.3 < orients[0] < 88.4 assert 64.0 < orients[3] < 64.2 @pytest.mark.parametrize('deepcopy', [False, True]) def test_copy(self, psfmodel, deepcopy): flux = psfmodel.flux.value model_copy = psfmodel.deepcopy() if deepcopy else psfmodel.copy() assert_equal(model_copy.data, psfmodel.data) assert_equal(model_copy.grid_xypos, psfmodel.grid_xypos) assert_equal(model_copy.oversampling, psfmodel.oversampling) assert_equal(model_copy.meta, psfmodel.meta) assert model_copy.flux.value == psfmodel.flux.value assert model_copy.x_0.value == psfmodel.x_0.value assert model_copy.y_0.value == psfmodel.y_0.value assert model_copy.fixed == psfmodel.fixed model_copy.data[0, 0, 0] = 42 if deepcopy: assert model_copy.data[0, 0, 0] != psfmodel.data[0, 0, 0] else: assert model_copy.data[0, 0, 0] == psfmodel.data[0, 0, 0] model_copy.flux = 100 assert model_copy.flux.value != flux model_copy.x_0.fixed = True model_copy.y_0.fixed = True new_model = model_copy.copy() assert new_model.x_0.fixed assert new_model.fixed == model_copy.fixed def test_repr(self, psfmodel): model_repr = repr(psfmodel) assert '= 5.0) grouper1 = SourceGrouper(min_separation=5) result1 = grouper1(xx, yy) assert len(np.unique(result1)) == 1 # Small separation: all isolated (threshold < 5.0) grouper2 = SourceGrouper(min_separation=4) result2 = grouper2(xx, yy) assert len(np.unique(result2)) == 3 # Very large separation: still one group grouper3 = SourceGrouper(min_separation=20) result3 = grouper3(xx, yy) assert len(np.unique(result3)) == 1 def test_clustered_with_isolated(self): """ Test mixture of clustered and isolated sources. """ # Create a cluster of 5 sources close together cluster_x = np.array([0, 0.1, 0.2, 0.1, 0.2]) cluster_y = np.array([0, 0.1, 0.0, 0.0, 0.1]) # Add isolated sources far away isolated_x = np.array([10, 20, 30]) isolated_y = np.array([10, 20, 30]) xx = np.concatenate([cluster_x, isolated_x]) yy = np.concatenate([cluster_y, isolated_y]) grouper = SourceGrouper(min_separation=1.0) result = grouper(xx, yy) # Should have 4 groups: 1 cluster + 3 isolated assert len(np.unique(result)) == 4 # First 5 sources should be in the same group assert len(np.unique(result[:5])) == 1 # Last 3 sources should each be in different groups assert len(np.unique(result[5:])) == 3 def test_very_small_separation(self): """ Test with very small min_separation (almost touching sources). """ xx = np.array([0, 0.001, 0.002]) yy = np.array([0, 0.001, 0.002]) grouper = SourceGrouper(min_separation=0.01) result = grouper(xx, yy) # All should be in one group assert len(np.unique(result)) == 1 assert_equal(result, [1, 1, 1]) def test_returns_array_by_default(self): """ Test that __call__ returns array by default. """ xx = np.array([0, 10]) yy = np.array([0, 10]) grouper = SourceGrouper(min_separation=5) result = grouper(xx, yy) assert isinstance(result, np.ndarray) assert_equal(result, [1, 2]) def test_returns_sourcegroups_object_when_requested(self): """ Test that __call__ returns SourceGroups when return_groups_object=True. """ xx = np.array([0, 10]) yy = np.array([0, 10]) grouper = SourceGrouper(min_separation=5) result = grouper(xx, yy, return_groups_object=True) assert isinstance(result, SourceGroups) assert hasattr(result, 'groups') assert hasattr(result, 'x') assert hasattr(result, 'y') assert hasattr(result, 'n_sources') assert hasattr(result, 'n_groups') @pytest.fixture def sample_groups(): """ Fixture providing sample source grouping data for tests. Creates a simple dataset with 3 groups: - Group 1: 3 sources at (0, 0), (0.1, 0.1), (0.2, 0.0) - Group 2: 2 sources at (10, 10), (10.1, 10.1) - Group 3: 1 isolated source at (20, 20) Returns ------- dict Dictionary containing: - 'x': x coordinates array - 'y': y coordinates array - 'groups_array': group IDs array - 'groups': SourceGroups object """ x = np.array([0, 0.1, 0.2, 10, 10.1, 20]) y = np.array([0, 0.1, 0.0, 10, 10.1, 20]) groups_array = np.array([1, 1, 1, 2, 2, 3]) grouper = SourceGrouper(min_separation=1.0) groups = grouper(x, y, return_groups_object=True) return { 'x': x, 'y': y, 'groups_array': groups_array, 'groups': groups, } class TestSourceGroups: """ Tests for the SourceGroups class. """ def test_initialization(self, sample_groups): """ Test SourceGroups initialization. """ groups = SourceGroups(sample_groups['x'], sample_groups['y'], sample_groups['groups_array']) assert_equal(groups.x, sample_groups['x']) assert_equal(groups.y, sample_groups['y']) assert_equal(groups.groups, sample_groups['groups_array']) assert groups.n_sources == 6 assert groups.n_groups == 3 def test_initialization_mismatched_shapes(self): """ Test that mismatched array shapes raise ValueError. """ x = np.array([1, 2, 3]) y = np.array([1, 2]) groups = np.array([1, 1, 1]) match = 'x, y, and groups must have the same shape' with pytest.raises(ValueError, match=match): SourceGroups(x, y, groups) def test_initialization_groups_mismatch(self): """ Test that groups array with wrong shape raises ValueError. """ x = np.array([1, 2, 3]) y = np.array([1, 2, 3]) groups = np.array([1, 1]) match = 'x, y, and groups must have the same shape' with pytest.raises(ValueError, match=match): SourceGroups(x, y, groups) def test_repr(self, sample_groups): """ Test string representation. """ repr_str = repr(sample_groups['groups']) assert 'SourceGroups' in repr_str assert 'n_sources=6' in repr_str assert 'n_groups=3' in repr_str def test_len(self, sample_groups): """ Test len() returns n_sources. """ assert len(sample_groups['groups']) == 6 def test_sizes_property(self, sample_groups): """ Test sizes property. """ sizes = sample_groups['groups'].sizes # Should return size for each source assert len(sizes) == 6 assert_equal(sizes, [3, 3, 3, 2, 2, 1]) def test_group_centers_property(self, sample_groups): """ Test group_centers property. """ centers = sample_groups['groups'].group_centers assert isinstance(centers, dict) assert len(centers) == 3 # Check group 1 center (mean of first 3 sources) expected_x1 = np.mean([0, 0.1, 0.2]) expected_y1 = np.mean([0, 0.1, 0.0]) assert_allclose(centers[1], (expected_x1, expected_y1)) # Check group 2 center expected_x2 = np.mean([10, 10.1]) expected_y2 = np.mean([10, 10.1]) assert_allclose(centers[2], (expected_x2, expected_y2)) # Check group 3 center (single source) assert_allclose(centers[3], (20, 20)) def test_get_group_sources(self, sample_groups): """ Test get_group_sources method. """ groups = sample_groups['groups'] # Get sources from group 1 x1, y1 = groups.get_group_sources(1) assert len(x1) == 3 assert len(y1) == 3 assert_equal(x1, [0, 0.1, 0.2]) assert_equal(y1, [0, 0.1, 0.0]) # Get sources from group 2 x2, y2 = groups.get_group_sources(2) assert len(x2) == 2 assert len(y2) == 2 assert_equal(x2, [10, 10.1]) assert_equal(y2, [10, 10.1]) # Get sources from group 3 (isolated) x3, y3 = groups.get_group_sources(3) assert len(x3) == 1 assert len(y3) == 1 assert_equal(x3, [20]) assert_equal(y3, [20]) def test_get_group_sources_invalid_id(self, sample_groups): """ Test that invalid group ID raises ValueError. """ match = 'Group ID 99 not found in groups' with pytest.raises(ValueError, match=match): sample_groups['groups'].get_group_sources(99) def test_single_source(self): """ Test SourceGroups with single source. """ x = np.array([1.0]) y = np.array([2.0]) groups = np.array([1]) result = SourceGroups(x, y, groups) assert result.n_sources == 1 assert result.n_groups == 1 assert len(result) == 1 assert_equal(result.sizes, [1]) def test_all_isolated_sources(self): """ Test with all sources isolated (each in own group). """ x = np.array([0, 10, 20, 30]) y = np.array([0, 10, 20, 30]) groups = np.array([1, 2, 3, 4]) result = SourceGroups(x, y, groups) assert result.n_sources == 4 assert result.n_groups == 4 assert_equal(result.sizes, [1, 1, 1, 1]) def test_all_in_one_group(self): """ Test with all sources in a single group. """ x = np.array([0, 0.1, 0.2, 0.3]) y = np.array([0, 0.1, 0.2, 0.3]) groups = np.array([1, 1, 1, 1]) result = SourceGroups(x, y, groups) assert result.n_sources == 4 assert result.n_groups == 1 assert_equal(result.sizes, [4, 4, 4, 4]) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_basic(self, sample_groups): """ Test basic plot functionality. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() result_ax = sample_groups['groups'].plot(radius=0.5, ax=ax) assert result_ax is ax plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_cmap(self, sample_groups): """ Test cmap string input. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() groups = sample_groups['groups'] result_ax = groups.plot(radius=0.5, ax=ax, cmap='Blues') assert result_ax is ax plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_with_label_offset(self, sample_groups): """ Test plot with label offset. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() result_ax = sample_groups['groups'].plot( radius=0.5, ax=ax, label_groups=True, label_offset=(5, 0)) assert result_ax is ax plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_with_custom_kwargs(self, sample_groups): """ Test plot with custom styling kwargs. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() label_kwargs = {'fontsize': 12, 'fontweight': 'bold'} result_ax = sample_groups['groups'].plot( radius=0.5, ax=ax, label_groups=True, label_kwargs=label_kwargs, lw=3, alpha=0.5) assert result_ax is ax plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_no_axes(self, sample_groups): """ Test plot creates axes if none provided. """ import matplotlib.pyplot as plt result_ax = sample_groups['groups'].plot(radius=0.5) assert result_ax is not None plt.close('all') astropy-photutils-3322558/photutils/psf/tests/test_image_models.py000066400000000000000000000166601517052111400254720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the image_models module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.psf import CircularGaussianPSF, ImagePSF @pytest.fixture(name='gaussian_psf') def fixture_gaussian_psf(): return CircularGaussianPSF(fwhm=2.1) @pytest.fixture(name='image_psf') def fixture_image_psf(gaussian_psf): yy, xx = np.mgrid[-10:11, -10:11] psf_data = gaussian_psf(xx, yy) psf_data /= np.sum(psf_data) return ImagePSF(psf_data) class TestImagePSF: def test_imagepsf(self, gaussian_psf): yy, xx = np.mgrid[-10:11, -10:11] psf_data = gaussian_psf(xx, yy) psf_data /= np.sum(psf_data) model = ImagePSF(psf_data) assert_allclose(model(xx, yy), gaussian_psf(xx, yy), atol=1e-6) # subpixel should not match, but be reasonably close for x, y in [(0.5, 0.5), (-0.5, 1.75)]: assert_allclose(model(x, y), gaussian_psf(x, y), atol=4e-3) def test_imagepsf_oversampling(self, gaussian_psf): oversamp = 3 yy, xx = np.mgrid[-3:3.00001:(1 / oversamp), -3:3.00001:(1 / oversamp)] psf_data = gaussian_psf(xx, yy) model = ImagePSF(psf_data, oversampling=oversamp) for x, y in [(0, 0), (1, 1), (-2, 1)]: assert_allclose(model(x, y), gaussian_psf(x, y)) for x, y in [(0.5, 0.5), (-0.5, 1.75)]: # subpixel values assert_allclose(model(x, y), gaussian_psf(x, y), rtol=0.001) for x, y in [(0.33, 0.33), (0.66, 0.66)]: assert_allclose(model(x, y), gaussian_psf(x, y), rtol=2.0e-5) x_0 = 2.5 y_0 = -3.5 model.x_0 = x_0 model.y_0 = y_0 for x, y in [(0, 0), (0.66, 0.66)]: assert_allclose(model(x, y), gaussian_psf(x + x_0, y + y_0), atol=3.0e-6) # without oversampling the same tests should fail except for at # the origin model = ImagePSF(psf_data) assert_allclose(model(0, 0), gaussian_psf(0, 0)) for x, y in [(1, 1), (-2, 1)]: # integer values assert not np.allclose(model(x, y), gaussian_psf(x, y)) for x, y in [(0.5, 0.5), (-0.5, 1.75)]: assert not np.allclose(model(x, y), gaussian_psf(x, y), rtol=0.001) def test_origin(self): yy, xx = np.mgrid[:5, :5] gaussian_psf = CircularGaussianPSF(x_0=2, y_0=2, fwhm=2.1) psf_data = gaussian_psf(xx, yy) origin = (0, 0) model = ImagePSF(psf_data, x_0=2, y_0=2, origin=origin) assert_equal(model.origin, origin) for x, y in [(0, 0), (1, 1), (-2, 1)]: assert_allclose(model(x + 2, y + 2), gaussian_psf(x, y), atol=5e-6) def test_bounding_box(self): psf_data = np.arange(30, dtype=float).reshape(5, 6) psf_data /= np.sum(psf_data) model = ImagePSF(psf_data, flux=1, x_0=0, y_0=0) assert_equal(model.bounding_box.bounding_box(), ((-2.5, 2.5), (-3.0, 3.0))) model = ImagePSF(psf_data, flux=1, x_0=0, y_0=0, oversampling=2) assert_equal(model.bounding_box.bounding_box(), ((-1.25, 1.25), (-1.5, 1.5))) def test_data_inputs(self): match = 'Input data must be a 2D numpy array' with pytest.raises(TypeError, match=match): ImagePSF(42) with pytest.raises(ValueError, match=match): ImagePSF(np.ones(10)) with pytest.raises(ValueError, match=match): ImagePSF(np.ones((10, 10, 10))) match = 'The length of the x and y axes must both be at least 4' with pytest.raises(ValueError, match=match): ImagePSF(np.ones((3, 4))) data = np.ones((10, 10)) data[0, 0] = np.nan match = 'All elements of input data must be finite' with pytest.raises(ValueError, match=match): ImagePSF(data) def test_oversampling_inputs(self): data = np.arange(30).reshape(5, 6) for oversampling in [4, (3, 3), (3, 4)]: model = ImagePSF(data, oversampling=oversampling) if np.ndim(oversampling) == 0: assert_equal(model.oversampling, (oversampling, oversampling)) else: assert_equal(model.oversampling, oversampling) match = 'oversampling must be > 0' for oversampling in [-1, [-2, 4]]: with pytest.raises(ValueError, match=match): ImagePSF(data, oversampling=oversampling) match = 'oversampling must have 1 or 2 elements' oversampling = (1, 4, 8) with pytest.raises(ValueError, match=match): ImagePSF(data, oversampling=oversampling) match = 'oversampling must be 1D' for oversampling in [((1, 2), (3, 4)), np.ones((2, 2, 2))]: with pytest.raises(ValueError, match=match): ImagePSF(data, oversampling=oversampling) match = 'oversampling must have integer values' with pytest.raises(ValueError, match=match): ImagePSF(data, oversampling=2.1) match = 'oversampling must be a finite value' for oversampling in [np.nan, (1, np.inf)]: with pytest.raises(ValueError, match=match): ImagePSF(data, oversampling=oversampling) def test_origin_inputs(self): match = 'origin must be 1D and have 2-elements' with pytest.raises(ValueError, match=match): ImagePSF(np.ones((10, 10)), origin=(1, 2, 3)) with pytest.raises(ValueError, match=match): ImagePSF(np.ones((10, 10)), origin=np.ones((2, 2))) match = 'All elements of origin must be finite' with pytest.raises(ValueError, match=match): ImagePSF(np.ones((10, 10)), origin=(np.nan, 1)) @pytest.mark.parametrize('deepcopy', [False, True]) def test_copy(self, deepcopy): data = np.arange(30).reshape(5, 6) model = ImagePSF(data, flux=1, x_0=0, y_0=0) model_copy = model.deepcopy() if deepcopy else model.copy() assert_equal(model.data, model_copy.data) assert_equal(model.flux, model_copy.flux) assert_equal(model.x_0, model_copy.x_0) assert_equal(model.y_0, model_copy.y_0) assert_equal(model.oversampling, model_copy.oversampling) assert_equal(model.origin, model_copy.origin) model_copy.data[0, 0] = 42 if deepcopy: assert model.data[0, 0] != model_copy.data[0, 0] else: assert model.data[0, 0] == model_copy.data[0, 0] model_copy.flux = 2 assert model.flux != model_copy.flux model_copy.x_0.fixed = True model_copy.y_0.fixed = True model_copy2 = model_copy.copy() assert model_copy2.x_0.fixed assert model_copy2.fixed == model_copy.fixed def test_repr(self, image_psf): model_repr = repr(image_psf) expected = ('') assert model_repr == expected for param in image_psf.param_names: assert param in model_repr def test_str(self, image_psf): model_str = str(image_psf) keys = ('PSF shape', 'Origin', 'Oversampling', 'Fill Value') for key in keys: assert key in model_str for param in image_psf.param_names: assert param in model_str astropy-photutils-3322558/photutils/psf/tests/test_iterative.py000066400000000000000000000605211517052111400250340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the iterative module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian2D 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 (CircularGaussianPRF, IterativePSFPhotometry, SourceGrouper, make_psf_model, make_psf_model_image) from photutils.utils.exceptions import NoDetectionsWarning @pytest.fixture(name='test_data') def fixture_test_data(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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 def make_mock_finder(x_col, y_col): def finder(data, *, mask=None): # noqa: ARG001 source_table = Table() source_table[x_col] = [25.1] source_table[y_col] = [24.9] return source_table return finder FINDER_COLUMN_NAMES = [ ('x', 'y'), ('x_init', 'y_init'), ('xcentroid', 'ycentroid'), ('x_centroid', 'y_centroid'), ('xpos', 'ypos'), ('x_peak', 'y_peak'), ('xcen', 'ycen'), ('x_fit', 'y_fit'), ('x_invalid', 'y_invalid'), ] @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, sub_shape=fit_shape, 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=psf_shape, include_local_bkg=False) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=True) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=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].results_to_init_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 def test_iterative_psf_photometry_mode_new(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) bkgstat = MMMBackground() local_bkg_estimator = LocalBackground(5, 10, bkg_estimator=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', local_bkg_estimator=local_bkg_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, psf_shape=fit_shape) assert isinstance(resid_data, np.ndarray) assert resid_data.shape == data.shape nddata = NDData(data) resid_nddata = psfphot.make_residual_image(nddata, psf_shape=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, psf_shape=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', local_bkg_estimator=local_bkg_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, psf_shape=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', local_bkg_estimator=local_bkg_estimator, aperture_radius=4) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): phot = psfphot(data, error=error) assert phot is None 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 = CircularGaussianPRF(flux=500, fwhm=9.4) psf_shape = (41, 41) data = make_model_image(shape, psf_model, sources, model_shape=psf_shape) fit_shape = (5, 5) finder = DAOStarFinder(0.2, fwhm=6.0, min_separation=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, psf_shape=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, fwhm=6.0, min_separation=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, psf_shape=fit_shape) assert isinstance(resid_nddata, NDData) assert resid_nddata.unit == unit def test_iterative_methods(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) 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', aperture_radius=4) match = 'No results available. Please run the IterativePSFPhotometry' with pytest.raises(ValueError, match=match): psfphot.make_model_image(data.shape) with pytest.raises(ValueError, match=match): psfphot.make_residual_image(data) 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 init_params = psfphot.results_to_init_params() assert isinstance(init_params, QTable) assert len(init_params) == len(sources) model_params = psfphot.results_to_model_params() assert isinstance(model_params, QTable) assert len(model_params) == len(sources) def test_iterative_psf_photometry_overlap(): """ Regression test for #1769. A ValueError should not be raised for no overlap. """ fwhm = 3.5 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) data, _ = 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=fwhm, min_separation=0) grouper = SourceGrouper(min_separation=1.3 * fwhm) fitter = TRFLSQFitter() fit_shape = (5, 5) sub_shape = fit_shape psfphot = IterativePSFPhotometry(psf_model, fit_shape=fit_shape, finder=daofinder, mode='all', grouper=grouper, maxiters=2, sub_shape=sub_shape, aperture_radius=3, fitter=fitter) match = r'One or more .* may not have converged' with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error) assert len(phot) == 38 def test_iterative_psf_photometry_subshape(): """ A ValueError should not be raised if sub_shape=None and the model does not have a bounding box. """ fwhm = 3.5 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) data, _ = make_psf_model_image((150, 150), psf_model, n_sources=30, model_shape=(11, 11), flux=(50, 100), min_separation=1, seed=0) daofinder = DAOStarFinder(threshold=0.5, fwhm=fwhm) grouper = SourceGrouper(min_separation=1.3 * fwhm) fitter = TRFLSQFitter() fit_shape = (5, 5) sub_shape = None psf_model.bounding_box = None psfphot = IterativePSFPhotometry(psf_model, fit_shape=fit_shape, finder=daofinder, mode='all', grouper=grouper, maxiters=2, sub_shape=sub_shape, aperture_radius=3, fitter=fitter) match = r'model_shape must be specified .* does not have a bounding_box' with pytest.raises(ValueError, match=match): psfphot(data) def test_iterative_psf_photometry_inputs(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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.parametrize(('x_col', 'y_col'), FINDER_COLUMN_NAMES) def test_iterative_finder_column_names(x_col, y_col): """ Test that IterativePSFPhotometry works correctly with a finder that returns different column names for source positions. """ finder = make_mock_finder(x_col, y_col) sources = Table() sources['id'] = [1] sources['flux'] = 7.0 sources['x_0'] = 25.0 sources['y_0'] = 25.0 shape = (31, 31) psf_model = CircularGaussianPRF(flux=1.0, fwhm=3.1) data = make_model_image(shape, psf_model, sources) fit_shape = (9, 9) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=10, maxiters=3) # invalid column names should raise an error if x_col == 'x_invalid' or y_col == 'y_invalid': match = 'must contain columns for x and y coordinates' with pytest.raises(ValueError, match=match): psfphot(data, init_params=sources) return phot_tbl = psfphot(data) assert_allclose(phot_tbl['x_init'][0], 25.1) assert_allclose(phot_tbl['y_init'][0], 24.9) assert_allclose(phot_tbl['x_fit'][0], 25.0, atol=1e-6) assert_allclose(phot_tbl['y_fit'][0], 25.0, atol=1e-6) assert_allclose(phot_tbl['flux_fit'][0], 7.0, rtol=1e-6) def test_repr(): psf_model = CircularGaussianPRF(flux=1.0, fwhm=3.1) fit_shape = (9, 9) finder = DAOStarFinder(6.0, 2.0) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=10) cls_repr = repr(psfphot) assert cls_repr.startswith(f'{psfphot.__class__.__name__}(') def test_move_column(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=1) 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'] def test_iterative_model_residual_image_nonfinite_localbkg(test_data): """ Test that make_model_image and make_residual_image handle non-finite local background values correctly for IterativePSFPhotometry. When include_local_bkg=True and the local_bkg is non-finite (NaN or inf), the non-finite value should be treated as 0 and not included in the model or residual images. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) # Find sources and manually set some local_bkg values to non-finite sources = finder(data) sources['local_bkg'] = np.zeros(len(sources)) sources['local_bkg'][0] = np.nan sources['local_bkg'][1] = np.inf if len(sources) > 2: sources['local_bkg'][2] = -np.inf # Perform iterative PSF photometry with non-finite local_bkg psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=2) phot = psfphot(data, error=error, init_params=sources) # Test make_model_image with include_local_bkg=True psf_shape = (15, 15) model_with_bkg = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=True) model_without_bkg = psfphot.make_model_image( data.shape, psf_shape=psf_shape, include_local_bkg=False) # The model images should be finite everywhere assert np.all(np.isfinite(model_with_bkg)) assert np.all(np.isfinite(model_without_bkg)) # For sources with non-finite local_bkg, the model with and without # local_bkg should be identical (since non-finite is treated as 0) # Check this by comparing the models at source positions for i in range(min(3, len(phot))): if not np.isfinite(phot['local_bkg'][i]): x_fit = int(phot['x_fit'][i]) y_fit = int(phot['y_fit'][i]) assert_allclose(model_with_bkg[y_fit, x_fit], model_without_bkg[y_fit, x_fit], rtol=1e-6) def test_iterative_residual_image_localbkg_invalid_sources(test_data): """ Test that make_residual_image handles sources with non-finite local_bkg values and sources outside the image (invalid sources) correctly for IterativePSFPhotometry. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) sources = finder(data) # Add non-finite local_bkg values to init_params sources['local_bkg'] = np.zeros(len(sources)) sources['local_bkg'][0] = np.nan sources['local_bkg'][1] = np.inf sources['local_bkg'][2] = -np.inf # Add an invalid source outside the image sources['x_centroid'][-3] = 1000 sources['y_centroid'][-3] = 1000 # Perform iterative PSF photometry with init_params containing # non-finite local_bkg psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=2) psfphot(data, error=error, init_params=sources) residual_img = psfphot.make_residual_image(data, include_local_bkg=True) assert residual_img.shape == data.shape assert np.all(np.isfinite(residual_img)) def test_decode_flags(): """ Test the decode_flags convenience method. """ # Create test data with some sources that will have flags yy, xx = np.mgrid[:21, :21] psf_model = CircularGaussianPRF(flux=1, x_0=10, y_0=10, fwhm=2) # Source 1: normal source (no flags expected) m1 = CircularGaussianPRF(flux=100, x_0=10, y_0=10, fwhm=2) # Source 2: negative flux (will have negative_flux flag) m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) # Source 3: outside bounds (will have outside_bounds flag) m3 = CircularGaussianPRF(flux=100, x_0=25, y_0=25, fwhm=2) data = m1(xx, yy) + m2(xx, yy) + m3(xx, yy) init_params = Table({ 'x': [10, 5, 25], 'y': [10, 5, 25], 'flux': [100, 100, 100], }) finder = DAOStarFinder(6.0, 2.0) psfphot = IterativePSFPhotometry(psf_model, (3, 3), finder=finder, aperture_radius=4, maxiters=1) # Test that decode_flags raises ValueError before running photometry match = 'No results available' with pytest.raises(ValueError, match=match): psfphot.decode_flags() # Run photometry results = psfphot(data, init_params=init_params) # Test decode_flags method decoded_flags = psfphot.decode_flags() # Check that we get a list of lists assert isinstance(decoded_flags, list) assert len(decoded_flags) == len(results) # Each element should be a list of strings for decoded in decoded_flags: assert isinstance(decoded, list) for flag_name in decoded: assert isinstance(flag_name, str) # Check that the first source has no flags or minimal flags # (depending on fitting success) assert isinstance(decoded_flags[0], list) # Check that the second source has the negative_flux flag assert 'negative_flux' in decoded_flags[1] # Check that the third source has flags (it's outside the image bounds) # It should have 'no_overlap' since it's completely outside assert len(decoded_flags[2]) > 0 assert 'no_overlap' in decoded_flags[2] # Verify that decode_flags gives the same result as calling # decode_psf_flags directly from photutils.psf.flags import decode_psf_flags direct_decoded = decode_psf_flags(results['flags']) assert decoded_flags == direct_decoded astropy-photutils-3322558/photutils/psf/tests/test_model_helpers.py000066400000000000000000000261241517052111400256630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model_helpers module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Const2D, Gaussian2D, Moffat2D from astropy.nddata import NDData from astropy.table import Table from astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_allclose, assert_equal from photutils import datasets from photutils.detection import find_peaks from photutils.psf import (EPSFBuilder, extract_stars, grid_from_epsfs, make_psf_model) from photutils.psf.model_helpers import _integrate_model, _InverseShift def test_inverse_shift(): model = _InverseShift(10) assert model(1) == -9.0 assert model(-10) == -20.0 assert model.fit_deriv(10, 1)[0] == -1.0 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' model = Gaussian2D(1, np.inf, 5, 1, 1) with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean', y_name='y_mean') @pytest.fixture(name='moffat_source', scope='module') def fixture_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) xx, yy = np.meshgrid(*([np.linspace(-2, 2, 100)] * 2)) return model, (xx, yy, model(xx, yy)) 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 = TRFLSQFitter() 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))]) 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 renormalization # 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 = TRFLSQFitter() 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 def test_make_psf_model_units(): model = Moffat2D(amplitude=1.0 * u.Jy, x_0=25, y_0=25, alpha=4.8, gamma=3.1) model.amplitude = (model.amplitude.unit * (model.alpha - 1.0) / (np.pi * model.gamma**2)) # normalize to flux=1 psf_model = make_psf_model(model, x_name='x_0', y_name='y_0', normalize=True) yy, xx = np.mgrid[:51, :51] data1 = model(xx, yy) data2 = psf_model(xx, yy) assert_allclose(data1, data2) 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') 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 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, _ = 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 = [val['epsf'] for val in quad_stars.values()] self.grid_xypos = [val['fiducial'] for val in quad_stars.values()] def test_basic_test_grid_from_epsfs(self): with pytest.warns(AstropyDeprecationWarning): 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 with pytest.warns(AstropyDeprecationWarning): 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)] with pytest.warns(AstropyDeprecationWarning): 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 with pytest.warns(AstropyDeprecationWarning): 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'} with pytest.warns(AstropyDeprecationWarning): 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 astropy-photutils-3322558/photutils/psf/tests/test_photometry.py000066400000000000000000002307371517052111400252620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import tempfile import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import (LevMarLSQFitter, LMLSQFitter, SimplexLSQFitter, TRFLSQFitter) from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable, Table from astropy.utils.exceptions import (AstropyDeprecationWarning, 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 (CircularGaussianPRF, PSFPhotometry, SourceGrouper, make_psf_model, make_psf_model_image) from photutils.utils.exceptions import NoDetectionsWarning @pytest.fixture(name='test_data') def fixture_test_data(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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) sigma = 0.9 noise = make_noise_image(data.shape, mean=0, stddev=sigma, seed=0) data += noise error = np.full(data.shape, sigma) return data, error, true_params def make_mock_finder(x_col, y_col, *, units=False): def finder(data, *, mask=None): # noqa: ARG001 source_table = Table() x_val = [25.1] y_val = [24.9] if units: x_val *= u.pixel y_val *= u.pixel source_table[x_col] = x_val source_table[y_col] = y_val return source_table return finder def test_invalid_inputs(): model = CircularGaussianPRF(fwhm=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' psf_model = Gaussian1D() with pytest.raises(ValueError, match=match): _ = PSFPhotometry(psf_model, 3) match = 'psf_model must be two-dimensional' psf_model = Gaussian1D() with pytest.raises(ValueError, match=match): _ = PSFPhotometry(psf_model, 3) match = 'Invalid PSF model - could not find PSF parameter names' psf_model = Gaussian2D() with pytest.raises(ValueError, match=match): _ = 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 >= 1' 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 = 'local_bkg_estimator must be a LocalBackground instance' localbkg = MMMBackground() with pytest.raises(TypeError, match=match): _ = PSFPhotometry(model, 1, local_bkg_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 callable object" with pytest.raises(TypeError, 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' data = np.ones((11, 11)) error = np.ones((3, 3)) with pytest.raises(ValueError, match=match): _ = psfphot(data, error=error) match = 'data and mask must have the same shape' data = np.ones((11, 11)) mask = np.ones((3, 3)) with pytest.raises(ValueError, match=match): _ = psfphot(data, mask=mask) match = 'init_params must be an astropy Table' data = np.ones((11, 11)) with pytest.raises(TypeError, match=match): _ = psfphot(data, init_params=1) match = ('init_params must contain valid column names for the x and y ' 'source positions') tbl = Table() tbl['a'] = np.arange(3) data = np.ones((11, 11)) with pytest.raises(ValueError, match=match): _ = 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) data = np.ones((11, 11)) with pytest.raises(ValueError, match=match): _ = 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] data = np.ones((11, 11)) data[5, 5] = np.nan with pytest.warns(AstropyUserWarning, match=match): _ = psfphot2(data, init_params=init_params) # mask is input, but data has unmasked non-finite value match = 'Input data contains unmasked non-finite values' data = np.ones((11, 11)) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[7, 7] = True with pytest.warns(AstropyUserWarning, match=match): _ = psfphot2(data, mask=mask, init_params=init_params) data = np.ones((11, 11)) tbl = Table() tbl['x'] = [1, 2] tbl['y'] = [1, 2] tbl['group_id'] = [1.1, 2.0] match = 'group_id must be an integer array' with pytest.raises(TypeError, match=match): _ = psfphot(data, init_params=tbl) tbl['group_id'] = [1, np.nan] match = 'group_id must be finite' with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=tbl) tbl['group_id'] = [0, 1] match = 'group_id must contain only positive' with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=tbl) tbl['group_id'] = [-1, 1] with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=tbl) def test_psf_photometry(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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, psf_shape=fit_shape) assert isinstance(psfphot.finder_results, QTable) assert isinstance(phot, QTable) assert isinstance(psfphot.results, 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 assert isinstance(psfphot.fit_info, list) # test that repeated calls reset the results phot = psfphot(data, error=error) assert len(psfphot.fit_info) == 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, psf_shape=fit_shape) assert resid_datau.unit == unit colnames = ('qfit', 'cfit', 'reduced_chi2') for col in colnames: assert not isinstance(photu[col], u.Quantity) match = 'The fit_params function is deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): assert isinstance(psfphot.fit_params, Table) @pytest.mark.parametrize('fit_fwhm', [False, True]) def test_psf_photometry_forced(test_data, fit_fwhm): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model.x_0.fixed = True psf_model.y_0.fixed = True if fit_fwhm: psf_model.fwhm.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, psf_shape=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_fwhm: col = 'fwhm' suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes] for colname in colnames: assert colname in phot.colnames def test_psf_photometry_nddata(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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, psf_shape=fit_shape) resid_data2 = psfphot.make_residual_image(nddata, psf_shape=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, psf_shape=fit_shape) assert resid_data3.unit == unit def test_psf_photometry_finite_weights(test_data): data, _, _ = test_data error = np.zeros_like(data) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) match = 'Error array contains non-positive or non-finite values' with pytest.raises(ValueError, match=match): _ = psfphot(data, error=error) def test_model_residual_image(test_data): data, error, _ = test_data data = data + 10 psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(16.0, 2.0) bkgstat = MMMBackground() local_bkg_estimator = LocalBackground(5, 10, bkg_estimator=bkgstat) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, local_bkg_estimator=local_bkg_estimator) psfphot(data, error=error) psf_shape = (25, 25) model1 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=True) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=False) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=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 def test_model_residual_image_nonfinite_localbkg(test_data): """ Test that make_model_image and make_residual_image handle non-finite local background values correctly. When include_local_bkg=True and the local_bkg is non-finite (NaN or inf), the non-finite value should be treated as 0 and not included in the model or residual images. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) # Find sources and manually set some local_bkg values to non-finite sources = finder(data) sources['local_bkg'] = np.zeros(len(sources)) sources['local_bkg'][0] = np.nan sources['local_bkg'][1] = np.inf if len(sources) > 2: sources['local_bkg'][2] = -np.inf # Perform PSF photometry with non-finite local_bkg psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error, init_params=sources) # Test make_model_image with include_local_bkg=True psf_shape = (15, 15) model_with_bkg = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=True) model_without_bkg = psfphot.make_model_image( data.shape, psf_shape=psf_shape, include_local_bkg=False) # The model images should be finite everywhere assert np.all(np.isfinite(model_with_bkg)) assert np.all(np.isfinite(model_without_bkg)) # For sources with non-finite local_bkg, the model with and without # local_bkg should be identical (since non-finite is treated as 0) # Check this by comparing the models at source positions for i in range(min(3, len(phot))): if not np.isfinite(phot['local_bkg'][i]): x_fit = int(phot['x_fit'][i]) y_fit = int(phot['y_fit'][i]) # At the source center, both models should be similar assert_allclose(model_with_bkg[y_fit, x_fit], model_without_bkg[y_fit, x_fit], atol=1e-6) def test_residual_image_localbkg_invalid_sources(test_data): """ Test that make_residual_image handles sources with non-finite local_bkg values and sources outside the image (invalid sources) correctly. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) sources = finder(data) # Add non-finite local_bkg values to init_params sources['local_bkg'] = np.zeros(len(sources)) sources['local_bkg'][0] = np.nan sources['local_bkg'][1] = np.inf sources['local_bkg'][2] = -np.inf # Add an invalid source outside the image sources['x_centroid'][-3] = 1000 sources['y_centroid'][-3] = 1000 # Perform PSF photometry with init_params containing non-finite local_bkg psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) psfphot(data, error=error, init_params=sources) residual_img = psfphot.make_residual_image(data, include_local_bkg=True) assert residual_img.shape == data.shape assert np.all(np.isfinite(residual_img)) @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.2 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=psf_shape, include_local_bkg=False) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_local_bkg=True) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_local_bkg=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.results_to_init_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=3) match = r'One or more fit\(s\) may not have converged.' psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, fitter_maxiters=3) with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error) assert len(phot) == len(sources) def test_psf_photometry_mask(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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' mask = ~np.isfinite(data) mask[55, 65] = False with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error, mask=mask) assert len(phot) == len(sources) # mask all True; finder returns no sources match = 'No sources were found' mask = np.ones(data.shape, dtype=bool) with pytest.warns(NoDetectionsWarning, match=match): psfphot(data, mask=mask) # completely masked source should return NaNs and not raise init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.ones(data.shape, dtype=bool) phot_masked = psfphot(data_orig, mask=mask, init_params=init_params) assert len(phot_masked) == 1 colnames = ('x_fit', 'y_fit', 'flux_fit', 'x_err', 'y_err', 'flux_err', 'qfit', 'cfit', 'reduced_chi2') for col in colnames: assert np.isnan(phot_masked[col][0]) assert phot_masked['n_pixels_fit'][0] == 0 assert phot_masked['group_size'][0] == 1 # new flag 128 for fully masked assert (phot_masked['flags'][0] & 128) == 128 # 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) def test_psf_photometry_init_params(test_data): data, error, _ = test_data data = data.copy() psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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 a flux column is not' psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=None) with pytest.raises(ValueError, match=match): _ = 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) colnames = ('x_fit', 'y_fit', 'flux_fit', 'x_err', 'y_err', 'flux_err', 'qfit', 'cfit', 'reduced_chi2') # no-overlap source should return NaNs and not raise; also test # too-few-pixels init_params = QTable() init_params['x'] = [-63] init_params['y'] = [-49] init_params['flux'] = [100] phot_no_overlap = psfphot(data, init_params=init_params) assert len(phot_no_overlap) == 1 for col in colnames: assert np.isnan(phot_no_overlap[col][0]) assert phot_no_overlap['n_pixels_fit'][0] == 0 assert phot_no_overlap['group_size'][0] == 1 # new flag 64 for no overlap assert (phot_no_overlap['flags'][0] & 64) == 64 # too-few pixels (unmasking only 2 pixels < 3 free params) should # give NaNs init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.ones(data.shape, dtype=bool) mask[49, 63] = False mask[49, 64] = False phot_few = psfphot(data, error=error, mask=mask, init_params=init_params) assert len(phot_few) == 1 for col in colnames: assert np.isnan(phot_few[col][0]) assert phot_few['n_pixels_fit'][0] == 2 assert phot_few['group_size'][0] == 1 # new flag 256 for too few pixels assert (phot_few['flags'][0] & 256) == 256 # 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 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 = CircularGaussianPRF(flux=1, fwhm=2.7) 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, psf_shape=fit_shape, include_local_bkg=val) assert isinstance(im, u.Quantity) assert im.unit == unit resid = psfphot.make_residual_image(data2, psf_shape=fit_shape, include_local_bkg=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) def test_psf_photometry_init_params_columns(test_data): data, error, _ = test_data data = data.copy() psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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'] pad = len(xy_suffixes) - len(flux_cols) flux_cols += flux_cols[0:pad] # 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, strict=True): 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']) def test_grouper(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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)) def test_grouper_init_params(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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) phot0 = psfphot(data, error=error) init_params = QTable() init_params['id'] = phot0['id'] init_params['group_id'] = 1 init_params['x'] = phot0['x_init'] init_params['y'] = phot0['y_init'] init_params['flux'] = phot0['flux_init'] phot1 = psfphot(data, error=error, init_params=init_params) nsources = len(phot1) assert isinstance(phot1, QTable) assert_equal(phot1['group_id'], np.ones(nsources, dtype=int)) assert_equal(phot1['group_size'], np.ones(nsources, dtype=int) * nsources) # test with grouper=None psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=None, aperture_radius=4) phot2 = psfphot(data, error=error, init_params=init_params) assert isinstance(phot2, QTable) assert_equal(phot1['group_id'], np.ones(nsources, dtype=int)) assert_equal(phot1['group_size'], np.ones(nsources, dtype=int) * nsources) def test_large_group_warning(): psf_model = CircularGaussianPRF(flux=1, fwhm=2) 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' psfphot = PSFPhotometry(psf_model, fit_shape, grouper=grouper) with pytest.warns(AstropyUserWarning, match=match): psfphot(data, init_params=true_params) def test_local_bkg(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) grouper = SourceGrouper(min_separation=20) bkgstat = MMMBackground() local_bkg_estimator = LocalBackground(5, 10, bkg_estimator=bkgstat) finder = DAOStarFinder(10.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, local_bkg_estimator=local_bkg_estimator) phot = psfphot(data, error=error) assert np.count_nonzero(phot['local_bkg']) == len(sources) def test_local_bkg_nonfinite(test_data): """ Test that non-finite local background values are handled correctly. When local_bkg is NaN or inf, the code should: 1. Report the actual local_bkg value in the output table 2. Not subtract it from the data before fitting 3. Set a flag indicating non-finite local background """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) # Find sources using the finder sources = finder(data) # Add non-finite local_bkg values to init_params sources['local_bkg'] = np.zeros(len(sources)) sources['local_bkg'][0] = np.nan sources['local_bkg'][1] = np.inf sources['local_bkg'][2] = -np.inf # Perform PSF photometry with init_params containing non-finite local_bkg psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error, init_params=sources) # Check that non-finite local_bkg values are preserved in output assert np.isnan(phot['local_bkg'][0]) assert np.isinf(phot['local_bkg'][1]) assert np.isinf(phot['local_bkg'][2]) # Check that flags are set correctly (bit 2048 for non-finite local_bkg) assert phot['flags'][0] & 2048 assert phot['flags'][1] & 2048 assert phot['flags'][2] & 2048 # Check that sources with finite local_bkg don't have this flag if len(phot) > 3: assert not (phot['flags'][3] & 2048) def test_local_bkg_nonfinite_measured(test_data): """ Test that non-finite local background values measured from the image are handled correctly. When the LocalBackground estimator returns NaN (e.g., due to a fully masked region), the code should: 1. Report the NaN local_bkg value in the output table 2. Not subtract it from the data before fitting 3. Set a flag (bit 2048) indicating non-finite local background """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) # Create a mask that will cause LocalBackground to return NaN for # some sources (mask out a large region around a source) mask = np.ones(data.shape, dtype=bool) mask[44:54, 58:68] = False # around source at ~(63, 49) mask[70:, :20] = False # two sources bkgstat = MMMBackground() local_bkg_estimator = LocalBackground(10, 25, bkg_estimator=bkgstat) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, local_bkg_estimator=local_bkg_estimator) phot = psfphot(data, error=error, mask=mask) assert_equal(phot['flags'], [2048, 0, 0]) assert np.isnan(phot['local_bkg'][0]) assert np.all(np.isfinite(phot['local_bkg'][1:])) assert np.all(phot['flux_fit'] > 0) assert np.all(phot['flux_err'] > 0) def test_fixed_params(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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'`bounds` must contain 2 elements' with pytest.raises(ValueError, match=match): psfphot(data, error=error) def test_fixed_params_units(test_data): data, error, _ = test_data unit = u.nJy psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model.x_0.fixed = False psf_model.y_0.fixed = False psf_model.flux.fixed = True fit_shape = (5, 5) finder = DAOStarFinder(6.0 * unit, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data << unit, error=error << unit) assert phot['local_bkg'].unit == unit assert phot['flux_init'].unit == unit assert phot['flux_fit'].unit == unit assert phot['flux_err'].unit == unit def test_fit_warning(test_data): data, _, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model.flux.fixed = False psf_model.fwhm.bounds = (None, None) 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): phot = psfphot(data) # check that flag=8 is set for these sources assert_equal(phot['flags'][0] & 8, np.ones(len(phot)) * 8) 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 = CircularGaussianPRF(flux=1, fwhm=2.7) 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) colnames = ('qfit', 'cfit', 'reduced_chi2') for col in colnames: assert np.all(np.isnan(phot[col])) def test_xy_bounds(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) init_params = QTable() init_params['x'] = [65] init_params['y'] = [51] xy_bounds = (1, 1) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) phot = psfphot(data, error=error, init_params=init_params) assert len(phot) == len(init_params) assert_allclose(phot['x_fit'], 64.0) # at lower bound assert_allclose(phot['y_fit'], 50.0) # at lower bound psfphot2 = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=1) phot2 = psfphot2(data, error=error, init_params=init_params) cols = ('x_fit', 'y_fit', 'flux_fit') for col in cols: assert np.all(phot[col] == phot2[col]) xy_bounds = (None, 1) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) phot = psfphot(data, error=error, init_params=init_params) assert phot['x_fit'] < 64.0 assert_allclose(phot['y_fit'], 50.0) # at lower bound xy_bounds = (1, None) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds, fitter_maxiters=500) phot = psfphot(data, error=error, init_params=init_params) assert_allclose(phot['x_fit'], 64.0) # at lower bound assert phot['y_fit'] < 50.0 xy_bounds = (None, None) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) init_params['x'] = [63] init_params['y'] = [49] phot = psfphot(data, error=error, init_params=init_params) assert phot['x_fit'] < 63.3 assert phot['y_fit'] < 48.7 assert phot['flags'] == 0 # test invalid inputs match = 'xy_bounds must have 1 or 2 elements' with pytest.raises(ValueError, match=match): PSFPhotometry(psf_model, fit_shape, xy_bounds=(1, 2, 3)) match = 'xy_bounds must be a 1D array' with pytest.raises(ValueError, match=match): PSFPhotometry(psf_model, fit_shape, xy_bounds=np.ones((1, 1))) match = 'xy_bounds must be strictly positive' with pytest.raises(ValueError, match=match): PSFPhotometry(psf_model, fit_shape, xy_bounds=(-1, 2)) match = 'xy_bounds must be finite' with pytest.raises(ValueError, match=match): PSFPhotometry(psf_model, fit_shape, xy_bounds=(np.nan, 2)) def test_grouper_with_xy_bounds(test_data): """ Test source grouping functionality with xy_bounds applied. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) init_params = QTable() init_params['x_init'] = [20.0, 22.0, 25.0] init_params['y_init'] = [20.0, 21.0, 23.0] init_params['flux_init'] = [1000.0, 800.0, 600.0] # Test with grouper and xy_bounds grouper = SourceGrouper(min_separation=5) xy_bounds = (1.0, 1.5) # Different bounds for x and y psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, grouper=grouper, xy_bounds=xy_bounds, aperture_radius=4) phot = psfphot(data, error=error, init_params=init_params) # verify sources were grouped assert len(phot) == len(init_params) assert len(np.unique(phot['group_id'])) < len(phot) # Verify that xy_bounds were applied during fitting # The fitted positions should be constrained for i, row in enumerate(phot): x_init = init_params['x_init'][i] y_init = init_params['y_init'][i] x_fit = row['x_fit'] y_fit = row['y_fit'] # Check that fitted positions are within bounds # (allowing some tolerance for fitting convergence) assert abs(x_fit - x_init) <= xy_bounds[0] + 0.1 assert abs(y_fit - y_init) <= xy_bounds[1] + 0.1 # Test that the flat model creation worked with xy_bounds flat_model = psfphot._psf_fitter.make_psf_model(init_params) if len(init_params) > 1: # For multiple sources, check flat model has correct structure assert hasattr(flat_model, 'flux_0') assert hasattr(flat_model, 'x_0_0') assert hasattr(flat_model, 'y_0_0') if len(init_params) > 1: assert hasattr(flat_model, 'flux_1') assert hasattr(flat_model, 'x_0_1') assert hasattr(flat_model, 'y_0_1') 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 = CircularGaussianPRF(flux=1, fwhm=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']) def test_init_params_xy_with_units(): """ Test that init_params table x/y columns with units are accepted. """ shape = (41, 41) psf_model = CircularGaussianPRF(flux=500, fwhm=3.0) data = np.zeros(shape) init_params = QTable() init_params['x'] = [20.0] * u.pixel # units should be stripped init_params['y'] = [20.0] * u.pixel init_params['flux'] = [500] fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=None) phot = psfphot(data, init_params=init_params) assert len(phot) == 1 assert_equal(phot['x_init'][0], 20.0) assert_equal(phot['y_init'][0], 20.0) finder = make_mock_finder('x', 'y', units=True) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, init_params=None) assert len(phot) == 1 assert_equal(phot['x_init'][0], 25.1) assert_equal(phot['y_init'][0], 24.9) 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 = CircularGaussianPRF(flux=1, fwhm=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'])) 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)) FINDER_COLUMN_NAMES = [ ('x', 'y'), ('x_init', 'y_init'), ('xcentroid', 'ycentroid'), ('x_centroid', 'y_centroid'), ('xpos', 'ypos'), ('x_peak', 'y_peak'), ('xcen', 'ycen'), ('x_fit', 'y_fit'), ('x_invalid', 'y_invalid'), ] @pytest.mark.parametrize(('x_col', 'y_col'), FINDER_COLUMN_NAMES) def test_finder_column_names(x_col, y_col): """ Test that PSFPhotometry works correctly with a finder that returns different column names for source positions. """ finder = make_mock_finder(x_col, y_col) sources = Table() sources['id'] = [1] sources['flux'] = 7.0 sources['x_0'] = 25.0 sources['y_0'] = 25.0 shape = (31, 31) psf_model = CircularGaussianPRF(flux=1.0, fwhm=3.1) data = make_model_image(shape, psf_model, sources) fit_shape = (9, 9) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=10) # invalid column names should raise an error if x_col == 'x_invalid' or y_col == 'y_invalid': match = 'must contain columns for x and y coordinates' with pytest.raises(ValueError, match=match): psfphot(data) return phot_tbl = psfphot(data) assert len(phot_tbl) == 1 assert_allclose(phot_tbl['x_init'][0], 25.1) assert_allclose(phot_tbl['y_init'][0], 24.9) assert_allclose(phot_tbl['x_fit'][0], 25.0, atol=1e-6) assert_allclose(phot_tbl['y_fit'][0], 25.0, atol=1e-6) assert_allclose(phot_tbl['flux_fit'][0], 7.0, rtol=1e-6) def test_repr(): psf_model = CircularGaussianPRF(flux=1.0, fwhm=3.1) fit_shape = (9, 9) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=10) cls_repr = repr(psfphot) assert cls_repr.startswith(f'{psfphot.__class__.__name__}(') def test_group_warning_threshold(test_data): data, error, sources = test_data sources['group_id'] = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2] psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, group_warning_threshold=6) match = 'Some groups have more than 6 sources' with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error, init_params=sources) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, group_warning_threshold=7) phot = psfphot(data, error=error, init_params=sources) assert len(phot) == 10 def test_flag2_boundaries(): shape = (35, 21) psf_model = CircularGaussianPRF(fwhm=3.0) init_params = QTable() init_params['x_0'] = [-0.4, 20.4, -1.0, 21.0, 5.0, 5.0, 15.0, 15.0] init_params['y_0'] = [10.0, 10.0, 20.0, 20.0, -0.4, 34.4, -1.0, 35.0] init_params['flux'] = 500 data = make_model_image(shape, psf_model, init_params) fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape) phot = psfphot(data, init_params=init_params) assert len(phot) == 8 assert_equal(phot['flags'][[2, 3, 6, 7]], [3, 3, 3, 3]) assert_equal(phot['flags'][[0, 1, 4, 5]], [1, 1, 1, 1]) def test_flag64_no_overlap(): """ Test flag=64 for source with no overlap (completely outside). """ shape = (21, 21) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) init_params = QTable() # place source completely outside (beyond + side) init_params['x_0'] = [100.0] init_params['y_0'] = [100.0] init_params['flux'] = [500.0] fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape) phot = psfphot(data, init_params=init_params) assert len(phot) == 1 # Expect bits include 64 (no overlap). Others (2,1,16,32) may also # be present. Only assert 64 is set. assert (phot['flags'][0] & 64) == 64 assert phot['n_pixels_fit'][0] == 0 def test_flag128_fully_masked(): """ Test flag=128 for fully masked source region. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) init_params = QTable() init_params['x_0'] = [12.0] init_params['y_0'] = [12.0] init_params['flux'] = [500.0] mask = np.ones(shape, dtype=bool) # fully masked image fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape) phot = psfphot(data, init_params=init_params, mask=mask) assert len(phot) == 1 assert phot['n_pixels_fit'][0] == 0 assert (phot['flags'][0] & 128) == 128 def test_flag256_too_few_pixels(): """ Test flag=256 for too few unmasked pixels to perform a fit. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) init_params = QTable() init_params['x_0'] = [12.0] init_params['y_0'] = [12.0] init_params['flux'] = [500.0] mask = np.ones(shape, dtype=bool) # Unmask only a single pixel inside the fit box (fewer than params) mask[12, 12] = False fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape) phot = psfphot(data, init_params=init_params, mask=mask) assert len(phot) == 1 assert phot['n_pixels_fit'][0] == 1 # Ensure 256 bit set (too few pixels); not fully masked (no 128). assert (phot['flags'][0] & 256) == 256 def test_flag16_missing_covariance(): """ Test flag=16 when fitter does not provide a covariance matrix. """ shape = (21, 21) psf_model = CircularGaussianPRF(fwhm=2.5) data = np.zeros(shape) init_params = QTable() init_params['x_0'] = [10.0, 20.0] init_params['y_0'] = [10.0, 20.0] init_params['flux'] = [500.0, 500.0] init_params['group_id'] = [1, 1] # mock fitter that does not return a covariance matrix def mock_fitter(model, *args, **kwargs): # noqa: ARG001 mock_fitter.fit_info = {'status': 1} return model mock_fitter.fit_info = {} fit_shape = (5, 5) match = r"'maxiters' will be ignored because it is not accepted" with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, fitter=mock_fitter) phot = psfphot(data, init_params=init_params) assert len(phot) == 2 cols = ('x_err', 'y_err', 'flux_err') for col in cols: assert col in phot.colnames assert np.all(np.isnan(phot[col])) assert (phot['flags'][0] & 16) == 16 def test_flag32_parameter_at_bounds(): """ Test flag=32 when fitted x/y are exactly at imposed bounds. """ shape = (21, 21) psf_model = CircularGaussianPRF(fwhm=2.5) data = np.zeros(shape) data[11, 11] = 1000.0 init_params = QTable() init_params['x_0'] = [10.0] init_params['y_0'] = [10.0] init_params['flux'] = [500.0] fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape, xy_bounds=0.1) phot = psfphot(data, init_params=init_params) assert len(phot) == 1 assert (phot['flags'][0] & 32) == 32 def test_flag512_non_finite_position(): """ Test flag=512 for non-finite fitted position. When a source has non-finite initial position or when the fit fails to converge properly, the fitted x or y position can be non-finite. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) # Create init_params with non-finite initial position init_params = QTable() init_params['x_init'] = [12.0, np.nan, 12.0] init_params['y_init'] = [12.0, 12.0, np.inf] init_params['flux_init'] = [500.0, 500.0, 500.0] fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=3) phot = psfphot(data, init_params=init_params) assert len(phot) == 3 # First source should be valid (no flag 512) assert np.isfinite(phot['x_fit'][0]) assert np.isfinite(phot['y_fit'][0]) assert (phot['flags'][0] & 512) == 0 # Second source should have non-finite x_fit (flag 512 set) assert not np.isfinite(phot['x_fit'][1]) assert (phot['flags'][1] & 512) == 512 # Third source should have non-finite y_fit (flag 512 set) assert not np.isfinite(phot['y_fit'][2]) assert (phot['flags'][2] & 512) == 512 def test_flag1024_non_finite_flux(): """ Test flag=1024 for non-finite flux in init_params. When a source has non-finite initial flux (NaN or inf), the code should handle it gracefully and set flag 1024. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) # Create init_params with non-finite initial flux init_params = QTable() init_params['x_init'] = [12.0, 12.0, 12.0] init_params['y_init'] = [12.0, 12.0, 12.0] init_params['flux_init'] = [500.0, np.nan, np.inf] fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=3) phot = psfphot(data, init_params=init_params) assert len(phot) == 3 # First source should be valid (no flag 1024) assert np.isfinite(phot['flux_fit'][0]) assert (phot['flags'][0] & 1024) == 0 # Second source should have non-finite flux_fit (flag 1024 set) assert not np.isfinite(phot['flux_fit'][1]) assert (phot['flags'][1] & 1024) == 1024 # Third source should have non-finite flux_fit (flag 1024 set) assert not np.isfinite(phot['flux_fit'][2]) assert (phot['flags'][2] & 1024) == 1024 def test_psf_photometry_methods(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) match = 'The fit_params function is deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): assert psfphot.fit_params is None match = 'No results available. Please run the PSFPhotometry' with pytest.raises(ValueError, match=match): psfphot.make_model_image(data.shape) with pytest.raises(ValueError, match=match): psfphot.make_residual_image(data.shape) assert psfphot.results_to_init_params() is None assert psfphot.results_to_model_params() is None phot = psfphot(data, error=error) assert isinstance(phot, QTable) resid_data = psfphot.make_residual_image(data, psf_shape=fit_shape) assert isinstance(resid_data, np.ndarray) assert isinstance(psfphot.fit_info, list) match = 'The fit_params function is deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): assert isinstance(psfphot.fit_params, Table) @pytest.mark.parametrize('units', [False, True]) def test_invalid_sources(test_data, units): data, error, sources = test_data if units: unit = u.nJy data = data << unit error = error << unit init_params = sources.copy() # one item in group is invalid init_params['x_0'][0] = 1000 init_params['y_0'][0] = 1000 init_params['x_0'][5] = 1000 # entire group is invalid init_params['x_0'][-2] = 1000 init_params['x_0'][-1] = 1000 if units: init_params['flux'] *= unit init_params['group_id'] = [1, 2, 1, 2, 2, 3, 2, 3, 4, 4] psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) 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, init_params=init_params) assert len(phot) == len(init_params) assert_equal(phot['group_id'], init_params['group_id']) assert_equal(phot['group_size'], [2, 4, 2, 4, 4, 2, 4, 2, 2, 2]) cols = ('x_fit', 'y_fit', 'flux_fit', 'x_err', 'y_err', 'flux_err', 'qfit', 'cfit', 'reduced_chi2') for col in cols: assert np.all(np.isnan(phot[col][[0, 5, -2, -1]])) resid = psfphot.make_residual_image(data, psf_shape=fit_shape) assert isinstance(resid, np.ndarray) assert resid.shape == data.shape if units: assert isinstance(resid, u.Quantity) assert resid.unit == unit init_params = psfphot.results_to_init_params() assert isinstance(init_params, QTable) assert len(init_params) == 6 # 6 valid sources assert_equal(init_params['id'], np.arange(1, 7)) model_params = psfphot.results_to_model_params() assert isinstance(model_params, QTable) assert len(model_params) == 6 assert_equal(model_params['id'], np.arange(1, 7)) def test_psf_photometry_table_serialization(test_data): """ Test that photometry results table can be written to file. """ data, error, _ = test_data # Create PSFPhotometry with various components to test metadata # serialization psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) grouper = SourceGrouper(min_separation=2.0) local_bkg_estimator = LocalBackground(5, 10) fitter = TRFLSQFitter() psfphot = PSFPhotometry( psf_model, fit_shape, finder=finder, grouper=grouper, local_bkg_estimator=local_bkg_estimator, fitter=fitter, aperture_radius=4, ) # Perform photometry results = psfphot(data, error=error) # Test that we have results assert isinstance(results, QTable) assert len(results) > 0 # Test that metadata contains repr strings for class objects meta = results.meta assert 'psf_model' in meta assert 'finder' in meta assert 'grouper' in meta assert 'local_bkg_estimator' in meta assert 'fitter' in meta # Verify these are string representations, not objects assert isinstance(meta['psf_model'], str) assert isinstance(meta['finder'], str) assert isinstance(meta['grouper'], str) assert isinstance(meta['local_bkg_estimator'], str) assert isinstance(meta['fitter'], str) # Verify the repr strings contain expected content assert 'CircularGaussianPRF' in meta['psf_model'] assert 'DAOStarFinder' in meta['finder'] assert 'SourceGrouper' in meta['grouper'] assert 'LocalBackground' in meta['local_bkg_estimator'] assert 'TRFLSQFitter' in meta['fitter'] # Test file writing - this should not raise any errors with tempfile.NamedTemporaryFile(mode='w', suffix='.ecsv', delete=False) as tmp: # Write table to ECSV format results.write(tmp.name, format='ascii.ecsv', overwrite=True) # Read it back to verify it's readable read_table = Table.read(tmp.name, format='ascii.ecsv') # Basic checks that the table was written and read correctly assert len(read_table) == len(results) assert set(read_table.colnames) == set(results.colnames) # Check that metadata was preserved read_meta = read_table.meta assert 'psf_model' in read_meta assert 'finder' in read_meta assert isinstance(read_meta['psf_model'], str) assert isinstance(read_meta['finder'], str) def test_psf_photometry_invalid_coordinates(): """ Test PSF photometry with invalid coordinates. """ yy, xx = np.mgrid[:50, :50] psf_model = CircularGaussianPRF(x_0=25, y_0=25, flux=120, fwhm=2.7) data = psf_model(xx, yy) psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=3) n_sources = 3 init_params = Table() init_params['id'] = np.arange(1, n_sources + 1) init_params['x_init'] = [25.0, -5.0, 55.0] init_params['y_init'] = [25.0, 25.0, 25.0] init_params['flux_init'] = 100.0 init_params['group_id'] = 1 results = psfphot(data, init_params=init_params) assert len(results) == n_sources assert_equal(results['group_size'], [3, 3, 3]) cols = ('x_fit', 'y_fit', 'flux_fit', 'x_err', 'y_err', 'flux_err', 'qfit', 'cfit', 'reduced_chi2') for col in cols: assert np.all(np.isnan(results[col][1:])) def test_should_skip_source_coverage(): """ Test the _should_skip_source method directly to ensure coverage of specific boundary conditions. """ psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=3) data_shape = (50, 50) should_skip_source = psfphot._data_processor.should_skip_source # Test outside bounds - clearly beyond fit region row_data = { psfphot._param_mapper.init_colnames['x']: -5.0, psfphot._param_mapper.init_colnames['y']: 25.0, psfphot._param_mapper.init_colnames['flux']: 100.0, } row = Table([row_data])[0] # Create a table row should_skip, reason = should_skip_source(row, data_shape) assert should_skip is True assert reason == 'no_overlap' # Test outside bounds - coordinates well beyond data dimensions row_data = { psfphot._param_mapper.init_colnames['x']: 25.0, psfphot._param_mapper.init_colnames['y']: 60.0, psfphot._param_mapper.init_colnames['flux']: 100.0, } row = Table([row_data])[0] should_skip, reason = should_skip_source(row, data_shape) assert should_skip is True assert reason == 'no_overlap' # Test non-finite coordinates - NaN (this will bypass bounds check) row_data = { psfphot._param_mapper.init_colnames['x']: np.nan, psfphot._param_mapper.init_colnames['y']: 25.0, psfphot._param_mapper.init_colnames['flux']: 100.0, } row = Table([row_data])[0] should_skip, reason = should_skip_source(row, data_shape) assert should_skip is True assert reason == 'invalid_position' # Test non-finite coordinates - NaN in y coordinate row_data = { psfphot._param_mapper.init_colnames['x']: 25.0, psfphot._param_mapper.init_colnames['y']: np.nan, psfphot._param_mapper.init_colnames['flux']: 100.0, } row = Table([row_data])[0] should_skip, reason = should_skip_source(row, data_shape) assert should_skip is True assert reason == 'invalid_position' # Test non-finite flux row_data = { psfphot._param_mapper.init_colnames['x']: 25.0, psfphot._param_mapper.init_colnames['y']: 25.0, psfphot._param_mapper.init_colnames['flux']: np.nan, } row = Table([row_data])[0] should_skip, reason = should_skip_source(row, data_shape) assert should_skip is True assert reason == 'non_finite_flux' # Test valid coordinates row_data = { psfphot._param_mapper.init_colnames['x']: 25.0, psfphot._param_mapper.init_colnames['y']: 25.0, psfphot._param_mapper.init_colnames['flux']: 100.0, } row = Table([row_data])[0] should_skip, reason = should_skip_source(row, data_shape) assert should_skip is False assert reason is None def test_get_source_cutout_data_no_overlap(): """ Test the _get_source_cutout_data method with NoOverlapError exception. """ shape = (10, 10) data = np.zeros(shape) psf_model = CircularGaussianPRF(fwhm=2.0) fit_shape = (5, 5) psfphot = PSFPhotometry(psf_model, fit_shape) y_offsets, x_offsets = psfphot._data_processor.get_fit_offsets() # Create a source that will definitely cause NoOverlapError # Place it far outside the data bounds (-100, -100) to trigger # the exception in overlap_slices and test lines 1183-1192 init_params = QTable() init_params['x_init'] = [-100.0] init_params['y_init'] = [-100.0] init_params['flux_init'] = [1000.0] init_params['local_bkg'] = [0.0] init_params['id'] = [1] row = init_params[0] # Call the method that should trigger NoOverlapError result = psfphot._data_processor.get_source_cutout_data(row, data, None, y_offsets, x_offsets) # Verify the expected result for NoOverlapError exception handling assert result['valid'] is False assert result['reason'] == 'no_overlap' assert result['xx'] is None assert result['yy'] is None assert result['cutout'] is None assert result['npix'] == 0 assert np.isnan(result['cen_index']) def test_flags_with_invalid_and_nonfinite_sources(): """ Test flag computation with invalid and non-finite sources. This test creates scenarios with invalid sources and sources that end up with non-finite fitted positions, triggering the continue statements in the flag computation. """ shape = (30, 30) yy, xx = np.mgrid[:shape[0], :shape[1]] psf_model = CircularGaussianPRF(x_0=15, y_0=15, fwhm=2.0, flux=100) data = psf_model(xx, yy) # Use xy_bounds to trigger bound checking code paths psf_model = CircularGaussianPRF(fwhm=2.0) fit_shape = (7, 7) psfphot = PSFPhotometry(psf_model, fit_shape, xy_bounds=2.0) # Create init_params with mix of valid and invalid sources init_params = QTable() init_params['x_init'] = [15.0, # Valid source -100.0, # Invalid - outside bounds 15.0] # Valid source init_params['y_init'] = [15.0, # Valid source -100.0, # Invalid - outside bounds 15.0] # Valid source init_params['flux_init'] = [100.0, 100.0, 100.0] # Run photometry - should handle mix of valid/invalid sources results = psfphot(data, init_params=init_params) # Should return results for all sources assert len(results) == 3 # Check that flags were computed appropriately assert 'flags' in results.colnames # First and third sources should have valid fits assert np.isfinite(results['x_fit'][0]) assert np.isfinite(results['x_fit'][2]) # Second source should be flagged as invalid (no overlap) assert (results['flags'][1] & 64) == 64 # No overlap flag def test_levmar_fitter_with_fvec_residuals(): """ Test LevMarLSQFitter to exercise the 'fvec' residual key path. """ shape = (25, 25) psf_model = CircularGaussianPRF(flux=100, fwhm=2.5) data, _ = make_psf_model_image(shape, psf_model, n_sources=1, model_shape=(7, 7), flux=(100, 100), seed=0) psf_model = CircularGaussianPRF(flux=1, fwhm=2.5) # Use LevMarLSQFitter which produces 'fvec' in fit_info fitter = LevMarLSQFitter() init_params = Table() init_params['x_init'] = [12.0] init_params['y_init'] = [12.0] init_params['flux_init'] = [100.0] psfphot = PSFPhotometry(psf_model, fit_shape=(7, 7), fitter=fitter) # Run photometry - should use 'fvec' residual key results = psfphot(data, init_params=init_params) # Verify the fit completed successfully assert len(results) == 1 assert np.isfinite(results['x_fit'][0]) assert np.isfinite(results['y_fit'][0]) assert np.isfinite(results['flux_fit'][0]) # Verify that 'fvec' was found in fit_info assert 'fvec' in psfphot.fitter.fit_info def _compare_lists_with_arrays(list1, list2): if len(list1) != len(list2): return False for item1, item2 in zip(list1, list2, strict=True): if isinstance(item1, dict) and isinstance(item2, dict): if item1.keys() != item2.keys(): return False for key in item1: if isinstance(item1[key], np.ndarray): if not np.array_equal(item1[key], item2[key], equal_nan=True): return False elif item1[key] != item2[key]: return False elif item1 != item2: return False return True @pytest.mark.parametrize('reorder', ['reversed', 'permutate']) @pytest.mark.parametrize('with_groups', [True, False]) @pytest.mark.parametrize('nonconsec_groups', [True, False]) @pytest.mark.parametrize('nonconsec_ids', [True, False]) def test_init_params_id_order(test_data, reorder, with_groups, nonconsec_groups, nonconsec_ids): data, error, sources = test_data init_params = sources.copy() nsrc = len(sources) rng = np.random.default_rng(seed=0) init_params['id'] = np.arange(1, nsrc + 1) if with_groups: # same groupings, but different group ids if nonconsec_groups: group_ids = [11, 20, 11, 20, 20, 39, 20, 39, 44, 44] else: group_ids = [1, 2, 1, 2, 2, 3, 2, 3, 4, 4] init_params['group_id'] = group_ids init_params['local_bkg'] = rng.normal(size=nsrc) init_params['x_0'][0] = 1000 init_params['y_0'][0] = 1000 init_params['x_0'][5] = 1000 init_params['x_0'][-2] = 1000 init_params['x_0'][-1] = 1000 psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) psfphot1 = PSFPhotometry(psf_model, fit_shape) phot1 = psfphot1(data, error=error, init_params=init_params) # reorder init_params init_params2 = init_params.copy() if nonconsec_ids: # non-consecutive random ids # monotonically increasing so final results order should be same steps = rng.integers(1, 51, size=nsrc - 1) init_params2['id'] = np.concatenate(([1], 1 + np.cumsum(steps))) if reorder == 'reversed': init_params2 = init_params2[::-1] elif reorder == 'permutate': init_params2 = init_params2[rng.permutation(nsrc)] psfphot2 = PSFPhotometry(psf_model, fit_shape) phot2 = psfphot2(data, error=error, init_params=init_params2) if not nonconsec_ids: assert_equal(phot1['id'], phot2['id']) if with_groups: # without group_id, group_id gets set to id assert_equal(phot1['group_id'], phot2['group_id']) assert np.all([np.allclose(phot1[col], phot2[col], equal_nan=True) for col in phot1.colnames if col not in ('id', 'group_id')]) # Compare fit_info with special handling for numpy arrays _compare_lists_with_arrays(psfphot1.fit_info, psfphot2.fit_info) def test_reduced_chi2_metric(): """ Test the reduced chi-squared metric calculation. """ psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) model_shape = (9, 9) n_sources = 3 shape = (51, 51) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=10, seed=0) sigma = 0.9 noise = make_noise_image(data.shape, mean=0, stddev=sigma, seed=0) data += noise error = np.full(data.shape, sigma) # Test with error array psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4) results = psfphot(data, error=error, init_params=true_params) assert 'reduced_chi2' in results.colnames valid_fits = results['flags'] == 0 assert np.all(np.isfinite(results['reduced_chi2'][valid_fits])) assert np.all(results['reduced_chi2'][valid_fits] > 0) assert not isinstance(results['reduced_chi2'], u.Quantity) # Test without error array results_no_error = psfphot(data, init_params=true_params) assert np.all(np.isnan(results_no_error['reduced_chi2'])) def test_qfit_cfit_with_different_errors(test_data): """ Test qfit and cfit with different error values. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) # Test without errors phot_no_error = psfphot(data) # Test without providing error array phot = psfphot(data, error=error) # Test with small errors error_small = np.full(data.shape, 0.1) phot_small_error = psfphot(data, error=error_small) # Test with large errors error_large = np.full(data.shape, 10.0) phot_large_error = psfphot(data, error=error_large) assert np.all(phot['qfit'] >= 0) assert np.all(phot_no_error['qfit'] >= 0) assert np.all(phot_small_error['qfit'] >= 0) assert np.all(phot_large_error['qfit'] >= 0) assert_allclose(phot['qfit'], phot_no_error['qfit']) assert_allclose(phot['cfit'], phot_no_error['cfit']) assert_allclose(phot_small_error['qfit'], phot_no_error['qfit']) assert_allclose(phot_small_error['cfit'], phot_no_error['cfit']) assert_allclose(phot_large_error['qfit'], phot_no_error['qfit']) assert_allclose(phot_large_error['cfit'], phot_no_error['cfit']) def test_decode_flags(): """ Test the decode_flags convenience method. """ # Create test data with some sources that will have flags yy, xx = np.mgrid[:21, :21] psf_model = CircularGaussianPRF(flux=1, x_0=10, y_0=10, fwhm=2) # Source 1: normal source (no flags expected) m1 = CircularGaussianPRF(flux=100, x_0=10, y_0=10, fwhm=2) # Source 2: negative flux (will have negative_flux flag) m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) # Source 3: outside bounds (will have outside_bounds flag) m3 = CircularGaussianPRF(flux=100, x_0=25, y_0=25, fwhm=2) data = m1(xx, yy) + m2(xx, yy) + m3(xx, yy) init_params = Table({ 'x': [10, 5, 25], 'y': [10, 5, 25], 'flux': [100, 100, 100], }) psfphot = PSFPhotometry(psf_model, (3, 3)) # Test that decode_flags raises ValueError before running photometry match = 'No results available' with pytest.raises(ValueError, match=match): psfphot.decode_flags() # Run photometry results = psfphot(data, init_params=init_params) # Test decode_flags method decoded_flags = psfphot.decode_flags() # Check that we get a list of lists assert isinstance(decoded_flags, list) assert len(decoded_flags) == len(results) # Each element should be a list of strings for decoded in decoded_flags: assert isinstance(decoded, list) for flag_name in decoded: assert isinstance(flag_name, str) # Check that the first source has no flags or minimal flags # (depending on fitting success) assert isinstance(decoded_flags[0], list) # Check that the second source has the negative_flux flag assert 'negative_flux' in decoded_flags[1] # Check that the third source has flags (it's outside the image bounds) # It should have 'no_overlap' since it's completely outside assert len(decoded_flags[2]) > 0 assert 'no_overlap' in decoded_flags[2] # Verify that decode_flags gives the same result as calling # decode_psf_flags directly from photutils.psf.flags import decode_psf_flags direct_decoded = decode_psf_flags(results['flags']) assert decoded_flags == direct_decoded astropy-photutils-3322558/photutils/psf/tests/test_positional_kwargs.py000066400000000000000000000026021517052111400265730ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.psf.flags import decode_psf_flags from photutils.psf.groupers import SourceGrouper class TestDecodePSFFlagsPositionalKwargs: """ Test decode_psf_flags warns for positional optional args. """ def test_positional_warns(self): match = 'decode_psf_flags' with pytest.warns(AstropyDeprecationWarning, match=match): decode_psf_flags(0, True) # noqa: FBT003 def test_keyword_no_warning(self): decode_psf_flags(0, return_bit_values=True) class TestSourceGrouperPositionalKwargs: """ Test SourceGrouper.__call__ warns for positional optional args. """ def test_positional_warns(self): grouper = SourceGrouper(min_separation=10) x = np.array([0.0, 5.0, 50.0]) y = np.array([0.0, 5.0, 50.0]) match = '__call__' with pytest.warns(AstropyDeprecationWarning, match=match): grouper(x, y, True) # noqa: FBT003 def test_keyword_no_warning(self): grouper = SourceGrouper(min_separation=10) x = np.array([0.0, 5.0, 50.0]) y = np.array([0.0, 5.0, 50.0]) grouper(x, y, return_groups_object=True) astropy-photutils-3322558/photutils/psf/tests/test_simulation.py000066400000000000000000000053721517052111400252270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the simulation module. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.table import Table from numpy.testing import assert_equal from photutils.psf import (CircularGaussianPRF, make_psf_model, make_psf_model_image) def test_make_psf_model_image(): shape = (401, 451) n_sources = 100 model = CircularGaussianPRF(fwhm=2.7) 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) fwhm = (2.5, 4.5) alpha = (0, 1) n_sources = 10 data, params = make_psf_model_image(shape, model, n_sources, seed=0, flux=flux, fwhm=fwhm, alpha=alpha) assert len(params) == n_sources colnames = ('id', 'x_0', 'y_0', 'flux', 'fwhm') 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['fwhm']) >= fwhm[0] assert np.max(params['fwhm']) <= fwhm[1] 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 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' model = CircularGaussianPRF(fwhm=2.7) model.n_inputs = 3 with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) match = 'model_shape must be specified if the model does not have' model = CircularGaussianPRF(fwhm=2.7) model.bounding_box = None with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) match = 'Invalid PSF model - could not find PSF parameter names' model = Gaussian2D() with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) astropy-photutils-3322558/photutils/psf/tests/test_utils.py000066400000000000000000000264261517052111400242060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the utils module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.table import QTable from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.psf import CircularGaussianPRF, make_psf_model_image from photutils.psf.utils import (_get_psf_model_main_params, _interpolate_missing_data, _validate_psf_model, fit_2dgaussian, fit_fwhm) @pytest.fixture(name='test_data') def fixture_test_data(): psf_model = CircularGaussianPRF() 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), fwhm=(2.7, 2.7), min_separation=10, seed=0) return data, true_params @pytest.mark.parametrize('fix_fwhm', [False, True]) def test_fit_2dgaussian_single(fix_fwhm): yy, xx = np.mgrid[:51, :51] fwhm = 3.123 model = CircularGaussianPRF(x_0=22.17, y_0=28.87, fwhm=fwhm) data = model(xx, yy) match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source') with pytest.warns(AstropyUserWarning, match=match): fit = fit_2dgaussian(data, fwhm=3, fix_fwhm=fix_fwhm) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == 1 if fix_fwhm: assert 'fwhm_fit' not in fit_tbl.colnames else: assert 'fwhm_fit' in fit_tbl.colnames assert_allclose(fit_tbl['fwhm_fit'], fwhm) # test with NaNs - will emit two warnings data[22, 29] = np.nan with pytest.warns(AstropyUserWarning) as record: fit = fit_2dgaussian(data, fwhm=3, fix_fwhm=fix_fwhm) # Check that both warnings were emitted assert len(record) == 2 warning_msgs = [str(w.message) for w in record] assert any('fit_shape is None' in msg for msg in warning_msgs) assert any('non-finite values' in msg for msg in warning_msgs) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == 1 # test with NaNs and mask - only fit_shape warning data[22, 29] = np.nan mask = np.isnan(data) match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source') with pytest.warns(AstropyUserWarning, match=match): fit = fit_2dgaussian(data, fwhm=3, fix_fwhm=fix_fwhm, mask=mask) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == 1 @pytest.mark.parametrize(('fix_fwhm', 'with_units'), [(False, True), (True, False)]) def test_fit_2dgaussian_multiple(test_data, fix_fwhm, with_units): data, sources = test_data unit = u.nJy if with_units: data = data * unit xypos = list(zip(sources['x_0'], sources['y_0'], strict=True)) fit = fit_2dgaussian(data, xypos=xypos, fit_shape=(5, 5), fix_fwhm=fix_fwhm) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == len(sources) if fix_fwhm: assert 'fwhm_fit' not in fit_tbl.colnames else: assert 'fwhm_fit' in fit_tbl.colnames assert_allclose(fit_tbl['fwhm_fit'], sources['fwhm']) # test with zip instead of list xypos = zip(sources['x_0'], sources['y_0'], strict=True) fit2 = fit_2dgaussian(data, xypos=xypos, fit_shape=(5, 5), fix_fwhm=fix_fwhm) fit_tbl2 = fit2.results assert isinstance(fit_tbl2, QTable) assert fit_tbl2.values_equal(fit_tbl) if with_units: for column in fit_tbl.colnames: if 'flux' in column: assert fit_tbl['flux_fit'].unit == unit def test_fit_2dgaussian_fit_shape_none_single(): """ Test fit_2dgaussian with fit_shape=None for a single source. Should emit a warning and use the data shape (adjusted to odd). """ yy, xx = np.mgrid[:50, :50] # Even shape fwhm = 3.0 model = CircularGaussianPRF(x_0=25.0, y_0=25.0, fwhm=fwhm) data = model(xx, yy) match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source') with pytest.warns(AstropyUserWarning, match=match): fit = fit_2dgaussian(data, fit_shape=None, fix_fwhm=False) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == 1 assert_allclose(fit_tbl['fwhm_fit'], fwhm, rtol=0.01) def test_fit_2dgaussian_fit_shape_none_single_odd(): """ Test fit_2dgaussian with fit_shape=None for single source with odd shape. Should emit a warning and use the data shape unchanged. """ yy, xx = np.mgrid[:51, :51] # Odd shape fwhm = 3.123 model = CircularGaussianPRF(x_0=25.0, y_0=25.0, fwhm=fwhm) data = model(xx, yy) match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source') with pytest.warns(AstropyUserWarning, match=match): fit = fit_2dgaussian(data, fit_shape=None, fix_fwhm=False) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == 1 assert_allclose(fit_tbl['fwhm_fit'], fwhm, rtol=0.01) def test_fit_2dgaussian_fit_shape_none_multiple(): """ Test fit_2dgaussian raises ValueError with fit_shape=None for multiple sources. """ yy, xx = np.mgrid[:51, :51] model1 = CircularGaussianPRF(x_0=20.0, y_0=20.0, fwhm=3.0, flux=100) model2 = CircularGaussianPRF(x_0=30.0, y_0=30.0, fwhm=3.0, flux=100) data = model1(xx, yy) + model2(xx, yy) xypos = [(20.0, 20.0), (30.0, 30.0)] match = ('fit_shape is required when fitting multiple sources') with pytest.raises(ValueError, match=match): fit_2dgaussian(data, xypos=xypos, fit_shape=None) def test_fit_fwhm_single(): yy, xx = np.mgrid[:51, :51] fwhm0 = 3.123 model = CircularGaussianPRF(x_0=22.17, y_0=28.87, fwhm=fwhm0) data = model(xx, yy) # fit_fwhm re-emits the fit_shape warning match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source') with pytest.warns(AstropyUserWarning, match=match): fwhm = fit_fwhm(data, fwhm=3) assert isinstance(fwhm, np.ndarray) assert len(fwhm) == 1 assert_allclose(fwhm, fwhm0) # test warning message for convergence issues - flat data should also # emit the fit_shape warning since fit_shape=None with pytest.warns(AstropyUserWarning) as record: fwhm = fit_fwhm(np.zeros(data.shape) + 1) # Should get at least the fit_shape warning assert len(record) >= 1 warning_msgs = [str(w.message) for w in record] assert any('fit_shape is None' in msg for msg in warning_msgs) assert len(fwhm) == 1 @pytest.mark.parametrize('with_units', [False, True]) def test_fit_fwhm_multiple(test_data, with_units): data, sources = test_data unit = u.nJy if with_units: data = data * unit xypos = list(zip(sources['x_0'], sources['y_0'], strict=True)) fwhms = fit_fwhm(data, xypos=xypos, fit_shape=(5, 5)) assert isinstance(fwhms, np.ndarray) assert len(fwhms) == len(sources) assert_allclose(fwhms, sources['fwhm']) def test_fit_fwhm_fit_shape_none(test_data): data, sources = test_data xypos = (sources['x_0'][0], sources['y_0'][0]) match = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image') with pytest.warns(AstropyUserWarning, match=match): fit_fwhm(data, xypos=xypos, fit_shape=None) 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') def test_interpolate_missing_data_edge_pixels(): """ Test that edge pixels are always filled with cubic interpolation. """ data = np.arange(100, dtype=float).reshape(10, 10) mask = np.zeros_like(data, dtype=bool) # Mask corner and edge pixels where cubic interpolation typically # fails mask[0, 0] = True # corner mask[0, 5] = True # top edge mask[9, 9] = True # corner mask[5, 9] = True # right edge data_int = _interpolate_missing_data(data, mask, method='cubic') # All masked pixels should be filled (no NaN values) assert np.all(np.isfinite(data_int)) assert not np.any(np.isnan(data_int[mask])) def test_interpolate_missing_data_no_mask(): """ Test that data is returned unchanged when no pixels are masked. """ data = np.arange(100, dtype=float).reshape(10, 10) mask = np.zeros_like(data, dtype=bool) data_int = _interpolate_missing_data(data, mask, method='cubic') assert np.array_equal(data, data_int) def test_interpolate_missing_data_all_masked(): """ Test that all-masked data returns NaN array. """ data = np.arange(100, dtype=float).reshape(10, 10) mask = np.ones_like(data, dtype=bool) # All pixels masked data_int = _interpolate_missing_data(data, mask, method='cubic') # All values should be NaN when all data is masked assert np.all(np.isnan(data_int)) # Same for nearest-neighbor method data_int = _interpolate_missing_data(data, mask, method='nearest') assert np.all(np.isnan(data_int)) def test_validate_psf_model(): model = np.arange(10) match = 'psf_model must be an Astropy Model subclass' with pytest.raises(TypeError, match=match): _validate_psf_model(model) match = 'psf_model must be two-dimensional' model = Gaussian1D() with pytest.raises(ValueError, match=match): _validate_psf_model(model) match = 'psf_model must be two-dimensional' model = Gaussian1D() with pytest.raises(ValueError, match=match): _validate_psf_model(model) def test_get_psf_model_main_params(): model = CircularGaussianPRF(fwhm=1.0) params = _get_psf_model_main_params(model) assert len(params) == 3 assert params == ('x_0', 'y_0', 'flux') match = 'Invalid PSF model - could not find PSF parameter names' model = Gaussian2D() with pytest.raises(ValueError, match=match): _get_psf_model_main_params(model) set_params = ('x_mean', 'y_mean', 'amplitude') model.x_name = set_params[0] model.y_name = set_params[1] model.flux_name = set_params[2] params = _get_psf_model_main_params(model) assert len(params) == 3 assert params == set_params astropy-photutils-3322558/photutils/psf/utils.py000066400000000000000000000671431517052111400220060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for PSF-fitting photometry. """ import warnings import numpy as np from astropy.modeling import Model from astropy.table import QTable from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning from scipy import interpolate from photutils.centroids import centroid_com from photutils.psf.functional_models import CircularGaussianPRF from photutils.utils import CutoutImage from photutils.utils._parameters import as_pair __all__ = ['fit_2dgaussian', 'fit_fwhm'] def _make_mask(image, mask): """ Create a mask for the input image. Non-finite values (e.g., NaN or inf) in the ``image`` array are automatically masked. If a mask is provided, then the non-finite values are combined with the provided mask. Parameters ---------- image : 2D `~numpy.ndarray` The input image. mask : 2D bool `~numpy.array` or None A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- mask : 2D bool `~numpy.ndarray` or `None` The mask for the input image. A `True` value indicates the corresponding element of ``image`` is masked. """ def warn_nonfinite(): msg = ('Input data contains unmasked non-finite values ' '(NaN or inf), which were automatically ignored.') warnings.warn(msg, 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 fit_2dgaussian(data, *, xypos=None, fwhm=None, fix_fwhm=True, fit_shape=None, mask=None, error=None): """ Fit a 2D Gaussian model to one or more sources in an image. This convenience function uses a `~photutils.psf.CircularGaussianPRF` model to fit the sources using the `~photutils.psf.PSFPhotometry` class. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : 2D array The 2D array of the image. The input array must be background subtracted. xypos : array-like, optional The initial (x, y) pixel coordinates of the sources as a list of tuples or a 2D array. If `None`, then one source will be fit with an initial position using the center-of-mass centroid of the ``data`` array. fwhm : float, optional The initial guess for the FWHM of the Gaussian PSF model. If `None`, then the initial guess is half the mean of the x and y sizes of the ``fit_shape`` values. fix_fwhm : bool, optional Whether to fix the FWHM of the Gaussian PSF model during the fitting process. fit_shape : int or tuple of two ints, optional The shape of the fitting region. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` will be used. mask : array-like (bool), optional A boolean mask with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D array, 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. Returns ------- result : `~photutils.psf.PSFPhotometry` The PSF-fitting photometry results. See Also -------- fit_fwhm : Fit the FWHM of one or more sources in an image. Notes ----- The source(s) are fit with a `~photutils.psf.CircularGaussianPRF` model using the `~photutils.psf.PSFPhotometry` class. The initial guess for the flux is the sum of the pixel values within the fitting region. If ``fwhm`` is `None`, then the initial guess for the FWHM is half the mean of the x and y sizes of the ``fit_shape`` values. Examples -------- Fit a 2D Gaussian model to an image containing only one source (e.g., a cutout image): >>> import numpy as np >>> from photutils.psf import CircularGaussianPRF, fit_2dgaussian >>> yy, xx = np.mgrid[:51, :51] >>> model = CircularGaussianPRF(x_0=22.17, y_0=28.87, fwhm=3.123, flux=9.7) >>> data = model(xx, yy) >>> fit = fit_2dgaussian(data, fix_fwhm=False, fit_shape=7) >>> phot_tbl = fit.results # doctest: +FLOAT_CMP >>> cols = ['x_fit', 'y_fit', 'fwhm_fit', 'flux_fit'] >>> for col in cols: ... phot_tbl[col].info.format = '.4f' # optional format >>> print(phot_tbl[['id'] + cols]) id x_fit y_fit fwhm_fit flux_fit --- ------- ------- -------- -------- 1 22.1700 28.8700 3.1230 9.7000 Fit a 2D Gaussian model to multiple sources in an image: >>> import numpy as np >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import (CircularGaussianPRF, fit_2dgaussian, ... make_psf_model_image) >>> model = CircularGaussianPRF() >>> data, sources = make_psf_model_image((100, 100), model, 5, ... min_separation=25, ... model_shape=(15, 15), ... flux=(100, 200), fwhm=[3, 8]) >>> finder = DAOStarFinder(0.1, 5) >>> finder_tbl = finder(data) >>> xypos = zip(sources['x_0'], sources['y_0']) >>> psfphot = fit_2dgaussian(data, xypos=xypos, fit_shape=7, ... fix_fwhm=False) >>> phot_tbl = psfphot.results >>> len(phot_tbl) 5 Here we show only a few columns of the photometry table: >>> cols = ['x_fit', 'y_fit', 'fwhm_fit', 'flux_fit'] >>> for col in cols: ... phot_tbl[col].info.format = '.4f' # optional format >>> print(phot_tbl[['id'] + cols]) id x_fit y_fit fwhm_fit flux_fit --- ------- ------- -------- -------- 1 61.7787 74.6905 5.6947 147.9988 2 30.2017 27.5858 5.2138 123.2373 3 10.5237 82.3776 7.6551 180.1881 4 8.4214 12.0369 3.2026 192.3530 5 76.9412 35.9061 6.6600 126.6130 """ # prevent circular import from photutils.psf.photometry import PSFPhotometry # mask non-finite values mask = _make_mask(data, mask) if xypos is None: xypos = centroid_com(data, mask=mask) if isinstance(xypos, zip): xypos = np.array(list(xypos)) xypos = np.atleast_2d(xypos) if fit_shape is None: if len(xypos) > 1: msg = ('fit_shape is required when fitting multiple sources. If ' 'fit_shape is not provided, then the fit may not converge ' 'or may give poor results. For multiple sources, you ' 'should provide a fit_shape value that is appropriate for ' 'the size of the sources in your image.') raise ValueError(msg) fit_shape = data.shape # Ensure odd shape required by the PSF photometry fitting # Here we trim the even edges by 1 pixel fit_shape = tuple(s - 1 if s % 2 == 0 else s for s in fit_shape) msg = ('fit_shape is None, so the input data array is assumed to be ' 'a cutout image containing only one source. If your input ' 'data is not a cutout image, then the fit may not converge ' 'or may give poor results. For non-cutout input data, you ' 'should provide a fit_shape value that is appropriate for ' 'the size of the sources in your image. ' f'Using fit_shape={fit_shape}.') warnings.warn(msg, AstropyUserWarning) else: fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(1, 1), check_odd=True) flux_init = [] for yxpos in xypos[:, ::-1]: cutout = CutoutImage(data, yxpos, tuple(fit_shape)) cutout = cutout.data[np.isfinite(cutout.data)] flux_init.append(np.nansum(cutout)) if isinstance(data, Quantity): flux_init <<= data.unit init_params = QTable() init_params['x'] = xypos[:, 0] init_params['y'] = xypos[:, 1] init_params['flux'] = flux_init if fwhm is None: fwhm = np.mean(fit_shape) / 2.0 init_params['fwhm'] = fwhm model = CircularGaussianPRF(fwhm=fwhm) model.fwhm.min = 0.0 if not fix_fwhm: model.fwhm.fixed = False phot = PSFPhotometry(model, fit_shape) _ = phot(data, mask=mask, error=error, init_params=init_params) return phot def fit_fwhm(data, *, xypos=None, fwhm=None, fit_shape=None, mask=None, error=None): """ Fit the FWHM of one or more sources in an image. This convenience function uses a `~photutils.psf.CircularGaussianPRF` model to fit the sources using the `~photutils.psf.PSFPhotometry` class. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : 2D array The 2D array of the image. The input array must be background subtracted. xypos : array-like, optional The initial (x, y) pixel coordinates of the sources as a list of tuples or a 2D array. If `None`, then one source will be fit with an initial position using the center-of-mass centroid of the ``data`` array. fwhm : float, optional The initial guess for the FWHM of the Gaussian PSF model. If `None`, then the initial guess is half the mean of the x and y sizes of the ``fit_shape`` values. fit_shape : int or tuple of two ints, optional The shape of the fitting region. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` will be used. mask : array-like (bool), optional A boolean mask with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D array, 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. Returns ------- fwhm : `~numpy.ndarray` The FWHM of the sources. Note that the returned FWHM values are always positive. See Also -------- fit_2dgaussian : Fit a 2D Gaussian model to one or more sources in an image. Notes ----- The source(s) are fit using the :func:`fit_2dgaussian` function, which uses a `~photutils.psf.CircularGaussianPRF` model with the `~photutils.psf.PSFPhotometry` class. The initial guess for the flux is the sum of the pixel values within the fitting region. If ``fwhm`` is `None`, then the initial guess for the FWHM is half the mean of the x and y sizes of the ``fit_shape`` values. Examples -------- Fit the FWHM of a single source (e.g., a cutout image): >>> import numpy as np >>> from photutils.psf import CircularGaussianPRF, fit_fwhm >>> yy, xx = np.mgrid[:51, :51] >>> model = CircularGaussianPRF(x_0=22.17, y_0=28.87, fwhm=3.123, flux=9.7) >>> data = model(xx, yy) >>> fwhm = fit_fwhm(data, fit_shape=7) >>> fwhm # doctest: +FLOAT_CMP array([3.123]) Fit the FWHMs of multiple sources in an image: >>> import numpy as np >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import (CircularGaussianPRF, fit_fwhm, ... make_psf_model_image) >>> model = CircularGaussianPRF() >>> data, sources = make_psf_model_image((100, 100), model, 5, ... min_separation=25, ... model_shape=(15, 15), ... flux=(100, 200), fwhm=[3, 8]) >>> finder = DAOStarFinder(0.1, 5) >>> finder_tbl = finder(data) >>> xypos = zip(sources['x_0'], sources['y_0']) >>> fwhms = fit_fwhm(data, xypos=xypos, fit_shape=7) >>> fwhms # doctest: +FLOAT_CMP array([5.69467204, 5.21376414, 7.65508658, 3.20255356, 6.66003098]) """ with warnings.catch_warnings(record=True) as fit_warnings: phot = fit_2dgaussian(data, xypos=xypos, fwhm=fwhm, fix_fwhm=False, fit_shape=fit_shape, mask=mask, error=error) # Re-emit fit_shape warnings and check for other warnings fit_shape_warning = False other_warnings = False for warning in fit_warnings: if 'fit_shape is None' in str(warning.message): warnings.warn(str(warning.message), warning.category) fit_shape_warning = True else: other_warnings = True if other_warnings and not fit_shape_warning: msg = ('One or more fit(s) may not have converged. Please ' 'carefully check your results. You may need to change ' 'the input "xypos" and "fit_shape" parameters.') warnings.warn(msg, AstropyUserWarning) return np.array(phot.results['fwhm_fit']) 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. If any masked pixels cannot be interpolated using cubic interpolation (e.g., at the edges), they will be filled using nearest-neighbor interpolation as a fallback. * ``'nearest'``: Masked data are interpolated using nearest-neighbor interpolation. Returns ------- data_interp : 2D `~numpy.ndarray` The interpolated 2D image. All masked pixels are guaranteed to be filled if there are any valid (unmasked) pixels. If all pixels are masked, the returned array will contain NaN values. """ data_interp = np.copy(data) if len(data_interp.shape) != 2: msg = 'data must be a 2D array' raise ValueError(msg) if mask.shape != data.shape: msg = 'mask and data must have the same shape' raise ValueError(msg) if not np.any(mask): return data_interp # Check if all pixels are masked - cannot interpolate if np.all(mask): data_interp[:] = np.nan return data_interp # initialize the interpolator y, x = np.indices(data_interp.shape) xy = np.dstack((x[~mask].ravel(), y[~mask].ravel()))[0] z = data_interp[~mask].ravel() # interpolate the missing data if method == 'nearest': interpol = interpolate.NearestNDInterpolator(xy, z) elif method == 'cubic': interpol = interpolate.CloughTocher2DInterpolator(xy, z) else: msg = 'Unsupported interpolation method' raise ValueError(msg) xy_missing = np.dstack((x[mask].ravel(), y[mask].ravel()))[0] data_interp[mask] = interpol(xy_missing) # For cubic interpolation, some edge pixels may not be interpolated # (NaN values). Use nearest-neighbor interpolation as a fallback. if method == 'cubic': remaining_mask = ~np.isfinite(data_interp) if np.any(remaining_mask): xy_valid = np.dstack((x[~remaining_mask].ravel(), y[~remaining_mask].ravel()))[0] z_valid = data_interp[~remaining_mask].ravel() interpol_nn = interpolate.NearestNDInterpolator(xy_valid, z_valid) xy_remaining = np.dstack((x[remaining_mask].ravel(), y[remaining_mask].ravel()))[0] data_interp[remaining_mask] = interpol_nn(xy_remaining) return data_interp 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): msg = 'psf_model must be an Astropy Model subclass' raise TypeError(msg) if psf_model.n_inputs != 2 or psf_model.n_outputs != 1: msg = ('psf_model must be two-dimensional with ' 'n_inputs=2 and n_outputs=1') raise ValueError(msg) return psf_model def _get_psf_model_main_params(psf_model): """ Get the names of the main 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. These are always returned in the order of (x, y, flux). """ 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 _create_call_docstring(*, iterative=False): """ Decorator factory to create the __call__ method docstring for PSF photometry methods. This decorator factory creates a decorator that provides a base docstring for PSF photometry methods and customizes it based on the class type (PSFPhotometry vs IterativePSFPhotometry). Parameters ---------- iterative : bool, optional If True, customize the docstring for IterativePSFPhotometry. If False, customize for PSFPhotometry. Returns ------- decorator : callable A method decorator that updates the method's docstring. """ def decorator(func): """ Method decorator that updates the method's docstring. """ # Import PSF_FLAGS here to avoid circular imports from .flags import PSF_FLAGS base_docstring = """ 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. ``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{init_params_suffix}. If the initial x and y values are not included, then the ``finder`` keyword must be defined. If the initial flux values are not included, then the ``aperture_radius`` keyword must be defined to measure the initial flux values. Note that the initial flux values refer to the model flux parameters and are not corrected for local background values (computed using ``local_bkg_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 ``local_bkg_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 in the group. This number includes sources that are in the group, but were not fit due to being masked, having no overlap with the input data, or having too few pixels for a fit. {iter_detected_column} * ``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 * ``n_pixels_fit`` : the number of unmasked pixels used to fit the source * ``qfit`` : a quality-of-fit metric defined as the sum of the absolute value of the fit residuals divided by the fit flux. ``qfit`` is zero for sources that are perfectly fit by the PSF model. * ``cfit`` : a quality-of-fit metric defined as the fit residual (data - model) in the initial central pixel value divided by the fit flux. NaN values indicate that the central pixel was masked. Large positive values indicate sources that are sharper than the PSF model (e.g., cosmic ray, hot pixel, etc.). Large negative values indicate sources that are broader than the PSF model * ``reduced_chi2`` : the reduced chi-squared statistic. If no ``error`` array is provided, ``reduced_chi2`` values will be NaN. * ``flags`` : bitwise flag values Notes ----- The ``qfit`` and ``cfit`` metrics are equivalent to the ``q`` and ``C`` fits metrics defined by the HST PSF photometry `hst1pass `_ software. They are also similar to the ``chi`` (``qfit``) and ``sharp`` (``cfit``) metrics used by `DAOPHOT `_. """ if iterative: # Customizations for IterativePSFPhotometry customized_docstring = base_docstring.format( init_params_suffix=(' *only for\n ' 'the first iteration*'), iter_detected_column=(' * ``iter_detected`` : the ' 'iteration number in which the' '\n source was detected\n'), ) else: # Customizations for PSFPhotometry customized_docstring = base_docstring.format( init_params_suffix='', iter_detected_column='', ) # Apply the flag descriptions replacement placeholder = '' if placeholder in customized_docstring: # Generate the flag descriptions flag_descriptions = [] flag_descriptions.append('') flag_descriptions.append(' - 0 : no flags') for flag_def in PSF_FLAGS.FLAG_DEFINITIONS: desc = flag_def.description line = f' - {flag_def.bit_value} : {desc}' flag_descriptions.append(line) # Replace the placeholder with the flag descriptions flag_text = '\n'.join(flag_descriptions) customized_docstring = customized_docstring.replace( placeholder, flag_text) # Update the method's docstring func.__doc__ = customized_docstring return func return decorator astropy-photutils-3322558/photutils/psf_matching/000077500000000000000000000000001517052111400221335ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf_matching/__init__.py000066400000000000000000000004401517052111400242420ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing tools for generating kernels for matching point spread functions. """ from .fourier import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 from .windows import * # noqa: F401, F403 astropy-photutils-3322558/photutils/psf_matching/fourier.py000066400000000000000000000461361517052111400241720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for matching PSFs using Fourier methods. """ import numpy as np from astropy.utils.decorators import deprecated from scipy.fft import fft2, fftshift, ifft2 from photutils.psf_matching.utils import (_apply_window_to_fourier, _convert_psf_to_otf, _validate_kernel_inputs) __all__ = ['create_matching_kernel', 'make_kernel', 'make_wiener_kernel'] def make_kernel(source_psf, target_psf, *, window=None, regularization=1e-4): """ Make a convolution kernel that matches an input PSF to a target PSF using the ratio of Fourier transforms. This function computes the matching kernel in the Fourier domain by dividing the target PSF's Fourier transform by the source PSF's Fourier transform. To avoid division by near-zero values, the Fourier ratio is set to zero at frequencies where the source OTF (Optical Transfer Function) amplitude falls below a threshold. The kernel is computed as: .. math:: K = \\mathcal{F}^{-1} \\left[ W \\cdot R \\right] where the Fourier-space ratio :math:`R` is defined as: .. math:: R = \\begin{cases} \\frac{T}{S} & \\text{if } |S| > \\lambda \\cdot \\max(|S|) \\\\ 0 & \\text{otherwise} \\end{cases} Here, :math:`\\mathcal{F}^{-1}` is the inverse Fourier transform, :math:`S` and :math:`T` are the Fourier transforms of the source and target PSFs (the optical transfer functions, OTFs), :math:`\\lambda` is the ``regularization`` parameter, and :math:`W` is the optional ``window`` function (defaulting to 1 if not provided). 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. It is assumed to be centered on the central pixel. 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. It is assumed to be centered on the central pixel. window : callable, optional The window (taper) function or callable class instance used to remove high frequency noise from the PSF matching kernel. The window function should be a callable that accepts a single ``shape`` parameter (a tuple defining the 2D array shape) and returns a 2D array of the same shape. The returned window values must be in the range [0, 1], where 1.0 indicates full preservation of that spatial frequency and 0.0 indicates complete suppression. The window must be centered on the central pixel. Built-in window classes 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, custom windows, and example usage, see :ref:`PSF Matching `. regularization : float, optional The regularization parameter that controls the OTF amplitude threshold for the source OTF (Optical Transfer Function, the Fourier transform of the PSF). At frequencies where the source OTF amplitude is below ``regularization`` times the peak amplitude, the Fourier ratio is set to zero to avoid division by near-zero values. Must be in the range [0, 1), where 0 provides no thresholding (only exact zeros are excluded) and values closer to 1 apply more aggressive thresholding. A value of 1 would zero out all frequencies and produce a degenerate kernel. 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. Raises ------ ValueError If the PSFs are not 2D arrays, have even dimensions, or do not have the same shape, if ``regularization`` is not in the range [0, 1), or if the window function output is invalid (not a 2D array, wrong shape, or values outside [0, 1]). TypeError If the input ``window`` is not callable. See Also -------- make_wiener_kernel : Make a matching kernel using Wiener (Tikhonov) regularization instead of hard amplitude thresholding. Examples -------- Make a matching kernel between two Gaussian PSFs: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.psf_matching import make_kernel >>> y, x = np.mgrid[0:51, 0:51] >>> psf1 = Gaussian2D(100, 25, 25, 3, 3)(x, y) >>> psf2 = Gaussian2D(100, 25, 25, 5, 5)(x, y) >>> psf1 /= psf1.sum() >>> psf2 /= psf2.sum() >>> kernel = make_kernel(psf1, psf2) >>> print(f'{kernel.sum():.1f}') 1.0 """ source_psf, target_psf = _validate_kernel_inputs( source_psf, target_psf, window) if not 0 <= regularization < 1: msg = (f'regularization must be in the range [0, 1), ' f'got {regularization}.') raise ValueError(msg) source_otf = fft2(source_psf) target_otf = fft2(target_psf) # Note: the following calculations are performed in the Fourier # domain with the DC component at the corner of the array (standard # FFT layout). # Regularized division to avoid dividing by near-zero values abs_source_otf = np.abs(source_otf) max_otf = np.max(abs_source_otf) mask = abs_source_otf > regularization * max_otf ratio = np.zeros_like(source_otf) # dtype='complex128' from fft2 ratio[mask] = target_otf[mask] / source_otf[mask] # Apply a window function in frequency space if window is not None: ratio = _apply_window_to_fourier(ratio, window, target_psf.shape) kernel = np.real(fftshift(ifft2(ratio))) if np.sum(kernel) < 1e-30: msg = ('The computed kernel sums to zero, which likely indicates ' 'that the regularization threshold is too high. Try reducing ' 'the regularization parameter or using a different window ' 'function.') raise ValueError(msg) return kernel / kernel.sum() def make_wiener_kernel(source_psf, target_psf, *, regularization=1e-4, penalty=None, window=None): """ Make a convolution kernel that matches an input PSF to a target PSF using Wiener regularization in Fourier space. This function computes a Wiener-regularized PSF-matching kernel in the Fourier domain. The denominator includes a regularization term that stabilizes inversion of the source OTF (Optical Transfer Function, the Fourier transform of the PSF) by preventing division by small values, thereby suppressing noise amplification at spatial frequencies where the source response is weak. When no ``penalty`` is provided, the regularization is a frequency-independent (zero-order scalar) Tikhonov term expressed as a fraction of the peak power in the source OTF. In this case, the kernel is computed as: .. math:: K = \\mathcal{F}^{-1} \\left[ W \\cdot \\frac{T \\cdot S^{*}} {|S|^{2} + \\lambda \\cdot \\max(|S|^{2})} \\right] When a ``penalty`` array is provided (e.g., a Laplacian operator), the regularization becomes frequency-dependent: .. math:: K = \\mathcal{F}^{-1} \\left[ W \\cdot \\frac{T \\cdot S^{*}} {|S|^{2} + \\lambda \\cdot |P|^{2}} \\right] where :math:`P` is the OTF of the ``penalty`` operator. This penalizes high spatial frequencies more heavily, which is particularly effective at suppressing noise amplification. In both equations, :math:`\\mathcal{F}^{-1}` is the inverse Fourier transform, :math:`S` and :math:`T` are the Fourier transforms of the source and target PSFs (the OTFs), :math:`S^{*}` is the complex conjugate of :math:`S`, :math:`\\lambda` is the ``regularization`` parameter, and :math:`W` is the optional ``window`` function (defaulting to 1 if not provided). :math:`|S|^{2}` is the power spectrum of the source OTF. When the ``penalty`` is set to ``'laplacian'``, the regularization reproduces the approach used by the ``pypher`` package (`Boucaud et al. 2016`_), which applies a discrete Laplacian operator as the penalty. This provides stronger suppression of high spatial frequencies, which can be beneficial when working with noisy or undersampled PSFs. Compared to `~photutils.psf_matching.make_kernel`, which uses a hard threshold on Fourier amplitude, this approach provides continuous, smooth regularization that is better suited for PSFs that have near-zero power at high spatial frequencies. The hard-cutoff approach zeros out frequencies where the source amplitude is below a threshold, which can introduce discontinuities in Fourier space. Wiener regularization instead smoothly down-weights those frequencies, producing matching kernels with less ringing. .. _Boucaud et al. 2016: https://ui.adsabs.harvard.edu/abs/2016A%26A...596A..63B/abstract 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. It is assumed to be centered on the central pixel. 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. It is assumed to be centered on the central pixel. regularization : float, optional The regularization parameter that controls the strength of the Wiener (Tikhonov) regularization. When ``penalty`` is `None`, this is expressed as a fraction of the peak power in the source PSF's Fourier transform. When ``penalty`` is provided, this scales the penalty operator's power spectrum directly. Larger values produce smoother but less accurate matching kernels; smaller values preserve more detail but may amplify noise. Must be a positive number. penalty : `None`, ``'laplacian'``, ``'biharmonic'``, or 2D \ `~numpy.ndarray`, optional The regularization penalty operator. This controls the structure of the regularization term in the denominator: * `None` (default): Scalar Tikhonov regularization. The denominator is :math:`|S|^2 + \\lambda \\cdot \\max(|S|^2)`, providing uniform regularization across all spatial frequencies. Use this for well-behaved PSFs or when you want simple, frequency-independent smoothing. * ``'laplacian'``: Uses a discrete Laplacian operator (second derivative) as the penalty, producing frequency-dependent regularization that penalizes high spatial frequencies more heavily. The denominator becomes :math:`|S|^2 + \\lambda \\cdot |L|^2` where :math:`L` is the OTF of the Laplacian kernel ``[[0, -1, 0], [-1, 4, -1], [0, -1, 0]]``. This reproduces the regularization used by the ``pypher`` package (`Boucaud et al. 2016`_). This is the most commonly used penalty for PSF matching and works well for most applications. Requires PSFs to be at least 3x3. * ``'biharmonic'``: Uses a biharmonic operator (fourth derivative, Laplacian of the Laplacian) as the penalty, producing very strong suppression of high spatial frequencies. Uses the kernel ``[[0, 0, 1, 0, 0], [0, 2, -8, 2, 0], [1, -8, 20, -8, 1], [0, 2, -8, 2, 0], [0, 0, 1, 0, 0]]``. This produces the smoothest matching kernels and is useful when working with very noisy or poorly sampled PSFs, at the cost of reduced accuracy in matching. Requires PSFs to be at least 5x5. * 2D `~numpy.ndarray`: A custom penalty operator array. Its OTF will be computed and used in the denominator as :math:`|S|^2 + \\lambda \\cdot |P|^2`. The PSFs must be at least as large as the penalty array in both dimensions. window : callable, optional The window (taper) function or callable class instance used to remove high frequency noise from the PSF matching kernel. The window function should be a callable that accepts a single ``shape`` parameter (a tuple defining the 2D array shape) and returns a 2D array of the same shape. The returned window values must be in the range [0, 1], where 1.0 indicates full preservation of that spatial frequency and 0.0 indicates complete suppression. The window must be centered on the central pixel. Built-in window classes include: * `~photutils.psf_matching.HanningWindow` * `~photutils.psf_matching.TukeyWindow` * `~photutils.psf_matching.CosineBellWindow` * `~photutils.psf_matching.SplitCosineBellWindow` * `~photutils.psf_matching.TopHatWindow` A window function is generally not needed when using Wiener regularization because the regularization itself suppresses high-frequency noise. However, a window may still be useful when working with noisy or undersampled PSFs. For more information on window functions, custom windows, 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. Raises ------ ValueError If the PSFs are not 2D arrays, have even dimensions, do not have the same shape, are too small for the specified penalty, if ``regularization`` is not positive, or if ``penalty`` is not a valid value. TypeError If the input ``window`` is not callable. See Also -------- make_kernel : Make a matching kernel using a hard frequency cutoff instead of Wiener regularization. Examples -------- Make a matching kernel between two Gaussian PSFs: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.psf_matching import make_wiener_kernel >>> y, x = np.mgrid[0:51, 0:51] >>> psf1 = Gaussian2D(100, 25, 25, 3, 3)(x, y) >>> psf2 = Gaussian2D(100, 25, 25, 5, 5)(x, y) >>> psf1 /= psf1.sum() >>> psf2 /= psf2.sum() >>> kernel = make_wiener_kernel(psf1, psf2) >>> print(f'{kernel.sum():.1f}') 1.0 Use the Laplacian penalty for frequency-dependent regularization: >>> kernel = make_wiener_kernel(psf1, psf2, penalty='laplacian') >>> print(f'{kernel.sum():.1f}') 1.0 Use the biharmonic penalty for maximum smoothness: >>> kernel = make_wiener_kernel(psf1, psf2, penalty='biharmonic') >>> print(f'{kernel.sum():.1f}') 1.0 """ source_psf, target_psf = _validate_kernel_inputs( source_psf, target_psf, window) if regularization <= 0: msg = 'regularization must be a positive number.' raise ValueError(msg) # Validate and build the penalty term if penalty is None: penalty_array = None elif isinstance(penalty, str): if penalty == 'laplacian': penalty_array = np.array([[+0, -1, +0], [-1, +4, -1], [+0, -1, +0]], dtype=float) elif penalty == 'biharmonic': penalty_array = np.array([[+0, +0, +1, +0, +0], [+0, +2, -8, +2, +0], [+1, -8, 20, -8, +1], [+0, +2, -8, +2, +0], [+0, +0, +1, +0, +0]], dtype=float) else: msg = (f'Invalid penalty string {penalty!r}. ' 'Must be "laplacian" or "biharmonic".') raise ValueError(msg) elif isinstance(penalty, np.ndarray): if penalty.ndim != 2: msg = 'penalty array must be 2D.' raise ValueError(msg) penalty_array = np.asarray(penalty, dtype=float) else: msg = ("penalty must be None, 'laplacian', 'biharmonic', or a 2D " 'numpy array.') raise ValueError(msg) # Validate that PSF is large enough for the penalty if penalty_array is not None: penalty_shape = penalty_array.shape psf_shape = source_psf.shape if (psf_shape[0] < penalty_shape[0] or psf_shape[1] < penalty_shape[1]): msg = (f'PSFs must be at least as large as the penalty ' f'operator. PSF shape is {psf_shape}, but penalty ' f'shape is {penalty_shape}.') raise ValueError(msg) source_otf = fft2(source_psf) target_otf = fft2(target_psf) source_power = np.abs(source_otf) ** 2 if penalty_array is not None: # Frequency-dependent regularization penalty_otf = _convert_psf_to_otf(penalty_array, source_psf.shape) reg_term = regularization * np.abs(penalty_otf) ** 2 else: # Wiener (Tikhonov; scalar/zero-order) regularization. # This is frequency-independent and expressed as a fraction of # the peak power in the source OTF reg_term = regularization * np.max(source_power) # Compute the Wiener-regularized kernel in Fourier space kernel_otf = (target_otf * np.conj(source_otf) / (source_power + reg_term)) # Apply a window function in frequency space if window is not None: kernel_otf = _apply_window_to_fourier( kernel_otf, window, target_psf.shape) kernel = np.real(fftshift(ifft2(kernel_otf))) if np.sum(kernel) < 1e-30: msg = ('The computed kernel sums to zero, which likely indicates ' 'that the regularization threshold is too high. Try reducing ' 'the regularization parameter or using a different window ' 'function.') raise ValueError(msg) return kernel / kernel.sum() @deprecated('3.0', alternative='make_kernel') def create_matching_kernel(source_psf, target_psf, *, window=None, regularization=1e-4): """ Create a kernel to match 2D point spread functions (PSF). .. deprecated:: 3.0 ``create_matching_kernel`` is deprecated as of Photutils 3.0 and will be removed in version 4.0. Use `make_kernel` instead. """ return make_kernel(source_psf, target_psf, window=window, regularization=regularization) astropy-photutils-3322558/photutils/psf_matching/tests/000077500000000000000000000000001517052111400232755ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf_matching/tests/__init__.py000066400000000000000000000000001517052111400253740ustar00rootroot00000000000000astropy-photutils-3322558/photutils/psf_matching/tests/conftest.py000066400000000000000000000020011517052111400254650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Pytest configuration and shared fixtures for psf_matching tests. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D def _make_gaussian_psf(size, std): """ Make a centered, normalized 2D Gaussian PSF. """ cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] model = Gaussian2D(1.0, cen, cen, std, std) psf = model(xx, yy) return psf / psf.sum() def _make_gaussian_psf_noncentered(shape, std, xcen, ycen): """ Make a non-centered, normalized 2D Gaussian PSF. """ yy, xx = np.mgrid[0:shape[0], 0:shape[1]] model = Gaussian2D(1.0, xcen, ycen, std, std) psf = model(xx, yy) return psf / psf.sum() @pytest.fixture(name='psf1') def psf1(): """ Narrow Gaussian PSF (source). """ return _make_gaussian_psf(25, 3.0) @pytest.fixture(name='psf2') def psf2(): """ Broad Gaussian PSF (target). """ return _make_gaussian_psf(25, 5.0) astropy-photutils-3322558/photutils/psf_matching/tests/test_fourier.py000066400000000000000000000536021517052111400263670ustar00rootroot00000000000000# 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 TRFLSQFitter from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_allclose from photutils.psf_matching.fourier import (create_matching_kernel, make_kernel, make_wiener_kernel) from photutils.psf_matching.tests.conftest import ( _make_gaussian_psf, _make_gaussian_psf_noncentered) from photutils.psf_matching.windows import SplitCosineBellWindow class TestMakeKernel: def test_with_window(self, psf1, psf2): """ Test with noiseless 2D Gaussians and a window. """ size = psf1.shape[0] cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] window = SplitCosineBellWindow(0.0, 0.2) kernel = make_kernel(psf1, psf2, window=window) fitter = TRFLSQFitter() gm1 = Gaussian2D(1.0, cen, cen, 3.0, 3.0) gfit = fitter(gm1, xx, yy, kernel) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(25 - 9), atol=0.06) def test_without_window(self, psf1, psf2): """ Test without a window function. """ kernel = make_kernel(psf1, psf2) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_shape_mismatch(self, psf1): """ Test that mismatched PSF shapes raise ValueError. """ psf_small = _make_gaussian_psf(5, 1.5) match = 'must have the same shape' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf_small) def test_non_2d_source(self, psf2): """ Test that non-2D source PSF raises ValueError. """ match = 'source_psf must be a 2D array' with pytest.raises(ValueError, match=match): make_kernel(np.ones(25), psf2) def test_non_2d_target(self, psf1): """ Test that non-2D target PSF raises ValueError. """ match = 'target_psf must be a 2D array' with pytest.raises(ValueError, match=match): make_kernel(psf1, np.ones(25)) def test_even_shape(self): """ Test that even-shaped PSFs raise ValueError. """ psf = np.zeros((4, 4)) psf[2, 2] = 1.0 match = 'must have odd dimensions' with pytest.raises(ValueError, match=match): make_kernel(psf, psf) def test_non_callable_window(self, psf1, psf2): """ Test that non-callable window raises TypeError. """ match = 'window must be a callable' with pytest.raises(TypeError, match=match): make_kernel(psf1, psf2, window='bad') def test_regularization(self, psf1, psf2): """ Test with an aggressive regularization threshold. """ kernel = make_kernel(psf1, psf2, regularization=0.5) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_regularization_zero(self, psf1, psf2): """ Test with regularization=0 (minimum thresholding). """ kernel = make_kernel(psf1, psf2, regularization=0) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_regularization_negative(self, psf1, psf2): """ Test that negative regularization raises an error. """ match = 'regularization must be in the range' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, regularization=-0.1) def test_regularization_greater_than_one(self, psf1, psf2): """ Test that regularization > 1 raises an error. """ match = 'regularization must be in the range' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, regularization=1.5) def test_regularization_invalid(self, psf1, psf2): """ Test that regularization=1 raises an error (range is [0, 1)). """ match = 'regularization must be in the range' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, regularization=1.0) def test_window_not_2d(self, psf1, psf2): """ Test that window function returning non-2D array raises error. """ def bad_window(shape): return np.ones(shape[0]) # 1D array match = 'window function must return a 2D array' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, window=bad_window) def test_window_wrong_shape(self, psf1, psf2): """ Test that window function returning wrong shape raises error. """ def bad_window(shape): # noqa: ARG001 return np.ones((10, 10)) # wrong shape match = 'window function must return an array with shape' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, window=bad_window) def test_window_values_below_zero(self, psf1, psf2): """ Test that window function with values < 0 raises error. """ def bad_window(shape): arr = np.ones(shape) arr[0, 0] = -0.1 return arr match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, window=bad_window) def test_window_values_above_one(self, psf1, psf2): """ Test that window function with values > 1 raises error. """ def bad_window(shape): arr = np.ones(shape) arr[0, 0] = 1.5 return arr match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, window=bad_window) def test_asymmetric_shape(self): """ Test with asymmetric PSF shapes. """ # Create 51x25 PSFs centered at (x=12, y=25) shape = (51, 25) psf1 = _make_gaussian_psf_noncentered(shape, 3, xcen=12, ycen=25) psf2 = _make_gaussian_psf_noncentered(shape, 5, xcen=12, ycen=25) kernel = make_kernel(psf1, psf2) assert kernel.shape == shape assert_allclose(kernel.sum(), 1.0) def test_zero_kernel_raises(self, psf1, psf2): """ Test that a window returning all zeros raises ValueError because the computed kernel sums to zero. """ def zero_window(shape): return np.zeros(shape) match = 'The computed kernel sums to zero' with pytest.raises(ValueError, match=match): make_kernel(psf1, psf2, window=zero_window) class TestMakeKernelWiener: def test_basic(self, psf1, psf2): """ Test basic Wiener kernel creation with noiseless Gaussians. """ kernel = make_wiener_kernel(psf1, psf2) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_kernel_shape(self, psf1, psf2): """ Test that the kernel has the expected Gaussian shape. For two Gaussians with sigma=3 and sigma=5, the matching kernel should be a Gaussian with sigma=sqrt(25-9)=4. """ size = psf1.shape[0] cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] kernel = make_wiener_kernel(psf1, psf2) fitter = TRFLSQFitter() gm1 = Gaussian2D(1.0, cen, cen, 3.0, 3.0) gfit = fitter(gm1, xx, yy, kernel) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(25 - 9), atol=0.06) def test_with_window(self, psf1, psf2): """ Test with a window function applied. """ window = SplitCosineBellWindow(0.0, 0.2) kernel = make_wiener_kernel(psf1, psf2, window=window) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_regularization(self, psf1, psf2): """ Test with different regularization strengths. """ kernel_weak = make_wiener_kernel(psf1, psf2, regularization=1e-8) kernel_strong = make_wiener_kernel(psf1, psf2, regularization=1e-1) # Both should be normalized assert_allclose(kernel_weak.sum(), 1.0) assert_allclose(kernel_strong.sum(), 1.0) # Stronger regularization should produce a smoother kernel # (lower max value) assert kernel_strong.max() < kernel_weak.max() def test_shape_mismatch(self, psf1): """ Test that mismatched PSF shapes raise ValueError. """ g_small = _make_gaussian_psf(5, 1.5) match = 'must have the same shape' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, g_small) def test_non_2d_source(self, psf2): """ Test that non-2D source PSF raises ValueError. """ match = 'source_psf must be a 2D array' with pytest.raises(ValueError, match=match): make_wiener_kernel(np.ones(25), psf2) def test_non_2d_target(self, psf1): """ Test that non-2D target PSF raises ValueError. """ match = 'target_psf must be a 2D array' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, np.ones(25)) def test_even_shape(self): """ Test that even-shaped PSFs raise ValueError. """ psf = np.zeros((4, 4)) psf[2, 2] = 1.0 match = 'must have odd dimensions' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf, psf) def test_non_callable_window(self, psf1, psf2): """ Test that non-callable window raises TypeError. """ match = 'window must be a callable' with pytest.raises(TypeError, match=match): make_wiener_kernel(psf1, psf2, window='bad') def test_negative_regularization(self, psf1, psf2): """ Test that negative regularization raises ValueError. """ match = 'regularization must be a positive number' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, regularization=-1.0) def test_zero_regularization(self, psf1, psf2): """ Test that zero regularization raises ValueError. """ match = 'regularization must be a positive number' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, regularization=0.0) def test_penalty_string_equals_array(self, psf1, psf2): """ Test that 'laplacian' string gives the same result as the explicit Laplacian array. """ laplacian = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]) kernel_str = make_wiener_kernel(psf1, psf2, penalty='laplacian') kernel_arr = make_wiener_kernel(psf1, psf2, penalty=laplacian) assert_allclose(kernel_str, kernel_arr) def test_penalty_laplacian_kernel_shape(self, psf1, psf2): """ Test that Laplacian penalty kernel has the expected Gaussian shape. """ size = psf1.shape[0] cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] kernel = make_wiener_kernel(psf1, psf2, penalty='laplacian') fitter = TRFLSQFitter() gm1 = Gaussian2D(1.0, cen, cen, 3.0, 3.0) gfit = fitter(gm1, xx, yy, kernel) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(25 - 9), atol=0.06) def test_penalty_invalid_string(self, psf1, psf2): """ Test that an invalid penalty string raises ValueError. """ match = 'Invalid penalty string' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty='invalid') def test_penalty_invalid_type(self, psf1, psf2): """ Test that an invalid penalty type raises ValueError. """ match = 'penalty must be None' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=42) def test_penalty_non_2d_array(self, psf1, psf2): """ Test that a non-2D penalty array raises ValueError. """ match = 'penalty array must be 2D' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=np.ones(5)) def test_penalty_psf_too_small_for_laplacian(self): """ Test that a PSF smaller than 3x3 raises ValueError when using laplacian penalty. """ # Create 1x1 PSFs (too small for 3x3 laplacian) psf1 = np.array([[1.0]]) psf2 = np.array([[1.0]]) match = 'PSFs must be at least as large as the penalty operator' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty='laplacian') def test_penalty_psf_too_small_for_biharmonic(self): """ Test that a PSF smaller than 5x5 raises ValueError when using biharmonic penalty. """ # Create 3x3 PSFs (too small for 5x5 biharmonic) psf1 = _make_gaussian_psf(3, 1.0) psf2 = _make_gaussian_psf(3, 1.5) match = 'PSFs must be at least as large as the penalty operator' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty='biharmonic') def test_penalty_psf_minimum_size_laplacian(self): """ Test that 3x3 PSF (minimum size) works with laplacian penalty. """ psf1 = _make_gaussian_psf(3, 0.8) psf2 = _make_gaussian_psf(3, 1.0) kernel = make_wiener_kernel(psf1, psf2, penalty='laplacian') assert kernel.shape == (3, 3) assert_allclose(kernel.sum(), 1.0) def test_penalty_psf_minimum_size_biharmonic(self): """ Test that 5x5 PSF (minimum size) works with biharmonic penalty. """ psf1 = _make_gaussian_psf(5, 1.2) psf2 = _make_gaussian_psf(5, 1.5) kernel = make_wiener_kernel(psf1, psf2, penalty='biharmonic') assert kernel.shape == (5, 5) assert_allclose(kernel.sum(), 1.0) def test_penalty_custom_array_too_large(self): """ Test that a custom penalty array larger than the PSF raises ValueError. """ # Create 3x3 PSFs but 5x5 penalty psf1 = _make_gaussian_psf(3, 0.8) psf2 = _make_gaussian_psf(3, 1.0) penalty = np.ones((5, 5)) match = 'PSFs must be at least as large as the penalty operator' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=penalty) def test_penalty_custom_array(self, psf1, psf2): """ Test with a custom 2D penalty array. """ # Use a simple high-pass operator penalty = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]]) kernel = make_wiener_kernel(psf1, psf2, penalty=penalty) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_penalty_with_window(self, psf1, psf2): """ Test that penalty and window can be used together. """ window = SplitCosineBellWindow(0.0, 0.2) kernel = make_wiener_kernel(psf1, psf2, penalty='laplacian', window=window) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_penalty_differs_from_scalar(self, psf1, psf2): """ Test that Laplacian penalty gives a different result than scalar Tikhonov with the same regularization parameter. """ reg = 1e-4 kernel_scalar = make_wiener_kernel(psf1, psf2, regularization=reg) kernel_laplacian = make_wiener_kernel(psf1, psf2, regularization=reg, penalty='laplacian') assert not np.allclose(kernel_scalar, kernel_laplacian) def test_penalty_biharmonic_basic(self, psf1, psf2): """ Test basic biharmonic penalty functionality. """ kernel = make_wiener_kernel(psf1, psf2, penalty='biharmonic') assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_penalty_biharmonic_kernel_shape(self, psf1, psf2): """ Test that biharmonic penalty kernel has the expected Gaussian shape. """ size = psf1.shape[0] cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] kernel = make_wiener_kernel(psf1, psf2, penalty='biharmonic') fitter = TRFLSQFitter() gm1 = Gaussian2D(1.0, cen, cen, 3.0, 3.0) gfit = fitter(gm1, xx, yy, kernel) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(25 - 9), atol=0.08) def test_penalty_biharmonic_results(self, psf1, psf2): """ Test that biharmonic penalty gives different results than scalar and Laplacian penalties. """ reg = 1e-4 kernel_scalar = make_wiener_kernel(psf1, psf2, regularization=reg) kernel_laplacian = make_wiener_kernel(psf1, psf2, regularization=reg, penalty='laplacian') kernel_biharmonic = make_wiener_kernel(psf1, psf2, regularization=reg, penalty='biharmonic') assert not np.allclose(kernel_scalar, kernel_biharmonic) assert not np.allclose(kernel_laplacian, kernel_biharmonic) def test_penalty_biharmonic_smoothness(self, psf1, psf2): """ Test that biharmonic penalty produces smoother kernels than Laplacian (lower peak value indicates more smoothing). """ reg = 1e-4 kernel_laplacian = make_wiener_kernel(psf1, psf2, regularization=reg, penalty='laplacian') kernel_biharmonic = make_wiener_kernel(psf1, psf2, regularization=reg, penalty='biharmonic') # Biharmonic should produce a smoother kernel with lower peak assert kernel_biharmonic.max() < kernel_laplacian.max() def test_penalty_biharmonic_with_window(self, psf1, psf2): """ Test that biharmonic penalty works with window functions. """ window = SplitCosineBellWindow(0.0, 0.2) kernel = make_wiener_kernel(psf1, psf2, penalty='biharmonic', window=window) assert kernel.shape == psf1.shape assert_allclose(kernel.sum(), 1.0) def test_asymmetric_shape(self): """ Test with asymmetric PSF shapes. """ # Create 51x25 PSFs centered at (x=12, y=25) shape = (51, 25) psf1 = _make_gaussian_psf_noncentered(shape, 3, xcen=12, ycen=25) psf2 = _make_gaussian_psf_noncentered(shape, 5, xcen=12, ycen=25) kernel = make_wiener_kernel(psf1, psf2) assert kernel.shape == shape assert_allclose(kernel.sum(), 1.0) def test_window_not_2d(self, psf1, psf2): """ Test that window function returning non-2D array raises error. """ def bad_window(shape): return np.ones(shape[0]) # 1D array match = 'window function must return a 2D array' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, window=bad_window) def test_window_wrong_shape(self, psf1, psf2): """ Test that window function returning wrong shape raises error. """ def bad_window(shape): # noqa: ARG001 return np.ones((10, 10)) # wrong shape match = 'window function must return an array with shape' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, window=bad_window) def test_window_values_below_zero(self, psf1, psf2): """ Test that window function with values < 0 raises error. """ def bad_window(shape): arr = np.ones(shape) arr[0, 0] = -0.1 return arr match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, window=bad_window) def test_window_values_above_one(self, psf1, psf2): """ Test that window function with values > 1 raises error. """ def bad_window(shape): arr = np.ones(shape) arr[0, 0] = 1.5 return arr match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, window=bad_window) def test_zero_kernel_raises(self, psf1, psf2): """ Test that a window returning all zeros raises ValueError because the computed kernel sums to zero. """ def zero_window(shape): return np.zeros(shape) match = 'The computed kernel sums to zero' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, window=zero_window) class TestCreateMatchingKernelDeprecated: def test_deprecation_warning(self, psf1, psf2): """ Test that create_matching_kernel raises a deprecation warning. """ with pytest.warns(AstropyDeprecationWarning): kernel = create_matching_kernel(psf1, psf2) assert_allclose(kernel.sum(), 1.0) def test_deprecation_result_matches_make_kernel(self, psf1, psf2): """ Test that create_matching_kernel returns the same result as make_kernel. """ with pytest.warns(AstropyDeprecationWarning): kernel_old = create_matching_kernel(psf1, psf2) kernel_new = make_kernel(psf1, psf2) assert_allclose(kernel_old, kernel_new) astropy-photutils-3322558/photutils/psf_matching/tests/test_utils.py000066400000000000000000000275631517052111400260630ustar00rootroot00000000000000# 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 from scipy.fft import fft2 from photutils.psf_matching.tests.conftest import _make_gaussian_psf from photutils.psf_matching.utils import (_apply_window_to_fourier, _convert_psf_to_otf, _validate_psf, _validate_window_array, resize_psf) class TestValidatePSF: def test_valid_psf(self): """ Test that a valid PSF passes validation without error. """ psf = _make_gaussian_psf(5, 1.5) _validate_psf(psf, 'psf') # should not raise def test_non_2d(self): """ Test that non-2D array raises ValueError. """ match = 'psf must be a 2D array' with pytest.raises(ValueError, match=match): _validate_psf(np.ones(5), 'psf') def test_even_shape(self): """ Test that even-shaped array raises ValueError. """ psf = np.zeros((4, 4)) psf[2, 2] = 1.0 match = 'must have odd dimensions' with pytest.raises(ValueError, match=match): _validate_psf(psf, 'psf') def test_nan_inf_values(self): """ Test that NaN or Inf values raise ValueError. """ psf = np.zeros((25, 25)) psf[12, 12] = np.nan match = 'contains NaN or Inf values' with pytest.raises(ValueError, match=match): _validate_psf(psf, 'psf') psf[12, 12] = np.inf with pytest.raises(ValueError, match=match): _validate_psf(psf, 'psf') def test_zero_psf_raises(self): """ Test that an all-zero PSF raises ValueError (cannot be normalized). """ psf = np.zeros((5, 5)) match = 'must have a non-zero sum' with pytest.raises(ValueError, match=match): _validate_psf(psf, 'psf') class TestValidateWindowArray: def test_valid_window(self): """ Test that a valid window array passes validation. """ shape = (25, 25) window = np.ones(shape) _validate_window_array(window, shape) # should not raise def test_not_2d(self): """ Test that non-2D window raises ValueError. """ match = 'window function must return a 2D array' with pytest.raises(ValueError, match=match): _validate_window_array(np.ones(10), (10,)) def test_wrong_shape(self): """ Test that wrong-shaped window raises ValueError. """ match = 'window function must return an array with shape' with pytest.raises(ValueError, match=match): _validate_window_array(np.ones((10, 10)), (25, 25)) def test_values_below_zero(self): """ Test that window values < 0 raise ValueError. """ arr = np.ones((10, 10)) arr[0, 0] = -0.1 match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): _validate_window_array(arr, (10, 10)) def test_values_above_one(self): """ Test that window values > 1 raise ValueError. """ arr = np.ones((10, 10)) arr[0, 0] = 1.5 match = 'window function values must be in the range' with pytest.raises(ValueError, match=match): _validate_window_array(arr, (10, 10)) class TestConvertPsfToOtf: def test_zero_psf(self): """ Test that an all-zero PSF returns an all-zero OTF. """ psf = np.zeros((3, 3)) otf = _convert_psf_to_otf(psf, (5, 5)) assert_allclose(otf, 0.0) def test_invalid_psf(self): match = 'psf must be a 2D array' with pytest.raises(ValueError, match=match): _convert_psf_to_otf(np.ones(5), (5, 5)) match = 'psf must have odd dimensions' with pytest.raises(ValueError, match=match): _convert_psf_to_otf(np.ones((6, 6)), (11, 11)) def test_output_shape(self): """ Test that the output OTF has the requested shape. """ psf = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]) otf = _convert_psf_to_otf(psf, (25, 25)) assert otf.shape == (25, 25) def test_delta_function(self): """ Test that a delta function produces a flat OTF. """ # A single-pixel PSF at the center of a 1x1 array psf = np.array([[1.0]]) otf = _convert_psf_to_otf(psf, (5, 5)) assert_allclose(np.abs(otf), 1.0) def test_power_spectrum_shift_invariant(self): """ Test that |OTF|^2 is the same regardless of PSF centering. The power spectrum should be independent of the input kernel position because the circular shift only affects the phase. """ laplacian = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]) shape = (25, 25) otf = _convert_psf_to_otf(laplacian, shape) power = np.abs(otf) ** 2 # Compare to a naive approach (no circular shift) padded = np.zeros(shape) padded[:3, :3] = laplacian otf_naive = fft2(padded) power_naive = np.abs(otf_naive) ** 2 assert_allclose(power, power_naive) def test_psf_larger_than_shape(self): """ Test that a PSF larger than the target shape raises ValueError. """ psf = np.ones((7, 7)) match = 'PSF shape.*is larger than the target shape' with pytest.raises(ValueError, match=match): _convert_psf_to_otf(psf, (5, 5)) def test_laplacian_dc_is_zero(self): """ Test that the Laplacian OTF has zero power at DC. The Laplacian sums to zero, so its DC component should be zero. """ laplacian = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]) otf = _convert_psf_to_otf(laplacian, (25, 25)) assert_allclose(otf[0, 0], 0.0, atol=1e-15) def test_normalized_psf_dc_component(self): """ Test that a normalized PSF has DC component equal to 1.0. For a normalized PSF (sum = 1), the DC component of the OTF should be 1.0. """ psf = _make_gaussian_psf(5, 1.5) otf = _convert_psf_to_otf(psf, (25, 25)) assert_allclose(otf[0, 0], 1.0, rtol=1e-10) def test_non_square_shapes(self): """ Test with non-square PSF and output shapes. """ # 3x5 PSF to 11x21 output psf = np.zeros((3, 5)) psf[1, 2] = 1.0 # center at (1, 2) otf = _convert_psf_to_otf(psf, (11, 21)) assert otf.shape == (11, 21) assert_allclose(np.abs(otf), 1.0, rtol=1e-10) def test_no_padding_needed(self): """ Test when PSF size equals output size (no padding). """ psf = _make_gaussian_psf(7, 1.5) otf = _convert_psf_to_otf(psf, (7, 7)) assert otf.shape == (7, 7) assert_allclose(otf[0, 0], 1.0, rtol=1e-10) def test_symmetric_psf_real_otf(self): """ Test that a symmetric PSF produces a nearly real OTF. A centered, symmetric PSF should produce an OTF with negligible imaginary components. """ psf = _make_gaussian_psf(7, 2.0) otf = _convert_psf_to_otf(psf, (25, 25)) # Imaginary components should be negligible assert np.max(np.abs(otf.imag)) < 1e-10 def test_larger_psf_sizes(self): """ Test with various PSF sizes to ensure centering works correctly. """ for psf_size in [5, 7, 9]: psf = _make_gaussian_psf(psf_size, psf_size / 4.0) otf = _convert_psf_to_otf(psf, (25, 25)) # Normalized PSF should have DC component of 1.0 assert_allclose(otf[0, 0], 1.0, rtol=1e-10) # OTF should be nearly real for symmetric PSF assert np.max(np.abs(otf.imag)) < 1e-10 class TestResizePSF: def test_resize(self): """ Test that resizing returns an odd-shaped output. For a (5,5) input with ratio=2.0, ceil gives 10 (even), so one pixel is added to give (11, 11). """ psf = _make_gaussian_psf(5, 1.5) result = resize_psf(psf, 0.1, 0.05) assert result.shape == (11, 11) assert_allclose(result.sum(), psf.sum()) def test_resize_odd_output(self): """ Test that resizing to a naturally odd output shape is unchanged. """ psf = _make_gaussian_psf(5, 1.5) result = resize_psf(psf, 0.1, 0.1) # ratio=1.0 -> 5x5 (odd) assert result.shape == (5, 5) def test_resize_always_odd(self): """ Test that the output is always odd across a range of ratios. """ psf = _make_gaussian_psf(5, 1.5) for scale_out in [0.04, 0.05, 0.06, 0.07, 0.08]: result = resize_psf(psf, 0.1, scale_out) assert result.shape[0] % 2 == 1 assert result.shape[1] % 2 == 1 def test_non_2d(self): """ Test that non-2D PSF raises ValueError. """ match = 'psf must be a 2D array' with pytest.raises(ValueError, match=match): resize_psf(np.ones(5), 0.1, 0.05) def test_even_shape(self): """ Test that even-shaped PSF raises ValueError. """ psf = np.zeros((4, 4)) psf[2, 2] = 1.0 match = 'must have odd dimensions' with pytest.raises(ValueError, match=match): resize_psf(psf, 0.1, 0.05) def test_non_positive_input_scale(self): """ Test that negative input_pixel_scale raises ValueError. """ psf = _make_gaussian_psf(5, 1.5) match = 'must be positive' with pytest.raises(ValueError, match=match): resize_psf(psf, -0.1, 0.05) def test_non_positive_output_scale(self): """ Test that negative output_pixel_scale raises ValueError. """ psf = _make_gaussian_psf(5, 1.5) match = 'must be positive' with pytest.raises(ValueError, match=match): resize_psf(psf, 0.1, -0.05) def test_zero_scale(self): """ Test that zero input_pixel_scale raises ValueError. """ psf = _make_gaussian_psf(5, 1.5) match = 'must be positive' with pytest.raises(ValueError, match=match): resize_psf(psf, 0.0, 0.05) class TestApplyWindowToFourier: def test_basic(self): """ Test that _apply_window_to_fourier applies a window to a Fourier array. """ shape = (11, 11) fourier_array = np.ones(shape, dtype=complex) def uniform_window(shape): return np.ones(shape) result = _apply_window_to_fourier(fourier_array, uniform_window, shape) assert result.shape == shape assert np.allclose(result, fourier_array) def test_zero_window(self): """ Test that a zero window zeros out the Fourier array. """ shape = (11, 11) fourier_array = np.ones(shape, dtype=complex) def zero_window(shape): return np.zeros(shape) result = _apply_window_to_fourier(fourier_array, zero_window, shape) assert np.allclose(result, 0.0) def test_invalid_window_raises(self): """ Test that an invalid window function raises ValueError. """ shape = (11, 11) fourier_array = np.ones(shape, dtype=complex) def bad_window(shape): # noqa: ARG001 return np.ones((5, 5)) # wrong shape match = 'window function must return an array with shape' with pytest.raises(ValueError, match=match): _apply_window_to_fourier(fourier_array, bad_window, shape) astropy-photutils-3322558/photutils/psf_matching/tests/test_windows.py000066400000000000000000000107011517052111400263770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the windows module. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from scipy.signal.windows import tukey from photutils.psf_matching.windows import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) def test_hanning(): """ Test Hanning window with basic array values. """ window = HanningWindow() data = window((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) window = HanningWindow() data = window(shape) ref1d = np.hanning(shape[0]) assert_allclose(data[cen, :], ref1d) def test_tukey(): """ Test Tukey window with basic array values. """ window = TukeyWindow(0.5) data = window((5, 5)) ref = [0.0, 0.63312767, 1.0, 0.63312767, 0.0] assert_allclose(data[1, :], ref) def test_tukey_scipy(): """ Test Tukey window against 1D scipy version. """ size = 101 cen = (size - 1) // 2 shape = (size, size) alpha = 0.4 window = TukeyWindow(alpha=alpha) data = window(shape) ref1d = tukey(shape[0], alpha=alpha) assert_allclose(data[cen, :], ref1d) def test_cosine_bell(): """ Test cosine bell window with basic array values. """ window = CosineBellWindow(alpha=0.8) data = window((7, 7)) ref = [0.0, 0.011467736745367552, 0.36162762260757253, 0.6294095225512605, 0.36162762260757253, 0.011467736745367552, 0.0] assert_allclose(data[2, :], ref) def test_split_cosine_bell(): """ Test split cosine bell window with basic array values. """ window = SplitCosineBellWindow(alpha=0.8, beta=0.2) data = window((5, 5)) ref = [0.0, 0.6913417161825449, 1.0, 0.6913417161825449, 0.0] assert_allclose(data[2, :], ref) def test_split_cosine_bell_invalid_inputs(): """ Test that invalid alpha and beta values raise ValueError. """ match = 'alpha must be between 0.0 and 1.0' with pytest.raises(ValueError, match=match): SplitCosineBellWindow(alpha=-0.1, beta=0.2) with pytest.raises(ValueError, match=match): SplitCosineBellWindow(alpha=1.1, beta=0.2) match = 'beta must be between 0.0 and 1.0' with pytest.raises(ValueError, match=match): SplitCosineBellWindow(alpha=0.8, beta=-0.1) with pytest.raises(ValueError, match=match): SplitCosineBellWindow(alpha=0.8, beta=1.1) def test_split_cosine_bell_alpha_plus_beta_gt_one(): """ Test that alpha + beta > 1.0 warns about taper clipping. """ match = 'alpha.*beta.*>.*1.0' with pytest.warns(AstropyUserWarning, match=match): SplitCosineBellWindow(alpha=0.8, beta=0.5) def test_tophat(): """ Test top hat window with basic array values. """ window = TopHatWindow(beta=0.5) data = window((5, 5)) ref = [0.0, 1.0, 1.0, 1.0, 0.0] assert_allclose(data[2, :], ref) def test_invalid_shape(): """ Test that invalid shape raises ValueError. """ window = HanningWindow() match = 'shape must have only 2 elements' with pytest.raises(ValueError, match=match): window((5,)) def test_asymmetric_shape(): """ Test window with asymmetric shape. """ shape = (51, 25) window = HanningWindow() data = window(shape) assert data.shape == shape assert_allclose(data[25, 12], 1.0) def test_repr_and_str(): """ Test __repr__ and __str__ for all window classes. """ window = SplitCosineBellWindow(alpha=0.4, beta=0.3) assert repr(window) == 'SplitCosineBellWindow(alpha=0.4, beta=0.3)' assert str(window) == repr(window) window = HanningWindow() assert repr(window) == 'HanningWindow()' assert str(window) == repr(window) window = TukeyWindow(alpha=0.5) assert repr(window) == 'TukeyWindow(alpha=0.5)' assert str(window) == repr(window) window = CosineBellWindow(alpha=0.3) assert repr(window) == 'CosineBellWindow(alpha=0.3)' assert str(window) == repr(window) window = TopHatWindow(beta=0.4) assert repr(window) == 'TopHatWindow(beta=0.4)' assert str(window) == repr(window) astropy-photutils-3322558/photutils/psf_matching/utils.py000066400000000000000000000237611517052111400236560ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Utility functions for the psf_matching subpackage. """ import numpy as np from scipy.fft import fft2, fftshift, ifftshift from scipy.ndimage import zoom __all__ = ['resize_psf'] def _validate_kernel_inputs(source_psf, target_psf, window): """ Validate and prepare common inputs for kernel-making functions. Parameters ---------- source_psf : array-like The source PSF array. target_psf : array-like The target PSF array. window : callable or None The window function or None. Returns ------- source_psf : `~numpy.ndarray` The validated and normalized source PSF as a float array. target_psf : `~numpy.ndarray` The validated and normalized target PSF as a float array. Raises ------ ValueError If the PSFs are not 2D arrays, have even dimensions, do not have the same shape, or contain NaN or Inf values. TypeError If the input ``window`` is not callable. """ # Copy as float so in-place normalization doesn't modify inputs source_psf = np.array(source_psf, dtype=float) target_psf = np.array(target_psf, dtype=float) _validate_psf(source_psf, 'source_psf') _validate_psf(target_psf, 'target_psf') if source_psf.shape != target_psf.shape: msg = ('source_psf and target_psf must have the same shape ' '(i.e., registered with the same pixel scale).') raise ValueError(msg) if window is not None and not callable(window): msg = 'window must be a callable.' raise TypeError(msg) # Ensure input PSFs are normalized source_psf /= source_psf.sum() target_psf /= target_psf.sum() return source_psf, target_psf def _validate_psf(psf, name): """ Validate that a PSF is 2D with odd dimensions. Parameters ---------- psf : `~numpy.ndarray` The PSF array to validate. name : str The parameter name used in error messages. Raises ------ ValueError If the PSF is not 2D, has even dimensions, or contains NaN or Inf values. """ if psf.ndim != 2: msg = f'{name} must be a 2D array.' raise ValueError(msg) if psf.shape[0] % 2 == 0 or psf.shape[1] % 2 == 0: msg = (f'{name} must have odd dimensions, got ' f'shape {psf.shape}.') raise ValueError(msg) if not np.all(np.isfinite(psf)): msg = f'{name} contains NaN or Inf values.' raise ValueError(msg) if np.sum(psf) == 0: msg = f'{name} must have a non-zero sum; it cannot be normalized.' raise ValueError(msg) def _validate_window_array(window_array, expected_shape): """ Validate window function output. Parameters ---------- window_array : any The array returned by the window function. expected_shape : tuple The expected shape of the window array. Raises ------ ValueError If the window array is not a 2D array, has the wrong shape, or contains values outside the range [0, 1]. """ if not isinstance(window_array, np.ndarray) or window_array.ndim != 2: msg = ('window function must return a 2D array, got ' f'{type(window_array).__name__} with ' f'ndim={getattr(window_array, "ndim", "undefined")}.') raise ValueError(msg) if window_array.shape != expected_shape: msg = (f'window function must return an array with shape ' f'{expected_shape}, got {window_array.shape}.') raise ValueError(msg) if np.any(np.logical_or(window_array < 0, window_array > 1)): msg = ('window function values must be in the range [0, 1], ' f'got range [{np.min(window_array)}, ' f'{np.max(window_array)}].') raise ValueError(msg) def _convert_psf_to_otf(psf, shape): """ Convert a point-spread function to an optical transfer function. This computes the FFT of a PSF array after centering it in a zero-padded array of the output shape and applying `ifftshift` to move the PSF center to position [0, 0]. The PSF is first placed at the center of the zero-padded array, ensuring its center aligns with the array's center. The zero-padding is needed when the input kernel (e.g., a 3x3 Laplacian) is smaller than the target shape, so that the resulting OTF has the correct size for element-wise operations with other same-shaped OTFs. The `ifftshift` operation then moves the PSF center from the array center to position [0, 0], which is the standard convention for computing OTFs via FFT. This ensures correct complex phase in the resulting OTF for general use. Note that when only the power spectrum (|OTF|^2) is needed, the shift has no effect because it only changes the phase. Parameters ---------- psf : 2D `~numpy.ndarray` The PSF array. The PSF must have odd dimensions and be centered on the central pixel. The PSF shape must be smaller than or equal to the target shape in both dimensions. shape : tuple of int The desired output shape. Returns ------- otf : 2D `~numpy.ndarray` The optical transfer function (complex array). """ if np.all(psf == 0): return np.zeros(shape, dtype=complex) if psf.ndim != 2: msg = 'psf must be a 2D array.' raise ValueError(msg) if psf.shape[0] % 2 == 0 or psf.shape[1] % 2 == 0: msg = f'psf must have odd dimensions, got shape {psf.shape}.' raise ValueError(msg) inshape = psf.shape if any(i > s for i, s in zip(inshape, shape, strict=True)): msg = (f'The PSF shape {inshape} is larger than the target ' f'shape {shape} in at least one dimension.') raise ValueError(msg) # Zero-pad to the output shape with PSF centered in the array padded = np.zeros(shape, dtype=psf.dtype) # Calculate where to place PSF so its center aligns with padded # array center center = tuple(s // 2 for s in shape) psf_center = tuple(s // 2 for s in inshape) start = tuple(c - pc for c, pc in zip(center, psf_center, strict=True)) padded[start[0]:start[0] + inshape[0], start[1]:start[1] + inshape[1]] = psf # Shift the centered PSF so its center moves to [0, 0] padded = ifftshift(padded) return fft2(padded) def _apply_window_to_fourier(fourier_array, window, shape): """ Apply a centered window function to a Fourier-domain array. The window function is assumed to be defined with the DC component at the center of the array. Since Fourier arrays use the standard FFT layout with the DC component at the corner, this function shifts the array to the center, applies the window, and shifts it back. Parameters ---------- fourier_array : 2D `~numpy.ndarray` A complex Fourier-domain array with the DC component at the corner. window : callable The window function. Must accept a single ``shape`` tuple and return a 2D array with values in [0, 1]. shape : tuple of int The shape passed to the window function and the expected shape of the window output. Returns ------- result : 2D `~numpy.ndarray` The windowed Fourier-domain array, still in standard FFT layout (DC at the corner). """ window_array = window(shape) _validate_window_array(window_array, shape) fourier_array = fftshift(fourier_array) fourier_array *= window_array return ifftshift(fourier_array) def resize_psf(psf, input_pixel_scale, output_pixel_scale, *, order=3): """ Resize a PSF using spline interpolation of the requested order. The total flux of the PSF is conserved during the resizing. Parameters ---------- psf : 2D `~numpy.ndarray` The 2D data array of the PSF. The PSF must have odd dimensions. It is assumed to be centered on the central pixel. 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 : int, optional The order of the spline interpolation (0-5). The default is 3. Returns ------- result : 2D `~numpy.ndarray` The resampled/interpolated 2D data array. The output always has odd dimensions. The natural resampled size is computed by taking the ceiling of ``input_size * (input_pixel_scale / output_pixel_scale)`` for each axis, then adding 1 to any axis whose size is even. This guarantees the output is centered and usable for PSF matching. When the output size is adjusted, the effective pixel scale will be slightly smaller than ``output_pixel_scale``; the exact value per axis is ``input_pixel_scale * input_size / output_size``. Raises ------ ValueError If ``psf`` is not a 2D array, has even dimensions, is not centered, or if the pixel scales are not positive. """ psf = np.asarray(psf, dtype=float) if input_pixel_scale <= 0 or output_pixel_scale <= 0: msg = ('input_pixel_scale and output_pixel_scale must be ' 'positive.') raise ValueError(msg) _validate_psf(psf, 'psf') ratio = input_pixel_scale / output_pixel_scale # Compute target shape using ceiling (never discard pixels), then # add 1 to any even dimension to guarantee an odd output, which is # required for PSF matching. in_shape = np.array(psf.shape) out_shape = np.maximum(1, np.ceil(in_shape * ratio).astype(int)) out_shape += out_shape % 2 == 0 # Per-axis zoom factors for the forced-odd target shape zoom_factors = out_shape / in_shape # Normalize the PSF to conserve total flux after resizing. psf_sum = psf.sum() result = zoom(psf, zoom_factors, order=order) return result * (psf_sum / result.sum()) astropy-photutils-3322558/photutils/psf_matching/windows.py000066400000000000000000000303411517052111400242000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Window (tapering) functions for matching PSFs using Fourier methods. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning __all__ = [ 'CosineBellWindow', 'HanningWindow', 'SplitCosineBellWindow', 'TopHatWindow', 'TukeyWindow', ] def _distance_grid(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. Must have only 2 elements. To have a well defined array center, the size along each axis should be an odd integer, but this is not enforced. Returns ------- result : 2D `~numpy.ndarray` An array containing the Euclidean radial distances from the array center. """ if len(shape) != 2: msg = 'shape must have only 2 elements' raise ValueError(msg) y_cen, x_cen = (shape[0] - 1) / 2, (shape[1] - 1) / 2 y_vals, x_vals = np.ogrid[:shape[0], :shape[1]] return np.hypot(x_vals - x_cen, y_vals - y_cen) class SplitCosineBellWindow: """ Class to define a 2D split cosine bell taper function. This is the base class for window functions, providing full control over both the inner flat region (``beta``) and the taper width (``alpha``). The window equals 1.0 in the inner region, smoothly transitions to 0.0 using a cosine taper, and remains 0.0 outside. This window is useful when you need precise control over both the preserved central region and the taper characteristics. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. ``alpha`` must be between 0.0 and 1.0, inclusive. beta : float, optional The inner diameter as a fraction of the array size beyond which the taper begins. ``beta`` must be between 0.0 and 1.0, inclusive. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import SplitCosineBellWindow taper = SplitCosineBellWindow(alpha=0.4, beta=0.3) data = taper((101, 101)) fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') fig.colorbar(axim) A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import SplitCosineBellWindow taper = SplitCosineBellWindow(alpha=0.4, beta=0.3) data = taper((101, 101)) fig, ax = plt.subplots() ax.plot(data[50, :]) """ def __init__(self, alpha, beta): if not (0.0 <= alpha <= 1.0): msg = ('alpha must be between 0.0 and 1.0, inclusive. ' f'Got: {alpha}') raise ValueError(msg) if not (0.0 <= beta <= 1.0): msg = ('beta must be between 0.0 and 1.0, inclusive. ' f'Got: {beta}') raise ValueError(msg) if alpha + beta > 1.0: msg = ('alpha + beta > 1.0; the taper region will be ' 'clipped to the array boundary.') warnings.warn(msg, AstropyUserWarning) self.alpha = float(alpha) self.beta = float(beta) def __repr__(self): return (f'{self.__class__.__name__}(' f'alpha={self.alpha!r}, beta={self.beta!r})') def __str__(self): return self.__repr__() def __call__(self, shape): """ Generate the window function for 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. """ dist = _distance_grid(shape) # Define geometry based on the smallest shape dimension max_r = (min(shape) - 1.0) / 2.0 r_inner = self.beta * max_r taper_width = self.alpha * max_r r_outer = r_inner + taper_width if taper_width > 0: r = dist - r_inner result = 0.5 * (1.0 + np.cos(np.pi * r / taper_width)) else: result = np.ones(shape, dtype=float) result[dist < r_inner] = 1.0 result[dist > r_outer] = 0.0 return result 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. The taper begins at the center and smoothly decreases to zero at the edges. This window equals 1.0 only at the exact center point. This is a classic general-purpose window function widely used in signal processing. It provides good sidelobe suppression in Fourier space, reducing ringing artifacts at the cost of tapering the entire image. For PSF matching, use this window when edge effects and ringing artifacts are a primary concern and you can accept tapering most of the data. If you want to preserve more of the central region, consider using `TukeyWindow` instead. Notes ----- Equivalent to ``SplitCosineBellWindow(alpha=1.0, beta=0.0)``. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import HanningWindow taper = HanningWindow() data = taper((101, 101)) fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') fig.colorbar(axim) A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import HanningWindow taper = HanningWindow() data = taper((101, 101)) fig, ax = plt.subplots() ax.plot(data[50, :]) """ def __init__(self): # alpha=1.0 (full taper), beta=0.0 (taper starts at center) super().__init__(alpha=1.0, beta=0.0) def __repr__(self): return f'{self.__class__.__name__}()' def __str__(self): return self.__repr__() class TukeyWindow(SplitCosineBellWindow): """ Class to define a 2D `Tukey window `_ function. The Tukey window features a flat inner plateau equal to 1.0, surrounded by a smooth cosine taper that transitions to 0.0 at the edges. This provides an excellent balance between preserving data in the central region and suppressing edge artifacts. The ``alpha`` parameter controls the trade-off: smaller values preserve more data but create stronger edge effects, while larger values reduce artifacts but taper more of the image. Compared to `HanningWindow`, Tukey preserves a larger central region. Compared to `TopHatWindow`, it provides much better artifact suppression at the cost of tapering the outer regions. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Must be between 0.0 and 1.0, inclusive. When ``alpha=0``, this becomes a `TopHatWindow`. When ``alpha=1``, this becomes a `HanningWindow`. Notes ----- Equivalent to ``SplitCosineBellWindow(alpha=alpha, beta=1.0 - alpha)``. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import TukeyWindow taper = TukeyWindow(alpha=0.4) data = taper((101, 101)) fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') fig.colorbar(axim) A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import TukeyWindow taper = TukeyWindow(alpha=0.4) data = taper((101, 101)) fig, ax = plt.subplots() ax.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=1.0 - alpha) def __repr__(self): return (f'{self.__class__.__name__}' f'(alpha={self.alpha!r})') def __str__(self): return self.__repr__() class CosineBellWindow(SplitCosineBellWindow): """ Class to define a 2D cosine bell window function. This window equals 1.0 only at the exact center point and smoothly tapers to 0.0. The taper begins immediately from the center (no inner plateau) and extends outward over a fraction ``alpha`` of the maximum radius. Use this window when you want to preserve the very center of an image while applying a gentle taper that starts relatively far from the edges. It provides less artifact suppression than `TukeyWindow` for the same ``alpha`` value because the taper region is positioned differently. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Must be between 0.0 and 1.0, inclusive. When ``alpha=1``, this becomes a `HanningWindow`. Notes ----- Equivalent to ``SplitCosineBellWindow(alpha=alpha, beta=0.0)``. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import CosineBellWindow taper = CosineBellWindow(alpha=0.3) data = taper((101, 101)) fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') fig.colorbar(axim) A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import CosineBellWindow taper = CosineBellWindow(alpha=0.3) data = taper((101, 101)) fig, ax = plt.subplots() ax.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=0.0) def __repr__(self): return (f'{self.__class__.__name__}' f'(alpha={self.alpha!r})') def __str__(self): return self.__repr__() class TopHatWindow(SplitCosineBellWindow): """ Class to define a 2D top hat window function. This window equals 1.0 inside a circular region defined by ``beta`` and drops sharply to 0.0 outside, with no smooth transition. It is also known as a rectangular or boxcar window. This window preserves the most data (everything inside the cutoff radius is untouched), but the sharp edge creates strong ringing artifacts in Fourier space. Use this only when you need to strictly preserve data within a specific region and can tolerate significant artifacts, or when the sharp cutoff is explicitly desired. For most PSF matching applications, `TukeyWindow` is preferred as it provides much better artifact suppression while still preserving a large central region. Use `TopHatWindow` primarily for masking or when studying the effects of abrupt truncation. Parameters ---------- beta : float, optional The inner diameter as a fraction of the array size beyond which the window drops to zero. Must be between 0.0 and 1.0, inclusive. Notes ----- Equivalent to ``SplitCosineBellWindow(alpha=0.0, beta=beta)``. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import TopHatWindow taper = TopHatWindow(beta=0.4) data = taper((101, 101)) fig, ax = plt.subplots() axim = ax.imshow(data, origin='lower') fig.colorbar(axim) A 1D cut across the image center: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf_matching import TopHatWindow taper = TopHatWindow(beta=0.4) data = taper((101, 101)) fig, ax = plt.subplots() ax.plot(data[50, :]) """ def __init__(self, beta): super().__init__(alpha=0.0, beta=beta) def __repr__(self): return (f'{self.__class__.__name__}' f'(beta={self.beta!r})') def __str__(self): return self.__repr__() astropy-photutils-3322558/photutils/segmentation/000077500000000000000000000000001517052111400221665ustar00rootroot00000000000000astropy-photutils-3322558/photutils/segmentation/__init__.py000066400000000000000000000007301517052111400242770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing 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 astropy-photutils-3322558/photutils/segmentation/catalog.py000066400000000000000000005020121517052111400241520ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating the properties of sources defined by a segmentation image. """ import functools import inspect import math 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.utils import lazyproperty from scipy.ndimage import map_coordinates from scipy.optimize import root_scalar from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture, RectangularAnnulus) from photutils.background import SExtractorBackground from photutils.geometry import circular_overlap_grid, elliptical_overlap_grid from photutils.morphology import gini as gini_func from photutils.segmentation.core import SegmentationImage from photutils.segmentation.utils import _mask_to_mirrored_value from photutils.utils._deprecation import (_get_future_column_names, create_empty_deprecated_qtable, deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._misc import _get_meta 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'] # Default table columns for `to_table()` output DEFAULT_COLUMNS = ['label', 'x_centroid', 'y_centroid', 'sky_centroid', 'bbox_xmin', 'bbox_xmax', 'bbox_ymin', 'bbox_ymax', 'area', 'semimajor_axis', 'semiminor_axis', 'orientation', 'eccentricity', 'min_value', 'max_value', 'segment_flux', 'segment_flux_err', 'kron_flux', 'kron_flux_err'] # Remove in 4.0 _DEPRECATED_ATTRIBUTES = { 'add_extra_property': 'add_property', 'apermask_method': 'aperture_mask_method', 'background': 'background_cutout', 'background_ma': 'background_cutout_masked', 'convdata': 'conv_data_cutout', 'convdata_ma': 'conv_data_cutout_masked', 'covar_sigx2': 'covariance_xx', 'covar_sigxy': 'covariance_xy', 'covar_sigy2': 'covariance_yy', 'cutout_maxval_index': 'cutout_max_value_index', 'cutout_minval_index': 'cutout_min_value_index', 'cxx': 'ellipse_cxx', 'cxy': 'ellipse_cxy', 'cyy': 'ellipse_cyy', 'data': 'data_cutout', 'data_ma': 'data_cutout_masked', 'error': 'error_cutout', 'error_ma': 'error_cutout_masked', 'extra_properties': 'custom_properties', 'fluxfrac_radius': 'flux_radius', 'get_label': 'select_label', 'get_labels': 'select_labels', 'kron_fluxerr': 'kron_flux_err', 'localbkg_width': 'local_bkg_width', 'maxval_index': 'max_value_index', 'maxval_xindex': 'max_value_xindex', 'maxval_yindex': 'max_value_yindex', 'minval_index': 'min_value_index', 'minval_xindex': 'min_value_xindex', 'minval_yindex': 'min_value_yindex', 'nlabels': 'n_labels', 'remove_extra_properties': 'remove_properties', 'remove_extra_property': 'remove_property', 'rename_extra_property': 'rename_property', 'segment': 'segment_cutout', 'segment_fluxerr': 'segment_flux_err', 'segment_ma': 'segment_cutout_masked', 'semimajor_sigma': 'semimajor_axis', 'semiminor_sigma': 'semiminor_axis', 'xcentroid': 'x_centroid', 'xcentroid_quad': 'x_centroid_quad', 'xcentroid_win': 'x_centroid_win', 'ycentroid': 'y_centroid', 'ycentroid_quad': 'y_centroid_quad', 'ycentroid_win': 'y_centroid_win', } _DEPRECATED_META_KEYS = { 'localbkg_width': 'local_bkg_width', 'apermask_method': 'aperture_mask_method', } def as_scalar(method): """ Return a decorated method where it will always return a scalar value (instead of a length-1 tuple/list/array) 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. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @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 a decorated method where it will return the value from the detection image catalog instead of using the method to calculate it. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @functools.wraps(method) def _use_detcat(self, *args, **kwargs): if self._detection_catalog is None: return method(self, *args, **kwargs) return getattr(self._detection_catalog, method.__name__) return _use_detcat class SourceCatalog: """ 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. segmentation_image : `~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_catalog`` is input. local_bkg_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 ``flux_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. aperture_mask_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_catalog`` is input. In that case, the ``aperture_mask_method`` set in the ``detection_catalog`` will be used. 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_axis` * `semiminor_axis`) 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_catalog`` is input. detection_catalog : `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 ``segmentation_image``. 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_catalog`` is input, then the input ``wcs``, ``aperture_mask_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 `flux_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``, ``flux_radius``, ``circular_photometry``, ``centroid_win``, ``centroid_quad``). The progress bar requires that the `tqdm `_ optional dependency be installed. 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. `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`` is `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_flux_err` 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_flux_err`, :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_cutout_masked` and `~photutils.segmentation.SourceCatalog.background_cutout_masked` properties, which are 2D `~numpy.ma.MaskedArray` cutout versions of the input ``error`` and ``background`` arrays. The mask is `True` for pixels outside the source segment, masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). **Scalar vs. Multi-source Catalogs** A `SourceCatalog` can represent a single source or multiple sources. Most properties adapt their return type accordingly: for a multi-source catalog, properties return arrays or lists (one element per source); for a single-source (scalar) catalog, the same properties return a scalar value or a single object. For example, `kron_aperture` returns a list of aperture objects for a multi-source catalog, but a single aperture object for a scalar catalog. Similarly, `data_cutout` returns a list of 2D cutout arrays for a multi-source catalog, but a single 2D array for a scalar catalog. A scalar catalog is created when the a multi-source catalog is indexed to select a single source. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ @deprecated_renamed_argument('segment_img', 'segmentation_image', '3.0', until='4.0') @deprecated_renamed_argument('localbkg_width', 'local_bkg_width', '3.0', until='4.0') @deprecated_renamed_argument('apermask_method', 'aperture_mask_method', '3.0', until='4.0') @deprecated_renamed_argument('detection_cat', 'detection_catalog', '3.0', until='4.0') def __init__(self, data, segmentation_image, *, convolved_data=None, error=None, mask=None, background=None, wcs=None, local_bkg_width=0, aperture_mask_method='correct', kron_params=(2.5, 1.4, 0.0), detection_catalog=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._segmentation_image = self._validate_segmentation_image( segmentation_image) 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.local_bkg_width = self._validate_local_bkg_width( local_bkg_width) self.aperture_mask_method = self._validate_aperture_mask_method( aperture_mask_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._segmentation_image.slices self._labels = self._segmentation_image.labels if self._labels.shape == (0,): msg = 'segmentation_image must have at least one non-zero label' raise ValueError(msg) self._detection_catalog = self._validate_detection_catalog( detection_catalog) attrs = ('wcs', 'aperture_mask_method', 'kron_params') if self._detection_catalog is not None: for attr in attrs: setattr(self, attr, getattr(self._detection_catalog, attr)) if convolved_data is None: self._convolved_data = self._data self._aperture_mask_kwargs = { 'circ': {'method': 'exact'}, 'kron': {'method': 'exact'}, 'flux_radius': {'method': 'exact'}, 'cen_win': {'method': 'center'}, } self.default_columns = DEFAULT_COLUMNS self._custom_properties = [] self._flux_radius_cache = {} self.meta = _get_meta() self._update_meta() def _validate_segmentation_image(self, segmentation_image): if not isinstance(segmentation_image, SegmentationImage): msg = 'segmentation_image must be a SegmentationImage' raise TypeError(msg) if segmentation_image.shape != self._data.shape: msg = 'segmentation_image and data must have the same shape' raise ValueError(msg) return segmentation_image 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: msg = f'{name} must be a 2D array' raise ValueError(msg) if shape and array.shape != self._data.shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array @staticmethod def _validate_local_bkg_width(local_bkg_width): if local_bkg_width < 0: msg = 'local_bkg_width must be >= 0' raise ValueError(msg) local_bkg_width_int = int(local_bkg_width) if local_bkg_width_int != local_bkg_width: msg = 'local_bkg_width must be an integer' raise ValueError(msg) return local_bkg_width_int @staticmethod def _validate_aperture_mask_method(aperture_mask_method): if aperture_mask_method not in ('none', 'mask', 'correct'): msg = 'Invalid aperture_mask_method value' raise ValueError(msg) return aperture_mask_method @staticmethod def _validate_kron_params(kron_params): if np.ndim(kron_params) != 1: msg = 'kron_params must be 1D' raise ValueError(msg) nparams = len(kron_params) if nparams not in (2, 3): msg = 'kron_params must have 2 or 3 elements' raise ValueError(msg) if kron_params[0] <= 0: msg = 'kron_params[0] must be > 0' raise ValueError(msg) if kron_params[1] <= 0: msg = 'kron_params[1] must be > 0' raise ValueError(msg) if nparams == 3 and kron_params[2] < 0: msg = 'kron_params[2] must be >= 0' raise ValueError(msg) return tuple(kron_params) def _validate_detection_catalog(self, detection_catalog): if detection_catalog is None: return None if not isinstance(detection_catalog, SourceCatalog): msg = 'detection_catalog must be a SourceCatalog instance' raise TypeError(msg) if not np.array_equal(detection_catalog._segmentation_image, self._segmentation_image): msg = ('detection_catalog must have same segmentation_image ' 'as the input segmentation_image') raise ValueError(msg) return detection_catalog def _update_meta(self): meta_values = {} attrs = ('local_bkg_width', 'aperture_mask_method', 'kron_params') for attr in attrs: meta_values[attr] = getattr(self, attr) if not _get_future_column_names(): for old_name, new_name in _DEPRECATED_META_KEYS.items(): if new_name in meta_values: meta_values[old_name] = meta_values[new_name] self.meta.update(meta_values) def _set_semode(self): # SE emulation self._aperture_mask_kwargs = { 'circ': {'method': 'subpixel', 'subpixels': 5}, 'kron': {'method': 'center'}, 'flux_radius': {'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). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_properties' # Subclasses get their own property list if attr not in cls.__dict__: def isproperty(obj): return isinstance(obj, property) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=isproperty)]) return getattr(cls, attr) @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('n_labels') lazyproperties.extend(['label', 'labels', 'slices']) lazyproperties.sort() return lazyproperties @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_lazyproperties' # Subclasses get their own lazyproperty list if attr not in cls.__dict__: def islazyproperty(obj): return isinstance(obj, lazyproperty) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=islazyproperty)]) return getattr(cls, attr) @staticmethod def _index_object_list(lst, index): """ Index a list of heterogeneous objects using numpy-style indexing. A numpy object array is used to support fancy and boolean indices on lists of tuples or other structured objects. A sentinel ``None`` is appended (and then removed) to prevent numpy from recursing into nested sequences (e.g., tuples of slices). Parameters ---------- lst : list The list of objects to index. index : int, slice, list, or array The index to apply to the list. Returns ------- result : list or object A list for array results or the element itself for scalar (integer) indices. """ result = np.array([*lst, None], dtype=object)[:-1][index] if isinstance(result, np.ndarray): return result.tolist() return result def __getitem__(self, index): if self.isscalar: msg = (f'A scalar {self.__class__.__name__!r} object cannot ' 'be indexed') raise TypeError(msg) newcls = object.__new__(self.__class__) # Attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_segmentation_image', '_error', '_mask', '_background', 'wcs', '_data_unit', '_convolved_data', 'local_bkg_width', 'aperture_mask_method', 'kron_params', 'default_columns', '_custom_properties', 'meta', '_aperture_mask_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_catalog, if input attr = '_detection_catalog' if getattr(self, attr) is None: setattr(newcls, attr, None) else: setattr(newcls, attr, getattr(self, attr)[index]) attr = '_slices' value = self._index_object_list(getattr(self, attr), index) setattr(newcls, attr, value) # Slice the flux_radius cache values newcls._flux_radius_cache = {key: value[index] for key, value in self._flux_radius_cache.items()} # Evaluated lazyproperty objects and extra properties keys = (set(self.__dict__.keys()) & (set(self._lazyproperties) | set(self._custom_properties))) for key in keys: value = self.__dict__[key] # Do not insert attributes that are always scalar (e.g., # isscalar, n_labels), 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 = self._index_object_list(value, index) 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_labels}', f'labels: {self.labels}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: msg = f'Scalar {self.__class__.__name__!r} object has no len()' raise TypeError(msg) return self.n_labels def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') @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. Returns ------- result : `SourceCatalog` A deep copy of this object. """ return deepcopy(self) @property def custom_properties(self): """ A list of the user-defined source properties. """ return self._custom_properties @deprecated_positional_kwargs(since='3.0', until='4.0') def add_property(self, name, value, overwrite=False): """ Add a user-defined 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 properties is stored in the `custom_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.custom_properties)) if name in internal_attributes: msg = f'{name} cannot be set because it is a built-in attribute' raise ValueError(msg) if not overwrite: if hasattr(self, name): msg = (f'{name} already exists as an attribute. Set ' 'overwrite=True to overwrite an existing attribute.') raise ValueError(msg) if name in self._custom_properties: msg = (f'{name} already exists in the custom_properties ' 'attribute list.') raise ValueError(msg) property_error = False if self.isscalar: # This allows flux_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 elif not np.isscalar(value): property_error = True elif not self._has_len(value) or len(value) != self.n_labels: property_error = True if property_error: msg = ('value must have the same number of elements as the ' 'catalog in order to add it as a property.') raise ValueError(msg) setattr(self, name, value) if name not in self._custom_properties: self._custom_properties.append(name) def remove_property(self, name): """ Remove a user-defined property. The property must have been defined using `add_property`. The complete list of user-defined properties is stored in the `custom_properties` attribute. Parameters ---------- name : str The name of the property to remove. """ self.remove_properties(name) def remove_properties(self, names): """ Remove user-defined properties. The properties must have been defined using `add_property`. The complete list of user-defined properties is stored in the `custom_properties` attribute. Parameters ---------- names : list of str or str The names of the properties to remove. """ if isinstance(names, str): names = [names] # We copy the list here to prevent changing the list in-place # during the for loop below, e.g., in case a user inputs # self.custom_properties to ``names`` custom_properties = self._custom_properties.copy() for name in names: if name in custom_properties: delattr(self, name) custom_properties.remove(name) else: msg = f'{name} is not a defined property' raise ValueError(msg) self._custom_properties = custom_properties def rename_property(self, name, new_name): """ Rename a user-defined property. The renamed property will remain at the same index in the `custom_properties` list. Parameters ---------- name : str The old attribute name. new_name : str The new attribute name. """ self.add_property(new_name, getattr(self, name)) idx = self.custom_properties.index(name) self.remove_property(name) # Preserve the order of self.custom_properties self.custom_properties.remove(new_name) self.custom_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.n_labels) @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.n_labels) 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 _segmentation_image_cutouts(self): """ A list of segmentation image cutouts using the segmentation image slices. """ return [self._segmentation_image.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 ``error`` 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, strict=True): 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 the source segment. """ return [segm != label for label, segm in zip(self.labels, self._segmentation_image_cutouts, strict=True)] @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, strict=True): 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 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, strict=True): 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: msg = 'Both units and masked cannot be True' raise ValueError(msg) if dtype is not None: cutouts = [cutout.astype(dtype, copy=True) for cutout in arrays] 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, strict=True)] return cutouts def select_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.select_labels(label) def select_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._segmentation_image.check_labels(labels) sorter = np.argsort(self.labels) indices = sorter[np.searchsorted(self.labels, labels, sorter=sorter)] return self[indices] @deprecated_positional_kwargs(since='3.0', until='4.0') 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_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 elif isinstance(columns, str): table_columns = [columns] else: table_columns = columns # Replace with QTable() in 4.0 tbl = create_empty_deprecated_qtable( _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') 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_labels(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`. Returns an array for multi-source catalogs, or a scalar for a single-source catalog. """ 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. Returns a list for multi-source catalogs, or a single tuple for a single-source catalog. """ 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_cutout(self): """ A 2D `~numpy.ndarray` cutout of the segmentation image using the minimal bounding box of the source. Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts( self._segmentation_image_cutouts, units=False, masked=False) @lazyproperty @as_scalar def segment_cutout_masked(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 the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts( self._segmentation_image_cutouts, units=False, masked=True) @lazyproperty @as_scalar def data_cutout(self): """ A 2D `~numpy.ndarray` cutout from the data using the minimal bounding box of the source. Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._data_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def data_cutout_masked(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 the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._data_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def conv_data_cutout(self): """ A 2D `~numpy.ndarray` cutout from the convolved data using the minimal bounding box of the source. Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._convdata_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def conv_data_cutout_masked(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 the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._convdata_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def error_cutout(self): """ A 2D `~numpy.ndarray` cutout from the error array using the minimal bounding box of the source. Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ 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_cutout_masked(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 the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ 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_cutout(self): """ A 2D `~numpy.ndarray` cutout from the background array using the minimal bounding box of the source. Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ 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_cutout_masked(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 the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). Returns a list of arrays for multi-source catalogs, or a single array for a single-source catalog. """ 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] @staticmethod def _reduceat(values, ufunc, *, transform=None): """ Apply ``ufunc.reduceat`` to a list of arrays. This is significantly faster than a list comprehension with individual NumPy calls for each array. Parameters ---------- values : list of 1D `~numpy.ndarray` A list of 1D arrays. ufunc : `~numpy.ufunc` The NumPy ufunc to apply (e.g., `~numpy.add`, `~numpy.minimum`, `~numpy.maximum`). transform : callable or None, optional An optional transformation to apply to the concatenated array before reducing (e.g., `~numpy.square`). Returns ------- result : `~numpy.ndarray` The reduceat result. sizes : `~numpy.ndarray` The sizes of the input arrays. """ if not values: return np.array([]), np.array([], dtype=int) sizes = np.array([len(arr) for arr in values]) splits = np.concatenate(([0], np.cumsum(sizes[:-1]))) concat = np.concatenate(values) if transform is not None: concat = transform(concat) return ufunc.reduceat(concat, splits), sizes @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_cutout_masked) @lazyproperty def _error_values(self): """ A 1D array of unmasked error values. An array with a single NaN is returned for completely-masked sources. """ if self._error is None: return self._null_objects return self._get_values(self.error_cutout_masked) @lazyproperty def _background_values(self): """ A 1D array of unmasked background values. An array with a single NaN is returned for completely-masked sources. """ if self._background is None: return self._null_objects return self._get_values(self.background_cutout_masked) @lazyproperty @use_detcat @as_scalar def moments(self): """ Spatial moments up to 3rd order of the source. """ result = [] for arr in self._moment_data_cutouts: ny, nx = arr.shape y = np.arange(ny, dtype=float) x = np.arange(nx, dtype=float) yp = np.column_stack([np.ones(ny), y, y * y, y ** 3]) xp = np.column_stack([np.ones(nx), x, x * x, x ** 3]) result.append(yp.T @ arr @ xp) return np.array(result) @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, :] result = [] for arr, xcen, ycen in zip(self._moment_data_cutouts, cutout_centroid[:, 0], cutout_centroid[:, 1], strict=True): ny, nx = arr.shape yc = np.arange(ny, dtype=float) - ycen xc = np.arange(nx, dtype=float) - xcen yp = np.column_stack([np.ones(ny), yc, yc * yc, yc ** 3]) xp = np.column_stack([np.ones(nx), xc, xc * xc, xc ** 3]) result.append(yp.T @ arr @ xp) return np.array(result) @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) y_centroid = moments[:, 1, 0] / moments[:, 0, 0] x_centroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((x_centroid, y_centroid)) @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 _x_centroid(self): """ The ``x`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: x_centroid = self.centroid[0:1] # scalar array else: x_centroid = self.centroid[:, 0] return x_centroid @lazyproperty @use_detcat @as_scalar def x_centroid(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._x_centroid @lazyproperty @use_detcat def _y_centroid(self): """ The ``y`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: y_centroid = self.centroid[1:2] # scalar array else: y_centroid = self.centroid[:, 1] return y_centroid @lazyproperty @use_detcat @as_scalar def y_centroid(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._y_centroid @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 ----- During 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., ``flux_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 the 1-sigma ellipse shape based on the image moments, then the isophotal `centroid` will be used instead. If the half-light radius is not finite (e.g., due to a non-finite Kron radius), then ``np.nan`` will be returned. """ # Use .copy() to avoid mutating the cached flux_radius value radius_hl = self.flux_radius(0.5).value.copy() if self.isscalar: radius_hl = np.array([radius_hl]) # Track which sources have non-finite half-light radii (e.g., # due to NaN kron_radius). These sources cannot have a # meaningful windowed centroid. nan_hl = ~np.isfinite(radius_hl) # Apply a minimum half-light radius of 0.5 pixels (matching # SourceExtractor) for valid but very small values min_radius = 0.5 small_mask = np.isfinite(radius_hl) & (radius_hl < min_radius) radius_hl[small_mask] = min_radius labels = self.labels if self.progress_bar: desc = 'centroid_win' labels = add_progress_bar(labels, desc=desc) # Pre-fetch arrays used in the inner loop data_arr = self._data mask_arr = self._mask segm_data = self._segmentation_image.data data_shape = data_arr.shape do_correct = self.aperture_mask_method == 'correct' do_segm_mask = self.aperture_mask_method != 'none' max_aper_size = max(data_arr.size, 1_000_000) max_iters = 16 centroid_threshold = 0.0001 xcen_win = [] ycen_win = [] for label, xcen, ycen, rad_hl, nan_hl_ in zip( labels, self._x_centroid, self._y_centroid, radius_hl, nan_hl, strict=True): if nan_hl_ or math.isnan(xcen) or math.isnan(ycen): xcen_win.append(np.nan) ycen_win.append(np.nan) continue sigma = 2.0 * rad_hl * gaussian_fwhm_to_sigma inv_2sigma2 = -1.0 / (2.0 * sigma * sigma) radius = 4.0 * sigma radius_sq = radius * radius # Compute the full (unclipped) bounding box for the aperture # using the initial centroid. The radius is fixed, so the # bbox size stays the same across iterations even if the # center shifts slightly. bbox_halfsize = int(radius + 1.5) full_ny = full_nx = 2 * bbox_halfsize + 1 # OOM guard if full_ny * full_nx > max_aper_size: xcen_win.append(np.nan) ycen_win.append(np.nan) continue # Cache for cutout data when the integer bbox doesn't change prev_ixcen = prev_iycen = None cached_data = None cached_mask = None iter_ = 0 dcen = 1.0 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) while iter_ < max_iters and dcen > centroid_threshold: # Compute integer bounding box ixmin = int(xcen + 0.5) - bbox_halfsize ixmax = ixmin + full_nx iymin = int(ycen + 0.5) - bbox_halfsize iymax = iymin + full_ny # Clip to data boundaries slc_y = slice(max(0, iymin), min(data_shape[0], iymax)) slc_x = slice(max(0, ixmin), min(data_shape[1], ixmax)) if (slc_y.start >= slc_y.stop or slc_x.start >= slc_x.stop): xcen = np.nan ycen = np.nan break cur_ixcen = int(xcen + 0.5) cur_iycen = int(ycen + 0.5) # Recompute cutout data only when the integer center # changes to avoid redundant _mask_to_mirrored_value # calls if cur_ixcen != prev_ixcen or cur_iycen != prev_iycen: prev_ixcen = cur_ixcen prev_iycen = cur_iycen data = data_arr[slc_y, slc_x].astype(float) data_mask = ~np.isfinite(data) if mask_arr is not None: data_mask |= mask_arr[slc_y, slc_x] cutout_xycen = (xcen - max(0, ixmin), ycen - max(0, iymin)) if do_segm_mask: seg_cut = segm_data[slc_y, slc_x] segm_mask = ((seg_cut != label) & (seg_cut != 0)) if self.aperture_mask_method == 'mask': data_mask = data_mask | segm_mask if do_correct: data = _mask_to_mirrored_value( data, segm_mask, cutout_xycen, mask=data_mask) cached_data = data cached_mask = data_mask # Centroid position in cutout coordinates cx = xcen - max(0, ixmin) cy = ycen - max(0, iymin) ny = slc_y.stop - slc_y.start nx = slc_x.stop - slc_x.start # Build coordinate grids relative to centroid # (reused for circle mask, Gaussian, and moments) xvals = np.arange(nx) - cx yvals = np.arange(ny) - cy xx = xvals[np.newaxis, :] yy = yvals[:, np.newaxis] # Inline binary circle mask rr2 = xx * xx + yy * yy aper_weights = (rr2 <= radius_sq).astype(float) # Inline Gaussian weight gweight = np.exp(rr2 * inv_2sigma2) # Apply weights and mask weighted = (cached_data * aper_weights * gweight) weighted[cached_mask] = 0.0 # Inline moment computation total = np.sum(weighted) dx = np.sum(weighted * xx) / total dy = np.sum(weighted * yy) / total dcen = math.sqrt(dx * dx + dy * dy) 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 the 1-sigma ellipse or if the iteration failed (NaN # from aperture off-image). Sources with NaN half-light radius # keep NaN (no valid window size). dx = self._x_centroid - xcen_win dy = self._y_centroid - ycen_win cxx = self.ellipse_cxx.value cxy = self.ellipse_cxy.value cyy = self.ellipse_cyy.value if self.isscalar: cxx = (cxx,) cxy = (cxy,) cyy = (cyy,) reset = ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1) nan_cen = np.isnan(xcen_win) | np.isnan(ycen_win) reset |= nan_cen & ~nan_hl if np.any(reset): xcen_win[reset] = self._x_centroid[reset] ycen_win[reset] = self._y_centroid[reset] return np.transpose((xcen_win, ycen_win)) @lazyproperty @use_detcat @as_scalar def x_centroid_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: x_centroid = self.centroid_win[0] # scalar array else: x_centroid = self.centroid_win[:, 0] return x_centroid @lazyproperty @use_detcat @as_scalar def y_centroid_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: y_centroid = self.centroid_win[1] # scalar array else: y_centroid = self.centroid_win[:, 1] return y_centroid @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. """ # Precompute the pseudo-inverse for the 3x3 relative coordinate # design matrix [1, x, y, xy, x^2, y^2]. This is constant for # all sources and avoids per-source lstsq calls. xi = np.arange(3) x, y = np.meshgrid(xi, xi) x = x.ravel() y = y.ravel() coeff_matrix = np.empty((9, 6), dtype=float) coeff_matrix[:, 0] = 1 coeff_matrix[:, 1] = x coeff_matrix[:, 2] = y coeff_matrix[:, 3] = x * y coeff_matrix[:, 4] = x * x coeff_matrix[:, 5] = y * y pinv = np.linalg.pinv(coeff_matrix) _nan = np.nan centroid_quad = [] cutouts = self._data_cutouts if self.progress_bar: desc = 'centroid_quad' cutouts = add_progress_bar(cutouts, desc=desc) for cutout, mask in zip(cutouts, self._cutout_total_masks, strict=True): ny, nx = cutout.shape # Cutout must be at least 3x3 for the quadratic fit if ny < 3 or nx < 3: centroid_quad.append((_nan, _nan)) continue # Apply mask: _cutout_total_masks already includes # non-finite data values, so cutout[mask] = 0.0 handles both # masked pixels and non-finite values. cutout = np.array(cutout, dtype=float) cutout[mask] = 0.0 # Find peak pixel yidx, xidx = np.unravel_index(np.argmax(cutout), cutout.shape) # If peak at edge of cutout, return peak position if xidx == 0 or xidx == nx - 1 or yidx == 0 or yidx == ny - 1: centroid_quad.append((float(xidx), float(yidx))) continue # Extract 3x3 box centered on peak (guaranteed to fit # since peak is not at edge) xidx0 = xidx - 1 yidx0 = yidx - 1 cutout_flat = cutout[yidx0:yidx0 + 3, xidx0:xidx0 + 3].ravel() # Compute polynomial coefficients via precomputed # pseudo-inverse c = pinv @ cutout_flat c10, c01, c11, c20, c02 = c[1], c[2], c[3], c[4], c[5] det = 4.0 * c20 * c02 - c11 * c11 if det <= 0 or c20 > 0: centroid_quad.append((_nan, _nan)) continue # Maximum in relative coords, then convert to cutout coords xm = (c01 * c11 - 2.0 * c02 * c10) / det + xidx0 ym = (c10 * c11 - 2.0 * c20 * c01) / det + yidx0 if 0.0 < xm < (nx - 1.0) and 0.0 < ym < (ny - 1.0): centroid_quad.append((xm, ym)) else: centroid_quad.append((_nan, _nan)) 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 x_centroid_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: x_centroid = self.centroid_quad[0] # scalar array else: x_centroid = self.centroid_quad[:, 0] return x_centroid @lazyproperty @use_detcat @as_scalar def y_centroid_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: y_centroid = self.centroid_quad[1] # scalar array else: y_centroid = self.centroid_quad[:, 1] return y_centroid @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.x_centroid, self.y_centroid) @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.x_centroid_win, self.y_centroid_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.x_centroid_quad, self.y_centroid_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. Returns a list for multi-source catalogs, or a single `~photutils.aperture.BoundingBox` for a single-source catalog. """ 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). """ return np.array([(bbox_.ixmin - 0.5, bbox_.iymin - 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_ul(self): """ Upper-left *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmin - 0.5, bbox_.iymax + 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_lr(self): """ Lower-right *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmax + 0.5, bbox_.iymin - 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_ur(self): """ Upper-right *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmax + 0.5, bbox_.iymax + 0.5) for bbox_ in self._bbox]) @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 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 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 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 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, _ = self._reduceat(self._data_values, np.minimum) 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, _ = self._reduceat(self._data_values, np.maximum) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty @as_scalar def cutout_min_value_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_cutout_masked 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_max_value_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_cutout_masked 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 min_value_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_min_value_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter, strict=True): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def max_value_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_max_value_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter, strict=True): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def min_value_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.min_value_index[1] else: xidx = self.min_value_index[:, 1] return xidx @lazyproperty @as_scalar def min_value_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.min_value_index[0] else: yidx = self.min_value_index[:, 0] return yidx @lazyproperty @as_scalar def max_value_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.max_value_index[1] else: xidx = self.max_value_index[:, 1] return xidx @lazyproperty @as_scalar def max_value_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.max_value_index[0] else: yidx = self.max_value_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, _ = self._reduceat(self._data_values, np.add) 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_flux_err(self): r""" The uncertainty of `segment_flux`, propagated from the input ``error`` array. ``segment_flux_err`` 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_err`, :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_sq, _ = self._reduceat(self._error_values, np.add, transform=np.square) err = np.sqrt(err_sq) 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, _ = self._reduceat( self._background_values, np.add) 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_sum, sizes = self._reduceat( self._background_values, np.add) bkg_mean = bkg_sum / sizes 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_catalog`` is input, then its `centroid` will be used. The background values at fractional position values are determined using bilinear interpolation. """ if self._background is None: bkg = self._null_values else: xcen = self._x_centroid ycen = self._y_centroid bkg = map_coordinates(self._background, (ycen, xcen), 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 ``segmentation_image``. 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, strict=True): areas.append(np.count_nonzero( self._segmentation_image[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). """ 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 ny, nx = mask.shape # Pad source array with zeros (border_value=0) padded = np.zeros((ny + 2, nx + 2), dtype=np.int8) padded[1:-1, 1:-1] = ~mask # Binary erosion with cross footprint (4-connectivity): # a pixel is eroded if any 4-connected neighbor is 0 p = padded eroded = (p[1:-1, 1:-1] & p[:-2, 1:-1] & p[2:, 1:-1] & p[1:-1, :-2] & p[1:-1, 2:]) # Border pixels are source pixels that were eroded away border = np.zeros((ny + 2, nx + 2), dtype=np.int8) border[1:-1, 1:-1] = padded[1:-1, 1:-1] & ~eroded # Convolution with kernel [[10,2,10], [2,1,2], [10,2,10]] b = border conv = (10 * b[:-2, :-2] + 2 * b[:-2, 1:-1] + 10 * b[:-2, 2:] + 2 * b[1:-1, :-2] + b[1:-1, 1:-1] + 2 * b[1:-1, 2:] + 10 * b[2:, :-2] + 2 * b[2:, 1:-1] + 10 * b[2:, 2:]) hist = np.bincount(conv.ravel(), minlength=size) perimeter.append(hist[: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.n_labels, 2)) eigvals.fill(np.nan) # np.linalg.eigvalsh requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvalsh(self._covariance[idx]) # Check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) eigvals[idx2] = (np.nan, np.nan) # Sort each eigenvalue pair in descending order # (eigvalsh returns values in ascending order) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @use_detcat @as_scalar def semimajor_axis(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_axis(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_axis`) and semiminor (`semiminor_axis`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_axis**2 + self.semiminor_axis**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 and will be in the range [0, 360) degrees. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return (np.rad2deg(orient_radians) % 360) << 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_axis / self.semiminor_axis @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_axis / self.semimajor_axis) @lazyproperty @use_detcat @as_scalar def covariance_xx(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 covariance_yy(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 covariance_xy(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 ellipse_cxx(self): r""" Coefficient for ``x**2`` in the generalized ellipse equation 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_axis)**2 + (np.sin(self.orientation) / self.semiminor_axis)**2) @lazyproperty @use_detcat @as_scalar def ellipse_cyy(self): r""" Coefficient for ``y**2`` in the generalized ellipse equation 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_axis)**2 + (np.cos(self.orientation) / self.semiminor_axis)**2) @lazyproperty @use_detcat @as_scalar def ellipse_cxy(self): r""" Coefficient for ``x * y`` in the generalized ellipse equation 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_axis**2) - (1.0 / self.semiminor_axis**2))) @lazyproperty @use_detcat @as_scalar def gini(self): r""" The `Gini coefficient `_ of the source. The Gini coefficient of the distribution of absolute flux values is calculated using the prescription from `Lotz et al. 2004 `_ (Eq. 6) as: .. math:: G = \frac{1}{\overline{|x|} \, n \, (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\overline{|x|}` is the mean of the absolute value of all pixel values :math:`x_i`. If the sum of all pixel values is zero, the Gini coefficient is zero. Negative pixel values are used via their absolute value. Invalid values (NaN and inf) in the input are automatically excluded from the calculation. If only a single finite pixel remains after filtering, the Gini coefficient is 0.0. """ return np.array([gini_func(arr) for arr in self._data_values]) @lazyproperty def _local_background_apertures(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. """ if self.local_bkg_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.local_bkg_width height_in = (bbox_.iymax - bbox_.iymin) * scale height_out = height_in + 2 * self.local_bkg_width apertures.append(RectangularAnnulus((xpos, ypos), width_in, width_out, height_out, h_in=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. Returns a list of apertures for multi-source catalogs, or a single aperture for a single-source catalog. """ 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.local_bkg_width == 0: local_bkgs = np.zeros(self.n_labels) else: sigma_clip = SigmaClip(sigma=3.0, cenfunc='median', maxiters=20) bkg_func = SExtractorBackground(sigma_clip=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._segmentation_image.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: 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 _aperture_to_mask(self, aperture, **kwargs): """ Call ``aperture.to_mask()``, but first check that the aperture bounding box is not larger than the input data to prevent out-of-memory errors from pathologically large apertures. The aperture mask is allocated at the full (unclipped) bounding box size by ``to_mask()``, before ``get_overlap_slices`` clips it to the data shape. For pathological apertures (e.g., from huge Kron radii), this allocation can cause out-of-memory issues. Returns `None` if the aperture mask would be unreasonably large. """ bbox = aperture.bbox # Limit the aperture mask size to prevent OOM errors max_size = max(self._data.size, 1_000_000) if bbox.shape[0] * bbox.shape[1] > max_size: return None return aperture.to_mask(**kwargs) def _make_aperture_data(self, label, x_centroid, y_centroid, 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 ``aperture_mask_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 mask_cutout = None if self._mask is None else 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 = (x_centroid - max(0, aperture_bbox.ixmin), y_centroid - max(0, aperture_bbox.iymin)) # Mask or correct neighboring sources if self.aperture_mask_method == 'none': mask = data_mask else: segment_img = self._segmentation_image.data[slc_lg] segm_mask = np.logical_and(segment_img != label, segment_img != 0) if self.aperture_mask_method == 'mask': mask = data_mask | segm_mask else: mask = data_mask if self.aperture_mask_method == 'correct': 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_catalog`` 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._x_centroid)) if np.any(radius <= 0): msg = 'radius must be > 0' raise ValueError(msg) apertures = [] for (xcen, ycen, radius_, all_masked) in zip(self._x_centroid, self._y_centroid, radius, self._all_masked, strict=True): 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_catalog`` 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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_catalog`` 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, optional 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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_catalog`` was input to `SourceCatalog`, then its `centroid` values will be used. See the `SourceCatalog` ``aperture_mask_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]_flux_err`` 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, flux_err : 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: msg = 'radius must be > 0' raise ValueError(msg) apertures = self._make_circular_apertures(radius) kwargs = self._aperture_mask_kwargs['circ'] flux, flux_err = self._aperture_photometry(apertures, desc='circular_photometry', **kwargs) if self._data_unit is not None: flux <<= self._data_unit flux_err <<= self._data_unit if self.isscalar: flux = flux[0] flux_err = flux_err[0] if name is not None: flux_name = f'{name}_flux' flux_err_name = f'{name}_flux_err' self.add_property(flux_name, flux, overwrite=overwrite) self.add_property(flux_err_name, flux_err, overwrite=overwrite) return flux, flux_err 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_catalog`` 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 ``n_labels``. 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._x_centroid ycen = self._y_centroid major_size = self.semimajor_axis.value * scale minor_size = self.semiminor_axis.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, strict=True): 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. """ scale = 6.0 xcen_arr = self._x_centroid ycen_arr = self._y_centroid a_arr = self.semimajor_axis.value * scale b_arr = self.semiminor_axis.value * scale theta_arr = self.orientation.to(u.radian).value cxx_arr = self.ellipse_cxx.value cxy_arr = self.ellipse_cxy.value cyy_arr = self.ellipse_cyy.value all_masked = self._all_masked if self.isscalar: a_arr = (a_arr,) b_arr = (b_arr,) theta_arr = (theta_arr,) cxx_arr = (cxx_arr,) cxy_arr = (cxy_arr,) cyy_arr = (cyy_arr,) data_full = self._data data_shape = data_full.shape mask_full = self._mask segm_data = self._segmentation_image.data max_size = max(data_full.size, 1_000_000) kron_min = self.kron_params[1] min_circ_radius = (self.kron_params[2] if len(self.kron_params) == 3 else 0.0) aperture_mask_method = self.aperture_mask_method labels = self.labels if self.progress_bar: desc = 'kron_radius' labels = add_progress_bar(labels, desc=desc) kron_radius = [] for (label, xc, yc, a, b, theta, cxx_, cxy_, cyy_, masked) in zip(labels, xcen_arr, ycen_arr, a_arr, b_arr, theta_arr, cxx_arr, cxy_arr, cyy_arr, all_masked, strict=True): if masked or not (math.isfinite(xc) and math.isfinite(yc) and math.isfinite(a) and math.isfinite(b) and math.isfinite(theta)): kron_radius.append(np.nan) continue # Circular aperture fallback when semimajor/semiminor are # zero (matching _make_elliptical_apertures behavior) use_circular = (a == 0 and b == 0) if use_circular: if min_circ_radius <= 0: kron_radius.append(np.nan) continue half_w = min_circ_radius half_h = min_circ_radius else: cos_theta = math.cos(theta) sin_theta = math.sin(theta) half_w = math.sqrt(a * a * cos_theta * cos_theta + b * b * sin_theta * sin_theta) half_h = math.sqrt(a * a * sin_theta * sin_theta + b * b * cos_theta * cos_theta) # Compute bounding box from ellipse/circle parameters ixmin = math.floor(xc - half_w + 0.5) ixmax = math.floor(xc + half_w + 0.5) + 1 iymin = math.floor(yc - half_h + 0.5) iymax = math.floor(yc + half_h + 0.5) + 1 # OOM guard if (ixmax - ixmin) * (iymax - iymin) > max_size: kron_radius.append(np.nan) continue # Compute overlap slices with data boundaries dx_min = max(0, -ixmin) dy_min = max(0, -iymin) dx_max = max(0, ixmax - data_shape[1]) dy_max = max(0, iymax - data_shape[0]) lg_xmin = ixmin + dx_min lg_xmax = ixmax - dx_max lg_ymin = iymin + dy_min lg_ymax = iymax - dy_max if lg_xmin >= lg_xmax or lg_ymin >= lg_ymax: kron_radius.append(np.nan) continue slc_lg = (slice(lg_ymin, lg_ymax), slice(lg_xmin, lg_xmax)) # Cutout data (local background explicitly zero for SE # agreement) data = data_full[slc_lg].astype(float) # Build data mask (non-finite + input mask) data_mask = ~np.isfinite(data) if mask_full is not None: data_mask |= mask_full[slc_lg] # Mask or correct neighboring sources if aperture_mask_method != 'none': seg_cut = segm_data[slc_lg] segm_mask = (seg_cut != label) & (seg_cut != 0) if aperture_mask_method == 'mask': mask = data_mask | segm_mask else: mask = data_mask if aperture_mask_method == 'correct': cutout_xycen = (xc - max(0, ixmin), yc - max(0, iymin)) data = _mask_to_mirrored_value(data, segm_mask, cutout_xycen, mask=mask) else: mask = data_mask # Coordinate arrays (ogrid-style broadcasting avoids # allocating full 2D meshgrid arrays) ny, nx = data.shape xval = np.arange(nx) - (xc - lg_xmin) yval = np.arange(ny) - (yc - lg_ymin) yy = yval[:, np.newaxis] xx = xval[np.newaxis, :] # Elliptical radius rr_sq = cxx_ * xx * xx + cxy_ * xx * yy + cyy_ * yy * yy rr = np.sqrt(np.maximum(rr_sq, 0.0)) # Aperture mask: for method='center', pixels whose center # falls inside the ellipse (rr <= scale) or circle if use_circular: dx = xx dy = yy pixel_mask = ((dx * dx + dy * dy) <= min_circ_radius * min_circ_radius) & ~mask else: pixel_mask = (rr <= scale) & ~mask # Ignore RuntimeWarning for invalid data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux_numer = np.sum(data[pixel_mask] * rr[pixel_mask]) flux_denom = np.sum(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(kron_min) 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 values exceeding the measurement aperture scale (6.0) # to NaN. Such values are unphysical (the Kron radius cannot # meaningfully exceed the aperture used to measure it) and are # caused by near-cancellation in the denominator of the Kron # formula due to outlier pixels or noise. max_kron_radius = 6.0 kron_radius[kron_radius > max_kron_radius] = np.nan # 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: semimajor_axis = self.semimajor_axis.value semiminor_axis = self.semiminor_axis.value circ_radius = (kron_params[0] * kron_radius * np.sqrt(semimajor_axis * semiminor_axis)) 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_axis`) and semiminor axes (`semiminor_axis`) 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 (`ellipse_cxx`, `ellipse_cxy`, and `ellipse_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 the measured unscaled Kron radius exceeds 6.0 (the measurement aperture scale factor), ``np.nan`` will be returned. Such values are unphysical, typically caused by near-cancellation in the denominator of the Kron formula due to outlier pixels or noise. 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_axis` * `semiminor_axis`) 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_catalog`` was input to `SourceCatalog`, then its ``kron_radius`` will be returned. See the `SourceCatalog` ``aperture_mask_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] return self._make_elliptical_apertures(scale=scale) @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_axis` * `semiminor_axis`) 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_catalog`` was input to `SourceCatalog`, then its ``kron_aperture`` will be returned. Returns a list of apertures for multi-source catalogs, or a single aperture for a single-source catalog. """ return self._make_kron_apertures(self.kron_params) @as_scalar @deprecated_positional_kwargs(since='3.0', until='4.0') 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_catalog`` 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_flux_err`) 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_axis` * `semiminor_axis`) 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 @deprecated_positional_kwargs(since='3.0', until='4.0') 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_catalog`` 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_flux_err`) 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_axis` * `semiminor_axis`) 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, optional 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 ``aperture_mask_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, optional Additional keyword arguments passed to the aperture ``to_mask`` method. Returns ------- flux, flux_err : 1D `~numpy.ndaray` The flux and flux error arrays. """ labels = self.labels if self.progress_bar: labels = add_progress_bar(labels, desc=desc) flux = [] flux_err = [] for label, aperture, bkg in zip(labels, apertures, self._local_background, strict=True): # Return NaN for completely masked sources or sources where # the centroid is not finite if aperture is None: flux.append(np.nan) flux_err.append(np.nan) continue xcen, ycen = aperture.positions aperture_mask = self._aperture_to_mask(aperture, **kwargs) if aperture_mask is None: flux.append(np.nan) flux_err.append(np.nan) continue # 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] flux_ = np.nan if values.shape == (0,) else np.sum(values) flux.append(flux_) if error is None: flux_err_ = np.nan else: values = (aperture_weights * error**2)[pixel_mask] if values.shape == (0,): flux_err_ = np.nan else: flux_err_ = np.sqrt(np.sum(values)) flux_err.append(flux_err_) flux = np.array(flux) flux_err = np.array(flux_err) return flux, flux_err def _calc_kron_photometry(self, *, kron_params=None): """ Calculate the flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``aperture_mask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. If ``detection_catalog`` is input, then its `centroid` values will be used. Returns ------- kron_flux, kron_flux_err : 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) labels = self.labels if self.progress_bar: labels = add_progress_bar(labels, desc='kron_photometry') _floor = math.floor max_size = max(self._data.size, 1_000_000) flux = [] flux_err = [] for label, aperture, bkg in zip(labels, kron_aperture, self._local_background, strict=True): if aperture is None: flux.append(np.nan) flux_err.append(np.nan) continue xcen, ycen = aperture.positions # Compute the aperture mask directly, bypassing the # aperture's to_mask() method and ApertureMask/BoundingBox # property overhead. if isinstance(aperture, CircularAperture): r = aperture.r ixmin = _floor(xcen - r + 0.5) ixmax = _floor(xcen + r + 1.5) iymin = _floor(ycen - r + 0.5) iymax = _floor(ycen + r + 1.5) nx = ixmax - ixmin ny = iymax - iymin if nx * ny > max_size: flux.append(np.nan) flux_err.append(np.nan) continue edges = (ixmin - 0.5 - xcen, ixmax - 0.5 - xcen, iymin - 0.5 - ycen, iymax - 0.5 - ycen) mask_data = circular_overlap_grid( edges[0], edges[1], edges[2], edges[3], nx, ny, r, 1, 1) else: a = aperture.a b = aperture.b theta_val = aperture.theta theta_rad = (theta_val.to(u.radian).value if hasattr(theta_val, 'to') else float(theta_val)) cos_t = math.cos(theta_rad) sin_t = math.sin(theta_rad) x_ext = math.sqrt((a * cos_t) ** 2 + (b * sin_t) ** 2) y_ext = math.sqrt((a * sin_t) ** 2 + (b * cos_t) ** 2) ixmin = _floor(xcen - x_ext + 0.5) ixmax = _floor(xcen + x_ext + 1.5) iymin = _floor(ycen - y_ext + 0.5) iymax = _floor(ycen + y_ext + 1.5) nx = ixmax - ixmin ny = iymax - iymin if nx * ny > max_size: flux.append(np.nan) flux_err.append(np.nan) continue edges = (ixmin - 0.5 - xcen, ixmax - 0.5 - xcen, iymin - 0.5 - ycen, iymax - 0.5 - ycen) mask_data = elliptical_overlap_grid( edges[0], edges[1], edges[2], edges[3], nx, ny, a, b, theta_rad, 1, 1) bbox = BoundingBox(ixmin, ixmax, iymin, iymax) data, error, mask, _, slc_sm = self._make_aperture_data( label, xcen, ycen, bbox, bkg) if data is None: flux.append(np.nan) flux_err.append(np.nan) continue aperture_weights = mask_data[slc_sm] pixel_mask = (aperture_weights > 0) & ~mask with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) values = (aperture_weights * data)[pixel_mask] flux_ = np.nan if values.shape == (0,) else np.sum(values) flux.append(flux_) if error is None: flux_err_ = np.nan else: values = (aperture_weights * error ** 2)[pixel_mask] if values.shape == (0,): flux_err_ = np.nan else: flux_err_ = np.sqrt(np.sum(values)) flux_err.append(flux_err_) flux = np.array(flux) flux_err = np.array(flux_err) return flux, flux_err @deprecated_positional_kwargs(since='3.0', until='4.0') 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` ``aperture_mask_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_axis` * `semiminor_axis`) 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]_flux_err`` 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, flux_err : 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_flux_err = self._calc_kron_photometry( kron_params=kron_params) if self._data_unit is not None: kron_flux <<= self._data_unit kron_flux_err <<= self._data_unit if self.isscalar: kron_flux = kron_flux[0] kron_flux_err = kron_flux_err[0] if name is not None: flux_name = f'{name}_flux' flux_err_name = f'{name}_flux_err' self.add_property(flux_name, kron_flux, overwrite=overwrite) self.add_property(flux_err_name, kron_flux_err, overwrite=overwrite) return kron_flux, kron_flux_err @lazyproperty def _kron_photometry(self): """ The flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``aperture_mask_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` ``aperture_mask_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_flux_err(self): """ The flux error in the Kron aperture. See the `SourceCatalog` ``aperture_mask_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_err = self._kron_photometry[:, 1] if self._data_unit is not None: kron_flux_err <<= self._data_unit return kron_flux_err @lazyproperty @use_detcat def _max_circular_kron_radius(self): """ The maximum circular Kron radius used as the upper limit of ``flux_radius``. """ semimajor_sig = self.semimajor_axis.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 _flux_radius_fcn(radius, clean_data, grid_params, normflux): """ Function whose root is found to compute the flux_radius. Uses ``circular_overlap_grid`` directly on pre-computed cutout data (with masked pixels zeroed) to avoid per-call aperture object overhead. """ xmin_e, xmax_e, ymin_e, ymax_e, nx, ny, exact, subpx = grid_params weights = circular_overlap_grid(xmin_e, xmax_e, ymin_e, ymax_e, nx, ny, radius, exact, subpx) flux = np.sum(clean_data * weights) return 1.0 - (flux / normflux) @lazyproperty @use_detcat def _flux_radius_optimizer_args(self): kron_flux = self._kron_photometry[:, 0] # unitless max_radius = self._max_circular_kron_radius kwargs = self._aperture_mask_kwargs['flux_radius'] # Translate mask method keywords to circular_overlap_grid # parameters once method = kwargs.get('method', 'exact') if method == 'exact': use_exact = 1 subpixels = 1 elif method == 'center': use_exact = 0 subpixels = 1 else: # 'subpixel' use_exact = 0 subpixels = kwargs.get('subpixels', 5) # Pre-fetch arrays used inside the loop data_arr = self._data mask_arr = self._mask segm_data = self._segmentation_image.data data_shape = data_arr.shape aperture_mask_method = self.aperture_mask_method max_aper_size = max(data_arr.size, 1_000_000) labels = self.labels if self.progress_bar: desc = 'flux_radius prep' labels = add_progress_bar(labels, desc=desc) args = [] for label, xcen, ycen, kronflux, bkg, max_radius_ in zip( labels, self._x_centroid, self._y_centroid, kron_flux, self._local_background, max_radius, strict=True): if (np.any(~np.isfinite((xcen, ycen, kronflux, max_radius_))) or kronflux == 0): args.append(None) continue # Compute the bounding box for the max-radius aperture # inline, replacing CircularAperture + _aperture_to_mask + # _make_aperture_data ixmin = math.floor(xcen - max_radius_ + 0.5) ixmax = math.ceil(xcen + max_radius_ + 0.5) iymin = math.floor(ycen - max_radius_ + 0.5) iymax = math.ceil(ycen + max_radius_ + 0.5) # OOM guard (same logic as _aperture_to_mask) bbox_ny = iymax - iymin bbox_nx = ixmax - ixmin if bbox_ny * bbox_nx > max_aper_size: args.append(None) continue # Clip to data boundaries data_ymin = max(0, iymin) data_ymax = min(data_shape[0], iymax) data_xmin = max(0, ixmin) data_xmax = min(data_shape[1], ixmax) if data_ymin >= data_ymax or data_xmin >= data_xmax: args.append(None) continue slc_lg = (slice(data_ymin, data_ymax), slice(data_xmin, data_xmax)) cutout_data = data_arr[slc_lg].astype(float) - bkg # Build data mask (non-finite + user mask) data_mask = ~np.isfinite(cutout_data) if mask_arr is not None: data_mask |= mask_arr[slc_lg] # Cutout centroid position cutout_xcen = xcen - data_xmin cutout_ycen = ycen - data_ymin # Handle neighboring sources if aperture_mask_method != 'none': seg_cut = segm_data[slc_lg] segm_mask = (seg_cut != label) & (seg_cut != 0) if aperture_mask_method == 'mask': data_mask = data_mask | segm_mask elif aperture_mask_method == 'correct': cutout_data = _mask_to_mirrored_value( cutout_data, segm_mask, (cutout_xcen, cutout_ycen), mask=data_mask) # Pre-zero masked pixels so the root-finding function can # use a simple sum without masking clean_data = cutout_data.copy() clean_data[data_mask] = 0.0 # Pre-compute grid parameters for circular_overlap_grid ny, nx = clean_data.shape xmin_edge = -0.5 - cutout_xcen xmax_edge = nx - 0.5 - cutout_xcen ymin_edge = -0.5 - cutout_ycen ymax_edge = ny - 0.5 - cutout_ycen grid_params = (xmin_edge, xmax_edge, ymin_edge, ymax_edge, nx, ny, use_exact, subpixels) args.append([clean_data, grid_params, kronflux, max_radius_]) return args @as_scalar @deprecated_positional_kwargs(since='3.0', until='4.0') def flux_radius(self, fraction, name=None, overwrite=False): """ Calculate the circular radius that encloses the specified fraction of the Kron flux. To estimate the half-light radius, use ``fraction = 0.5``. Parameters ---------- fraction : 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 fraction <= 0 or fraction > 1: msg = 'fraction must be > 0 and <= 1' raise ValueError(msg) # Return cached result if available if fraction in self._flux_radius_cache: result = self._flux_radius_cache[fraction] if name is not None: self.add_property(name, result, overwrite=overwrite) return result args = self._flux_radius_optimizer_args if self.progress_bar: desc = 'flux_radius' args = add_progress_bar(args, desc=desc) radius = [] for flux_radius_args in args: if flux_radius_args is None: radius.append(np.nan) continue clean_data, grid_params, kronflux, max_radius = flux_radius_args normflux = kronflux * fraction args = (clean_data, grid_params, normflux) # Try to find the root of self._flux_radius_func, 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 = 0.1 * max_radius while max_radius > min_radius and found is False: try: bracket = [min_radius, max_radius] result = root_scalar(self._flux_radius_fcn, args=args, bracket=bracket, method='brentq') result = result.root found = True except ValueError: # 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 self._flux_radius_cache[fraction] = result if name is not None: self.add_property(name, result, overwrite=overwrite) return result @as_scalar def make_cutouts(self, shape, *, array=None, 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_catalog`` 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. array : `None` or 2D `~numpy.ndarray` A 2D array with the same shape as the ``data`` array input to `~photutils.segmentation.SourceCatalog`. If `None` then the ``data`` array will be used. If any cutout arrays are not fully contained within the ``array`` array and ``mode='partial'`` with ``fill_value=np.nan``, then the input ``array`` must have a float data type. 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 array is None: array = self._data elif array.shape != self._data.shape: msg = 'array must have the same shape as data' raise ValueError(msg) if mode not in ('partial', 'trim'): msg = "mode must be 'partial' or 'trim'" raise ValueError(msg) cutouts = [] for (xcen, ycen, all_masked) in zip(self._x_centroid, self._y_centroid, self._all_masked, strict=True): if all_masked or np.any(~np.isfinite((xcen, ycen))): cutouts.append(None) continue cutouts.append(CutoutImage(array, (ycen, xcen), shape, mode=mode, fill_value=fill_value)) return cutouts astropy-photutils-3322558/photutils/segmentation/core.py000066400000000000000000002542421517052111400235010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Classes for a segmentation image and a single segment within a segmentation image. """ import inspect import warnings from collections import defaultdict from copy import copy, deepcopy import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import find_objects, grey_dilation from scipy.signal import fftconvolve from photutils.aperture import BoundingBox from photutils.aperture.converters import _shapely_polygon_to_region from photutils.utils._deprecation import (deprecated_getattr, deprecated_positional_kwargs) 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__ = ['Segment', 'SegmentationImage'] # Remove in 4.0 _SEGM_DEPRECATED_ATTRIBUTES = { 'nlabels': 'n_labels', 'data_ma': 'data_masked', 'deblended_labels_map': 'deblended_label_to_parent', 'deblended_labels_inverse_map': 'parent_to_deblended_labels', } # Remove in 4.0 _SEGMENT_DEPRECATED_ATTRIBUTES = { 'data_ma': 'data_masked', } 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): msg = 'Input data must be a numpy array' raise TypeError(msg) self.data = data self._deblend_label_map = {} # set by source deblender def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' params = ['shape', 'n_labels'] cls_info = [(param, getattr(self, param)) for param in params] 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__() # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _SEGM_DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') 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) for i in (0, 1))): result = self.data[key] if result.size == 0: msg = ('The sliced result is empty; cannot create ' 'a SegmentationImage with zero size') raise ValueError(msg) return SegmentationImage(result) msg = f'{key!r} is not a valid 2D slice object' raise TypeError(msg) 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 preserves dtype and 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, strict=True): 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, strict=True): segments.append(Segment(self.data, label, slc, bbox, area)) return segments @lazyproperty def deblended_labels(self): """ A sorted 1D array of deblended label numbers. The list will be empty if deblending has not been performed or if no sources were deblended. """ if len(self._deblend_label_map) == 0: return np.array([], dtype=self._data.dtype) return np.sort(np.concatenate(list(self._deblend_label_map.values()))) @lazyproperty def deblended_label_to_parent(self): """ A dictionary mapping deblended label numbers to the original parent label numbers. The keys are the deblended label numbers and the values are the original parent label numbers. Only deblended sources are included in the dictionary. The dictionary will be empty if deblending has not been performed or if no sources were deblended. """ inverse_map = {} for key, values in self._deblend_label_map.items(): for value in values: inverse_map[value] = key return inverse_map @lazyproperty def parent_to_deblended_labels(self): """ A dictionary mapping the original parent label numbers to the deblended label numbers. The keys are the original parent label numbers and the values are the deblended label numbers. Only deblended sources are included in the dictionary. The dictionary will be empty if deblending has not been performed or if no sources were deblended. """ return self._deblend_label_map @property def data(self): """ The segmentation array. """ return self._data @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_lazyproperties' # Subclasses get their own lazyproperty list if attr not in cls.__dict__: def islazyproperty(obj): return isinstance(obj, lazyproperty) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=islazyproperty)]) return getattr(cls, attr) 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): msg = 'data must have integer type' raise TypeError(msg) labels = self._get_labels(value) # array([]) if value all zeros if labels.shape != (0,) and np.min(labels) < 0: msg = 'The segmentation image cannot contain negative integers.' raise ValueError(msg) 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 # Reset deblended labels explicitly since _deblend_label_map # is a regular attribute, not a lazyproperty cleared by # _reset_lazyproperties above. self.__dict__['_deblend_label_map'] = {} @lazyproperty def data_masked(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, strict=True): if slc is not None: labels.append(label) return np.array(labels, dtype=self._data.dtype) return self._get_labels(self.data) @lazyproperty def n_labels(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.n_labels == 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): """ 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 maximum label number and is indexed by (label - 1). If a label is missing, then the corresponding list element will be `None` instead of a slice. """ 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: msg = "The 'bbox' attribute requires a 2D segmentation image." raise ValueError(msg) 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. """ # NOTE: np.bincount was benchmarked but is slower for typical # large images because its cost is O(total_pixels) whereas the # per-bbox loop below is O(sum_of_bbox_areas), which is much # smaller when segments occupy a small fraction of the image. areas = [] for label, slices in zip(self.labels, self.slices, strict=True): 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] def _make_polygon(self, label, slc): """ Create a Shapely polygon for a single label using only its bounding-box cutout. Parameters ---------- label : int The label number. slc : tuple of slices The slice for the bounding box of the label. Returns ------- polygon : `shapely.Polygon` or `shapely.MultiPolygon` or `None` A Shapely Polygon or MultiPolygon, or `None` if rasterio and shapely are not available. """ if not (HAS_RASTERIO and HAS_SHAPELY): return None if slc is None: return None from rasterio.features import shapes from rasterio.transform import Affine from shapely import MultiPolygon from shapely.geometry import shape cutout = self._data[slc] # Create a mask for only this label within the cutout label_mask = (cutout == label) # Shift the vertices so that the (0, 0) origin is at the # center of the lower-left pixel, offset by the slice origin y0 = slc[0].start x0 = slc[1].start transform = Affine(1.0, 0.0, x0 - 0.5, 0.0, 1.0, y0 - 0.5) # Create a single-label array for the cutout label_data = np.where(label_mask, label, 0).astype(np.int32) raw_polys = list(shapes(label_data, connectivity=8, mask=label_mask, transform=transform)) geo_polys = [poly for poly, val in raw_polys if int(val) == label] if len(geo_polys) == 0: return None if len(geo_polys) == 1: return shape(geo_polys[0]) return MultiPolygon([shape(poly) for poly in geo_polys]) def _make_segment(self, label): """ Create a single `Segment` object for the given label. Parameters ---------- label : int The label number. Returns ------- segment : `Segment` The segment object. """ # _raw_slices is indexed by (label - 1) since it includes all # labels up to max_label, even if some are missing label = self._data.dtype.type(label) slc = self._raw_slices[label - 1] bbox = BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop, iymin=slc[0].start, iymax=slc[0].stop) area = np.count_nonzero(self._data[slc] == label) polygon = self._make_polygon(label, slc) return Segment(self.data, label, slc, bbox, area, polygon=polygon) def get_segment(self, label): """ Return a `Segment` object for the given label. This is significantly faster than ``segments[index]`` for segmentation images with many labels because it constructs only the requested `Segment` without building the full list. Parameters ---------- label : int The segment label number. Returns ------- segment : `Segment` The segment object for the input label. Raises ------ TypeError If ``label`` is not a scalar. ValueError If ``label`` is invalid. """ if np.ndim(label) != 0: msg = 'label must be a scalar value' raise TypeError(msg) self.check_labels(label) return self._make_segment(label) def get_segments(self, labels): """ Return a list of `Segment` objects for the given labels. This is significantly faster than indexing into ``segments`` when only a subset of labels is needed because it constructs only the requested `Segment` objects without building the full list. Parameters ---------- labels : int, array_like (1D, int) The label number(s) for which to return `Segment` objects. Returns ------- segments : list of `Segment` A list of `Segment` objects in the same order as the input ``labels``. Raises ------ ValueError If any input ``labels`` are invalid. """ labels = np.atleast_1d(labels) self.check_labels(labels) return [self._make_segment(label) for label in labels] @lazyproperty def is_consecutive(self): """ Boolean value indicating whether the non-zero labels in the segmentation array are consecutive and start from 1. """ if self.n_labels == 0: return False return ((self.labels[-1] - self.labels[0] + 1) == self.n_labels 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. """ if self.n_labels == 0: return np.array([], dtype=self._data.dtype) present = np.zeros(self.max_label + 1, dtype=bool) present[self.labels] = True present[0] = True # exclude 0 from missing return np.where(~present)[0].astype(self._data.dtype) def copy(self): """ Return a deep copy of this object. Returns ------- result : `SegmentationImage` A deep copy of this object. """ 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 if label is in the segmentation array valid_mask = np.isin(labels, self.labels) bad_labels.update(labels[~valid_mask]) if bad_labels: bad_labels = sorted(bad_labels) label_str = 'label' conj_str = 'is' if len(bad_labels) > 1: label_str = 'labels' conj_str = 'are' msg = f'{label_str} {bad_labels} {conj_str} invalid' raise ValueError(msg) def _make_cmap(self, n_colors, *, background_color='#000000ff', seed=None): """ Define a matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. Parameters ---------- n_colors : 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.n_labels == 0: return None from matplotlib import colors cmap = make_random_cmap(n_colors=n_colors, seed=seed) if background_color is not None: cmap.colors[0] = colors.to_rgba(background_color) return cmap @deprecated_positional_kwargs(since='3.0', until='4.0') 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 _update_deblend_label_map(self, relabel_map): """ Update the deblended label map based on the input ``relabel_map``. Parameters ---------- relabel_map : `~numpy.ndarray` An array mapping the original label numbers to the new label numbers. """ # child_labels are the deblended labels for parent_label, child_labels in self._deblend_label_map.items(): self._deblend_label_map[parent_label] = relabel_map[child_labels] @deprecated_positional_kwargs(since='3.0', until='4.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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 dtype = self.data.dtype # keep the original dtype relabel_map = np.zeros(self.max_label + 1, dtype=dtype) relabel_map[self.labels] = self.labels relabel_map[labels] = new_label # reassign labels if relabel: labels = np.unique(relabel_map[relabel_map != 0]) if len(labels) != 0: map2 = np.zeros(max(labels) + 1, dtype=dtype) map2[labels] = np.arange(len(labels), dtype=dtype) + 1 relabel_map = map2[relabel_map] data_new = relabel_map[self.data] self._reset_lazyproperties() # reset all cached properties self._data = data_new # use _data to avoid validation self._update_deblend_label_map(relabel_map) @deprecated_positional_kwargs(since='3.0', until='4.0') 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.n_labels == 0: msg = 'Cannot relabel a segmentation image with no non-zero labels' warnings.warn(msg, AstropyUserWarning) return if start_label <= 0: msg = 'start_label must be > 0' raise ValueError(msg) if ((self.labels[0] == start_label) and (self.labels[-1] - self.labels[0] + 1) == self.n_labels): return old_slices = self.__dict__.get('slices', None) dtype = self.data.dtype # keep the original dtype new_labels = np.arange(self.n_labels, dtype=dtype) + start_label new_label_map = np.zeros(self.max_label + 1, dtype=dtype) 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 self._update_deblend_label_map(new_label_map) @deprecated_positional_kwargs(since='3.0', until='4.0') 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 = np.setdiff1d(self.labels, labels) self.remove_labels(labels_tmp, relabel=relabel) @deprecated_positional_kwargs(since='3.0', until='4.0') 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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: msg = ('border_width must be smaller than half the array size ' 'in any dimension') raise ValueError(msg) 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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: msg = 'mask must have the same shape as the segmentation array' raise ValueError(msg) 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 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's 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). return grey_dilation(mask, footprint=footprint) # 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 return fftconvolve(mask, footprint, 'same') > 0.5 @lazyproperty def _geojson_polygons(self): """ A dictionary of GeoJSON-like polygons representing each source segment. The keys are the unique label numbers in the segmentation image, and the values are lists of polygons for each label. Each item in the dictionary is list containing tuples of (polygon, value) where the polygon is a GeoJSON-like dict and the value is the label from the segmentation image. Non- contiguous segments for a single label will have multiple tuples in the list (e.g., from slicing the segmentation image where a segment label is split into non-contiguous segments). Segments with holes will have a single tuple with a polygon containing the outer ring and the inner rings (holes) as a list of lists. Note that the coordinates of these polygon vertices are transformed to a reference frame with the (0, 0) origin at the center of the lower-left pixel. This is done by shifting the vertices by 0.5 pixels in both x and y directions, so that the origin is at the center of the lower-left pixel. By default, rasterio and GeoJSON use the corner of the lower-left pixel as the origin, which is not compatible with the pixel coordinates used in Photutils. """ from rasterio.features import shapes from rasterio.transform import Affine rasterio_int_dtypes = {np.dtype('uint8'), np.dtype('int8'), np.dtype('uint16'), np.dtype('int16'), np.dtype('int32')} # Try to convert the data to int32 if it has an unsupported # dtype if self.data.dtype not in rasterio_int_dtypes: min_val, max_val = self.data.min(), self.data.max() int32_info = np.iinfo(np.int32) if min_val >= int32_info.min and max_val <= int32_info.max: dtype = np.int32 else: msg = (f'The segmentation image dtype is {self.data.dtype} ' 'with values outside the safe np.int32 range ' f'[{int32_info.min}, {int32_info.max}]. The rasterio ' 'library cannot create polygons in this case. You may ' 'try to relabel your data to fit within an int32 ' 'range.') raise ValueError(msg) else: dtype = self.data.dtype # Shift the vertices so that the (0, 0) origin is at the # center of the lower-left pixel transform = Affine(1.0, 0.0, -0.5, 0.0, 1.0, -0.5) mask = self.data > 0 # mask out the background pixels polygons = list(shapes(self.data.astype(dtype), connectivity=8, mask=mask, transform=transform)) polygons.sort(key=lambda x: x[1]) # sort in label order # Group polygons by label polygon_dict = defaultdict(list) for polygon, label in polygons: polygon_dict[int(label)].append(polygon) # Check that the polygon labels match the segmentation image # labels; this is a sanity check to ensure that the rasterio # library is working correctly. # Note that polygons have been sorted by label. if not np.all(np.array(list(polygon_dict.keys())) == self.labels): msg = ('The segmentation image labels do not match the ' 'polygon labels. This may be due to a bug in the ' 'rasterio library or an unexpected data type in the ' 'segmentation image.') raise ValueError(msg) return polygon_dict @lazyproperty def polygons(self): """ A list of `Shapely `_ polygons representing each source segment. Polygon or MultiPolygon objects are returned, depending on whether the source segment is a single polygon or multiple polygons (e.g., holes or non-contiguous) for the same label. """ from shapely import MultiPolygon from shapely.geometry import shape polygons = [] for label, geo_polys in self._geojson_polygons.items(): if len(geo_polys) == 0: msg = f'Could not create a polygon for label {label}' raise ValueError(msg) if len(geo_polys) == 1: polygons.append(shape(geo_polys[0])) elif len(geo_polys) > 1: # Merge multiple polygons for the same label polys = [shape(poly) for poly in geo_polys] polygons.append(MultiPolygon(polys)) # NOTE: the returned polygons may return False for # is_valid due to ring self-intersections (e.g., # for corner-only intersections of two pixels). The # shapely.validation.explain_validity function can be # used to explain the validity of the polygons. The # shapely.validation.make_valid function can be used to make the # polygons valid, usually by converting Polygon objects into # MultiPolyon objects. return polygons def get_polygon(self, label): """ Return the `Shapely `_ polygon for the given label. Parameters ---------- label : int The label number. Returns ------- polygon : `shapely.Polygon` or `shapely.MultiPolygon` or `None` A Shapely Polygon or MultiPolygon object, or `None` if rasterio and shapely are not available. Raises ------ TypeError If ``label`` is not a scalar. ValueError If ``label`` is invalid. """ if np.ndim(label) != 0: msg = 'label must be a scalar value' raise TypeError(msg) return self.get_polygons(label)[0] def get_polygons(self, labels): """ Return a list of `Shapely `_ polygons for the given labels. Parameters ---------- labels : int, array_like (1D, int) The label number(s). Returns ------- polygons : list of `shapely.Polygon`, `shapely.MultiPolygon`, \ or `None` A list of Shapely Polygon or MultiPolygon objects, or `None` elements if rasterio and shapely are not available. Raises ------ ValueError If any input ``labels`` are invalid. """ labels = np.atleast_1d(labels) self.check_labels(labels) return [self._make_polygon(label, self._raw_slices[label - 1]) for label in labels] @staticmethod def _convert_ring_to_path(ring): """ Helper function to process a single Shapely ring (exterior or interior) into vertices and Matplotlib path codes. """ from matplotlib import path coords = np.array(ring.coords) # A closed polygon path in Matplotlib starts with MOVETO, # is followed by LINETO for each subsequent vertex, # and ends with a CLOSEPOLY. codes = ([path.Path.MOVETO] + [path.Path.LINETO] * (len(coords) - 2) + [path.Path.CLOSEPOLY]) return coords, codes def _convert_shapely_to_pathpatch(self, geometry, *, origin=(0, 0), scale=1.0, **kwargs): """ Create a single Matplotlib PathPatch from a Shapely geometry. Parameters ---------- geometry : `shapely.geometry.base.BaseGeometry` The Shapely geometry to convert to a PathPatch. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.PathPatch`. Returns ------- patch : `matplotlib.patches.PathPatch` or `None` A Matplotlib PathPatch representing the geometry, or `None` if the geometry is empty. """ from matplotlib import path from matplotlib.patches import PathPatch if geometry.is_empty: return None if geometry.geom_type == 'Polygon': polygons = [geometry] else: polygons = list(geometry.geoms) all_vertices = [] all_codes = [] for poly in polygons: # For each polygon, process its exterior and all its # interior rings. This loop structure avoids repeating the # call to the helper function. for ring in [poly.exterior, *list(poly.interiors)]: vertices, codes = self._convert_ring_to_path(ring) vertices = scale * (vertices + 0.5) - 0.5 vertices -= origin all_vertices.append(vertices) all_codes.extend(codes) if not all_vertices: return None final_path = path.Path(np.concatenate(all_vertices), all_codes) return PathPatch(final_path, **kwargs) def to_patches(self, *, origin=(0, 0), scale=1.0, **kwargs): """ Return a list of `~matplotlib.patches.PathPatch` objects representing each source segment. By default, the 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, optional Any keyword arguments accepted by `matplotlib.patches.PathPatch`. Returns ------- patches : list of `~matplotlib.patches.PathPatch` A list of matplotlib patches for the source segments. """ origin = np.array(origin) patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'} patch_kwargs.update(kwargs) return [self._convert_shapely_to_pathpatch(geometry, origin=origin, scale=scale, **patch_kwargs) for geometry in self.polygons] def get_patch(self, label, *, origin=(0, 0), scale=1.0, **kwargs): """ Return a `~matplotlib.patches.PathPatch` for the given label. By default, the patch will have a white edge color and no face color. Parameters ---------- label : int The label number. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. This effectively translates the position of the polygon. scale : float, optional The scale factor applied to the polygon vertices. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.PathPatch`. Returns ------- patch : `~matplotlib.patches.PathPatch` or `None` A matplotlib patch for the source segment, or `None` if the geometry is empty or rasterio and shapely are not available. Raises ------ TypeError If ``label`` is not a scalar. ValueError If ``label`` is invalid. """ if np.ndim(label) != 0: msg = 'label must be a scalar value' raise TypeError(msg) return self.get_patches(label, origin=origin, scale=scale, **kwargs)[0] def get_patches(self, labels, *, origin=(0, 0), scale=1.0, **kwargs): """ Return a list of `~matplotlib.patches.PathPatch` objects for the given labels. By default, the patches will have a white edge color and no face color. Parameters ---------- labels : int, array_like (1D, int) The label number(s). 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, optional Any keyword arguments accepted by `matplotlib.patches.PathPatch`. Returns ------- patches : list of `~matplotlib.patches.PathPatch` A list of matplotlib patches for the source segments. Raises ------ ValueError If any input ``labels`` are invalid. """ labels = np.atleast_1d(labels) self.check_labels(labels) origin = np.array(origin) patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'} patch_kwargs.update(kwargs) patches = [] for label in labels: poly = self._make_polygon(label, self._raw_slices[label - 1]) patches.append(self._convert_shapely_to_pathpatch( poly, origin=origin, scale=scale, **patch_kwargs)) return patches def plot_patches(self, *, ax=None, origin=(0, 0), scale=1.0, labels=None, **kwargs): """ Plot the `~matplotlib.patches.PathPatch` 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, optional Any keyword arguments accepted by `matplotlib.patches.PathPatch`. Returns ------- patches : list of `~matplotlib.patches.PathPatch` A list of matplotlib 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 to_regions(self, *, group=False, **kwargs): """ Return the `regions.Region` objects representing the source segments. The returned polygon region objects are defined as the exteriors of the source segments. Interior holes within the source segments are not included. See the ``group`` keyword below for details about how non-contiguous segments for a single label are handled. Parameters ---------- group : bool, optional If `False` (the default), then a `regions.Regions` object will be returned with a flattened list of `~regions.PolygonPixelRegion` objects. Note that in this case, there will be multiple `~regions.PolygonPixelRegion` objects for a single label if the label has non-contiguous segments. Because of this, the number of regions returned may not be equal to the number of unique labels in the segmentation image. If `True`, then a list of `~regions.PolygonPixelRegion` or `~regions.Regions` objects will be returned. There will be one item in the list for each label. If a label has non-contiguous segments, then the item will be a `~regions.Regions` object containing multiple `~regions.PolygonPixelRegion` objects for that label. **kwargs : dict, optional Any keyword arguments accepted by `regions.RegionVisual`. Common keywords include ``edgecolor``, ``facecolor``, ``color``, ``linewidth``, and ``linestyle``. Returns ------- regions : `~regions.Regions` A list of `~regions.Region` objects or a `~regions.Regions` object, depending on the value of ``group`` (see above). Notes ----- If ``group=False``, then the number of regions returned may not be equal to the number of unique labels in the segmentation image. This occurs when the segmentation image contains non-contiguous segments for a single label. That can happen as a result of slicing the segmentation image where a segment label is split into non-contiguous segments. The meta attribute of the `~regions.PolygonPixelRegion` objects will contain the label number as an integer value under the 'label' key. This can be used to identify the label of the region. """ from regions import Regions visual_kwargs = kwargs or None regions = [] for label, poly in zip(self.labels, self.polygons, strict=True): regions.append(_shapely_polygon_to_region( poly, label=int(label), visual_kwargs=visual_kwargs)) if group: return regions # If group=False, return a Regions object with a flattened list # of region objects flat_regions = [] for region in regions: if isinstance(region, Regions): flat_regions.extend(region.regions) else: flat_regions.append(region) return Regions(flat_regions) def get_region(self, label, **kwargs): """ Return the `regions `_ region object for the given label. The returned polygon region is defined as the exterior of the source segment. Interior holes within the source segment are not included. Parameters ---------- label : int The label number. **kwargs : dict, optional Any keyword arguments accepted by `regions.RegionVisual`. Common keywords include ``edgecolor``, ``facecolor``, ``color``, ``linewidth``, and ``linestyle``. Returns ------- region : `~regions.PolygonPixelRegion` or `~regions.Regions` A `~regions.PolygonPixelRegion` object, or a `~regions.Regions` object if the segment is a MultiPolygon (e.g., non-contiguous). Raises ------ TypeError If ``label`` is not a scalar. ValueError If ``label`` is invalid. """ if np.ndim(label) != 0: msg = 'label must be a scalar value' raise TypeError(msg) return self.get_regions(label, **kwargs)[0] def get_regions(self, labels, **kwargs): """ Return a list of `regions `_ region objects for the given labels. The returned polygon regions are defined as the exteriors of the source segments. Interior holes within the source segments are not included. Parameters ---------- labels : int, array_like (1D, int) The label number(s). **kwargs : dict, optional Any keyword arguments accepted by `regions.RegionVisual`. Common keywords include ``edgecolor``, ``facecolor``, ``color``, ``linewidth``, and ``linestyle``. Returns ------- regions : list of `~regions.PolygonPixelRegion` or `~regions.Regions` A list of `~regions.PolygonPixelRegion` objects, or `~regions.Regions` objects for labels with MultiPolygon segments (e.g., non-contiguous). Raises ------ ValueError If any input ``labels`` are invalid. """ labels = np.atleast_1d(labels) self.check_labels(labels) visual_kwargs = kwargs or None regions = [] for label in labels: poly = self._make_polygon(label, self._raw_slices[label - 1]) regions.append(_shapely_polygon_to_region( poly, label=int(label), visual_kwargs=visual_kwargs)) return regions @deprecated_positional_kwargs(since='3.0', until='4.0') 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 matplotlib.pyplot as plt 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) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 few 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 matplotlib.pyplot as plt 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 = ax.figure.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: msg = ('The colorbar was not plotted because the number of ' f'labels is greater than {max_labels=}.') warnings.warn(msg, 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. Notes ----- Only the minimal bounding-box cutout of the segmentation array is stored (as a copy), so `Segment` instances do not prevent garbage collection of the parent array. """ def __init__(self, segment_data, label, slices, bbox, area, *, polygon=None): self._segment_data_cutout = np.copy(segment_data[slices]) self._segment_data_shape = segment_data.shape 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__}>' params = ['label', 'slices', 'area'] cls_info = [(param, getattr(self, param)) for param in params] fmt = [f'{key}: {val}' for key, val in cls_info] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _SEGMENT_DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') def _repr_svg_(self): if self.polygon is not None: 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 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_cutout) cutout[cutout != self.label] = 0 return cutout @lazyproperty def data_masked(self): """ A `~numpy.ma.MaskedArray` cutout array of the segment using the minimal bounding box. The mask is `True` for pixels outside the source segment (i.e., neighboring segments within the rectangular cutout array are masked). """ mask = (self._segment_data_cutout != self.label) return np.ma.masked_array(self._segment_data_cutout, mask=mask) @deprecated_positional_kwargs(since='3.0', until='4.0') 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 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 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: msg = 'data must have the same shape as the segmentation array' raise ValueError(msg) if masked_array: mask = (self._segment_data_cutout != self.label) return np.ma.masked_array(data[self.slices], mask=mask) return data[self.slices] astropy-photutils-3322558/photutils/segmentation/deblend.py000066400000000000000000000671021517052111400241430ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for deblending overlapping sources labeled in a segmentation image. """ import warnings from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass from functools import partial from multiprocessing import cpu_count, get_context import numpy as np from astropy.units import Quantity from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import label as ndi_label from scipy.ndimage import sum_labels from photutils.segmentation.core import SegmentationImage from photutils.segmentation.detect import _detect_sources from photutils.segmentation.utils import _make_binary_structure from photutils.utils._deprecation import deprecated_renamed_argument from photutils.utils._progress_bars import add_progress_bar, tqdm from photutils.utils._stats import nanmax, nanmin, nansum __all__ = ['deblend_sources'] @dataclass class _DeblendParams: n_pixels: int footprint: np.ndarray n_levels: int contrast: float mode: str @deprecated_renamed_argument('segment_img', 'segmentation_image', '3.0', until='4.0') @deprecated_renamed_argument('npixels', 'n_pixels', '3.0', until='4.0') @deprecated_renamed_argument('nlevels', 'n_levels', '3.0', until='4.0') @deprecated_renamed_argument('nproc', 'n_processes', '3.0', until='4.0') def deblend_sources(data, segmentation_image, n_pixels, *, labels=None, n_levels=32, contrast=0.001, mode='exponential', connectivity=8, relabel=True, n_processes=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`. segmentation_image : `~photutils.segmentation.SegmentationImage` The segmentation image to deblend. n_pixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be deblended. ``n_pixels`` 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. n_levels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``n_levels`` 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 ``n_levels`` 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. n_processes : 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 if only a small number of sources are to be deblended. 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. If ``n_processes = 1``, then the ID shown after the progress bar is the source label being deblended. If multiprocessing is used (``n_processes > 1``), the ID shown is the last source label that was deblended. The progress bar requires that the `tqdm `_ optional dependency be installed. 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(segmentation_image, SegmentationImage): msg = 'segmentation_image must be a SegmentationImage' raise TypeError(msg) if segmentation_image.shape != data.shape: msg = 'segmentation_image must have the same shape as data' raise ValueError(msg) if n_levels < 1: msg = 'n_levels must be >= 1' raise ValueError(msg) if contrast < 0 or contrast > 1: msg = 'contrast must be >= 0 and <= 1' raise ValueError(msg) if contrast == 1: # no deblending return segmentation_image.copy() if mode not in ('exponential', 'linear', 'sinh'): msg = "mode must be 'exponential', 'linear', or 'sinh'" raise ValueError(msg) if labels is None: labels = segmentation_image.labels else: labels = np.atleast_1d(labels) segmentation_image.check_labels(labels) # Include only sources that have at least (2 * n_pixels); # this is required for a source to be deblended into multiple # sources, each with a minimum of n_pixels mask = (segmentation_image.areas[ segmentation_image.get_indices(labels)] >= (n_pixels * 2)) labels = labels[mask] footprint = _make_binary_structure(data.ndim, connectivity) deblend_params = _DeblendParams(n_pixels, footprint, n_levels, contrast, mode) segm_deblended = segmentation_image.data.copy() label_indices = segmentation_image.get_indices(labels) if n_processes is None: n_processes = cpu_count() deblend_label_map = {} max_label = segmentation_image.max_label if n_processes == 1: if progress_bar: desc = 'Deblending' label_indices = add_progress_bar(label_indices, desc=desc) nonposmin_labels = [] nmarkers_labels = [] for label, label_idx in zip(labels, label_indices, strict=True): if not isinstance(label_indices, np.ndarray): label_indices.set_postfix_str(f'ID: {label}') source_slice = segmentation_image.slices[label_idx] source_data = data[source_slice] source_segment = segmentation_image.data[source_slice] source_deblended, warns = _deblend_source(source_data, source_segment, label, deblend_params) if warns: if 'nonposmin' in warns: nonposmin_labels.append(label) if 'nmarkers' in warns: nmarkers_labels.append(label) if source_deblended is not None: source_mask = source_deblended > 0 new_segm = source_deblended[source_mask] # min label = 1 segm_deblended[source_slice][source_mask] = ( new_segm + max_label) new_labels = _get_labels(new_segm) + max_label deblend_label_map[label] = new_labels max_label += len(new_labels) else: # Use multiprocessing to deblend sources # Prepare the arguments for the worker function all_source_data = [] all_source_segments = [] all_source_slices = [] for label_idx in label_indices: source_slice = segmentation_image.slices[label_idx] source_data = data[source_slice] source_segment = segmentation_image.data[source_slice] all_source_data.append(source_data) all_source_segments.append(source_segment) all_source_slices.append(source_slice) args_all = zip(all_source_data, all_source_segments, labels, strict=True) # Create a partial function to pass the deblend_params to the # worker function worker = partial(_deblend_source, deblend_params=deblend_params) # Prepare to store futures and results to preserve the input # order of the labels when using as_completed() futures_dict = {} results = [None] * len(labels) disable_pbar = not progress_bar mp_context = get_context('spawn') with ProcessPoolExecutor(mp_context=mp_context, max_workers=n_processes) as executor: # Submit all jobs at once for index, args in enumerate(args_all): futures_dict[executor.submit(worker, *args)] = index with tqdm(total=len(labels), desc='Deblending', disable=disable_pbar) as pbar: # Process the results as they are completed for future in as_completed(futures_dict): pbar.update(1) idx = futures_dict[future] pbar.set_postfix_str(f'ID: {labels[idx]}') results[idx] = future.result() # Process the results nonposmin_labels = [] nmarkers_labels = [] for label, source_slice, source_deblended in zip(labels, all_source_slices, results, strict=True): source_deblended, warns = source_deblended if warns: if 'nonposmin' in warns: nonposmin_labels.append(label) if 'nmarkers' in warns: nmarkers_labels.append(label) if source_deblended is not None: source_mask = source_deblended > 0 new_segm = source_deblended[source_mask] # min label = 1 segm_deblended[source_slice][source_mask] = ( new_segm + max_label) new_labels = _get_labels(new_segm) + max_label deblend_label_map[label] = new_labels max_label += len(new_labels) # Process any warnings during deblending warning_info = {} if nonposmin_labels or nmarkers_labels: msg = ('The deblending mode of one or more source labels from the ' f'input segmentation image was changed from "{mode}" to ' '"linear". See the "info" attribute for the list of affected ' 'input labels.') warnings.warn(msg, AstropyUserWarning) if nonposmin_labels: nonposmin_labels = np.array(nonposmin_labels) msg = (f'Deblending mode changed from {mode} to linear due to ' 'non-positive minimum data values.') warn = {'message': msg, 'input_labels': nonposmin_labels} warning_info['nonposmin'] = warn if nmarkers_labels: nmarkers_labels = np.array(nmarkers_labels) msg = (f'Deblending mode changed from {mode} to linear due to ' 'too many potential deblended sources.') warn = {'message': msg, 'input_labels': nmarkers_labels} warning_info['nmarkers'] = warn if relabel: relabel_map = _create_relabel_map(segm_deblended, start_label=1) if relabel_map is not None: segm_deblended = relabel_map[segm_deblended] deblend_label_map = _update_deblend_label_map(deblend_label_map, relabel_map) segm_img = object.__new__(SegmentationImage) segm_img._data = segm_deblended segm_img._deblend_label_map = deblend_label_map # Store the warnings in the output SegmentationImage info attribute if warning_info: segm_img.info = {'warnings': warning_info} return segm_img def _deblend_source(data, segment_data, label, deblend_params): """ Convenience function to deblend a single labeled source. """ deblender = _SingleSourceDeblender(data, segment_data, label, deblend_params) return deblender.deblend_source(), deblender.warnings class _SingleSourceDeblender: """ Class to deblend a single labeled source. Parameters ---------- 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. segment_data : 2D int `~numpy.ndarray` The cutout segmentation image for a single source. Must have the same shape as ``data``. label : int The label of the source to deblend. This is needed because there may be more than one source label within the cutout. deblend_params : `_DeblendParams` The parameters for deblending the source. """ def __init__(self, data, segment_data, label, deblend_params): self.data = data self.segment_data = segment_data self.label = label self.n_pixels = deblend_params.n_pixels self.footprint = deblend_params.footprint self.n_levels = deblend_params.n_levels self.contrast = deblend_params.contrast self.mode = deblend_params.mode self.segment_mask = segment_data == label data_values = data[self.segment_mask] self.source_min = nanmin(data_values) self.source_max = nanmax(data_values) self.source_sum = nansum(data_values) self.warnings = {} @lazyproperty def linear_thresholds(self): """ Linearly spaced thresholds between the source minimum and maximum (inclusive). The source min/max are excluded later, giving n_levels thresholds between min and max (noninclusive). """ return np.linspace(self.source_min, self.source_max, self.n_levels + 2) @lazyproperty def normalized_thresholds(self): """ Normalized thresholds (from 0 to 1) between the source minimum and maximum (inclusive). """ 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. Returns ------- thresholds : 1D `~numpy.ndarray` 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. This method is useful for debugging and testing. Parameters ---------- deblend_mode : bool, optional If `True` then only segmentation images with more than one label will be returned. If `False` then all segmentation images will be returned. Returns ------- segments : list of 2D `~numpy.ndarray` A list of segmentation images, one for each threshold. Only segmentation images with more than one label will be returned. """ thresholds = self.compute_thresholds() segms = [] for threshold in thresholds: segm = _detect_sources(self.data, threshold, self.n_pixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) segms.append(segm) return segms def make_markers(self, *, return_all=False): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- return_all : bool, optional If `False` then return only the final segmentation marker image. If `True` then return all segmentation marker images. This keyword is useful for debugging and testing. Returns ------- markers : 2D `~numpy.ndarray` or list of 2D `~numpy.ndarray` A segmentation image that contain markers for possible sources. If ``return_all=True`` then a list of all segmentation marker images is returned. `None` is returned if there is only one source at every threshold. """ thresholds = self.compute_thresholds() segm_lower = _detect_sources(self.data, thresholds[0], self.n_pixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) if return_all: all_segms = [segm_lower] for threshold in thresholds[1:]: segm_upper = _detect_sources(self.data, threshold, self.n_pixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) if segm_upper is None: # 0 or 1 labels continue segm_lower = self.make_marker_segment(segm_lower, segm_upper) if return_all: all_segms.append(segm_lower) if return_all: return all_segms return segm_lower def make_marker_segment(self, segment_lower, segment_upper): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- segment_lower : 2D `~numpy.ndarray` The "lower" threshold level segmentation image. segment_upper : 2D `~numpy.ndarray` The next-highest threshold level segmentation image. Returns ------- markers : 2D `~numpy.ndarray` A segmentation image that contain markers for possible sources. Notes ----- For a given label in the lower level, find the labels in the upper level (higher threshold value) that are its children (i.e., the labels within the same mask as the lower level). If there are multiple children, then the lower-level parent label is replaced by its children. Parent labels that do not have multiple children in the upper level are kept as is (maximizing the marker size). """ if segment_lower is None: return segment_upper labels = _get_labels(segment_lower) new_markers = False markers = segment_lower.astype(bool) for label in labels: mask = (segment_lower == label) # Find label mapping from the lower to upper level upper_labels = _get_labels(segment_upper[mask]) if upper_labels.size >= 2: # new child markers found new_markers = True markers[mask] = segment_upper[mask].astype(bool) if new_markers: # Convert bool markers to integer labels return ndi_label(markers, structure=self.footprint)[0] return segment_lower 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 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 if a label was removed. """ from skimage.segmentation import watershed # 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.data, markers, mask=self.segment_mask, connectivity=self.footprint) labels = _get_labels(markers) if labels.size == 1: # only 1 source left remove_marker = False else: flux_frac = (sum_labels(self.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. Returns ------- segment_data : 2D int `~numpy.ndarray` A 2D int array containing the deblended source labels. The source labels are consecutive starting at 1. """ if self.source_min == self.source_max: # no deblending return None # Define the markers (possible sources) for the watershed algorithm markers = self.make_markers() if markers is None: return None # If there are too many markers (e.g., due to low threshold # and/or small n_pixels), 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. nlabels = len(_get_labels(markers)) if self.mode != 'linear' and nlabels > 200: del markers # free memory self.warnings['nmarkers'] = 'too many markers' self.mode = 'linear' markers = self.make_markers() if markers is None: return None # 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)): msg = (f'Deblending failed for source {self.label!r}. ' 'Please ensure you used the same pixel connectivity ' 'in detect_sources and deblend_sources.') raise ValueError(msg) if len(_get_labels(markers)) == 1: # no deblending return None # Markers may not be consecutive if a label was removed due to # the contrast criterion relabel_map = _create_relabel_map(markers, start_label=1) if relabel_map is not None: markers = relabel_map[markers] return markers def _get_labels(array): """ Get the unique labels greater than zero in an array. Parameters ---------- array : `~numpy.ndarray` The array to get the unique labels from. Returns ------- labels : int `~numpy.ndarray` The unique labels in the array. """ labels = np.unique(array) return labels[labels != 0] def _create_relabel_map(array, *, start_label=1): """ Create a mapping of original labels to new labels that are consecutive integers. By default, the new labels start from 1. Parameters ---------- array : 2D `~numpy.ndarray` The 2D array to relabel. start_label : int, optional The starting label number. Must be >= 1. The default is 1. Returns ------- relabel_map : 1D `~numpy.ndarray` or None The array mapping the original labels to the new labels. If the labels are already consecutive starting from ``start_label``, then `None` is returned. """ labels = _get_labels(array) # Check if the labels are already consecutive starting from # start_label if (labels[0] == start_label and (labels[-1] - start_label + 1) == len(labels)): return None # Create an array to map old labels to new labels relabel_map = np.zeros(labels.max() + 1, dtype=array.dtype) relabel_map[labels] = np.arange(len(labels)) + start_label return relabel_map def _update_deblend_label_map(deblend_label_map, relabel_map): """ Update the deblend_label_map to reflect the new labels that are consecutive integers. Parameters ---------- deblend_label_map : dict A dictionary mapping the original labels to the new deblended labels. relabel_map : 1D `~numpy.ndarray` The array mapping the original labels to the new labels. Returns ------- deblend_label_map : dict The updated deblend_label_map. """ for old_label, new_labels in deblend_label_map.items(): deblend_label_map[old_label] = relabel_map[new_labels] return deblend_label_map astropy-photutils-3322558/photutils/segmentation/detect.py000066400000000000000000000363171517052111400240220ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for detecting sources in an image. """ import warnings import numpy as np from astropy.stats import SigmaClip from scipy.ndimage import find_objects from scipy.ndimage import label as ndi_label from photutils.segmentation.core import SegmentationImage from photutils.segmentation.utils import _make_binary_structure from photutils.utils._deprecation import deprecated_renamed_argument from photutils.utils._parameters import (SigmaClipSentinelDefault, create_default_sigmaclip) from photutils.utils._quantity_helpers import check_units, process_quantities from photutils.utils._stats import nanmean, nanstd from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['detect_sources', 'detect_threshold'] SIGMA_CLIP = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) @deprecated_renamed_argument('nsigma', 'n_sigma', '3.0', until='4.0') def detect_threshold(data, n_sigma, *, background=None, error=None, mask=None, sigma_clip=SIGMA_CLIP): """ 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. n_sigma : 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` or `None`, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. 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 sigma_clip is SIGMA_CLIP: sigma_clip = create_default_sigmaclip(sigma=SIGMA_CLIP.sigma, maxiters=SIGMA_CLIP.maxiters) if not isinstance(sigma_clip, SigmaClip): msg = 'sigma_clip must be a SigmaClip object' raise TypeError(msg) 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: msg = ('If input background is 2D, then it must have the same ' 'shape as the input data.') raise ValueError(msg) if error is None: error = nanstd(clipped_data) if not np.isscalar(error) and error.shape != data.shape: msg = ('If input error is 2D, then it must have the same shape ' 'as the input data.') raise ValueError(msg) threshold = (np.broadcast_to(background, data.shape) + np.broadcast_to(error * n_sigma, data.shape)) if unit: threshold <<= unit return threshold def _detect_sources(data, threshold, n_pixels, footprint, inverse_mask, *, relabel=True, return_segmimg=True): """ Detect sources above a specified threshold value in an image. Detected sources must have ``n_pixels`` connected pixels that are each greater than the ``threshold`` value in the input ``data``. This function is the core algorithm for detecting sources in an image used by `detect_sources`. This function differs from `detect_sources` in that it does not perform any boilerplate checks, it accepts a ``footprint`` argument instead of a ``connectivity`` argument, and it accepts an ``inverse_mask`` argument instead of a ``mask`` argument. It is also used by the source deblending function for multithresholding. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image. 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``. n_pixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``n_pixels`` 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. relabel : bool, optional If `True`, relabel the segmentation image with consecutive numbers. return_segmimg : bool, optional If `True`, return a `~photutils.segmentation.SegmentationImage` object. If `False`, return a 2D `~numpy.ndarray` segmentation image. The latter is used by the source deblending function. In that case, if only one source is found, then `None` is returned. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage`, \ 2D `~numpy.ndarray`, 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 ``return_segmimg`` is `False`, then a 2D `~numpy.ndarray` segmentation image is returned. If no sources are found then `None` is returned. """ # Ignore RuntimeWarning caused by > comparison when data contains NaNs with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) segment_img = data > threshold if inverse_mask is not None: segment_img &= inverse_mask # Return None if threshold was too high to detect any sources if np.count_nonzero(segment_img) == 0: return None # NOTE: 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, dtype=segment_img.dtype) + 1 # Remove objects with less than n_pixels # 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, strict=True): cutout = segment_img[slc] segment_mask = (cutout == label) if np.count_nonzero(segment_mask) < n_pixels: cutout[segment_mask] = 0 continue segm_labels.append(label) segm_slices.append(slc) if np.count_nonzero(segment_img) == 0: return None if relabel: # Relabel the segmentation image with consecutive numbers; # ndimage.label returns segment_img with dtype = np.int32 # unless the input array has more than 2**31 - 1 pixels nlabels = len(segm_labels) if len(labels) != nlabels: label_map = np.zeros(np.max(labels) + 1, dtype=segment_img.dtype) labels = np.arange(nlabels, dtype=segment_img.dtype) + 1 label_map[segm_labels] = labels segment_img = label_map[segment_img] else: labels = segm_labels if return_segmimg: segm = object.__new__(SegmentationImage) segm._data = segment_img segm.__dict__['labels'] = labels segm.__dict__['slices'] = segm_slices segm.__dict__['_deblend_label_map'] = {} return segm # This is used by deblend_sources if len(labels) == 1: return None return segment_img @deprecated_renamed_argument('npixels', 'n_pixels', '3.0', until='4.0') def detect_sources(data, threshold, n_pixels, *, connectivity=8, mask=None): """ Detect sources above a specified threshold value in an image. Detected sources must have ``n_pixels`` connected pixels that are each greater than the ``threshold`` value in the input ``data``. 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. Alternatively, use the :class:`~photutils.segmentation.SourceFinder` class to detect and deblend sources in a single step. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image. 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``. n_pixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``n_pixels`` 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. Raises ------ NoDetectionsWarning If no sources are found. 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.visualization import simple_norm from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (detect_sources, make_2dgaussian_kernel) # Make a simulated image data = make_100gaussians_image() # Estimate the background using Background2D and subtract it bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background # Convolve the data kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) # Detect the sources threshold = 1.5 * bkg.background_rms # set the detection threshold segment_map = detect_sources(convolved_data, threshold, n_pixels=10) # Plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 10)) norm = simple_norm(data, 'sqrt', percent=99.5) ax1.imshow(data, norm=norm, origin='lower') segment_map.imshow(ax=ax2) fig.tight_layout() """ check_units((data, threshold), ('data', 'threshold')) if (n_pixels <= 0) or (int(n_pixels) != n_pixels): msg = f'n_pixels must be a positive integer, got {n_pixels!r}' raise ValueError(msg) if mask is not None: if mask.shape != data.shape: msg = 'mask must have the same shape as the input image' raise ValueError(msg) if mask.all(): msg = ('mask must not be True for every pixel. There are no ' 'unmasked pixels in the image to detect sources.') raise ValueError(msg) inverse_mask = np.logical_not(mask) else: inverse_mask = None footprint = _make_binary_structure(data.ndim, connectivity) segm = _detect_sources(data, threshold, n_pixels, footprint, inverse_mask, relabel=True, return_segmimg=True) if segm is None: msg = ('No sources were found. Try lowering the threshold or ' 'pixels parameters.') warnings.warn(msg, NoDetectionsWarning) return segm astropy-photutils-3322558/photutils/segmentation/finder.py000066400000000000000000000246001517052111400240110ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for detecting sources in an image. """ from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.utils._deprecation import (deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._parameters import as_pair from photutils.utils._repr import make_repr __all__ = ['SourceFinder'] # Remove in 4.0 _FINDER_DEPRECATED_ATTRIBUTES = { 'npixels': 'n_pixels', 'nlevels': 'n_levels', 'nproc': 'n_processes', } 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 ---------- n_pixels : 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 ``n_pixels`` is an integer, then the value will be used for both source detection and deblending (which internally uses source detection at multiple thresholds). If ``n_pixels`` contains two values, then the first value will be used for source detection and the second value used for source deblending. ``n_pixels`` 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. n_levels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``n_levels`` 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 ``n_levels`` 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``. n_processes : int, optional The number of processes to use for source deblending. 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 if only a small number of sources are to be deblended. 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. If ``n_processes = 1``, then the ID shown after the progress bar is the source label being deblended. If multiprocessing is used (``n_processes > 1``), the ID shown is the last source label that was deblended. The progress bar requires that the `tqdm `_ optional dependency be installed. 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() # Estimate the background using Background2D and subtract it bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background # Convolve the data kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) # Detect the sources threshold = 1.5 * bkg.background_rms # per-pixel detection threshold finder = SourceFinder(n_pixels=10, progress_bar=False) segment_map = 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.5) ax1.imshow(data, norm=norm, origin='lower') segment_map.imshow(ax=ax2) fig.tight_layout() """ @deprecated_renamed_argument('npixels', 'n_pixels', '3.0', until='4.0') @deprecated_renamed_argument('nlevels', 'n_levels', '3.0', until='4.0') @deprecated_renamed_argument('nproc', 'n_processes', '3.0', until='4.0') def __init__(self, n_pixels, *, connectivity=8, deblend=True, n_levels=32, contrast=0.001, mode='exponential', relabel=True, n_processes=1, progress_bar=True): self.n_pixels = as_pair('n_pixels', n_pixels, check_odd=False) self.deblend = deblend self.connectivity = connectivity self.n_levels = n_levels self.contrast = contrast self.mode = mode self.relabel = relabel self.n_processes = n_processes self.progress_bar = progress_bar def __repr__(self): params = ('n_pixels', 'deblend', 'connectivity', 'n_levels', 'contrast', 'mode', 'relabel', 'n_processes', 'progress_bar') return make_repr(self, params) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _FINDER_DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') @deprecated_positional_kwargs(since='3.0', until='4.0') 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.n_pixels[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.n_pixels[1], n_levels=self.n_levels, contrast=self.contrast, mode=self.mode, connectivity=self.connectivity, relabel=self.relabel, n_processes=self.n_processes, progress_bar=self.progress_bar) return segment_img astropy-photutils-3322558/photutils/segmentation/tests/000077500000000000000000000000001517052111400233305ustar00rootroot00000000000000astropy-photutils-3322558/photutils/segmentation/tests/__init__.py000066400000000000000000000000001517052111400254270ustar00rootroot00000000000000astropy-photutils-3322558/photutils/segmentation/tests/test_catalog.py000066400000000000000000002322141517052111400263570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the catalog module. """ from io import StringIO from unittest.mock import patch 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 astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_allclose, assert_equal from scipy.optimize import root_scalar 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_SKIMAGE) from photutils.utils.cutouts import CutoutImage @pytest.fixture def progress_bar_catalog(): """ A two-source SourceCatalog on a 101x101 grid with progress_bar=True. """ yy, xx = np.mgrid[0:101, 0:101] g1 = Gaussian2D(100, 50, 50, 5, 5) g2 = Gaussian2D(80, 30, 30, 4, 4) data = g1(xx, yy) + g2(xx, yy) segm = detect_sources(data, 10.0, n_pixels=5) return SourceCatalog(data, segm, progress_bar=True) @pytest.fixture def single_source_catalog(): """ A single-source SourceCatalog from a Gaussian on a 51x51 grid. Returns ``(data, segm, cat)``. """ yy, xx = np.mgrid[0:51, 0:51] g1 = Gaussian2D(100, 25, 25, 5, 5) data = g1(xx, yy) segm = detect_sources(data, 10.0, n_pixels=5) cat = SourceCatalog(data, segm) return data, segm, cat @pytest.fixture def gauss_101_data(): """ A single-source Gaussian on a 101x101 grid. Returns ``(data, segm)``. """ yy, xx = np.mgrid[0:101, 0:101] g1 = Gaussian2D(100, 50, 50, 5, 5) data = g1(xx, yy) segm = detect_sources(data, 10.0, n_pixels=5) return data, segm @pytest.fixture def gauss_101_catalog(gauss_101_data): """ A single-source SourceCatalog from a Gaussian on a 101x101 grid. Returns ``(data, segm, cat)``. """ data, segm = gauss_101_data cat = SourceCatalog(data, segm) return data, segm, cat @pytest.fixture def centroid_win_data(): """ Two-source data on a 21x21 grid for centroid_win tests. Returns ``(data, segment_map, convolved_data)``. """ 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) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels, progress_bar=False) threshold = 107.9 segment_map = finder(convolved_data, threshold) return data, segment_map, convolved_data class TestSourceCatalog: @pytest.fixture(autouse=True) def setup(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, n_pixels=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, local_bkg_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, local_bkg_width=24) @pytest.mark.parametrize('with_units', [True, False]) def test_catalog(self, with_units): """ Test catalog. """ 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.flux_radius(0.5, name='r_hl') segment_snr = cat1.segment_flux / cat1.segment_flux_err cat1.add_property('segment_snr', segment_snr) props = list(props) props.extend(cat1.custom_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.flux_radius(0.5, name='r_hl') segment_snr = obj.segment_flux / obj.segment_flux_err obj.add_property('segment_snr', segment_snr) for prop in props: assert_equal(getattr(obj, prop), getattr(cat1, prop)[idx]) match = 'Both units and masked cannot be True' with pytest.raises(ValueError, match=match): cat1._prepare_cutouts(cat1._segmentation_image_cutouts, units=True, masked=True) @pytest.mark.parametrize('with_units', [True, False]) def test_catalog_detection_catalog(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, local_bkg_width=24, detection_catalog=None) cat3 = SourceCatalog(data2 << self.unit, self.segm, error=error << self.unit, background=self.background << self.unit, mask=self.mask, wcs=self.wcs, local_bkg_width=24, detection_catalog=cat1) else: cat1 = self.cat.copy() cat2 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, local_bkg_width=24, detection_catalog=None) cat3 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, local_bkg_width=24, detection_catalog=cat1) assert_equal(cat1.kron_radius, cat3.kron_radius) # Assert not equal match = 'Arrays are not equal' with pytest.raises(AssertionError, match=match): assert_equal(cat1.kron_radius, cat2.kron_radius) with pytest.raises(AssertionError, match=match): assert_equal(cat2.kron_flux, cat3.kron_flux) with pytest.raises(AssertionError, match=match): assert_equal(cat2.kron_flux_err, cat3.kron_flux_err) with pytest.raises(AssertionError, match=match): assert_equal(cat1.kron_flux, cat3.kron_flux) with pytest.raises(AssertionError, match=match): assert_equal(cat1.kron_flux_err, cat3.kron_flux_err) flux1, flux_err1 = cat1.circular_photometry(1.0) flux2, flux_err2 = cat2.circular_photometry(1.0) flux3, flux_err3 = cat3.circular_photometry(1.0) with pytest.raises(AssertionError, match=match): assert_equal(flux2, flux3) with pytest.raises(AssertionError, match=match): assert_equal(flux_err2, flux_err3) with pytest.raises(AssertionError, match=match): assert_equal(flux1, flux2) with pytest.raises(AssertionError, match=match): assert_equal(flux_err1, flux_err2) flux1, flux_err1 = cat1.kron_photometry((2.5, 1.4)) flux2, flux_err2 = cat2.kron_photometry((2.5, 1.4)) flux3, flux_err3 = cat3.kron_photometry((2.5, 1.4)) with pytest.raises(AssertionError, match=match): assert_equal(flux2, flux3) with pytest.raises(AssertionError, match=match): assert_equal(flux_err2, flux_err3) with pytest.raises(AssertionError, match=match): assert_equal(flux1, flux2) with pytest.raises(AssertionError, match=match): assert_equal(flux_err1, flux_err2) radius1 = cat1.flux_radius(0.5) radius2 = cat2.flux_radius(0.5) radius3 = cat3.flux_radius(0.5) with pytest.raises(AssertionError, match=match): assert_equal(radius2, radius3) with pytest.raises(AssertionError, match=match): assert_equal(radius1, radius2) cat4 = cat3[0:1] assert len(cat4.kron_radius) == 1 def test_minimal_catalog(self): """ Test minimal catalog. """ cat = SourceCatalog(self.data, self.segm) obj = cat[4] props = ('background_cutout', 'background_cutout_masked', 'error_cutout', 'error_cutout_masked') 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_flux_err', 'kron_flux_err') 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): """ Test slicing. """ self.cat.to_table() # evaluate and cache several properties obj1 = self.cat[0] assert obj1.n_labels == 1 obj1b = self.cat.select_label(1) assert obj1b.n_labels == 1 obj2 = self.cat[0:1] assert obj2.n_labels == 1 assert len(obj2) == 1 obj3 = self.cat[0:3] obj3b = self.cat.select_labels((1, 2, 3)) assert_equal(obj3.label, obj3b.label) obj4 = self.cat[[0, 1, 2]] assert obj3.n_labels == 3 assert obj3b.n_labels == 3 assert obj4.n_labels == 3 assert len(obj3) == 3 assert len(obj4) == 3 obj5 = self.cat[[3, 2, 1]] labels = [4, 3, 2] obj5b = self.cat.select_labels(labels) assert_equal(obj5.label, obj5b.label) assert obj5.n_labels == 3 assert len(obj5) == 3 assert_equal(obj5.label, labels) # Test select_labels when labels are not sorted obj5 = self.cat[[3, 2, 1]] labels2 = (3, 4) obj5b = obj5.select_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.n_labels == 4 assert len(obj7) == 4 obj1 = self.cat[0] match = "A scalar 'SourceCatalog' object cannot be indexed" with pytest.raises(TypeError, match=match): obj2 = obj1[0] match = 'is invalid' with pytest.raises(ValueError, match=match): self.cat.select_label(1000) with pytest.raises(ValueError, match=match): self.cat.select_labels([1, 2, 1000]) def test_iter(self): """ Test iter. """ labels = [obj.label for obj in self.cat] assert len(labels) == len(self.cat) def test_table(self): """ Test table. """ columns = ['label', 'x_centroid', 'y_centroid'] tbl = self.cat.to_table(columns=columns) assert len(tbl) == 7 assert tbl.colnames == columns tbl = self.cat.to_table(columns=self.cat.default_columns) for col in tbl.columns: assert isinstance(col, str) assert not isinstance(col, np.str_) tbl = self.cat.to_table(columns='label') for col in tbl.columns: assert isinstance(col, str) assert not isinstance(col, np.str_) def test_invalid_inputs(self): """ Test invalid inputs. """ segm = SegmentationImage(np.zeros(self.data.shape, dtype=int)) match = 'segmentation_image must have at least one non-zero label' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, segm) # Test 1D arrays img1d = np.arange(4) segm = SegmentationImage(img1d) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): SourceCatalog(img1d, segm) wrong_shape = np.ones((3, 3), dtype=int) match = 'segmentation_image and data must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(wrong_shape, self.segm) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, error=wrong_shape) match = 'data and background must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, background=wrong_shape) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, mask=wrong_shape) segm = SegmentationImage(wrong_shape) match = 'segmentation_image and data must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, segm) match = 'segmentation_image must be a SegmentationImage' with pytest.raises(TypeError, match=match): SourceCatalog(self.data, wrong_shape) obj = SourceCatalog(self.data, self.segm)[0] match = "Scalar 'SourceCatalog' object has no len()" with pytest.raises(TypeError, match=match): len(obj) match = 'local_bkg_width must be >= 0' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, local_bkg_width=-1) match = 'local_bkg_width must be an integer' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, local_bkg_width=3.4) aperture_mask_method = 'invalid' match = 'Invalid aperture_mask_method value' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, aperture_mask_method=aperture_mask_method) kron_params = (0.0, 1.0) match = r'kron_params\[0\] must be > 0' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, kron_params=kron_params) kron_params = (-2.5, 0.0) with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, kron_params=kron_params) kron_params = (2.5, 0.0) match = r'kron_params\[1\] must be > 0' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, kron_params=kron_params) kron_params = (2.5, -4.0) with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, kron_params=kron_params) kron_params = (2.5, 1.4, -2.0) match = r'kron_params\[2\] must be >= 0' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, kron_params=kron_params) def test_invalid_units(self): """ Test invalid units. """ unit = u.uJy wrong_unit = u.km match = 'must all have the same units' with pytest.raises(ValueError, match=match): SourceCatalog(self.data << unit, self.segm, error=self.error << wrong_unit) with pytest.raises(ValueError, match=match): SourceCatalog(self.data << unit, self.segm, background=self.background << wrong_unit) # All array inputs must have the same unit with pytest.raises(ValueError, match=match): SourceCatalog(self.data << unit, self.segm, error=self.error) with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, background=self.background << unit) def test_wcs(self): """ Test wcs. """ 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): """ Test gwcs. """ 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): """ Test nowcs. """ 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): """ Test to table. """ 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.n_labels == 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 = ('x_centroid', 'y_centroid', 'area', 'orientation', 'segment_flux', 'segment_flux_err', 'kron_flux', 'kron_flux_err', '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].x_centroid == cat2[0].x_centroid def test_repr_str(self): """ Test repr str. """ 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_catalog(self): """ Test detection catalog. """ data2 = self.data - 5 cat1 = SourceCatalog(data2, self.segm) cat2 = SourceCatalog(data2, self.segm, detection_catalog=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_catalog=np.arange(4)) segm = self.segm.copy() segm.remove_labels((6, 7)) cat = SourceCatalog(self.data, segm) match = ('detection_catalog must have same' ' segmentation_image as the input') with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, detection_catalog=cat) def test_kron_minradius(self): """ Test kron minradius. """ kron_params = (2.5, 2.5) cat = SourceCatalog(self.data, self.segm, mask=self.mask, aperture_mask_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): """ Test kron masking. """ aperture_mask_method = 'none' cat1 = SourceCatalog(self.data, self.segm, aperture_mask_method=aperture_mask_method) aperture_mask_method = 'mask' cat2 = SourceCatalog(self.data, self.segm, aperture_mask_method=aperture_mask_method) aperture_mask_method = 'correct' cat3 = SourceCatalog(self.data, self.segm, aperture_mask_method=aperture_mask_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): """ Test kron negative. """ cat = SourceCatalog(self.data - 10, self.segm) assert_allclose(cat.kron_radius.value, cat.kron_params[1]) def test_kron_photometry(self): """ Test kron photometry. """ flux0, flux_err0 = self.cat.kron_photometry((2.5, 1.4)) assert_allclose(flux0, self.cat.kron_flux) assert_allclose(flux_err0, self.cat.kron_flux_err) flux1, flux_err1 = self.cat.kron_photometry((1.0, 1.4), name='kron1') flux2, flux_err2 = self.cat.kron_photometry((2.0, 1.4), name='kron2') assert_allclose(flux1, self.cat.kron1_flux) assert_allclose(flux_err1, self.cat.kron1_flux_err) assert_allclose(flux2, self.cat.kron2_flux) assert_allclose(flux_err2, self.cat.kron2_flux_err) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((flux_err2 > flux_err1) | (np.isnan(flux_err2) & np.isnan(flux_err1))) # Test different min Kron radius flux3, flux_err3 = self.cat.kron_photometry((2.5, 2.5)) assert np.all((flux3 > flux0) | (np.isnan(flux3) & np.isnan(flux0))) assert np.all((flux_err3 > flux_err0) | (np.isnan(flux_err3) & np.isnan(flux_err0))) obj = self.cat[1] flux1, flux_err1 = obj.kron_photometry((1.0, 1.4), name='kron0') assert np.isscalar(flux1) assert np.isscalar(flux_err1) assert_allclose(flux1, obj.kron0_flux) assert_allclose(flux_err1, obj.kron0_flux_err) cat = SourceCatalog(self.data, self.segm) _, flux_err = cat.kron_photometry((2.0, 1.4)) assert np.all(np.isnan(flux_err)) match = 'kron_params must be 1D' with pytest.raises(ValueError, match=match): self.cat.kron_photometry(2.5) match = r'kron_params\[0\] must be > 0' with pytest.raises(ValueError, match=match): self.cat.kron_photometry((0.0, 1.4)) match = r'kron_params\[1\] must be > 0' with pytest.raises(ValueError, match=match): self.cat.kron_photometry((2.5, 0.0)) with pytest.raises(ValueError, match=match): self.cat.kron_photometry((2.5, 0.0, 1.5)) def test_circular_photometry(self): """ Test circular photometry. """ flux1, flux_err1 = self.cat.circular_photometry(1.0, name='circ1') flux2, flux_err2 = self.cat.circular_photometry(5.0, name='circ5') assert_allclose(flux1, self.cat.circ1_flux) assert_allclose(flux_err1, self.cat.circ1_flux_err) assert_allclose(flux2, self.cat.circ5_flux) assert_allclose(flux_err2, self.cat.circ5_flux_err) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((flux_err2 > flux_err1) | (np.isnan(flux_err2) & np.isnan(flux_err1))) obj = self.cat[1] assert obj.isscalar flux1, flux_err1 = obj.circular_photometry(1.0, name='circ0') assert np.isscalar(flux1) assert np.isscalar(flux_err1) assert_allclose(flux1, obj.circ0_flux) assert_allclose(flux_err1, obj.circ0_flux_err) cat = SourceCatalog(self.data, self.segm) _, flux_err = cat.circular_photometry(1.0) assert np.all(np.isnan(flux_err)) # 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, flux_err1 = cat2.circular_photometry(0.1) assert np.all(np.isnan(flux1[2:4])) assert np.all(np.isnan(flux_err1[2:4])) match = 'radius must be > 0' with pytest.raises(ValueError, match=match): self.cat.circular_photometry(0.0) with pytest.raises(ValueError, match=match): self.cat.circular_photometry(-1.0) with pytest.raises(ValueError, match=match): self.cat.make_circular_apertures(0.0) with pytest.raises(ValueError, match=match): self.cat.make_circular_apertures(-1.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plots(self): """ Test plots. """ 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(kron_params=(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(kron_params=(2.0, 1.2)) assert isinstance(patch2, Patch) def test_flux_radius_cache(self): """ Test that flux_radius caches results and reuses them on repeated calls with the same flux_radius value. """ cat = SourceCatalog(self.data, self.segm) # Cache must start empty assert cat._flux_radius_cache == {} # First call computes and stores in cache r1 = cat.flux_radius(0.5) assert 0.5 in cat._flux_radius_cache assert r1 is cat._flux_radius_cache[0.5] # Second call returns the identical cached object r2 = cat.flux_radius(0.5) assert r2 is r1 # Different flux_radius values are cached independently r3 = cat.flux_radius(0.3) assert 0.3 in cat._flux_radius_cache assert np.all(r1.value >= r3.value) # Test "name"= still works on a cache hit and stores the # attribute cat2 = SourceCatalog(self.data, self.segm) cat2.flux_radius(0.5) # populate cache cat2.flux_radius(0.5, name='r_hl') # cache hit with name assert hasattr(cat2, 'r_hl') assert_allclose(cat2.r_hl, cat2._flux_radius_cache[0.5]) def test_flux_radius_cache_getitem(self): """ Test that sliced SourceCatalog objects preserve the flux_radius cache and produce correct results. """ cat = SourceCatalog(self.data, self.segm) # Sliced object without a populated parent cache has an empty # cache obj = cat[1] assert obj._flux_radius_cache == {} # Populate the parent cache with two flux_radius values r_parent_05 = cat.flux_radius(0.5) r_parent_03 = cat.flux_radius(0.3) assert 0.5 in cat._flux_radius_cache assert 0.3 in cat._flux_radius_cache # Scalar slice preserves the cache obj = cat[1] assert 0.5 in obj._flux_radius_cache assert 0.3 in obj._flux_radius_cache r_sliced = obj.flux_radius(0.5) assert_allclose(r_sliced.value, r_parent_05[1].value) r_sliced_03 = obj.flux_radius(0.3) assert_allclose(r_sliced_03.value, r_parent_03[1].value) # Range slice preserves the cache sub = cat[1:3] assert 0.5 in sub._flux_radius_cache assert 0.3 in sub._flux_radius_cache r_sub = sub.flux_radius(0.5) assert_allclose(r_sub.value, r_parent_05[1:3].value) # Fancy index slice preserves the cache sub2 = cat[[0, 2]] assert 0.5 in sub2._flux_radius_cache r_sub2 = sub2.flux_radius(0.5) assert_allclose(r_sub2.value, r_parent_05[[0, 2]].value) # Boolean mask slice preserves the cache mask = np.array([True, False, True, False, True, False, True]) sub3 = cat[mask] assert 0.5 in sub3._flux_radius_cache r_sub3 = sub3.flux_radius(0.5) assert_allclose(r_sub3.value, r_parent_05[mask].value) # Modifying the parent cache does not affect the sliced cache cat.flux_radius(0.7) assert 0.7 not in obj._flux_radius_cache def test_flux_radius_max_radius_delta(self): """ Test that the max_radius_delta fallback loop reduces max_radius by 10 percent on each failed bracketing attempt and still returns a valid result when the second (reduced) bracket succeeds. """ # Use a single-source scalar catalog to keep the mock simple cat = SourceCatalog(self.data, self.segm)[1] assert cat.isscalar brackets_seen = [] call_count = [0] def mock_root_scalar(fcn, args, bracket, method): call_count[0] += 1 brackets_seen.append(list(bracket)) if call_count[0] == 1: # Simulate a bracket with no sign change msg = 'no sign change in bracket' raise ValueError(msg) return root_scalar(fcn, args=args, bracket=bracket, method=method) with patch('photutils.segmentation.catalog.root_scalar', mock_root_scalar): r = cat.flux_radius(0.5) # Fallback triggered once then succeeded assert call_count[0] == 2 # Second bracket max_radius must be 10% smaller than the first assert_allclose(brackets_seen[1][1], 0.9 * brackets_seen[0][1], rtol=1e-10) # Result is a valid radius (not NaN) assert np.isfinite(r.value) def test_flux_radius(self): """ Test flux_radius. """ radius1 = self.cat.flux_radius(0.1, name='flux_radius_r1') radius2 = self.cat.flux_radius(0.5, name='flux_radius_r5') assert_allclose(radius1, self.cat.flux_radius_r1) assert_allclose(radius2, self.cat.flux_radius_r5) assert np.all((radius2 > radius1) | (np.isnan(radius2) & np.isnan(radius1))) cat = SourceCatalog(self.data, self.segm) obj = cat[1] radius = obj.flux_radius(0.5) assert radius.isscalar # Quantity radius - can't use np.isscalar assert_allclose(radius.value, 7.899648) match = 'fraction must be > 0 and <= 1' with pytest.raises(ValueError, match=match): radius = self.cat.flux_radius(0) with pytest.raises(ValueError, match=match): radius = self.cat.flux_radius(-1) cat = SourceCatalog(self.data - 50.0, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, local_bkg_width=24) radius_hl = cat.flux_radius(0.5) assert np.isnan(radius_hl[0]) def test_cutout_units(self): """ Test cutout units. """ obj = self.cat_units[0] quantities = (obj.data_cutout, obj.error_cutout, obj.background_cutout) ndarray = (obj.segment_cutout, obj.segment_cutout_masked, obj.data_cutout_masked, obj.error_cutout_masked, obj.background_cutout_masked) 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_custom_properties(self, scalar): """ Test extra properties. """ cat = SourceCatalog(self.data, self.segm) if scalar: cat = cat[1] segment_snr = cat.segment_flux / cat.segment_flux_err match = 'cannot be set because it is a built-in attribute' with pytest.raises(ValueError, match=match): # Built-in attribute cat.add_property('_data', segment_snr) with pytest.raises(ValueError, match=match): # Built-in property cat.add_property('label', segment_snr) with pytest.raises(ValueError, match=match): # Built-in lazyproperty cat.add_property('area', segment_snr) cat.add_property('segment_snr', segment_snr) match = 'already exists as an attribute' with pytest.raises(ValueError, match=match): # Already exists cat.add_property('segment_snr', segment_snr) cat.add_property('segment_snr', 2.0 * segment_snr, overwrite=True) assert len(cat.custom_properties) == 1 assert_equal(cat.segment_snr, 2.0 * segment_snr) match = 'is not a defined property' with pytest.raises(ValueError, match=match): cat.remove_property('invalid') cat.remove_property(cat.custom_properties) assert len(cat.custom_properties) == 0 cat.add_property('segment_snr', segment_snr) cat.add_property('segment_snr2', segment_snr) cat.add_property('segment_snr3', segment_snr) assert len(cat.custom_properties) == 3 cat.remove_properties(cat.custom_properties) assert len(cat.custom_properties) == 0 cat.add_property('segment_snr', segment_snr) new_name = 'segment_snr0' cat.rename_property('segment_snr', new_name) assert new_name in cat.custom_properties # Key in extra_properties, but not a defined attribute cat._custom_properties.append('invalid') match = 'already exists in the custom_properties attribute' with pytest.raises(ValueError, match=match): cat.add_property('invalid', segment_snr) cat._custom_properties.remove('invalid') assert cat._has_len([1, 2, 3]) assert not cat._has_len('test_string') cat.add_property('segment_snr4', segment_snr, overwrite=True) cat.add_property('segment_snr4', segment_snr, overwrite=True) def test_custom_properties_invalid(self): """ Test extra properties invalid. """ cat = SourceCatalog(self.data, self.segm) match = 'value must have the same number of elements as the catalog' with pytest.raises(ValueError, match=match): cat.add_property('invalid', 1.0) with pytest.raises(ValueError, match=match): cat.add_property('invalid', (1.0, 2.0)) obj = cat[1] with pytest.raises(ValueError, match=match): obj.add_property('invalid', (1.0, 2.0)) val = np.arange(2) << u.km with pytest.raises(ValueError, match=match): obj.add_property('invalid', val) coord = SkyCoord([42, 43], [44, 45], unit='deg') with pytest.raises(ValueError, match=match): obj.add_property('invalid', coord) def test_properties(self): """ Test properties. """ attrs = ('label', 'labels', 'slices', 'x_centroid', 'segment_flux', 'kron_flux') for attr in attrs: assert attr in self.cat.properties def test_lazyproperties_class_cache(self): """ Test that _lazyproperties is cached on the class and shared across instances. """ cat2 = SourceCatalog(self.data, self.segm) result1 = self.cat._lazyproperties result2 = cat2._lazyproperties assert result1 is result2 def test_properties_class_cache(self): """ Test that _properties is cached on the class and shared across instances. """ cat2 = SourceCatalog(self.data, self.segm) result1 = self.cat._properties result2 = cat2._properties assert result1 is result2 def test_copy(self): """ Test copy. """ 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): """ 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, local_bkg_width=3) assert cat.min_value == 10 assert cat.max_value == 10 def test_make_circular_apertures(self): """ Test make circular apertures. """ 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): """ Test make kron apertures. """ aper = self.cat.make_kron_apertures() assert len(aper) == len(self.cat) assert isinstance(aper[1], EllipticalAperture) aper2 = self.cat.make_kron_apertures(kron_params=(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): """ Test make cutouts. """ 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) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels, progress_bar=False) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) shape = (100, 100) match = "mode must be 'partial' or 'trim'" with pytest.raises(ValueError, match=match): 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) for cutout in cutouts: assert cutout.data.shape == shape # Test making cutouts from an input image image = np.ones(data.shape) cutouts = cat.make_cutouts(shape, array=image, mode='partial') for cutout in cutouts: assert np.all(cutout.data[np.isfinite(cutout.data)] == 1) assert cutout.data.shape == shape match = 'array must have the same shape as data' with pytest.raises(ValueError, match=match): cat.make_cutouts(shape, array=np.ones((3, 3)), mode='partial') 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): """ Test meta. """ meta = self.cat.meta attrs = ['local_bkg_width', 'aperture_mask_method', 'kron_params'] for attr in attrs: assert attr in meta deprecated_attrs = { 'localbkg_width': 'local_bkg_width', 'apermask_method': 'aperture_mask_method', } for old_name, new_name in deprecated_attrs.items(): assert old_name in meta assert meta[old_name] == meta[new_name] 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_meta_deprecated_kwargs(self): """ Test meta aliases for deprecated keyword names. """ with pytest.warns(AstropyDeprecationWarning) as record: cat = SourceCatalog(self.data, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, apermask_method='mask') assert len(record) == 2 messages = [str(item.message) for item in record] assert any('localbkg_width' in message for message in messages) assert any('apermask_method' in message for message in messages) assert cat.meta['localbkg_width'] == cat.meta['local_bkg_width'] assert cat.meta['apermask_method'] == cat.meta['aperture_mask_method'] def test_meta_future_column_names(self, monkeypatch): """ Test meta with future_column_names enabled. """ import photutils monkeypatch.setattr(photutils, 'future_column_names', True) with pytest.warns(AstropyDeprecationWarning) as record: cat = SourceCatalog(self.data, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, apermask_method='mask') assert len(record) == 2 meta = cat.meta attrs = ['local_bkg_width', 'aperture_mask_method', 'kron_params'] for attr in attrs: assert attr in meta assert 'localbkg_width' not in meta assert 'apermask_method' not in meta tbl = cat.to_table() assert tbl.meta == meta def test_semode(self): """ Test semode. """ self.cat._set_semode() tbl = self.cat.to_table() assert len(tbl) == 7 def test_tiny_sources(self): """ Test tiny sources. """ 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_SKIMAGE, reason='skimage is required') def test_kron_params(): """ 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) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels, 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.flux_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.flux_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.flux_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.flux_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_SKIMAGE, reason='skimage is required') def test_centroid_win(centroid_win_data): """ Test centroid win. """ data, segment_map, convolved_data = centroid_win_data cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, aperture_mask_method='none') assert cat.x_centroid[0] != cat.x_centroid_win[0] assert cat.y_centroid[0] != cat.y_centroid_win[0] # centroid_win moved beyond 1-sigma ellipse and was reset to # isophotal centroid assert cat.x_centroid[1] == cat.x_centroid_win[1] assert cat.y_centroid[1] == cat.y_centroid_win[1] 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, n_pixels=5) cat = SourceCatalog(data, segm) indices = (0, 3, 14, 30) for idx in indices: assert_equal(cat.centroid_win[idx], cat.centroid[idx]) def test_background_centroid_coordinate_order(): """ Test that the background_centroid property correctly passes (y, x) coordinates to map_coordinates. """ yy, xx = np.mgrid[0:101, 0:101] # Background varies only along y (rows) background = yy.astype(float) g1 = Gaussian2D(200, 50, 25, 5, 5) g2 = Gaussian2D(200, 50, 75, 5, 5) data = g1(xx, yy) + g2(xx, yy) segm = detect_sources(data, 30.0, n_pixels=5) cat = SourceCatalog(data, segm, background=background) bkg_cen = cat.background_centroid # The expected value at each centroid is approximately y_centroid # (since background = y) for i in range(cat.n_labels): xcen = cat.x_centroid[i] ycen = cat.y_centroid[i] if np.isfinite(xcen) and np.isfinite(ycen): # The interpolated background at the centroid should be # close to ycen (not xcen) assert_allclose(bkg_cen[i], ycen, atol=0.5) # If x != y, the wrong order would give a value close to # xcen instead if abs(xcen - ycen) > 5: assert abs(bkg_cen[i] - xcen) > 2 def test_aperture_mask_method_none(): """ Test that circular_photometry with aperture_mask_method='none' does not mask neighboring sources. """ yy, xx = np.mgrid[0:101, 0:101] # Two overlapping sources g1 = Gaussian2D(200, 40, 50, 8, 8) g2 = Gaussian2D(200, 60, 50, 8, 8) data = g1(xx, yy) + g2(xx, yy) segm = detect_sources(data, 20.0, n_pixels=5) cat_none = SourceCatalog(data, segm, aperture_mask_method='none') cat_mask = SourceCatalog(data, segm, aperture_mask_method='mask') # Use a large aperture that overlaps both sources flux_none, _ = cat_none.circular_photometry(20.0) flux_mask, _ = cat_mask.circular_photometry(20.0) # 'none' should include neighbor flux, so should be >= 'mask' for i in range(cat_none.n_labels): if np.isfinite(flux_none[i]) and np.isfinite(flux_mask[i]): assert flux_none[i] >= flux_mask[i] def test_flux_radius_nan_fallback(): """ Test that flux_radius returns NaN when no root can be found (e.g., when the source has all-negative Kron flux within the search bracket or zero Kron flux). """ # Create a source with negative total flux by subtracting a large # constant. The Kron flux will be zero/negative, causing flux_radius # to return NaN. yy, xx = np.mgrid[0:51, 0:51] g1 = Gaussian2D(10, 25, 25, 3, 3) data = g1(xx, yy) - 50.0 # all negative segm_data = np.zeros((51, 51), dtype=int) segm_data[20:31, 20:31] = 1 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm) radius = cat.flux_radius(0.5) # Should be NaN since there's no meaningful flux assert np.isnan(radius.value) def test_reduceat_empty_input(): """ Test that _reduceat returns empty arrays when given an empty list. """ result, sizes = SourceCatalog._reduceat([], np.add) assert len(result) == 0 assert len(sizes) == 0 assert sizes.dtype == int def test_reduceat_negative_data(): """ Test that the _reduceat optimization gives correct results for min_value, max_value, and segment_flux when data contains negative pixel values. """ yy, xx = np.mgrid[0:101, 0:101] g1 = Gaussian2D(100, 30, 30, 5, 5) g2 = Gaussian2D(80, 70, 70, 4, 4) data = g1(xx, yy) + g2(xx, yy) - 20.0 # shift so many pixels negative segm = detect_sources(data, 0.5, n_pixels=5) cat = SourceCatalog(data, segm) for i in range(cat.n_labels): obj = cat[i] vals = obj._data_values[0] expected_min = np.min(vals) - obj._local_background expected_max = np.max(vals) - obj._local_background expected_flux = np.sum(vals) - obj._local_background * len(vals) assert_allclose(obj.min_value, expected_min) assert_allclose(obj.max_value, expected_max) assert_allclose(obj.segment_flux, expected_flux) def test_make_cutouts_trim_mode(): """ Test that make_cutouts with mode='trim' returns cutouts that are correctly trimmed when they extend beyond the array boundary. """ yy, xx = np.mgrid[0:101, 0:101] # Source near the edge g1 = Gaussian2D(100, 5, 5, 3, 3) # Source in the center g2 = Gaussian2D(100, 50, 50, 3, 3) data = g1(xx, yy) + g2(xx, yy) segm = detect_sources(data, 10, n_pixels=5) cat = SourceCatalog(data, segm) shape = (40, 40) cutouts = cat.make_cutouts(shape, mode='trim') for cutout in cutouts: if cutout is None: continue # Trim mode: cutout shape should be <= requested shape assert cutout.data.shape[0] <= shape[0] assert cutout.data.shape[1] <= shape[1] assert isinstance(cutout, CutoutImage) # At least one near-edge source should be trimmed (smaller than # shape) shapes = [c.data.shape for c in cutouts if c is not None] assert any(s != shape for s in shapes) # Center source should be full size assert any(s == shape for s in shapes) def test_progress_bar_centroid_win(progress_bar_catalog): """ Test that centroid_win works with progress_bar=True. """ cat = progress_bar_catalog cwin = cat.centroid_win assert cwin.shape == (cat.n_labels, 2) assert np.all(np.isfinite(cwin)) def test_progress_bar_centroid_quad(progress_bar_catalog): """ Test that centroid_quad works with progress_bar=True. """ cat = progress_bar_catalog cquad = cat.centroid_quad assert cquad.shape == (cat.n_labels, 2) assert np.all(np.isfinite(cquad)) def test_progress_bar_kron_radius(progress_bar_catalog): """ Test that kron_radius works with progress_bar=True. """ cat = progress_bar_catalog kr = cat.kron_radius assert len(kr) == cat.n_labels def test_progress_bar_kron_photometry(progress_bar_catalog): """ Test that kron_photometry (aperture photometry) works with progress_bar=True. """ cat = progress_bar_catalog flux, _flux_err = cat.kron_photometry((2.5, 1.4)) assert len(flux) == cat.n_labels def test_progress_bar_flux_radius(progress_bar_catalog): """ Test that flux_radius and its prep work with progress_bar=True. """ cat = progress_bar_catalog r = cat.flux_radius(0.5) assert len(r) == cat.n_labels def test_progress_bar_circular_photometry(progress_bar_catalog): """ Test that circular_photometry works with progress_bar=True. """ cat = progress_bar_catalog flux, _fluxerr = cat.circular_photometry(5.0) assert len(flux) == cat.n_labels def test_negative_covariance_eigvals(single_source_catalog): """ Test that negative eigenvalues in the covariance matrix are replaced with NaN. """ _data, _segm, cat = single_source_catalog # Patch np.linalg.eigvalsh to return negative eigenvalues real_eigvalsh = np.linalg.eigvalsh def mock_eigvalsh(a): result = real_eigvalsh(a) result[:] = -1.0 # force negative eigenvalues return result with patch('numpy.linalg.eigvalsh', mock_eigvalsh): eigvals = cat.covariance_eigvals assert np.all(np.isnan(eigvals.value)) def test_local_background_few_pixels(): """ Test that _local_background returns 0 when fewer than 10 unmasked pixels are available in the local background annulus. """ # Create a tiny image where the background annulus will have very # few unmasked pixels data = np.zeros((11, 11)) data[4:7, 4:7] = 100.0 segm_data = np.zeros((11, 11), dtype=int) segm_data[4:7, 4:7] = 1 segm = SegmentationImage(segm_data) # Use a large mask that leaves fewer than 10 pixels in the annulus mask = np.ones((11, 11), dtype=bool) # Unmask only the source and a thin border mask[3:8, 3:8] = False cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) bkg = cat._local_background assert bkg[0] == 0.0 def test_validate_kron_params_wrong_element_count(): """ Test that _validate_kron_params raises ValueError for wrong number of elements. """ match = 'kron_params must have 2 or 3 elements' with pytest.raises(ValueError, match=match): SourceCatalog._validate_kron_params([2.5, 1.4, 0.0, 99.0]) with pytest.raises(ValueError, match=match): SourceCatalog._validate_kron_params([2.5]) def test_error_values_with_error(single_source_catalog): """ Test that _error_values returns null objects when error is None. """ _data, _segm, cat = single_source_catalog err_vals = cat._error_values assert err_vals is cat._null_objects def test_background_values_with_background(single_source_catalog): """ Test that _background_values returns null objects when background is None. """ _data, _segm, cat = single_source_catalog bkg_vals = cat._background_values assert bkg_vals is cat._null_objects def test_sky_centroid_quad_with_wcs(single_source_catalog): """ Test that sky_centroid_quad returns a coordinate when wcs is provided. """ data, segm, _cat = single_source_catalog wcs = make_wcs(data.shape) cat = SourceCatalog(data, segm, wcs=wcs) sky_quad = cat.sky_centroid_quad assert sky_quad is not None def test_sky_centroid_quad_no_wcs(single_source_catalog): """ Test that sky_centroid_quad returns None when wcs is not provided. """ _data, _segm, cat = single_source_catalog sky_quad = cat.sky_centroid_quad # Single source returns scalar None (from _null_objects) assert sky_quad is None or np.all(sky_quad == np.array(None)) def test_centroid_quad_edge_cases(): """ Test cutout_centroid_quad edge cases. """ # Small cutout (< 3x3) triggers NaN fallback data = np.zeros((10, 10)) segm_data = np.zeros((10, 10), dtype=int) data[0, 4:6] = [5.0, 3.0] segm_data[0, 4:6] = 1 data[5, 5] = 100.0 segm_data[4:7, 4:7] = 2 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm) cquad = cat.cutout_centroid_quad assert cquad.shape == (2, 2) assert np.all(np.isfinite(cquad)) # Checkerboard pattern triggers det <= 0 or c20 > 0 data3 = np.zeros((7, 7)) data3[3, 3] = 10.0 data3[2, 2] = 9.0 data3[4, 4] = 9.0 data3[2, 4] = 9.0 data3[4, 2] = 9.0 data3[3, 2] = 1.0 data3[3, 4] = 1.0 data3[2, 3] = 1.0 data3[4, 3] = 1.0 segm_data3 = np.zeros((7, 7), dtype=int) segm_data3[1:6, 1:6] = 1 segm3 = SegmentationImage(segm_data3) cat3 = SourceCatalog(data3, segm3) cquad3 = cat3.cutout_centroid_quad assert np.all(np.isfinite(cquad3)) # Quadratic max falls outside cutout bounds. # Use a 3x3 segment so cutout is exactly 3x3 (xidx0=0, yidx0=0), and # the relative quadratic max falls outside [0, 2]. data4 = np.zeros((7, 7)) box4 = np.array([[7.45, 9.68, 3.26], [3.70, 10.67, 1.89], [1.30, 4.76, 2.27]]) data4[2:5, 2:5] = box4 segm_data4 = np.zeros((7, 7), dtype=int) segm_data4[2:5, 2:5] = 1 segm4 = SegmentationImage(segm_data4) cat4 = SourceCatalog(data4, segm4) cquad4 = cat4.cutout_centroid_quad assert np.all(np.isfinite(cquad4)) def test_flux_radius_no_solution(single_source_catalog): """ Test that flux_radius returns NaN when no solution is found (root_scalar always raises ValueError). """ _data, _segm, cat = single_source_catalog # Make root_scalar always raise ValueError so no solution is found def mock_root_scalar(*_args, **_kwargs): msg = 'bracket signs' raise ValueError(msg) with patch('photutils.segmentation.catalog.root_scalar', mock_root_scalar): result = cat.flux_radius(0.5) assert np.isnan(result.value[0]) def test_kron_radius_max(gauss_101_catalog): """ Test that measured kron_radius values exceeding the measurement aperture scale (6.0) are set to NaN. This flags unphysical Kron radii caused by near-cancellation in the denominator of the Kron formula (e.g., due to outlier pixels or noise). """ data, segm, cat = gauss_101_catalog # The measured kron radius should be reasonable (<= 6.0) assert cat.kron_radius.value <= 6.0 # Artificially test the NaN by patching _measured_kron_radius to # return a huge value with patch.object(type(cat), '_measured_kron_radius', new_callable=lambda: property( lambda _self: np.array([100.0]))): cat2 = SourceCatalog(data, segm) assert np.isnan(cat2.kron_radius.value) # Downstream properties should also be NaN / None assert cat2.kron_aperture[0] is None assert np.isnan(cat2.kron_flux[0]) assert np.isnan(cat2.flux_radius(0.5).value[0]) def test_aperture_to_mask_size_check(): """ Test that _aperture_to_mask returns None when the aperture bounding box exceeds the allowed size, preventing out-of-memory errors from pathologically large apertures (e.g., from huge Kron radii). The check happens before to_mask() allocates the mask array. """ data = np.zeros((11, 11)) data[4:7, 4:7] = 100.0 segm_data = np.zeros((11, 11), dtype=int) segm_data[4:7, 4:7] = 1 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm) # A small aperture is fine small_aper = CircularAperture((5, 5), r=3) result = cat._aperture_to_mask(small_aper, method='center') assert result is not None # An aperture larger than data.size but within the 1M floor is fine big_aper = CircularAperture((5, 5), r=100) assert big_aper.bbox.shape[0] * big_aper.bbox.shape[1] > data.size result = cat._aperture_to_mask(big_aper, method='center') assert result is not None # An aperture whose bbox exceeds 1_000_000 pixels returns None huge_aper = CircularAperture((5, 5), r=600) bbox_size = huge_aper.bbox.shape[0] * huge_aper.bbox.shape[1] assert bbox_size > 1_000_000 result = cat._aperture_to_mask(huge_aper, method='center') assert result is None def test_aperture_to_mask_none_branches(gauss_101_catalog): """ Test that _aperture_photometry gracefully returns NaN when _aperture_to_mask returns None (i.e., aperture too large to allocate). This covers the None-guard branch in _aperture_photometry (used by the general circle photometry path). """ _, _, cat = gauss_101_catalog # _aperture_photometry (general path) with _aperture_to_mask # returning None with patch.object(type(cat), '_aperture_to_mask', return_value=None): circ_aper = [CircularAperture((50, 50), r=10)] * cat.n_labels flux, _ = cat._aperture_photometry(circ_aper, method='exact') assert np.all(np.isnan(flux)) def test_kron_photometry_oom_guard(gauss_101_catalog): """ Test that _calc_kron_photometry returns NaN when the Kron aperture is too large (OOM guard). """ data, segm, cat = gauss_101_catalog _ = cat.kron_aperture # cache # Create huge elliptical apertures that exceed the max_size check huge_aper = [EllipticalAperture((50, 50), 2000, 2000, theta=0.0) for _ in range(cat.n_labels)] cat.__dict__['kron_aperture'] = huge_aper assert np.all(np.isnan(cat.kron_flux)) # Create huge circular apertures that exceed the max_size check cat2 = SourceCatalog(data, segm) _ = cat2.kron_aperture huge_circ = [CircularAperture((50, 50), r=2000) for _ in range(cat2.n_labels)] cat2.__dict__['kron_aperture'] = huge_circ assert np.all(np.isnan(cat2.kron_flux)) # Aperture completely off-image triggers data=None guard cat3 = SourceCatalog(data, segm) _ = cat3.kron_aperture off_aper = [EllipticalAperture((-1000, -1000), 5, 3, theta=0.0) for _ in range(cat3.n_labels)] cat3.__dict__['kron_aperture'] = off_aper assert np.all(np.isnan(cat3.kron_flux)) # All pixels masked in aperture overlap triggers empty values with # error branch error = np.ones_like(data) cat4 = SourceCatalog(data, segm, error=error) _ = cat4.kron_aperture # cache original_make = type(cat4)._make_aperture_data def _make_all_masked(self, label, xcen, ycen, bbox, bkg, **kwargs): result = original_make(self, label, xcen, ycen, bbox, bkg, **kwargs) if result[0] is not None: # Set mask to all True (all pixels masked) return (result[0], result[1], np.ones_like(result[2]), result[3], result[4]) return result with patch.object(type(cat4), '_make_aperture_data', _make_all_masked): assert np.all(np.isnan(cat4.kron_flux)) assert np.all(np.isnan(cat4.kron_flux_err)) def test_flux_radius_cache_not_mutated_by_centroid_win(gauss_101_data): """ Test that calling centroid_win before flux_radius does not corrupt the flux_radius cache. ``centroid_win`` calls ``flux_radius(0.5)`` internally and then modifies the returned half-light radius array in-place (replacing NaN with a minimum radius). This must not mutate the cached ``flux_radius`` Quantity. Regression test for a bug where ``.value`` returned a view of the cached Quantity's internal array, causing in-place modification. """ data, segm = gauss_101_data cat = SourceCatalog(data, segm) # Patch _measured_kron_radius to return a value > 6.0 so that # kron_radius is NaN, which makes flux_radius return NaN with patch.object(type(cat), '_measured_kron_radius', new_callable=lambda: property( lambda _self: np.array([100.0]))): cat2 = SourceCatalog(data, segm) assert np.isnan(cat2.kron_radius.value[0]) # Call centroid_win first (it internally calls flux_radius(0.5)) _ = cat2.centroid_win # flux_radius(0.5) must still return NaN, not 0.5 result = cat2.flux_radius(0.5) assert np.all(np.isnan(result.value)) def test_centroid_win_nan_when_flux_radius_nan(gauss_101_data): """ Test that centroid_win returns NaN when flux_radius(0.5) is NaN (e.g., because kron_radius is NaN). """ data, segm = gauss_101_data # Patch _measured_kron_radius to return a value > 6.0 so that # kron_radius is NaN, which makes flux_radius return NaN with patch.object(type(SourceCatalog(data, segm)), '_measured_kron_radius', new_callable=lambda: property( lambda _self: np.array([100.0]))): cat = SourceCatalog(data, segm) assert np.isnan(cat.kron_radius.value[0]) assert np.isnan(cat.flux_radius(0.5).value[0]) cwin = cat.centroid_win assert np.all(np.isnan(cwin)) def test_centroid_win_aperture_mask_none_in_loop(gauss_101_catalog): """ Test that centroid_win falls back to the isophotal centroid when _aperture_to_mask returns None during the iteration loop (e.g., because the circular aperture exceeds the size threshold). """ _data, _segm, cat = gauss_101_catalog # Pre-compute flux_radius before mocking, since it also uses # CircularAperture internally hl_val = cat.flux_radius(0.5) original_method = cat._aperture_to_mask def mock_aperture_to_mask(aperture, **kwargs): if isinstance(aperture, CircularAperture): return None return original_method(aperture, **kwargs) with patch.object(cat, '_aperture_to_mask', side_effect=mock_aperture_to_mask), \ patch.object(type(cat), 'flux_radius', return_value=hl_val): cwin = cat.centroid_win # NaN from the loop resets to isophotal centroid # because nan_hl is False (flux_radius was valid) assert_allclose(cwin[:, 0], cat.x_centroid) assert_allclose(cwin[:, 1], cat.y_centroid) def test_centroid_win_oom_guard(gauss_101_catalog): """ Test that centroid_win returns NaN for sources whose half-light radius would require an aperture larger than max_aper_size. """ _data, _segm, cat = gauss_101_catalog # Provide a huge half-light radius so the aperture bbox exceeds # max_aper_size (max(data.size, 1_000_000) = 1_000_000). huge_radius = np.array([200.0]) * u.pix with patch.object(type(cat), 'flux_radius', return_value=huge_radius): cwin = cat.centroid_win # OOM guard should force NaN, which then resets to isophotal # centroid (because nan_hl is False) assert_allclose(cwin[:, 0], cat.x_centroid) assert_allclose(cwin[:, 1], cat.y_centroid) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_centroid_win_aperture_mask_mask(centroid_win_data): """ Test centroid_win with aperture_mask_method='mask' to cover the ``data_mask = data_mask | segm_mask`` branch. """ data, segment_map, convolved_data = centroid_win_data cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, aperture_mask_method='mask') # Verify it runs without error and returns finite values for at # least the first source cwin = cat.centroid_win assert cwin.shape == (len(cat), 2) assert np.isfinite(cwin[0, 0]) def test_make_aperture_data_outside_image(gauss_101_catalog): """ Test that _make_aperture_data returns (None,) * 5 when the aperture bbox does not overlap the data. """ _data, _segm, cat = gauss_101_catalog # BoundingBox completely outside the 101x101 data offimage_bbox = BoundingBox(ixmin=200, ixmax=210, iymin=200, iymax=210) result = cat._make_aperture_data(1, 205.0, 205.0, offimage_bbox, 0.0) assert result == (None,) * 5 def test_flux_radius_optimizer_args_oom_guard(gauss_101_catalog): """ Test that _flux_radius_optimizer_args returns None for sources whose max-radius aperture bbox exceeds max_aper_size. """ data, segm, cat = gauss_101_catalog # Cache kron_photometry normally, then patch # _max_circular_kron_radius to return a huge radius that triggers # the OOM guard _ = cat._kron_photometry huge = np.array([2000.0]) with patch.object(type(cat), '_max_circular_kron_radius', new_callable=lambda: property(lambda _self: huge)): cat2 = SourceCatalog(data, segm) cat2.__dict__['_kron_photometry'] = cat._kron_photometry cat2.__dict__['_max_circular_kron_radius'] = huge assert np.all(np.isnan(cat2.flux_radius(0.5))) def test_flux_radius_optimizer_args_off_image(gauss_101_catalog): """ Test that _flux_radius_optimizer_args returns None for sources whose max-radius aperture bbox doesn't overlap the data. """ _data, _segm, cat = gauss_101_catalog # Cache kron_photometry, then move the centroid way off-image _ = cat._kron_photometry off = np.array([500.0]) cat.__dict__['_x_centroid'] = off cat.__dict__['_y_centroid'] = off assert np.all(np.isnan(cat.flux_radius(0.5))) def test_flux_radius_optimizer_args_center_method(gauss_101_catalog): """ Test _flux_radius_optimizer_args with method='center' to cover the center-method branch in the method translation logic. """ _data, _segm, cat = gauss_101_catalog cat._aperture_mask_kwargs['flux_radius'] = {'method': 'center'} r50 = cat.flux_radius(0.5) assert np.isfinite(r50.value[0]) def test_flux_radius_optimizer_args_subpixel_method(gauss_101_catalog): """ Test _flux_optimizer_args with method='subpixel' to cover the subpixel-method branch in the method translation logic. """ _data, _segm, cat = gauss_101_catalog cat._aperture_mask_kwargs['flux_radius'] = {'method': 'subpixel', 'subpixels': 5} r50 = cat.flux_radius(0.5) assert np.isfinite(r50.value[0]) def test_measured_kron_radius_oom_guard(gauss_101_catalog): """ Test that _measured_kron_radius returns NaN for sources whose aperture bounding box exceeds max_size (OOM guard). """ _data, _segm, cat = gauss_101_catalog # Patch semimajor_axis to a huge value so the bbox triggers OOM huge = np.array([1e6]) << u.pix with patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property(lambda _self: huge)): assert np.all(np.isnan(cat.kron_radius.value)) def test_measured_kron_radius_off_image(gauss_101_catalog): """ Test that _measured_kron_radius returns NaN for sources whose aperture bounding box doesn't overlap the data. """ _data, _segm, cat = gauss_101_catalog # Move the centroid off the image cat.__dict__['_x_centroid'] = np.array([5000.0]) cat.__dict__['_y_centroid'] = np.array([5000.0]) assert np.all(np.isnan(cat.kron_radius.value)) def test_measured_kron_radius_circular_fallback(gauss_101_data): """ Test _measured_kron_radius with the circular aperture fallback when semimajor/semiminor sigma are zero (kron_params[2] > 0). """ data, segm = gauss_101_data cat = SourceCatalog(data, segm, kron_params=(2.5, 1.4, 5.0)) # Force semimajor/semiminor to zero to trigger circular fallback. # Also patch ellipse_cxx/ellipse_cxy/ellipse_cyy to valid values # since the real ones depend on covariance eigenvalues. zero = np.array([0.0]) << u.pix cxx_val = np.array([1.0]) / (u.pix * u.pix) cyy_val = np.array([1.0]) / (u.pix * u.pix) cxy_val = np.array([0.0]) / (u.pix * u.pix) with (patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property(lambda _self: zero)), patch.object(type(cat), 'semiminor_axis', new_callable=lambda: property(lambda _self: zero)), patch.object(type(cat), 'ellipse_cxx', new_callable=lambda: property(lambda _self: cxx_val)), patch.object(type(cat), 'ellipse_cyy', new_callable=lambda: property(lambda _self: cyy_val)), patch.object(type(cat), 'ellipse_cxy', new_callable=lambda: property(lambda _self: cxy_val))): kr = cat._measured_kron_radius assert np.isfinite(kr[0]) def test_measured_kron_radius_circular_no_min_radius(gauss_101_data): """ Test _measured_kron_radius returns NaN for the circular fallback when kron_params has only 2 elements (no minimum circular radius). """ data, segm = gauss_101_data cat = SourceCatalog(data, segm, kron_params=(2.5, 1.4)) zero = np.array([0.0]) << u.pix cxx_val = np.array([1.0]) / (u.pix * u.pix) cyy_val = np.array([1.0]) / (u.pix * u.pix) cxy_val = np.array([0.0]) / (u.pix * u.pix) with (patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property(lambda _self: zero)), patch.object(type(cat), 'semiminor_axis', new_callable=lambda: property(lambda _self: zero)), patch.object(type(cat), 'ellipse_cxx', new_callable=lambda: property(lambda _self: cxx_val)), patch.object(type(cat), 'ellipse_cyy', new_callable=lambda: property(lambda _self: cyy_val)), patch.object(type(cat), 'ellipse_cxy', new_callable=lambda: property(lambda _self: cxy_val))): kr = cat._measured_kron_radius assert np.all(np.isnan(kr)) astropy-photutils-3322558/photutils/segmentation/tests/test_core.py000066400000000000000000001636661517052111400257130ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import sys from collections import defaultdict from unittest.mock import PropertyMock, patch import numpy as np import pytest from astropy.utils import lazyproperty from astropy.utils.exceptions import (AstropyDeprecationWarning, 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_REGIONS, HAS_SHAPELY) @pytest.fixture def segm_data(): """ Reusable 6x6 segmentation data array. """ return 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]]) class TestSegmentationImage: @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data self.segm = SegmentationImage(self.data) def test_array(self): """ Test array. """ assert_allclose(self.segm.data, self.segm.__array__()) def test_copy(self): """ Test copy. """ 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): """ Test slicing. """ segm2 = self.segm[1:5, 2:5] assert segm2.shape == (4, 3) assert_equal(segm2.labels, [3, 5]) assert segm2.data.sum() == 16 match = 'is not a valid 2D slice object' with pytest.raises(TypeError, match=match): self.segm[1] with pytest.raises(TypeError, match=match): self.segm[1:10] match = 'The sliced result is empty' with pytest.raises(ValueError, match=match): self.segm[1:1, 2:4] with pytest.raises(ValueError, match=match): self.segm[5:2, 0:3] def test_labels_via_raw_slices(self): """ Test that labels can be derived from _raw_slices when that lazyproperty is already cached. """ segm = SegmentationImage(self.data.copy()) # Force _raw_slices to be cached _ = segm._raw_slices # Remove labels from instance dict to force the _raw_slices path del segm.__dict__['labels'] labels = segm.labels assert_equal(labels, [1, 3, 4, 5, 7]) def test_data_all_zeros(self): """ Test data all zeros. """ 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 match = 'Cannot relabel a segmentation image with no non-zero labels' with pytest.warns(AstropyUserWarning, match=match): segm.relabel_consecutive() def test_data_reassignment(self): """ Test data reassignment. """ segm = SegmentationImage(self.data.copy()) segm.data = self.data[0:3, :].copy() assert_equal(segm.labels, [1, 3, 4]) def test_invalid_data(self): """ Test invalid data. """ # Is float dtype data = np.zeros((3, 3), dtype=float) match = 'data must have integer type' with pytest.raises(TypeError, match=match): SegmentationImage(data) # Contains a negative value data = np.arange(-1, 8).reshape(3, 3).astype(int) match = 'The segmentation image cannot contain negative integers' with pytest.raises(ValueError, match=match): SegmentationImage(data) # Is not ndarray data = [[1, 1], [0, 1]] match = 'Input data must be a numpy array' with pytest.raises(TypeError, match=match): SegmentationImage(data) @pytest.mark.parametrize('label', [0, -1, 2]) def test_invalid_label(self, label): """ Test invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.check_label(label) with pytest.raises(ValueError, match=match): self.segm.check_labels(label) def test_invalid_label_array(self): """ Test invalid label array. """ match = 'are invalid' with pytest.raises(ValueError, match=match): self.segm.check_labels([0, -1, 2]) def test_data_masked(self): """ Test data_masked. """ assert isinstance(self.segm.data_masked, np.ma.MaskedArray) assert np.ma.count(self.segm.data_masked) == 18 assert np.ma.count_masked(self.segm.data_masked) == 18 def test_segments(self): """ Test segments. """ 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_masked.shape == self.segm.segments[0].data.shape) assert (self.segm.segments[0].data_masked.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): """ Test repr str. """ assert repr(self.segm) == str(self.segm) props = ['shape', 'n_labels'] for prop in props: assert f'{prop}:' in repr(self.segm) def test_segment_repr_str(self): """ Test segment repr str. """ props = ['label', 'slices', 'area'] for prop in props: assert f'{prop}:' in repr(self.segm.segments[0]) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_segment_repr_svg_with_polygon(self): """ Test _repr_svg_ returns SVG string when polygon is present. """ segment = self.segm.segments[0] assert segment.polygon is not None svg = segment._repr_svg_() assert svg is not None assert isinstance(svg, str) def test_segment_repr_svg_without_polygon(self): """ Test _repr_svg_ returns None when polygon is None. """ segment = self.segm.segments[0] # Create a Segment without a polygon seg_no_poly = Segment(self.segm.data, segment.label, segment.slices, segment.bbox, segment.area, polygon=None) assert seg_no_poly._repr_svg_() is None def test_segment_array(self): """ Test that Segment.__array__ returns the correct labeled cutout. """ segment = self.segm.segments[0] # label=1 arr = segment.__array__() assert arr.shape == segment.data.shape assert_allclose(arr, segment.data) # Only the label and 0 should appear assert set(np.unique(arr)) <= {0, segment.label} def test_segment_data(self): """ Test segment data. """ 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): """ Test segment make cutout. """ 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): """ Test segment make cutout input. """ match = 'data must have the same shape as the segmentation array' with pytest.raises(ValueError, match=match): self.segm.segments[0].make_cutout(np.arange(10)) def test_segment_no_full_array_reference(self): """ Test that Segment stores only a cutout copy, not a reference to the full segmentation array. """ large = np.zeros((1000, 1000), dtype=np.int32) large[10:20, 10:20] = 1 segm = SegmentationImage(large) segment = segm.segments[0] # The cutout should be much smaller than the full array assert segment._segment_data_cutout.shape == (10, 10) assert segment._segment_data_shape == (1000, 1000) # Delete the SegmentationImage; the segment should not keep # the full array alive full_refcount = sys.getrefcount(large) del segm assert sys.getrefcount(large) < full_refcount def test_shape(self): """ Test that the shape lazyproperty returns the correct shape. """ assert self.segm.shape == (6, 6) def test_lazyproperties_class_cache(self): """ Test that _lazyproperties is cached on the class and shared across instances. """ segm2 = SegmentationImage(self.data.copy()) result1 = self.segm._lazyproperties result2 = segm2._lazyproperties assert result1 is result2 def test_labels(self): """ Test labels. """ assert_allclose(self.segm.labels, [1, 3, 4, 5, 7]) def test_n_labels(self): """ Test n_labels. """ assert self.segm.n_labels == 5 def test_max_label(self): """ Test max label. """ assert self.segm.max_label == 7 def test_get_index_invalid(self): """ Test get_index with an invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_index(999) with pytest.raises(ValueError, match=match): self.segm.get_index(0) def test_get_indices_invalid(self): """ Test get_indices with invalid labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_indices([1, 999]) match = 'are invalid' with pytest.raises(ValueError, match=match): self.segm.get_indices([999, 888]) def test_areas(self): """ Test areas. """ 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): """ Test background area. """ assert self.segm.background_area == 18 def test_is_consecutive(self): """ Test is consecutive. """ assert not self.segm.is_consecutive data = np.array([[2, 2, 0], [0, 3, 3], [0, 0, 4]], dtype=np.int32) segm = SegmentationImage(data) dtype = segm.data.dtype assert not segm.is_consecutive # does not start with label=1 segm.relabel_consecutive(start_label=1) assert segm.is_consecutive assert segm.data.dtype == dtype def test_missing_labels(self): """ Test missing labels. """ assert_allclose(self.segm.missing_labels, [2, 6]) def test_missing_labels_dtype(self): """ Test that missing_labels dtype matches the segmentation image label dtype. """ assert self.segm.missing_labels.dtype == self.segm.labels.dtype def test_missing_labels_empty(self): """ Test that missing_labels dtype matches the segmentation image label dtype when there are no labels. """ segm = SegmentationImage(np.zeros((5, 5), dtype=np.int32)) assert_equal(segm.missing_labels, []) assert segm.missing_labels.dtype == segm.data.dtype segm = SegmentationImage(np.zeros((5, 5), dtype=int)) assert_equal(segm.missing_labels, []) assert segm.missing_labels.dtype == segm.data.dtype def test_check_labels(self): """ Test check labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.check_label(2) with pytest.raises(ValueError, match=match): self.segm.check_labels([2]) match = 'are invalid' with pytest.raises(ValueError, match=match): self.segm.check_labels([2, 6]) @pytest.mark.parametrize(('label', 'expected'), [ (1, (0, 1, 0, 2)), (3, (2, 3, 2, 4)), (4, (0, 2, 4, 6)), (5, (3, 6, 3, 6)), (7, (3, 6, 0, 2)), ]) def test_bbox_values(self, label, expected): """ Test that bbox returns correct bounding box coordinates for each label. """ from photutils.aperture import BoundingBox idx = self.segm.get_index(label) bbox = self.segm.bbox[idx] assert isinstance(bbox, BoundingBox) assert (bbox.iymin, bbox.iymax, bbox.ixmin, bbox.ixmax) == expected def test_bbox_1d(self): """ Test bbox 1d. """ segm = SegmentationImage(np.array([0, 0, 1, 1, 0, 2, 2, 0])) match = "The 'bbox' attribute requires a 2D segmentation image" with pytest.raises(ValueError, match=match): _ = segm.bbox @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_reset_cmap(self): """ Test reset cmap. """ 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): """ Test make cmap. """ 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): """ Test make cmap alpha. """ cmap = self.segm.make_cmap(background_color=color) assert_allclose(cmap.colors[0], (0, 0, 0, alpha)) def test_reassign_labels(self): """ Test reassign labels. """ 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.n_labels == len(segm.slices) - segm.slices.count(None) @pytest.mark.parametrize('start_label', [1, 5]) def test_relabel_consecutive(self, start_label): """ Test relabel consecutive. """ 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.n_labels == 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): """ Test relabel consecutive start invalid. """ segm = SegmentationImage(self.data.copy()) match = 'start_label must be > 0' with pytest.raises(ValueError, match=match): segm.relabel_consecutive(start_label=start_label) def test_keep_labels(self): """ Test keep labels. """ 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): """ Test keep labels relabel. """ 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): """ Test remove labels. """ 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) dtype = np.int32 data2 = ref_data.copy().astype(dtype) segm2 = SegmentationImage(data2) segm2.remove_label(1) assert segm2.data.dtype == dtype def test_remove_labels_relabel(self): """ Test remove labels relabel. """ 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): """ Test remove border labels. """ 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): """ Test remove border labels border width. """ segm = SegmentationImage(self.data.copy()) match = 'border_width must be smaller than half the array size' with pytest.raises(ValueError, match=match): segm.remove_border_labels(border_width=3) def test_remove_border_labels_no_remaining_segments(self): """ Test remove border labels no remaining segments. """ 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.n_labels == 0 def test_remove_masked_labels(self): """ Test remove masked labels. """ 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): """ Test remove masked labels without partial overlap. """ 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): """ Test remove masked segments mask shape. """ segm = SegmentationImage(np.ones((5, 5), dtype=int)) mask = np.zeros((3, 3), dtype=bool) match = 'mask must have the same shape as the segmentation array' with pytest.raises(ValueError, match=match): segm.remove_masked_labels(mask) def test_make_source_mask(self): """ Test make source mask. """ 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): """ Test imshow. """ from matplotlib.image import AxesImage axim = self.segm.imshow(figsize=(5, 5)) assert isinstance(axim, AxesImage) axim, _ = 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): """ Test polygons. """ from shapely import Polygon polygons = self.segm.polygons assert len(polygons) == self.segm.n_labels 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') def test_polygon_hole(self): """ Test polygon hole. """ data = np.zeros((11, 11), dtype=int) data[3:8, 3:8] = 10 data[5, 5] = 0 # hole segm = SegmentationImage(data) polygons = segm.polygons assert len(polygons) == 1 verts = np.array(polygons[0].exterior.coords) expected_verts = np.array([[2.5, 2.5], [2.5, 7.5], [7.5, 7.5], [7.5, 2.5], [2.5, 2.5]]) assert_equal(verts, expected_verts) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_regions(self): """ Test regions. """ from regions import PolygonPixelRegion, Regions regions = self.segm.to_regions() assert isinstance(regions, Regions) assert isinstance(regions[0], PolygonPixelRegion) assert len(regions) == self.segm.n_labels segm = self.segm.copy() segm.reassign_labels(labels=4, new_label=1) regions = segm.to_regions(group=True) assert isinstance(regions, list) assert isinstance(regions[0], Regions) assert isinstance(regions[1], PolygonPixelRegion) data = np.zeros((5, 5), dtype=int) data[2, 2] = 10 segm = SegmentationImage(data) regions = segm.to_regions() assert len(regions) == 1 verts = regions[0].vertices expected_xverts = np.array([1.5, 1.5, 2.5, 2.5]) expected_yverts = np.array([1.5, 2.5, 2.5, 1.5]) assert_equal(verts.x, expected_xverts) assert_equal(verts.y, expected_yverts) @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): """ Test patches. """ from matplotlib.patches import PathPatch patches = self.segm.to_patches(edgecolor='blue') assert isinstance(patches[0], PathPatch) 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], PathPatch) 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], PathPatch) patches = self.segm.plot_patches(labels=(4, 7)) assert len(patches) == 2 assert isinstance(patches, list) assert isinstance(patches[0], PathPatch) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_REGIONS, reason='regions 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_corners(self): """ Test that patches are generated for "invalid" Shapely polygons. This occurs when two pixels within a segment intersect only at a corner. """ data = np.zeros((10, 10), dtype=np.uint32) data[5, 5] = 1 data[4, 4] = 1 data[3, 3] = 1 segm = SegmentationImage(data) assert segm.n_labels == 1 assert len(segm.segments) == 1 assert len(segm.polygons) == 1 assert len(segm.to_patches()) == 1 assert len(segm.to_regions()) == 1 @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_polygons_complex(self): """ Test polygons, patches, and regions for segments that have holes and/or are non-contiguous. """ from matplotlib.patches import PathPatch from regions import PolygonPixelRegion, Regions from shapely import MultiPolygon, Polygon image = np.zeros((150, 150), dtype=np.uint32) # Polygon with one hole image[10:90, 10:90] = 1 image[30:70, 30:70] = 0 # Simple Polygon image[15:25, 110:140] = 2 image[25:55, 110:120] = 2 # MultiPolygon image[100:120, 20:40] = 3 image[105:120, 45:55] = 3 image[114:130, 60:80] = 3 # Single polygon with multiple holes image[85:145, 95:145] = 4 image[105:115, 105:115] = 0 image[125:135, 125:138] = 0 image[120:125, 100:120] = 0 # Simple Polygon image[5, 125:145] = 5 segm = SegmentationImage(image) polygons = segm.polygons assert len(polygons) == 5 for polygon in polygons: assert isinstance(polygon, (Polygon, MultiPolygon)) assert isinstance(polygons[2], MultiPolygon) segments = segm.segments assert len(segments) == 5 assert isinstance(segments[0], Segment) patches = segm.to_patches() assert len(patches) == 5 for patch_ in patches: assert isinstance(patch_, PathPatch) regions = segm.to_regions() assert len(regions) == 7 assert isinstance(regions, Regions) for region in regions: assert isinstance(region, PolygonPixelRegion) regions = segm.to_regions(group=True) assert len(regions) == 5 assert isinstance(regions, list) for region in regions: assert isinstance(region, (Regions, PolygonPixelRegion)) assert isinstance(regions[2], Regions) # Combine all segments into a single segment; # now have multipolygon objects, some with holes segm.reassign_labels(segm.labels, new_label=4) polygons = segm.polygons assert len(polygons) == 1 assert isinstance(polygons[0], MultiPolygon) segments = segm.segments assert len(segments) == 1 patches = segm.to_patches() assert len(patches) == 1 assert isinstance(patches[0], PathPatch) regions = segm.to_regions() assert len(regions) == 7 assert isinstance(regions, Regions) assert isinstance(regions[0], PolygonPixelRegion) regions = segm.to_regions(group=True) assert len(regions) == 1 assert isinstance(regions, list) assert isinstance(regions[0], Regions) assert len(regions[0]) == 7 def test_deblended_labels(self): """ Test deblended labels. """ data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 7, 8, 0, 0], [6, 0, 0, 0, 0, 5], [6, 6, 0, 5, 5, 5], [6, 6, 0, 0, 5, 5]]) segm = SegmentationImage(data) segm0 = segm.copy() assert segm0._deblend_label_map == {} assert segm0.deblended_labels.size == 0 assert segm0.deblended_label_to_parent == {} assert segm0.parent_to_deblended_labels == {} deblend_map = {2: np.array([5, 6]), 3: np.array([7, 8])} segm._deblend_label_map = deblend_map assert_equal(segm._deblend_label_map, deblend_map) assert_equal(segm.deblended_labels, [5, 6, 7, 8]) assert segm.deblended_label_to_parent == {5: 2, 6: 2, 7: 3, 8: 3} assert segm.parent_to_deblended_labels == deblend_map segm2 = segm.copy() segm2.relabel_consecutive() deblend_map = {2: [3, 4], 3: [5, 6]} assert_equal(segm2._deblend_label_map, deblend_map) assert_equal(segm2.deblended_labels, [3, 4, 5, 6]) assert segm2.deblended_label_to_parent == {3: 2, 4: 2, 5: 3, 6: 3} assert_equal(segm2.parent_to_deblended_labels, deblend_map) segm3 = segm.copy() segm3.relabel_consecutive(start_label=10) deblend_map = {2: [12, 13], 3: [14, 15]} assert_equal(segm3._deblend_label_map, deblend_map) assert_equal(segm3.deblended_labels, [12, 13, 14, 15]) assert segm3.deblended_label_to_parent == {12: 2, 13: 2, 14: 3, 15: 3} assert_equal(segm3.parent_to_deblended_labels, deblend_map) segm4 = segm.copy() segm4.reassign_label(5, 50) segm4.reassign_label(7, 70) deblend_map = {2: [50, 6], 3: [70, 8]} assert_equal(segm4._deblend_label_map, deblend_map) assert_equal(segm4.deblended_labels, [6, 8, 50, 70]) assert segm4.deblended_label_to_parent == {50: 2, 6: 2, 70: 3, 8: 3} assert_equal(segm4.parent_to_deblended_labels, deblend_map) segm5 = segm.copy() segm5.reassign_label(5, 50, relabel=True) deblend_map = {2: [6, 3], 3: [4, 5]} assert_equal(segm5._deblend_label_map, deblend_map) assert_equal(segm5.deblended_labels, [3, 4, 5, 6]) assert segm5.deblended_label_to_parent == {6: 2, 3: 2, 4: 3, 5: 3} assert_equal(segm5.parent_to_deblended_labels, deblend_map) class CustomSegm(SegmentationImage): @lazyproperty def value(self): return np.median(self.data) def test_subclass(segm_data): """ Test that cached properties are reset in SegmentationImage subclasses. """ segm = CustomSegm(segm_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__) == 3 assert_equal(segm.areas, [1, 2, 2, 4]) def test_segments_no_rasterio(segm_data, monkeypatch): """ Test that segments property works without rasterio/shapely by creating Segment objects without polygon info. """ import photutils.segmentation.core as core_mod monkeypatch.setattr(core_mod, 'HAS_RASTERIO', False) segm = SegmentationImage(segm_data) segments = segm.segments assert len(segments) == segm.n_labels assert isinstance(segments[0], Segment) # Without rasterio, segments should not have polygon attribute set assert segments[0].polygon is None def test_reassign_labels_empty(segm_data): """ Test reassign_labels with an empty labels array returns early. """ segm = SegmentationImage(segm_data.copy()) original = segm.data.copy() segm.reassign_labels(labels=[], new_label=99) assert_equal(segm.data, original) def test_keep_label(segm_data): """ Test that keep_label delegates to keep_labels correctly. """ segm = SegmentationImage(segm_data.copy()) segm.keep_label(3, relabel=True) assert segm.n_labels == 1 assert_equal(segm.labels, [1]) # Only label 3 (now relabeled to 1) should remain assert segm.data[2, 2] == 1 assert segm.data[0, 0] == 0 @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_geojson_polygons_int64_dtype(): """ Test _geojson_polygons with int64 dtype data that fits in int32. """ data = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.int64) segm = SegmentationImage(data) polygons = segm._geojson_polygons assert 1 in polygons assert len(polygons[1]) >= 1 @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_geojson_polygons_int64_out_of_range(): """ Test _geojson_polygons raises ValueError when int64 values exceed int32 range. """ data = np.array([[0, 0, 0], [0, np.iinfo(np.int32).max + 1, 0], [0, 0, 0]], dtype=np.int64) segm = SegmentationImage.__new__(SegmentationImage) segm._data = data match = 'values outside the safe np.int32 range' with pytest.raises(ValueError, match=match): _ = segm._geojson_polygons @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_geojson_polygons_label_mismatch(): """ Test _geojson_polygons raises ValueError when polygon labels don't match segmentation labels. """ data = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.int32) segm = SegmentationImage(data) # Mock rasterio.features.shapes to return a wrong label def fake_shapes(_data, **_kwargs): from shapely import Polygon from shapely.geometry import mapping poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) yield mapping(poly), 999 # wrong label with patch('rasterio.features.shapes', fake_shapes): match = 'labels do not match' with pytest.raises(ValueError, match=match): _ = segm._geojson_polygons @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_polygons_empty_geopolys(): """ Test polygons property raises ValueError when _geojson_polygons returns an empty polygon list for a label. """ data = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.int32) segm = SegmentationImage(data) # Mock _geojson_polygons to return a dict with an empty list empty_dict = defaultdict(list) empty_dict[1] = [] # label 1 has no polygons with patch.object(type(segm), '_geojson_polygons', new_callable=PropertyMock, return_value=empty_dict): match = 'Could not create a polygon for label' with pytest.raises(ValueError, match=match): _ = segm.polygons @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_convert_shapely_to_pathpatch_empty(): """ Test _convert_shapely_to_pathpatch returns None for empty geometry. """ from shapely import Point data = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.int32) segm = SegmentationImage(data) # An empty geometry empty_geom = Point() # empty point result = segm._convert_shapely_to_pathpatch(empty_geom) assert result is None @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_convert_shapely_to_pathpatch_empty_geom_collection(): """ Test _convert_shapely_to_pathpatch returns None for a non-Polygon geometry type that yields no polygons (empty all_vertices). """ from shapely import GeometryCollection data = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.int32) segm = SegmentationImage(data) # A GeometryCollection that is not empty according to the # is_empty attribute but contains no polygon geometries. # We use a mock to make is_empty=False but geoms=[] geom = GeometryCollection() with patch.object(type(geom), 'is_empty', new_callable=PropertyMock, return_value=False): result = segm._convert_shapely_to_pathpatch(geom) assert result is None @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow_map_too_many_labels(): """ Test imshow_map warns when there are more labels than max_labels. """ # Create segmentation with many labels data = np.arange(1, 31, dtype=int).reshape(5, 6) segm = SegmentationImage(data) match = 'The colorbar was not plotted' with pytest.warns(AstropyUserWarning, match=match): _im, cbar_info = segm.imshow_map(max_labels=5) assert cbar_info is None @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow_map_cbar_labelsize(segm_data): """ Test imshow_map with cbar_labelsize parameter. """ segm = SegmentationImage(segm_data) _im, cbar_info = segm.imshow_map(cbar_labelsize=8) assert cbar_info is not None class TestGetSegment: """ Tests for get_segment and get_segments methods. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data self.segm = SegmentationImage(self.data) def test_get_segment_basic(self): """ Test that get_segment returns a valid Segment for each label. """ for label in self.segm.labels: seg = self.segm.get_segment(label) assert isinstance(seg, Segment) assert seg.label == label def test_get_segment_matches_segments(self): """ Test that get_segment returns the same data as indexing into the segments property. """ for idx, label in enumerate(self.segm.labels): seg_new = self.segm.get_segment(label) seg_old = self.segm.segments[idx] assert seg_new.label == seg_old.label assert seg_new.slices == seg_old.slices assert seg_new.area == seg_old.area assert seg_new.bbox == seg_old.bbox assert_equal(seg_new.data, seg_old.data) def test_get_segment_invalid_label(self): """ Test that get_segment raises ValueError for an invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_segment(99) with pytest.raises(ValueError, match=match): self.segm.get_segment(0) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_get_segment_polygon_matches(self): """ Test that get_segment produces the same polygon as the segments property. """ for idx, label in enumerate(self.segm.labels): seg_new = self.segm.get_segment(label) seg_old = self.segm.segments[idx] assert seg_new.polygon is not None assert seg_new.polygon.equals(seg_old.polygon) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_get_segment_polygon_multipolygon(self): """ Test that get_segment returns a MultiPolygon for a non-contiguous segment. """ from shapely import MultiPolygon data = np.zeros((10, 10), dtype=int) data[1:3, 1:3] = 1 data[7:9, 7:9] = 1 segm = SegmentationImage(data) seg = segm.get_segment(1) assert isinstance(seg.polygon, MultiPolygon) def test_get_segment_no_rasterio(self, monkeypatch): """ Test that get_segment returns polygon=None without rasterio/shapely. """ import photutils.segmentation.core as core_mod monkeypatch.setattr(core_mod, 'HAS_RASTERIO', False) segm = SegmentationImage(self.data.copy()) seg = segm.get_segment(1) assert isinstance(seg, Segment) assert seg.polygon is None def test_get_segments_basic(self): """ Test that get_segments returns a list of Segments in the correct order. """ labels = [7, 3, 1] segs = self.segm.get_segments(labels) assert len(segs) == 3 assert [s.label for s in segs] == labels def test_get_segments_single_label(self): """ Test that get_segments works with a scalar label. """ segs = self.segm.get_segments(5) assert len(segs) == 1 assert segs[0].label == 5 def test_get_segments_invalid_label(self): """ Test that get_segments raises ValueError for invalid labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_segments(99) match = 'are invalid' with pytest.raises(ValueError, match=match): self.segm.get_segments([1, 99, 200]) def test_get_segment_multiple_labels(self): """ Test that get_segment raises TypeError for multiple labels. """ match = 'label must be a scalar value' with pytest.raises(TypeError, match=match): self.segm.get_segment([1, 3]) with pytest.raises(TypeError, match=match): self.segm.get_segment(np.array([1, 3])) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_get_segment_polygon_empty_geopolys(self): """ Test that _make_polygon_for_label returns None when rasterio returns no polygons for the target label. """ def fake_shapes(_data, **_kwargs): # Return no polygons at all return iter([]) with patch('rasterio.features.shapes', fake_shapes): seg = self.segm.get_segment(1) assert seg.polygon is None def test_get_segments_matches_segments(self): """ Test that get_segments results match the segments property. """ labels = list(self.segm.labels) segs = self.segm.get_segments(labels) for seg_new, seg_old in zip(segs, self.segm.segments, strict=True): assert seg_new.label == seg_old.label assert seg_new.slices == seg_old.slices assert seg_new.area == seg_old.area assert seg_new.bbox == seg_old.bbox def test_get_segments_label_dtype(self): """ Test that segment label dtype matches the segmentation image label dtype. """ labels = [1, 5] segs = self.segm.get_segments(labels) expected_dtype = self.segm.labels.dtype for seg in segs: assert seg.label.dtype == expected_dtype @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') class TestGetPolygon: """ Tests for get_polygon and get_polygons methods. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data self.segm = SegmentationImage(self.data) def test_get_polygon_basic(self): """ Test that get_polygon returns a Shapely geometry for each label. """ from shapely import MultiPolygon, Polygon for label in self.segm.labels: poly = self.segm.get_polygon(label) assert isinstance(poly, (Polygon, MultiPolygon)) def test_get_polygon_matches_polygons(self): """ Test that get_polygon matches the polygons property. """ for idx, label in enumerate(self.segm.labels): poly_new = self.segm.get_polygon(label) poly_old = self.segm.polygons[idx] assert poly_new.equals(poly_old) def test_get_polygon_invalid_label(self): """ Test that get_polygon raises ValueError for an invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_polygon(99) def test_get_polygon_multiple_labels(self): """ Test that get_polygon raises TypeError for non-scalar input. """ match = 'label must be a scalar value' with pytest.raises(TypeError, match=match): self.segm.get_polygon([1, 3]) def test_get_polygon_no_rasterio(self, monkeypatch): """ Test that get_polygon returns None without rasterio/shapely. """ import photutils.segmentation.core as core_mod monkeypatch.setattr(core_mod, 'HAS_RASTERIO', False) segm = SegmentationImage(self.data.copy()) assert segm.get_polygon(1) is None def test_get_polygons_basic(self): """ Test that get_polygons returns a list in the correct order. """ from shapely import MultiPolygon, Polygon labels = [7, 3, 1] polys = self.segm.get_polygons(labels) assert len(polys) == 3 for poly in polys: assert isinstance(poly, (Polygon, MultiPolygon)) def test_get_polygons_matches_polygons(self): """ Test that get_polygons results match the polygons property. """ labels = list(self.segm.labels) polys_new = self.segm.get_polygons(labels) polys_old = self.segm.polygons for p_new, p_old in zip(polys_new, polys_old, strict=True): assert p_new.equals(p_old) def test_get_polygons_invalid_label(self): """ Test that get_polygons raises ValueError for invalid labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_polygons(99) def test_get_polygon_empty_geopolys(self): """ Test that get_polygon returns None when rasterio yields no polygons. """ def fake_shapes(_data, **_kwargs): return iter([]) with patch('rasterio.features.shapes', fake_shapes): poly = self.segm.get_polygon(1) assert poly is None def test_make_polygon_none_slice(self): """ Test that _make_polygon returns None when slc is None. """ assert self.segm._make_polygon(99, None) is None @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') class TestGetPatch: """ Tests for get_patch and get_patches methods. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data self.segm = SegmentationImage(self.data) def test_get_patch_basic(self): """ Test that get_patch returns a PathPatch for each label. """ from matplotlib.patches import PathPatch for label in self.segm.labels: p = self.segm.get_patch(label) assert isinstance(p, PathPatch) def test_get_patch_kwargs(self): """ Test that get_patch passes kwargs to PathPatch. """ p = self.segm.get_patch(1, edgecolor='red', facecolor='blue') assert p.get_edgecolor()[0] == pytest.approx(1.0) # red channel assert p.get_facecolor()[2] == pytest.approx(1.0) # blue channel def test_get_patch_invalid_label(self): """ Test that get_patch raises ValueError for an invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_patch(99) def test_get_patch_multiple_labels(self): """ Test that get_patch raises TypeError for non-scalar input. """ match = 'label must be a scalar value' with pytest.raises(TypeError, match=match): self.segm.get_patch([1, 3]) def test_get_patches_basic(self): """ Test that get_patches returns a list in the correct order. """ from matplotlib.patches import PathPatch labels = [7, 3, 1] patches = self.segm.get_patches(labels) assert len(patches) == 3 for p in patches: assert isinstance(p, PathPatch) def test_get_patches_matches_to_patches(self): """ Test that get_patches results have the same path vertices as to_patches for the same labels. """ labels = list(self.segm.labels) patches_new = self.segm.get_patches(labels) patches_old = self.segm.to_patches() for p_new, p_old in zip(patches_new, patches_old, strict=True): assert_equal(p_new.get_path().vertices, p_old.get_path().vertices) def test_get_patches_invalid_label(self): """ Test that get_patches raises ValueError for invalid labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_patches(99) @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_REGIONS, reason='regions is required') class TestGetRegion: """ Tests for get_region and get_regions methods. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data self.segm = SegmentationImage(self.data) def test_get_region_basic(self): """ Test that get_region returns a PolygonPixelRegion for each label. """ from regions import PolygonPixelRegion for label in self.segm.labels: region = self.segm.get_region(label) assert isinstance(region, PolygonPixelRegion) assert region.meta['label'] == label def test_get_region_matches_to_regions(self): """ Test that get_region matches the to_regions output. """ old_regions = self.segm.to_regions() label_to_old = {} for r in old_regions: lbl = r.meta['label'] label_to_old.setdefault(lbl, r) for label in self.segm.labels: r_new = self.segm.get_region(label) r_old = label_to_old[label] assert_equal(r_new.vertices.x, r_old.vertices.x) assert_equal(r_new.vertices.y, r_old.vertices.y) def test_get_region_invalid_label(self): """ Test that get_region raises ValueError for an invalid label. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_region(99) def test_get_region_multiple_labels(self): """ Test that get_region raises TypeError for non-scalar input. """ match = 'label must be a scalar value' with pytest.raises(TypeError, match=match): self.segm.get_region([1, 3]) def test_get_region_multipolygon(self): """ Test that get_region returns a Regions object for a non-contiguous (MultiPolygon) segment. """ from regions import Regions data = np.zeros((10, 10), dtype=int) data[1:3, 1:3] = 1 data[7:9, 7:9] = 1 segm = SegmentationImage(data) region = segm.get_region(1) assert isinstance(region, Regions) def test_get_regions_basic(self): """ Test that get_regions returns a list in the correct order. """ from regions import PolygonPixelRegion labels = [7, 3, 1] regions = self.segm.get_regions(labels) assert len(regions) == 3 for region, label in zip(regions, labels, strict=True): assert isinstance(region, PolygonPixelRegion) assert region.meta['label'] == label def test_get_regions_invalid_label(self): """ Test that get_regions raises ValueError for invalid labels. """ match = 'is invalid' with pytest.raises(ValueError, match=match): self.segm.get_regions(99) def test_to_regions_visual_kwargs(self): """ Test that to_regions passes visual kwargs to the regions. """ from regions import PolygonPixelRegion regions = self.segm.to_regions(edgecolor='red', linewidth=2) for region in regions: assert isinstance(region, PolygonPixelRegion) assert region.visual['edgecolor'] == 'red' assert region.visual['linewidth'] == 2 def test_get_region_visual_kwargs(self): """ Test that get_region passes visual kwargs to the region. """ region = self.segm.get_region(1, edgecolor='blue', linewidth=3) assert region.visual['edgecolor'] == 'blue' assert region.visual['linewidth'] == 3 def test_get_regions_visual_kwargs(self): """ Test that get_regions passes visual kwargs to the regions. """ regions = self.segm.get_regions([1, 3], color='green') for region in regions: assert region.visual['color'] == 'green' def test_to_regions_no_visual_kwargs(self): """ Test that to_regions with no kwargs has no visual attributes. """ regions = self.segm.to_regions() for region in regions: assert not region.visual def test_segment_deprecations(segm_data): segment_map = SegmentationImage(segm_data) segments = segment_map.segments match = 'attribute was deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): _ = segments[0].data_ma astropy-photutils-3322558/photutils/segmentation/tests/test_deblend.py000066400000000000000000000504151517052111400263430ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deblend module. """ from unittest.mock import patch 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 import (SegmentationImage, deblend_sources, detect_sources) from photutils.segmentation.deblend import (_DeblendParams, _SingleSourceDeblender) from photutils.utils._optional_deps import HAS_SKIMAGE @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') class TestDeblendSources: @pytest.fixture(autouse=True) def setup(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.n_pixels = 5 self.segm = detect_sources(self.data, self.threshold, self.n_pixels) self.segm3 = detect_sources(self.data3, self.threshold, self.n_pixels) @pytest.mark.parametrize('mode', ['exponential', 'linear', 'sinh']) def test_deblend_sources(self, mode): """ Test deblend sources. """ result = deblend_sources(self.data, self.segm, self.n_pixels, mode=mode, progress_bar=False) assert result.data.dtype == self.segm.data.dtype if mode == 'linear': # Test multiprocessing result2 = deblend_sources(self.data, self.segm, self.n_pixels, mode=mode, progress_bar=False, n_processes=2) assert_equal(result.data, result2.data) assert result2.data.dtype == self.segm.data.dtype assert result.n_labels == 2 assert result.n_labels == 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)) assert_equal(result.parent_to_deblended_labels, {1: [1, 2]}) def test_deblend_multiple_sources(self): """ Test deblend multiple sources. """ 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.n_pixels) result = deblend_sources(data, segm, self.n_pixels, progress_bar=False) assert result.n_labels == 6 assert result.n_labels == 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): """ Test deblend multiple sources with neighbor. """ 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.n_pixels) result = deblend_sources(data, segm, self.n_pixels, progress_bar=False) assert result.n_labels == 3 def test_deblend_labels(self): """ Test deblend labels. """ 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.n_pixels) result = deblend_sources(data, segm, self.n_pixels, labels=1, progress_bar=False) assert result.n_labels == 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): """ Test deblend contrast. """ 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)) n_pixels = 5 segm = detect_sources(data, 1.0, n_pixels) segm2 = deblend_sources(data, segm, n_pixels, mode='linear', n_levels=32, contrast=contrast, progress_bar=False) assert segm2.n_labels == nlabels def test_deblend_contrast_levels(self): """ Test deblend contrast levels. 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)) n_pixels = 5 segm = detect_sources(data, 1.0, n_pixels) for contrast in np.arange(1, 11) / 10.0: segm3 = deblend_sources(data, segm, n_pixels, mode='linear', n_levels=32, contrast=contrast, progress_bar=False) assert segm3.n_labels >= 1 def test_deblend_connectivity(self): """ Test deblend connectivity. """ 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.n_labels == 9 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) assert segm2.n_labels == 9 segm = detect_sources(data, 0.1, 1, connectivity=8) assert segm.n_labels == 1 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=8, progress_bar=False) assert segm2.n_labels == 3 match = 'Deblending failed for source' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) def test_deblend_label_assignment(self): """ 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)) n_pixels = 5 segm1 = detect_sources(data, 5.0, n_pixels) segm2 = deblend_sources(data, segm1, n_pixels, mode='linear', n_levels=32, contrast=0.3, progress_bar=False) assert segm2.n_labels == 4 @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_sources_norelabel(self, mode): """ Test deblend sources norelabel. """ result = deblend_sources(self.data, self.segm, self.n_pixels, mode=mode, relabel=False, progress_bar=False) assert result.n_labels == 2 assert_equal(result.labels, [2, 3]) assert_equal(result.parent_to_deblended_labels, {1: [2, 3]}) assert len(result.slices) <= result.max_label assert len(result.slices) == result.n_labels assert_allclose(np.nonzero(self.segm), np.nonzero(result)) @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_three_sources(self, mode): """ Test deblend three sources. """ result = deblend_sources(self.data3, self.segm3, self.n_pixels, mode=mode, progress_bar=False) assert result.n_labels == 3 assert_allclose(np.nonzero(self.segm3), np.nonzero(result)) def test_segmentation_image(self): """ Test segmentation image. """ segm_wrong = np.ones((2, 2), dtype=int) # ndarray match = 'segmentation_image must be a SegmentationImage' with pytest.raises(TypeError, match=match): deblend_sources(self.data, segm_wrong, self.n_pixels, progress_bar=False) segm_wrong = SegmentationImage(segm_wrong) # wrong shape match = 'segmentation_image must have the same shape as data' with pytest.raises(ValueError, match=match): deblend_sources(self.data, segm_wrong, self.n_pixels, progress_bar=False) def test_invalid_n_levels(self): """ Test invalid n_levels. """ match = 'n_levels must be >= 1' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, n_levels=0, progress_bar=False) def test_invalid_contrast(self): """ Test invalid contrast. """ match = 'contrast must be >= 0 and <= 1' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, contrast=-1, progress_bar=False) def test_invalid_mode(self): """ Test invalid mode. """ match = "mode must be 'exponential', 'linear', or 'sinh'" with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, mode='invalid', progress_bar=False) def test_invalid_connectivity(self): """ Test invalid connectivity. """ match = 'Invalid connectivity' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, connectivity='invalid', progress_bar=False) def test_constant_source(self): """ Test constant source. """ data = self.data.copy() data[data.nonzero()] = 1.0 result = deblend_sources(data, self.segm, self.n_pixels, progress_bar=False) assert_allclose(result, self.segm) def test_source_with_negval(self): """ Test source with negval. """ data = self.data.copy() data -= 20 match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_source_zero_min(self): """ Test source zero min. """ data = self.data.copy() data -= data[self.segm.data > 0].min() match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_connectivity(self): """ Test connectivity. 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, n_pixels=1, connectivity=8, progress_bar=False) assert segm_deblend.n_labels == 1 match = 'Deblending failed for source' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, n_pixels=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.n_labels == 2 def test_watershed(self): """ Test that the watershed input mask is a 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.n_pixels, progress_bar=False) assert result.n_labels == 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.n_pixels) deblend_sources(data, self.segm, self.n_pixels, progress_bar=False) def test_nonconsecutive_labels(self): """ Test nonconsecutive labels. """ segm = self.segm.copy() segm.reassign_label(1, 1000) result = deblend_sources(self.data, segm, self.n_pixels, progress_bar=False) assert result.n_labels == 2 def test_single_source_methods(self): """ Test the multithreshold and make_markers methods of the _SingleSourceDeblender class. These methods are useful for debugging but are not currently used by the deblend_sources function. """ data = self.data3 segm = self.segm3 n_pixels = 5 footprint = np.ones((3, 3)) deblend_params = _DeblendParams(n_pixels, footprint, 32, 0.001, 'linear') single_debl = _SingleSourceDeblender(data, segm.data, 1, deblend_params) segms = single_debl.multithreshold() assert len(segms) == 32 markers = single_debl.make_markers(return_all=True) assert len(markers) == 19 def test_deblend_progress_bar(self): """ Test deblend_sources with progress_bar=True (serial). """ result = deblend_sources(self.data, self.segm, self.n_pixels, mode='linear', progress_bar=True) assert result.n_labels == 2 def test_deblend_nproc_none(self): """ Test deblend_sources with n_processes=None (auto-detect CPU count). """ result = deblend_sources(self.data, self.segm, self.n_pixels, mode='linear', progress_bar=False, n_processes=None) assert result.n_labels == 2 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nmarkers_fallback(): """ Test that 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) match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): 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') @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nmarkers_fallback_multiproc(): """ Test the nmarkers fallback warning via multiprocessing (n_processes=2). This covers the multiprocessing result-processing block for nmarkers. """ 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) match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm2 = deblend_sources(data, segm, 1, mode='exponential', n_processes=2) assert segm2.info['warnings']['nmarkers']['input_labels'][0] == 1 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nonposmin_multiproc(): """ Test nonposmin warning via multiprocessing (n_processes=2). This covers the multiprocessing result-processing block for nonposmin. """ g1 = Gaussian2D(100, 50, 50, 8, 8) g2 = Gaussian2D(100, 35, 50, 8, 8) yy, xx = np.mgrid[0:101, 0:101] data = g1(xx, yy) + g2(xx, yy) - 20 # negative values segm = detect_sources(data + 20, 10, 5) # detect sources on positive data match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm2 = deblend_sources(data, segm, 5, progress_bar=False, n_processes=2) assert 'nonposmin' in segm2.info['warnings'] @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nmarkers_fallback_returns_none(): """ Test that deblend_source returns None when make_markers returns None on the linear-mode fallback (second attempt after >200 markers). """ # Create a source with varying data values so source_min != source_max data = np.ones((20, 20)) * 10.0 data[5:15, 5:15] = 50.0 data[8:12, 8:12] = 100.0 # peak in center segment = np.zeros((20, 20), dtype=int) segment[5:15, 5:15] = 1 deblend_params = _DeblendParams(5, np.ones((3, 3)), 32, 0.001, 'exponential') deblender = _SingleSourceDeblender(data, segment, 1, deblend_params) call_count = [0] def mock_make_markers(*, _return_all=False): call_count[0] += 1 if call_count[0] == 1: # First call: return markers with > 200 labels markers = np.zeros((20, 20), dtype=int) for i in range(201): r, c = divmod(i, 20) if r < 20 and c < 20: markers[r, c] = i + 1 return markers # Second call (linear fallback): return None return None with patch.object(deblender, 'make_markers', mock_make_markers): result = deblender.deblend_source() assert result is None astropy-photutils-3322558/photutils/segmentation/tests/test_detect.py000066400000000000000000000255751517052111400262270ustar00rootroot00000000000000# 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.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]]) class TestDetectThreshold: def test_nsigma(self): """ Test basic n_sigma. """ threshold = detect_threshold(DATA, n_sigma=0.1) ref = 0.4 * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.uJy, n_sigma=0.1) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_nsigma_zero(self): """ Test n_sigma=0. """ threshold = detect_threshold(DATA, n_sigma=0.0) ref = (1.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background(self): """ Test background. """ threshold = detect_threshold(DATA, n_sigma=1.0, background=1) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background_image(self): """ Test background image. """ background = np.ones((3, 3)) threshold = detect_threshold(DATA, n_sigma=1.0, background=background) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, n_sigma=1.0, background=background << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_background_badshape(self): """ Test background badshape. """ wrong_shape = np.zeros((2, 2)) match = 'input background is 2D, then it must have the same shape' with pytest.raises(ValueError, match=match): detect_threshold(DATA, n_sigma=2.0, background=wrong_shape) def test_error(self): """ Test error. """ threshold = detect_threshold(DATA, n_sigma=1.0, error=1) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_error_image(self): """ Test error image. """ error = np.ones((3, 3)) threshold = detect_threshold(DATA, n_sigma=1.0, error=error) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, n_sigma=1.0, error=error << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_error_badshape(self): """ Test error badshape. """ wrong_shape = np.zeros((2, 2)) match = 'If input error is 2D, then it must have the same shape' with pytest.raises(ValueError, match=match): detect_threshold(DATA, n_sigma=2.0, error=wrong_shape) def test_background_error(self): """ Test background error. """ threshold = detect_threshold(DATA, n_sigma=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, n_sigma=2.0, background=10.0 * u.Jy, error=1.0 * u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) match = 'must all have the same units' with pytest.raises(ValueError, match=match): detect_threshold(DATA << u.Jy, n_sigma=2.0, background=10.0, error=1.0 * u.Jy) with pytest.raises(ValueError, match=match): detect_threshold(DATA << u.Jy, n_sigma=2.0, background=10.0 * u.m, error=1.0 * u.Jy) def test_background_error_images(self): """ Test background error images. """ background = np.ones((3, 3)) * 10.0 error = np.ones((3, 3)) threshold = detect_threshold(DATA, n_sigma=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, n_sigma=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): """ Test invalid sigma clip. """ match = 'sigma_clip must be a SigmaClip object' with pytest.raises(TypeError, match=match): detect_threshold(DATA, 1.0, sigma_clip=10) 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, n_pixels=2) assert_equal(segm.data, self.refdata) assert segm.data.dtype == np.int32 assert segm.labels.dtype == np.int32 segm = detect_sources(self.data << u.uJy, threshold=0.9 * u.uJy, n_pixels=2) assert_equal(segm.data, self.refdata) match = 'must all have the same units' with pytest.raises(ValueError, match=match): detect_sources(self.data << u.uJy, threshold=0.9, n_pixels=2) with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=0.9 * u.Jy, n_pixels=2) with pytest.raises(ValueError, match=match): detect_sources(self.data << u.uJy, threshold=0.9 * u.m, n_pixels=2) def test_small_sources(self): """ Test detection where sources are smaller than n_pixels size. """ match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(self.data, threshold=0.9, n_pixels=5) def test_n_pixels(self): """ Test removal of sources whose size is less than n_pixels. 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, n_pixels=4) assert segm.n_labels == 2 assert segm.data.dtype == np.int32 # Removal of labels with size less than n_pixels # dtype should still be np.int32 segm = detect_sources(data, 0, n_pixels=8) assert segm.n_labels == 1 assert segm.data.dtype == np.int32 segm = detect_sources(data, 0, n_pixels=9) assert segm.n_labels == 1 assert segm.data.dtype == np.int32 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 n_pixels = np.arange(9, 14) for n_pixels in np.arange(9, 14): segm = detect_sources(data, 0, n_pixels=n_pixels) assert segm.n_labels == 1 assert segm.areas[0] == 13 match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(data, 0, n_pixels=14) def test_zerothresh(self): """ Test detection with zero threshold. """ segm = detect_sources(self.data, threshold=0.0, n_pixels=2) assert_equal(segm.data, self.refdata) def test_zerodet(self): """ Test detection with large threshold giving no detections. """ match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(self.data, threshold=7, n_pixels=2) def test_8connectivity(self): """ Test detection with connectivity=8. """ data = np.eye(3) segm = detect_sources(data, threshold=0.9, n_pixels=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, n_pixels=1, connectivity=4) assert_equal(segm.data, ref) def test_n_pixels_nonint(self): """ Test if an error is raised when npixel is noninteger. """ match = 'n_pixels must be a positive integer' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, n_pixels=0.1) def test_n_pixels_negative(self): """ Test if an error is raised when npixel is negative. """ match = 'n_pixels must be a positive integer' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, n_pixels=-1) @pytest.mark.parametrize('connectivity', [0, -1, 3, 6, 10]) def test_connectivity_invalid(self, connectivity): """ Test if an error is raised when connectivity is invalid. """ match = f'Invalid connectivity={connectivity}' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, n_pixels=1, connectivity=connectivity) def test_mask(self): """ Test mask. """ 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 mask = np.ones(data.shape, dtype=bool) match = 'mask must not be True for every pixel' with pytest.raises(ValueError, match=match): detect_sources(data, 1.0, 1.0, mask=mask) def test_mask_shape(self): """ Test mask shape. """ match = 'mask must have the same shape as the input image' with pytest.raises(ValueError, match=match): detect_sources(self.data, 1.0, 1.0, mask=np.ones((5, 5))) astropy-photutils-3322558/photutils/segmentation/tests/test_finder.py000066400000000000000000000073131517052111400262140ustar00rootroot00000000000000# 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 astropy.utils.exceptions import AstropyDeprecationWarning 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_SKIMAGE from photutils.utils.exceptions import NoDetectionsWarning 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 n_pixels = 10 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_deblend(self): """ Test deblend. """ finder = SourceFinder(n_pixels=self.n_pixels, progress_bar=False) segm1 = finder(self.convolved_data, self.threshold) assert segm1.n_labels == 94 segm2 = finder(self.convolved_data << u.uJy, self.threshold * u.uJy) assert segm2.n_labels == 94 assert np.all(segm1.data == segm2.data) def test_invalid_units(self): """ Test invalid units. """ finder = SourceFinder(n_pixels=self.n_pixels, progress_bar=False) match = 'must all have the same units' with pytest.raises(ValueError, match=match): finder(self.convolved_data << u.uJy, self.threshold) with pytest.raises(ValueError, match=match): finder(self.convolved_data, self.threshold * u.uJy) with pytest.raises(ValueError, match=match): finder(self.convolved_data << u.uJy, self.threshold * u.m) def test_no_deblend(self): """ Test no deblend. """ finder = SourceFinder(n_pixels=self.n_pixels, deblend=False, progress_bar=False) segm = finder(self.convolved_data, self.threshold) assert segm.n_labels == 87 def test_no_sources(self): """ Test no sources. """ finder = SourceFinder(n_pixels=self.n_pixels, deblend=True, progress_bar=False) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): segm = finder(self.convolved_data, 1000) assert segm is None @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_n_pixels_tuple(self): """ Test n_pixels tuple. """ 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(n_pixels=200) segm1 = sf1(data, threshold=0.1) assert segm1.n_labels == 1 sf2 = SourceFinder(n_pixels=(200, 5)) segm2 = sf2(data, threshold=0.1) assert segm2.n_labels == 3 def test_repr(self): """ Test repr. """ finder = SourceFinder(n_pixels=self.n_pixels, deblend=False, progress_bar=False) cls_repr = repr(finder) assert cls_repr.startswith(finder.__class__.__name__) def test_finder_deprecations(): finder = SourceFinder(n_pixels=10, progress_bar=False) match = 'attribute was deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): _ = finder.npixels with pytest.warns(AstropyDeprecationWarning, match=match): _ = finder.nlevels astropy-photutils-3322558/photutils/segmentation/tests/test_positional_kwargs.py000066400000000000000000000077321517052111400305110ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.segmentation.core import SegmentationImage from photutils.segmentation.utils import make_2dgaussian_kernel class TestSegmentationImagePositionalKwargs: """ Test SegmentationImage methods warn for positional optional args. """ def setup_method(self): self.data = np.array([[1, 1, 0, 0, 2, 2], [1, 1, 0, 0, 2, 2], [0, 0, 3, 3, 0, 0], [0, 0, 3, 3, 0, 0]]) def test_reassign_label_positional_warns(self): segm = SegmentationImage(self.data.copy()) match = 'reassign_label' with pytest.warns(AstropyDeprecationWarning, match=match): segm.reassign_label(1, 4, True) # noqa: FBT003 def test_reassign_label_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) segm.reassign_label(1, 4, relabel=True) def test_keep_label_positional_warns(self): segm = SegmentationImage(self.data.copy()) match = 'keep_label' with pytest.warns(AstropyDeprecationWarning, match=match): segm.keep_label(1, True) # noqa: FBT003 def test_keep_label_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) segm.keep_label(1, relabel=True) def test_remove_label_positional_warns(self): segm = SegmentationImage(self.data.copy()) match = 'remove_label' with pytest.warns(AstropyDeprecationWarning, match=match): segm.remove_label(1, True) # noqa: FBT003 def test_remove_label_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) segm.remove_label(1, relabel=True) def test_remove_border_labels_positional_warns(self): segm = SegmentationImage(self.data.copy()) match = 'remove_border_labels' with pytest.warns(AstropyDeprecationWarning, match=match): segm.remove_border_labels(1, True) # noqa: FBT003 def test_remove_border_labels_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) segm.remove_border_labels(1, partial_overlap=True) def test_remove_masked_labels_positional_warns(self): segm = SegmentationImage(self.data.copy()) mask = np.zeros(self.data.shape, dtype=bool) mask[0, 0] = True match = 'remove_masked_labels' with pytest.warns(AstropyDeprecationWarning, match=match): segm.remove_masked_labels(mask, True) # noqa: FBT003 def test_remove_masked_labels_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) mask = np.zeros(self.data.shape, dtype=bool) mask[0, 0] = True segm.remove_masked_labels(mask, partial_overlap=True) def test_make_cutout_positional_warns(self): segm = SegmentationImage(self.data.copy()) segment = segm.segments[0] data = np.random.default_rng(0).random(self.data.shape) match = 'make_cutout' with pytest.warns(AstropyDeprecationWarning, match=match): segment.make_cutout(data, True) # noqa: FBT003 def test_make_cutout_keyword_no_warning(self): segm = SegmentationImage(self.data.copy()) segment = segm.segments[0] data = np.random.default_rng(0).random(self.data.shape) segment.make_cutout(data, masked_array=True) class TestMake2DGaussianKernelPositionalKwargs: """ Test make_2dgaussian_kernel warns for positional optional args. """ def test_positional_warns(self): match = 'make_2dgaussian_kernel' with pytest.warns(AstropyDeprecationWarning, match=match): make_2dgaussian_kernel(3.0, 5, 'oversample') def test_keyword_no_warning(self): make_2dgaussian_kernel(3.0, 5, mode='oversample') astropy-photutils-3322558/photutils/segmentation/tests/test_utils.py000066400000000000000000000100731517052111400261020ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _utils module. """ import numpy as np from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.utils import (_make_binary_structure, _mask_to_mirrored_value, make_2dgaussian_kernel) def test_make_2dgaussian_kernel(): """ 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) def test_make_2dgaussian_kernel_modes(): """ 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) def test_make_binary_structure(): """ 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(): """ 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 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 astropy-photutils-3322558/photutils/segmentation/utils.py000066400000000000000000000137011517052111400237020ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for image segmentation. """ import numpy as np from astropy.convolution import Gaussian2DKernel from astropy.stats import gaussian_fwhm_to_sigma from scipy.ndimage import generate_binary_structure from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._parameters import as_pair __all__ = ['make_2dgaussian_kernel'] @deprecated_positional_kwargs(since='3.0', until='4.0') 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, 0), 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: msg = f'Invalid connectivity={connectivity} -- options are 4 or 8' raise ValueError(msg) else: 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 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 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 astropy-photutils-3322558/photutils/tests/000077500000000000000000000000001517052111400206335ustar00rootroot00000000000000astropy-photutils-3322558/photutils/tests/__init__.py000066400000000000000000000000001517052111400227320ustar00rootroot00000000000000astropy-photutils-3322558/photutils/utils/000077500000000000000000000000001517052111400206315ustar00rootroot00000000000000astropy-photutils-3322558/photutils/utils/__init__.py000066400000000000000000000007551517052111400227510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Subpackage containing general-purpose utility functions that do not fit into any of the other subpackages. """ 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 astropy-photutils-3322558/photutils/utils/_convolution.py000066400000000000000000000056551517052111400237340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ 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 from scipy.ndimage import convolve as ndi_convolve 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``. When ``data`` is a `~astropy.units.Quantity`, the result has the same unit; the numerical value of ``fill_value`` is used as-is (it is not converted to the data unit). check_normalization : bool, optional If `True` then a warning will be issued if the kernel is not normalized to 1. Returns ------- result : `~numpy.ndarray` or `~astropy.units.Quantity` The convolved image. A `~astropy.units.Quantity` is returned if ``data`` has units; otherwise a `~numpy.ndarray`. """ if kernel is None: return data kernel_array = kernel.array if isinstance(kernel, Kernel2D) else kernel if check_normalization and not np.allclose(np.sum(kernel_array), 1.0): msg = 'The kernel is not normalized.' warnings.warn(msg, 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 = ndi_convolve(data, kernel_array, mode=mode, cval=fill_value) # Reapply the input unit if unit is not None: result <<= unit return result astropy-photutils-3322558/photutils/utils/_coords.py000066400000000000000000000077551517052111400226510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating random (x, y) coordinates. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from scipy.spatial import KDTree def apply_separation(xycoords, min_separation): """ Apply a minimum separation to a set of (x, y) coordinates. Coordinates that are closer than the minimum separation are removed. Parameters ---------- xycoords : `~numpy.ndarray` The (x, y) coordinates with shape ``(N, 2)``. min_separation : float The minimum separation in pixels between coordinates. Returns ------- xycoords : `~numpy.ndarray` The (x, y) coordinates with shape ``(N, 2)`` after excluding points closer than the minimum separation. """ tree = KDTree(xycoords) pairs = tree.query_pairs(min_separation, output_type='ndarray') if len(pairs) == 0: return xycoords n = xycoords.shape[0] keep = np.ones(n, dtype=bool) # Group pairs by first index for vectorized neighbor removal. Each # pair has i < j (guaranteed by KDTree). Process groups in ascending # first-index order (greedy independent set algorithm): for each # kept point, discard all its higher-index neighbors. sorted_idx = pairs[:, 0].argsort(kind='stable') pairs_sorted = pairs[sorted_idx] unique_i, group_start = np.unique(pairs_sorted[:, 0], return_index=True) group_end = np.append(group_start[1:], len(pairs_sorted)) for k in range(len(unique_i)): i = unique_i[k] if keep[i]: js = pairs_sorted[group_start[k]:group_end[k], 1] keep[js] = False return xycoords[keep] def make_random_xycoords(size, x_range, y_range, *, min_separation=0.0, seed=None, oversample=10): """ 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. oversample : int, optional The oversampling factor used when ``min_separation`` > 0 to generate extra candidate coordinates before filtering by separation. Higher values increase the chance of producing the requested number of coordinates in crowded conditions, at the cost of speed. The default is 10. Returns ------- xycoords : `~numpy.ndarray` The (x, y) random coordinates with shape ``(size, 2)``. When ``min_separation`` > 0, fewer than ``size`` coordinates may be returned if the range and separation cannot be satisfied. A warning is issued in that case. """ if size == 0: return np.empty((0, 2)) if x_range[0] >= x_range[1] or y_range[0] >= y_range[1]: msg = 'x_range and y_range must be (min, max) with min < max.' raise ValueError(msg) ncoords = size if min_separation > 0: # Scale the number of random coordinates to account for # some being discarded due to min_separation ncoords *= oversample 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: msg = (f'Unable to produce {size!r} coordinates within the ' 'given shape and minimum separation. Only ' f'{len(xycoords)!r} coordinates were generated.') warnings.warn(msg, AstropyUserWarning) return xycoords astropy-photutils-3322558/photutils/utils/_deprecation.py000066400000000000000000000631411517052111400236440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ A module to create Astropy Tables with deprecated column names. It is designed to create new table objects from raw data, rather than modifying existing tables. The primary function, ``create_deprecated_table_from_data``, handles the data renaming and constructs an instance of a custom ``Table`` or ``QTable`` subclass that correctly handles all deprecated name access. Note that standalone Astropy functions like ``join`` inspect ``colnames`` directly and do not trigger the deprecation mapping. Users must use the new column names when calling such functions. """ import inspect import warnings from contextlib import contextmanager from contextvars import ContextVar from functools import wraps from astropy.table import QTable, Table from astropy.utils.decorators import deprecated as astropy_deprecated from astropy.utils.decorators import ( deprecated_renamed_argument as astropy_deprecated_renamed_argument) from astropy.utils.exceptions import AstropyDeprecationWarning _SENTINEL = object() _future_column_names_var = ContextVar( 'photutils_future_column_names', default=_SENTINEL, ) def _get_future_column_names(): """ Return the effective value of ``future_column_names``. A context-local override (set via `use_future_column_names`) takes precedence over the global ``photutils.future_column_names`` flag. Returns ------- result : bool Whether future column names are enabled. """ import photutils val = _future_column_names_var.get() if val is not _SENTINEL: return val return photutils.future_column_names @contextmanager def use_future_column_names(enabled=True): """ Context manager to temporarily override ``future_column_names``. Within the ``with`` block, photutils functions will behave as though ``photutils.future_column_names`` is set to enabled, without modifying the global flag. This is safe to use in multi-threaded and async code because the override is stored in a `~contextvars.ContextVar`. Parameters ---------- enabled : bool, optional The value to use inside the block. The default is `True`. Examples -------- >>> import photutils >>> from photutils import use_future_column_names >>> photutils.future_column_names # global default False >>> with use_future_column_names(): ... # inside here, tables use new column names only ... pass >>> photutils.future_column_names # unchanged False """ token = _future_column_names_var.set(enabled) try: yield finally: _future_column_names_var.reset(token) def deprecated(since, *, alternative=None, until=None): """ Decorator to mark a function or method as deprecated. This is a wrapper around `astropy.utils.decorators.deprecated` that allows for an optional ``until`` parameter to specify when the deprecated functionality will be removed. If ``until`` is provided, the warning message will include both the deprecation version and the removal version. Parameters ---------- since : str or int The version in which the function or method was deprecated. alternative : str or None, optional An optional string describing an alternative function or method to use instead of the deprecated one. If `None`, no alternative is mentioned in the warning message. until : str or int, optional The version in which the deprecated functionality will be removed. If `None`, the removal version is not mentioned in the warning message. Returns ------- decorator : function A decorator function that can be applied to any function or method to mark it as deprecated. """ if until is None: message = (f'This function was deprecated in version {since} and will ' 'be removed in a future version.') else: remove_version = 'version ' + str(until) message = (f'This function was deprecated in version {since} and will ' f'be removed in {remove_version}.') if alternative is not None: message += f' Use {alternative} instead.' return astropy_deprecated(since, message=message) def deprecated_renamed_argument(old_name, new_name, since, *, until=None): """ Decorator to warn when a renamed argument is used. This is a wrapper around `astropy.utils.decorators.deprecated_renamed_argument` that allows for an optional ``until`` parameter to specify when the old argument name will be removed. If ``until`` is provided, the warning message will include both the deprecation version and the removal version. Parameters ---------- old_name : str The old (deprecated) argument name. new_name : str or None The new argument name that should be used instead, or `None` if the argument has been removed entirely. since : str or int The version in which the argument was renamed or removed. until : str or int, optional The version in which the old argument name will be removed. If `None`, the removal version is not mentioned in the warning message. Returns ------- decorator : function A decorator function that can be applied to any function to warn about the use of a renamed argument. """ if until is None: return astropy_deprecated_renamed_argument( old_name, new_name, since) remove_version = 'version ' + str(until) message = (f"'{old_name}' was deprecated in version {since} and will " f'be removed in {remove_version}.') if new_name is not None: message += f" Use argument '{new_name}' instead." return astropy_deprecated_renamed_argument( old_name, new_name, since, message=message) def deprecated_getattr(instance, name, deprecated_map, *, since=None, until=None): """ Handle deprecated attribute access on an instance. This is a helper function for ``__getattr__`` methods on classes that have deprecated attribute names. It checks if ``name`` is in ``deprecated_map`` and, if so, issues a deprecation warning and returns the value of the new attribute. Otherwise, it raises an `AttributeError`. Parameters ---------- instance : object The instance on which the attribute was accessed. name : str The attribute name that was accessed. deprecated_map : dict A dictionary mapping old (deprecated) attribute names to their new attribute names. since : str or int, optional The version in which the attribute was deprecated. If `None`, the deprecation version is not mentioned in the warning message. until : str or int, optional The version in which the old attribute name will be removed. If `None`, the removal version is not mentioned in the warning message. Returns ------- value : object The value of the new attribute. Raises ------ AttributeError If ``name`` is not in ``deprecated_map``. """ if name in deprecated_map: new_name = deprecated_map[name] since_str = '' if since is not None: since_str = f' in version {since}' if until is not None: remove_str = 'version ' + str(until) else: remove_str = 'a future version' warn_msg = (f'The {name!r} attribute was deprecated{since_str}; ' f'use {new_name!r} instead. It will be removed in ' f'{remove_str}.') warnings.warn(warn_msg, AstropyDeprecationWarning, stacklevel=3) return getattr(instance, new_name) msg = f'{type(instance).__name__!r} object has no attribute {name!r}' raise AttributeError(msg) def deprecated_positional_kwargs(since, *, until=None): """ Decorator to warn when optional arguments are passed positionally. Parameters that have no default value (i.e., required parameters) are allowed positionally. Parameters with default values (i.e., optional parameters) will trigger a deprecation warning if passed positionally. Parameters ---------- since : str or int The version in which passing optional arguments positionally is deprecated. until : str or int, optional The version in which passing optional arguments positionally will be removed. If `None`, the removal version is not mentioned in the warning message. Returns ------- decorator : function A decorator function that can be applied to any function to warn about positional arguments. """ def decorator(func): # numpydoc ignore=GL08 since_str = str(since) until_str = str(until) if until is not None else None sig = inspect.signature(func) n_positional = 0 param_names = [] for name, param in sig.parameters.items(): param_names.append(name) if (param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and param.default is inspect.Parameter.empty): n_positional += 1 @wraps(func) def wrapper(*args, **kwargs): # numpydoc ignore=GL08 if len(args) > n_positional: extra_names = param_names[n_positional:len(args)] quoted = [f"'{name}'" for name in extra_names] if len(quoted) == 1: params_str = quoted[0] pronoun = 'it' kwarg_noun = 'a keyword argument' elif len(quoted) == 2: params_str = f'{quoted[0]} and {quoted[1]}' pronoun = 'them' kwarg_noun = 'keyword arguments' else: params_str = (', '.join(quoted[:-1]) + f', and {quoted[-1]}') pronoun = 'them' kwarg_noun = 'keyword arguments' examples_str = ', '.join(f'{name}=...' for name in extra_names) remove_str = 'a future version' if until_str is not None: remove_str = f'version {until_str}' msg = (f'Passing {params_str} positionally to ' f"'{func.__name__}' is deprecated as of version " f'{since_str} and will be removed in {remove_str}. ' f'Pass {pronoun} as {kwarg_noun} instead ' f'(e.g., {examples_str}).') warnings.warn(msg, AstropyDeprecationWarning, stacklevel=2) return func(*args, **kwargs) return wrapper return decorator class DeprecatedColumnMixin: """ A mixin to handle deprecated column names in Astropy tables. This mixin overrides common table methods to intercept calls using old column names. It translates them to new names, issues a deprecation warning, and then calls the original parent method via ``super()``. This works correctly because instances are created from this class directly, ensuring a valid method resolution order. """ deprecation_map = None _deprecation_since = None _deprecation_until = None def _warn_deprecated(self, name, new_name, stacklevel=4): """ Issue a deprecation warning for a column name. Parameters ---------- name : str The deprecated column name. new_name : str The new column name. stacklevel : int, optional The stack level for the warning. The default is 4. """ since_str = '' if self._deprecation_since is not None: since_str = f' in version {self._deprecation_since}' if self._deprecation_until is not None: remove_str = 'version ' + str(self._deprecation_until) else: remove_str = 'a future version' msg = (f"The column name '{name}' was deprecated{since_str}. Use " f"'{new_name}' instead. It will be removed in " f'{remove_str}. Once you have updated your code to use ' f"'{new_name}', set photutils.future_column_names = True " 'to opt into a standard QTable without the deprecated ' 'column name mapping.') warnings.warn(msg, AstropyDeprecationWarning, stacklevel=stacklevel) def _translate_name(self, name, stacklevel=4): """ Translate a single name, issue a warning, and return the new name. Parameters ---------- name : str The column name to be translated. stacklevel : int, optional The stack level for the warning. The default is 4. Returns ------- result : str The translated new column name, or the original name if it is not deprecated. """ if self.deprecation_map and name in self.deprecation_map: new_name = self.deprecation_map[name] self._warn_deprecated(name, new_name, stacklevel=stacklevel) return new_name return name def _translate_names(self, names, stacklevel=4): """ Translate a single name or a list/tuple of names. Parameters ---------- names : str or list or tuple The column name(s) to be translated. stacklevel : int, optional The stack level for the warning. The default is 4. Returns ------- str or list or tuple The translated new column name(s). """ if isinstance(names, (list, tuple)): return [self._translate_name(name, stacklevel=stacklevel) for name in names] if not isinstance(names, str): return names return self._translate_name(names, stacklevel=stacklevel) def __contains__(self, name): """ Override for ``in`` checks. """ if (isinstance(name, str) and self.deprecation_map and name in self.deprecation_map): new_name = self.deprecation_map[name] self._warn_deprecated(name, new_name, stacklevel=3) return new_name in self.colnames return name in self.colnames def __getitem__(self, item): """ Override for item access. """ if isinstance(item, (str, list, tuple)): item = self._translate_names(item) result = super().__getitem__(item) if isinstance(result, type(self)) and self.deprecation_map: result.deprecation_map = self.deprecation_map result._deprecation_since = self._deprecation_since result._deprecation_until = self._deprecation_until return result def __setitem__(self, item, value): """ Override for item assignment. """ if isinstance(item, str): item = self._translate_names(item) super().__setitem__(item, value) def __delitem__(self, item): """ Override for item deletion. """ if isinstance(item, str): item = self._translate_names(item) super().__delitem__(item) def keep_columns(self, names): """ Override for keeping specified columns. Parameters ---------- names : list or tuple A list or tuple of column names to keep. """ names = self._translate_names(names) super().keep_columns(names) def remove_column(self, name): """ Override for column removal. Parameters ---------- name : str The name of the column to be removed. """ name = self._translate_names(name) super().remove_column(name) def remove_columns(self, names): """ Override for multiple column removal. Parameters ---------- names : list or tuple A list or tuple of column names to be removed. """ names = self._translate_names(names) super().remove_columns(names) def rename_column(self, name, new_name): """ Override for column renaming. Parameters ---------- name : str The current name of the column to be renamed. new_name : str The new name for the column. """ name = self._translate_names(name) super().rename_column(name, new_name) def rename_columns(self, names, new_names): """ Override for multiple column renaming. Parameters ---------- names : list or tuple A list or tuple of current column names to be renamed. new_names : list or tuple A list or tuple of new names for the columns. """ names = self._translate_names(names) super().rename_columns(names, new_names) def replace_column(self, name, col, **kwargs): """ Override for column replacement. Parameters ---------- name : str The current name of the column to be replaced. col : `Column` or `MaskedColumn` The new column to replace the existing one. **kwargs : dict, optional Additional keyword arguments passed to the parent method. """ name = self._translate_names(name) super().replace_column(name, col, **kwargs) def add_index(self, names): """ Override for index addition. Parameters ---------- names : str or list or tuple The name(s) of the column(s) to be indexed. """ names = self._translate_names(names) super().add_index(names) def remove_indices(self, names): """ Override for index removal. Parameters ---------- names : str or list or tuple The name(s) of the column(s) whose indices are to be removed. """ names = self._translate_names(names) super().remove_indices(names) def sort(self, keys, **kwargs): """ Override for sorting. Parameters ---------- keys : str or list or tuple The name(s) of the column(s) to sort by. **kwargs : dict, optional Additional keyword arguments (e.g., ``kind``, ``reverse``) passed to the parent method. """ keys = self._translate_names(keys) super().sort(keys, **kwargs) def group_by(self, keys, **kwargs): """ Override for grouping. Parameters ---------- keys : str or list or tuple The name(s) of the column(s) to group by. **kwargs : dict, optional Additional keyword arguments passed to the parent method. """ keys = self._translate_names(keys) return super().group_by(keys, **kwargs) def copy(self, copy_data=True): """ Override to preserve the deprecation map on copy. Parameters ---------- copy_data : bool, optional Whether to copy the data. The default is `True`. Returns ------- result : `DeprecatedColumnTable` or `DeprecatedColumnQTable` A copy of the table with the deprecation map preserved. """ new_table = super().copy(copy_data=copy_data) new_table.deprecation_map = (self.deprecation_map.copy() if self.deprecation_map else None) new_table._deprecation_since = self._deprecation_since new_table._deprecation_until = self._deprecation_until return new_table class DeprecatedColumnTable(DeprecatedColumnMixin, Table): """ An Astropy Table with built-in support for deprecated names. """ class DeprecatedColumnQTable(DeprecatedColumnMixin, QTable): """ An Astropy QTable with built-in support for deprecated names. """ def create_empty_deprecated_qtable(deprecation_map, *, since=None, until=None, **kwargs): """ Create an empty `DeprecatedColumnQTable`. This is useful when building a table column by column rather than from a complete data dictionary. If ``photutils.future_column_names`` is `True`, a standard `~astropy.table.QTable` is returned instead, with no deprecation behavior. Parameters ---------- deprecation_map : dict A dictionary mapping old (deprecated) names to new names. since : str or int, optional The version in which the column names were deprecated. If `None`, the deprecation version is not mentioned in the warning message. until : str or int, optional The version in which the old column names will be removed. If `None`, the removal version is not mentioned in the warning message. **kwargs : dict, optional Any other keywords accepted by the `~astropy.table.QTable` constructor (e.g., ``meta={...}``). Returns ------- table : `DeprecatedColumnQTable` or `~astropy.table.QTable` A new empty QTable instance. If ``photutils.future_column_names`` is `True`, a standard `~astropy.table.QTable` is returned. Examples -------- Create an empty table and add columns incrementally: >>> import warnings >>> from photutils.utils._deprecation import ( ... create_empty_deprecated_qtable) >>> dep_map = {'xcentroid': 'x_centroid'} >>> table = create_empty_deprecated_qtable(dep_map) >>> table['x_centroid'] = [1.0, 2.0, 3.0] >>> table.colnames ['x_centroid'] Accessing via the deprecated name issues a warning: >>> with warnings.catch_warnings(): ... warnings.simplefilter('ignore') ... col = table['xcentroid'] >>> float(col[0]) 1.0 """ if _get_future_column_names(): return QTable(**kwargs) table = DeprecatedColumnQTable(**kwargs) table.deprecation_map = deprecation_map table._deprecation_since = since table._deprecation_until = until return table def create_deprecated_table_from_data(data, deprecation_map, *, since=None, until=None, use_qtable=False, **kwargs): """ Create a new table from scratch with deprecated column name support. This function takes raw data and a deprecation map, renames the data keys internally, and constructs the appropriate ``Table`` or ``QTable`` subclass. All other keywords are passed directly to the underlying table constructor. If ``photutils.future_column_names`` is `True`, a standard `~astropy.table.QTable` or `~astropy.table.Table` is returned instead, with no deprecation behavior. Parameters ---------- data : dict A dictionary of data for the table, using the OLD (soon to be deprecated) column names as keys. deprecation_map : dict A dictionary mapping old (deprecated) names to new names. since : str or int, optional The version in which the column names were deprecated. If `None`, the deprecation version is not mentioned in the warning message. until : str or int, optional The version in which the old column names will be removed. If `None`, the removal version is not mentioned in the warning message. use_qtable : bool, optional If ``True``, a ``DeprecatedColumnQTable`` (or `~astropy.table.QTable` when ``photutils.future_column_names`` is `True`) will be created. Defaults to ``False``. **kwargs : dict, optional Any other keywords accepted by the ``astropy.table.Table`` constructor (e.g., ``masked=True``, ``meta={...}``). Returns ------- table : `DeprecatedColumnTable` or `DeprecatedColumnQTable` A new table instance with deprecation behavior. If ``photutils.future_column_names`` is `True`, a standard `~astropy.table.Table` or `~astropy.table.QTable` is returned. Examples -------- Create a table with deprecated column names: >>> import warnings >>> from photutils.utils._deprecation import ( ... create_deprecated_table_from_data) >>> data = {'xcentroid': [1.0, 2.0], 'ycentroid': [3.0, 4.0]} >>> dep_map = {'xcentroid': 'x_centroid', 'ycentroid': 'y_centroid'} >>> table = create_deprecated_table_from_data(data, dep_map) The table stores data under the new column names: >>> table.colnames ['x_centroid', 'y_centroid'] Accessing via a deprecated name issues a warning: >>> with warnings.catch_warnings(): ... warnings.simplefilter('ignore') ... col = table['xcentroid'] >>> float(col[0]) 1.0 Use ``use_qtable=True`` to create a `~astropy.table.QTable`: >>> qtable = create_deprecated_table_from_data( ... data, dep_map, use_qtable=True) >>> type(qtable).__name__ 'DeprecatedColumnQTable' """ # Rename the keys in the data dictionary before creation renamed_data = { deprecation_map.get(k, k): v for k, v in data.items() } if _get_future_column_names(): table_class = QTable if use_qtable else Table return table_class(renamed_data, **kwargs) table_class = (DeprecatedColumnQTable if use_qtable else DeprecatedColumnTable) # Create the table instance table = table_class(renamed_data, **kwargs) table.deprecation_map = deprecation_map table._deprecation_since = since table._deprecation_until = until return table astropy-photutils-3322558/photutils/utils/_misc.py000066400000000000000000000035661517052111400223070ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for getting the installed astropy and photutils versions. """ import sys from datetime import UTC, datetime 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', '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. """ try: now = datetime.now().astimezone() if not utc else datetime.now(UTC) except OSError: # System timezone may be unavailable on some configurations; # fall back to UTC now = datetime.now(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. Parameters ---------- utc : bool, optional Whether to use the UTC timezone instead of the local timezone. Returns ------- result : dict A dictionary containing package versions and the current date/time. """ return {'date': _get_date(utc=utc), 'version': _get_version_info()} astropy-photutils-3322558/photutils/utils/_moments.py000066400000000000000000000025331517052111400230270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating image moments. """ import numpy as np def _image_moments(data, *, center=(0, 0), order=1): """ Calculate the 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 be calculated as the "center of mass" of the input ``data``. The default is ``(0, 0)``, which gives the raw image moments. order : int, optional The maximum order of the moments to calculate. Returns ------- moments : 2D `~numpy.ndarray` The image moments. """ data = np.asarray(data).astype(float) if data.ndim != 2: msg = 'data must be a 2D array' raise ValueError(msg) if order < 0: msg = 'order must be non-negative' raise ValueError(msg) 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) astropy-photutils-3322558/photutils/utils/_optional_deps.py000066400000000000000000000064551517052111400242140ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for optional dependencies. Attributes ``HAS_`` (e.g., ``HAS_MATPLOTLIB``, ``HAS_SKIMAGE``) are booleans that indicate whether the corresponding package can be imported. The actual import is performed lazily on first attribute access via :pep:`562`. The ``HAS_*`` names are derived from the *import* name of each optional dependency (uppercased, hyphens/dots replaced by underscores). For the handful of packages whose import name differs from their distribution (pip) name, a small translation dict (``_DIST_TO_IMPORT``) is maintained. """ import importlib from importlib.metadata import packages_distributions, requires from packaging.requirements import Requirement # Hardcoded translation for packages whose import name differs from # their distribution (pip) name. All other packages are assumed to be # importable using their distribution name directly. If a new optional # dependency is added whose import name does not match its dist name, # add a single entry below. _DIST_TO_IMPORT = { 'scikit-image': 'skimage', } def _get_optional_deps(dist_name, *, extra='all'): """ Return the optional-dependency distribution names for ``dist_name``. Parameters ---------- dist_name : str The distribution (pip) name of the package whose metadata is queried (e.g., ``'photutils'``). extra : str, optional The extras group to query (default ``'all'``). Returns ------- deps : list of str Sorted distribution names of the optional dependencies. """ deps = set() for req_str in (requires(dist_name) or []): req = Requirement(req_str) if req.marker and req.marker.evaluate({'extra': extra}): deps.add(req.name) return sorted(deps) def _dist_to_has_key(dist_name): """ Convert a distribution name to the corresponding ``HAS_*`` key. For example, ``'scikit-image'`` -> ``'SKIMAGE'`` and ``'matplotlib'`` -> ``'MATPLOTLIB'``. """ import_name = _DIST_TO_IMPORT.get(dist_name, dist_name) return import_name.upper().replace('-', '_').replace('.', '_') # Derive the distribution name of this package from its top-level import # name _pkg_import_name = __name__.split('.')[0] _pkg_dist_name = packages_distributions().get(_pkg_import_name, [_pkg_import_name])[0] # Build lookup: HAS_* suffix -> dist name. _optional_deps = _get_optional_deps(_pkg_dist_name, extra='all') _deps_by_key = {_dist_to_has_key(d): d for d in _optional_deps} __all__ = [f'HAS_{key}' for key in sorted(_deps_by_key)] _cache = {} # Implemented as a module-level __getattr__ to allow for lazy imports on # first access. See PEP 562 for details. def __getattr__(name): if name.startswith('HAS_'): key = name[4:] if key in _deps_by_key: if name not in _cache: dist_name = _deps_by_key[key] import_name = _DIST_TO_IMPORT.get(dist_name, dist_name) try: importlib.import_module(import_name) _cache[name] = True except ImportError: _cache[name] = False return _cache[name] msg = f'Module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) astropy-photutils-3322558/photutils/utils/_parameters.py000066400000000000000000000100451517052111400235050ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for parameter validation. """ import numpy as np from astropy.stats import SigmaClip class SigmaClipSentinelDefault: """ A sentinel object to indicate the default value for sigma_clip. Parameters ---------- sigma : float, optional The number of standard deviations for the clipping limit. maxiters : int, optional The maximum number of sigma-clipping iterations. """ def __init__(self, *, sigma=3.0, maxiters=10): self.sigma = sigma self.maxiters = maxiters def __repr__(self): return (f'') def create_default_sigmaclip(*, sigma=3.0, maxiters=10): """ Return a new, default SigmaClip instance. Parameters ---------- sigma : float, optional The number of standard deviations for the clipping limit. maxiters : int, optional The maximum number of sigma-clipping iterations. Returns ------- result : `~astropy.stats.SigmaClip` A new `~astropy.stats.SigmaClip` instance. """ return SigmaClip(sigma=sigma, maxiters=maxiters) 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 : tuple of 2 int, optional A tuple defining the allowed lower bound of the value. The first element is the bound; the second is 0 for exclusive or 1 for inclusive (e.g. (0, 1) means value must be >= 0). upper_bound : tuple of 2 int, 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, 1)) array([0, 0]) """ value = np.atleast_1d(value) if value.ndim != 1: msg = f'{name} must be 1D' raise ValueError(msg) if np.any(~np.isfinite(value)): msg = f'{name} must be a finite value' raise ValueError(msg) if len(value) not in (1, 2): msg = f'{name} must have 1 or 2 elements' raise ValueError(msg) if len(value) == 1: value = np.array((value[0], value[0])) if value.dtype.kind != 'i': msg = f'{name} must have integer values' raise ValueError(msg) if check_odd and np.any(value % 2 != 1): msg = f'{name} must have an odd value for both axes' raise ValueError(msg) if lower_bound is not None: if len(lower_bound) != 2: msg = 'lower_bound must contain only 2 elements' raise ValueError(msg) bound, inclusive = lower_bound if inclusive: oper = '>=' mask = value < bound else: oper = '>' mask = value <= bound if np.any(mask): msg = f'{name} must be {oper} {bound}' raise ValueError(msg) if upper_bound is not None: if len(upper_bound) != 2: msg = 'upper_bound must contain only 2 elements' raise ValueError(msg) # 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 astropy-photutils-3322558/photutils/utils/_progress_bars.py000066400000000000000000000050201517052111400242120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for progress bars. """ # pylint: disable-next=E0611 from photutils.utils._optional_deps import HAS_TQDM # Allow iterable to be passed positionally 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, iterable, or `None` When tqdm is installed, a tqdm progress bar (ipywidgets-based in a notebook if available, otherwise text-based). When tqdm is not installed, the original iterable is returned unchanged (which may be `None`). Notes ----- When ``iterable=None`` and ``total`` is provided, the returned tqdm object is a manually-managed progress bar. The caller is responsible for advancing it by calling ``.update()`` and closing it when finished. This mode is used when the caller drives the loop directly (e.g., with a ``while`` loop) rather than iterating over a sequence. When tqdm is not installed, `None` is returned in this case and the caller must guard against it. """ if HAS_TQDM: if text: from tqdm import tqdm else: try: # pylint: disable-next=W0611 from ipywidgets import FloatProgress # noqa: F401 from tqdm.auto import tqdm except ImportError: from tqdm import tqdm iterable = tqdm(iterable=iterable, desc=desc, total=total) return iterable # Define tqdm as a dummy class if it is not available. # This is needed to use tqdm as a context manager with multiprocessing. try: from tqdm.auto import tqdm except ImportError: class tqdm: # noqa: N801 def __init__(self, *args, **kwargs): pass def __enter__(self): return self def __exit__(self, *exc): pass def update(self, *args, **kwargs): pass def set_postfix_str(self, *args, **kwargs): pass astropy-photutils-3322558/photutils/utils/_quantity_helpers.py000066400000000000000000000065331517052111400247510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for Quantity helpers. """ import astropy.units as u import numpy as np def check_units(values, names): """ Check that input values have consistent units. 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 ------- units : set The set of distinct units across all non-`None` values. Raises ------ ValueError If the number of values does not match the number of names, or if the input values do not all have the same units. """ if len(values) != len(names): msg = 'The number of values must match the number of names.' raise ValueError(msg) all_units = {name: getattr(arr, 'unit', None) for arr, name in zip(values, names, strict=True) if arr is not None} units = set(all_units.values()) if len(units) > 1: param_names = list(all_units.keys()) msg = [f'The inputs {param_names} 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) return units 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 (including when all values are `None`). Raises ------ ValueError If the input values do not all have the same units. """ units = check_units(values, names) # When all values are None, the units set is empty; return unchanged # with unit=None if len(units) == 0: return values, None # Extract the unit and remove it from the return values unit = units.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 return np.isscalar(value) astropy-photutils-3322558/photutils/utils/_repr.py000066400000000000000000000052141517052111400223140ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for class __repr__ and __str__ strings. """ def make_repr(instance, params, *, brackets=False, overrides=None, long=False): """ Generate a __repr__ string for a class instance. Parameters ---------- instance : object The class instance. params : str or list of str List of parameter names to include in the repr. The order of returned parameters is the same as the order of ``params``. brackets : bool, optional Whether to use angle brackets at the start and end of the string. Angle brackets are typically used for __repr__ strings that are not valid executable Python expressions. overrides : `None` or dict, optional Dictionary of parameter names and values to override the instance's attributes. This is useful for cases where the instance's attributes are not stored long-term (e.g., Background2D). The keys of ``overrides`` must also be in ``params``, which determines the order of the returned parameters. long : bool, optional Whether to use the "long" format typically used by __str__. Returns ------- repr_str : str The generated __repr__ string. """ cls_name = f'{instance.__class__.__name__}' if long: cls_name = f'{instance.__class__.__module__}.{cls_name}' if isinstance(params, str): params = [params] if overrides is not None and not set(overrides).issubset(params): msg = 'The overrides keys must be a subset of the params list.' raise ValueError(msg) cls_info = [] for param in params: if overrides is not None and param in overrides: # Note that overrides may contain input parameters that are # not stored long-term in the instance (e.g., Background2D) if param in instance.__dict__ and instance.__dict__[param] is None: value = None else: value = overrides[param] elif param in instance.__dict__: value = instance.__dict__[param] else: msg = f'Parameter {param!r} not found in instance or overrides' raise ValueError(msg) 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}' repr_str = f'{cls_name}({fmt})' if brackets: repr_str = f'<{repr_str}>' return repr_str astropy-photutils-3322558/photutils/utils/_round.py000066400000000000000000000022261517052111400224730ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for rounding numpy arrays. """ import numpy as np def round_half_away(a): """ Round a float or array of floats to the nearest integer, rounding half away from zero. Parameters ---------- a : float or array_like The input float or array. Returns ------- result : int, float, or array_like The rounded values. Finite inputs are returned as integers. Non-finite inputs (NaN or infinity) are returned as floats, preserving the NaN or infinity value. Notes ----- NaN and infinity values are preserved in the output. Arrays containing any non-finite value are returned as float arrays; all-finite arrays are returned as integer arrays. """ data = np.atleast_1d(np.asarray(a, dtype=float)) rounded = np.where(data >= 0, np.floor(data + 0.5), np.ceil(data - 0.5)) if np.isscalar(a): val = rounded[0] if not np.isfinite(a): return val return int(val) if np.isfinite(data).all(): return rounded.astype(int) return rounded astropy-photutils-3322558/photutils/utils/_stats.py000066400000000000000000000107561517052111400225110ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Nan-ignoring statistical functions, using bottleneck for performance if available. When bottleneck is installed, it is used only for float64 arrays. For other dtypes (e.g., float32), NumPy is used instead to work around known accuracy issues in bottleneck (see bottleneck issues #379 and #462, and astropy issues #17185 and #11492). """ from functools import partial import numpy as np from astropy.units import Quantity from photutils.utils._optional_deps import HAS_BOTTLENECK _STAT_NAMES = ( 'nansum', 'nanmin', 'nanmax', 'nanmean', 'nanmedian', 'nanstd', 'nanvar', ) if HAS_BOTTLENECK: import bottleneck as bn def _move_tuple_axes_last(array, axis): """ Move the specified axes of a NumPy array to the last positions and combine them. Bottleneck can only take integer axis, not tuple, so this function takes all the axes to be operated on and combines them into the last dimension of the array so that we can then use axis=-1. Parameters ---------- array : `~numpy.ndarray` The input array. axis : tuple of int The axes on which to move and combine. Returns ------- array_new : `~numpy.ndarray` Array with the axes being operated on moved into the last dimension. """ other_axes = tuple(i for i in range(array.ndim) if i not in axis) # Move the specified axes to the last positions array_new = np.transpose(array, other_axes + axis) # Reshape the array by combining the moved axes return array_new.reshape((*array_new.shape[:len(other_axes)], -1)) def _apply_bottleneck(function, array, axis=None, **kwargs): """ Wrap a bottleneck function to handle tuple axis. This function also takes care to ensure the output is of the expected type, i.e., a quantity, numpy array, or numpy scalar. Parameters ---------- function : callable The bottleneck function to apply. array : `~numpy.ndarray` The array on which to operate. axis : int or tuple of int, optional The axis or axes on which to operate. **kwargs : dict, optional Additional keyword arguments to pass to the bottleneck function. Returns ------- result : `~numpy.ndarray` or float The result of the bottleneck function when called with the ``array``, ``axis``, and ``kwargs``. """ if isinstance(axis, tuple): array = _move_tuple_axes_last(array, axis=axis) axis = -1 result = function(array, axis=axis, **kwargs) if isinstance(array, Quantity): if function == bn.nanvar: result <<= array.unit ** 2 else: result = array.__array_wrap__(result) return result if isinstance(result, float): # For compatibility with numpy, always return a numpy scalar. return np.float64(result) return result bn_funcs = { name: partial(_apply_bottleneck, getattr(bn, name)) for name in _STAT_NAMES } np_funcs = {name: getattr(np, name) for name in _STAT_NAMES} class _DtypeDispatch: """ Dispatcher that routes to bottleneck or numpy based on dtype. This is done to workaround known accuracy bugs in Bottleneck affecting float32 calculations. See the following issues for more details: * https://github.com/pydata/bottleneck/issues/379 * https://github.com/pydata/bottleneck/issues/462 * https://github.com/astropy/astropy/issues/17185 * https://github.com/astropy/astropy/issues/11492 """ def __init__(self, func_name): self.func_name = func_name def __repr__(self): return f'_DtypeDispatch({self.func_name!r})' def __call__(self, *args, **kwargs): dt = args[0].dtype if dt.kind == 'f' and dt.itemsize == 8: return bn_funcs[self.func_name](*args, **kwargs) return np_funcs[self.func_name](*args, **kwargs) (nansum, nanmin, nanmax, nanmean, nanmedian, nanstd, nanvar) = ( _DtypeDispatch(name) for name in _STAT_NAMES ) else: (nansum, nanmin, nanmax, nanmean, nanmedian, nanstd, nanvar) = ( getattr(np, name) for name in _STAT_NAMES ) astropy-photutils-3322558/photutils/utils/_wcs_helpers.py000066400000000000000000001134541517052111400236700ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for WCS helpers. """ import astropy.units as u import numpy as np from astropy.coordinates import Angle def _has_distortion(wcs): """ Return True if the WCS has distortions or is non-FITS. """ return getattr(wcs, 'has_distortion', True) def _sky_to_pixel_jacobian(skycoord, wcs): """ Common setup for sky-to-pixel Jacobian-based conversions. Returns the pixel center, the local Jacobian matrix, and the WCS parity. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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 ------- center : tuple of float The ``(x, y)`` pixel center position. jacobian : 2x2 `~numpy.ndarray` The Jacobian matrix ``d(pixel)/d(sky_arcsec)``. parity : float The sign of ``det(jacobian)`` (+1 or -1). """ x0, y0 = wcs.world_to_pixel(skycoord) center = (float(x0), float(y0)) jacobian = compute_local_wcs_jacobian(skycoord, wcs) parity = np.sign(np.linalg.det(jacobian)) return center, jacobian, parity def _pixel_to_sky_jacobian(pixcoord, wcs): """ Common setup for pixel-to-sky Jacobian-based conversions. Returns the sky center, the local Jacobian matrix, its inverse, and the WCS parity. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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 ------- center : `~astropy.coordinates.SkyCoord` The sky center position. jacobian : 2x2 `~numpy.ndarray` The Jacobian matrix ``d(pixel)/d(sky_arcsec)``. jacobian_inv : 2x2 `~numpy.ndarray` The inverse Jacobian ``d(sky_arcsec)/d(pixel)``. parity : float The sign of ``det(jacobian)`` (+1 or -1). """ center = wcs.pixel_to_world(pixcoord[0], pixcoord[1]) jacobian = compute_local_wcs_jacobian(center, wcs) jacobian_inv = np.linalg.inv(jacobian) parity = np.sign(np.linalg.det(jacobian)) return center, jacobian, jacobian_inv, parity def _svd_ellipse_from_composite(m_comp, width_col_idx=0, use_parity_for_angle=False, parity=1): """ Extract ellipse width, height, and angle from a composite matrix using SVD. Given a 2x2 matrix ``m_comp`` whose columns represent the mapped semi-axis vectors of an ellipse, perform SVD and return the full widths, heights, and rotation angle, preserving the width/height assignment of the input ellipse. Parameters ---------- m_comp : 2x2 `~numpy.ndarray` The composite matrix whose SVD gives the output ellipse axes. width_col_idx : int, optional The column index (0 or 1) of ``m_comp`` that corresponds to the width semi-axis. Default is 0. use_parity_for_angle : bool, optional If True, apply ``parity`` to the RA (x) component when computing the sky rotation angle. Default is False (for pixel angles). parity : float, optional The WCS parity (+1 or -1). Only used if ``use_parity_for_angle`` is True. Returns ------- out_width : float The full width of the output ellipse. out_height : float The full height of the output ellipse. angle : `~astropy.coordinates.Angle` The rotation angle of the width axis, wrapped to [0, 360) degrees. """ u_mat, s_vals, _vt = np.linalg.svd(m_comp) # SVD returns singular values in descending order, so s_vals[0] # corresponds to the major axis. Determine whether the major axis # corresponds to the width or height by checking alignment with the # mapped width semi-axis. width_col = m_comp[:, width_col_idx] if (np.abs(np.dot(u_mat[:, 0], width_col)) >= np.abs(np.dot(u_mat[:, 1], width_col))): # Major axis aligns with width out_width = 2 * s_vals[0] out_height = 2 * s_vals[1] angle_col = u_mat[:, 0] else: # Major axis aligns with height; swap out_width = 2 * s_vals[1] out_height = 2 * s_vals[0] angle_col = u_mat[:, 1] # Fix SVD sign ambiguity: ensure the angle direction aligns with the # mapped width semi-axis if np.dot(angle_col, width_col) < 0: angle_col = -angle_col # Compute the rotation angle if use_parity_for_angle: # Sky position angle (PA) measured from North (eta/Dec) toward # East. The xi (RA) component in the composite matrix has # -parity baked in, so we multiply by -parity to recover the # physical East direction. angle = Angle( np.rad2deg(np.arctan2(-parity * angle_col[0], angle_col[1])) * u.deg, ).wrap_at(360 * u.deg) else: # Pixel angle: measured from +x toward +y angle = Angle( np.rad2deg(np.arctan2(angle_col[1], angle_col[0])) * u.deg, ).wrap_at(360 * u.deg) return out_width, out_height, angle def jacobian_sky_to_pixel_scales(skycoord, wcs, sky_angle_rad): """ Compute the pixel center, directional scale factors, and pixel angle for a sky-to-pixel conversion using the local WCS Jacobian. This function is used for directed (non-circular) regions such as ellipses, rectangles, and asymmetric annuli that have independent width and height axes and a rotation angle. Unlike simpler methods that use a single scalar pixel scale, this function uses the local 2x2 Jacobian matrix of the WCS transformation to compute directional scale factors along the width and height axes of the region. This better handles WCS distortions (e.g., SIP polynomial corrections) where the pixel scale varies along different directions. The algorithm works as follows: 1. Compute the 2x2 Jacobian matrix ``J = d(pixel)/d(sky)`` at the region center using finite differences (see `compute_local_wcs_jacobian`). 2. Construct unit tangent-plane direction vectors ``d_w`` and ``d_h`` for the region's width and height axes, respectively, using the sky rotation angle. The WCS parity (sign of det(J)) is applied to account for the reflected RA axis (RA increases to the left in standard projections, giving a negative determinant). 3. Map these direction vectors through the Jacobian to get the corresponding pixel-plane vectors: ``v_w = J @ d_w`` and ``v_h = J @ d_h``. 4. The directional scale factors are the magnitudes (norms) of ``v_w`` and ``v_h``, in units of pixels per arcsec. 5. The pixel rotation angle is the angle of ``v_w`` measured counterclockwise from the positive x-axis: ``arctan2(v_w[1], v_w[0])``. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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`). sky_angle_rad : float The sky rotation angle in radians as a position angle (PA). This is the angle of the region's width axis measured counterclockwise from North (the latitude/Dec axis) in the tangent-plane coordinate system. Returns ------- center : tuple of float The ``(x, y)`` pixel center position. scale_w : float The scale factor along the width direction (pixels per arcsec). scale_h : float The scale factor along the height direction (pixels per arcsec). pixel_angle : `~astropy.coordinates.Angle` The pixel rotation angle of the width axis, measured counterclockwise from the positive x-axis, in degrees. """ center, jacobian, parity = _sky_to_pixel_jacobian(skycoord, wcs) # Construct unit direction vectors in the tangent-plane coordinate # system for the region's width and height axes. # d_w points along the width axis at the given PA from North; # d_h is perpendicular to it. d_w = np.array([-parity * np.sin(sky_angle_rad), np.cos(sky_angle_rad)]) d_h = np.array([-parity * np.cos(sky_angle_rad), -np.sin(sky_angle_rad)]) # Map sky directions to pixel-plane vectors via the Jacobian v_w = jacobian @ d_w v_h = jacobian @ d_h # Directional scale factors: magnitudes of the mapped vectors # (pixels per arcsec along each axis) scale_w = np.hypot(v_w[0], v_w[1]) scale_h = np.hypot(v_h[0], v_h[1]) # Pixel rotation angle of the width axis pixel_angle = Angle( np.rad2deg(np.arctan2(v_w[1], v_w[0])) * u.deg).wrap_at(360 * u.deg) return center, scale_w, scale_h, pixel_angle def jacobian_pixel_to_sky_scales(pixcoord, wcs, pixel_angle_rad): """ Compute the sky center, directional scale factors, and sky angle for a pixel-to-sky conversion using the local WCS Jacobian. This is the inverse of `jacobian_sky_to_pixel_scales`. It is used for directed (non-circular) pixel regions such as ellipses, rectangles, and asymmetric annuli that have independent width and height axes and a rotation angle. The inverse of the local 2x2 Jacobian matrix is used to map pixel-plane direction vectors back to the tangent-plane coordinate system. The algorithm works as follows: 1. Compute the 2x2 Jacobian matrix ``J = d(pixel)/d(sky)`` at the region center, then invert it to get ``J^{-1} = d(sky)/d(pixel)``. 2. Construct unit pixel-plane direction vectors ``e_w`` and ``e_h`` for the region's width and height axes using the pixel rotation angle. 3. Map these through the inverse Jacobian to get the corresponding tangent-plane direction vectors: ``d_w = J^{-1} @ e_w`` and ``d_h = J^{-1} @ e_h``. 4. The directional scale factors are the magnitudes of ``d_w`` and ``d_h``, in units of arcsec per pixel. 5. The sky rotation angle is derived from the direction of ``d_w`` in the tangent-plane coordinate system as a position angle (PA) measured from North. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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`). pixel_angle_rad : float The pixel rotation angle in radians. This is the angle of the region's width axis measured counterclockwise from the positive x-axis in the pixel coordinate system. Returns ------- center : `~astropy.coordinates.SkyCoord` The sky center position. scale_w : float The scale factor along the width direction (arcsec per pixel). scale_h : float The scale factor along the height direction (arcsec per pixel). sky_angle : `~astropy.coordinates.Angle` The sky position angle (PA) of the width axis, measured counterclockwise from North (the latitude/Dec axis), wrapped to [0, 360) degrees. """ center, _, jacobian_inv, _ = _pixel_to_sky_jacobian(pixcoord, wcs) # Unit direction vectors in the pixel plane for width and height e_w = np.array([np.cos(pixel_angle_rad), np.sin(pixel_angle_rad)]) e_h = np.array([-np.sin(pixel_angle_rad), np.cos(pixel_angle_rad)]) # Map pixel directions to tangent-plane vectors via inverse Jacobian d_w = jacobian_inv @ e_w d_h = jacobian_inv @ e_h # Directional scale factors: magnitudes of the mapped vectors # (arcsec per pixel along each axis) scale_w = np.hypot(d_w[0], d_w[1]) scale_h = np.hypot(d_h[0], d_h[1]) # Sky position angle (PA) of the width axis: d_w is in raw # tangent-plane coordinates (xi=East, eta=North), so PA is simply # arctan2(xi, eta). sky_angle = Angle(np.rad2deg(np.arctan2( d_w[0], d_w[1])) * u.deg).wrap_at(360 * u.deg) return center, scale_w, scale_h, sky_angle def jacobian_sky_to_pixel_mean_scale(skycoord, wcs): """ Compute the pixel center and isotropic (mean) scale factor for a sky-to-pixel conversion using SVD of the local WCS Jacobian. This function is used for circular regions (circles and circle annuli) where a single isotropic scale factor is needed to preserve the circular shape. The scale factor is the mean of the two singular values of the Jacobian matrix. Singular Value Decomposition (SVD) of the 2x2 Jacobian ``J`` yields ``J = U @ diag(s1, s2) @ V^T``, where ``s1`` and ``s2`` are the singular values representing the maximum and minimum stretch factors of the linear transformation. Using their mean as the scale factor is the best isotropic approximation to the (potentially anisotropic) Jacobian, in the sense that it minimizes the sum of squared residuals between the true (elliptical) mapping and the isotropic (circular) approximation. For a WCS without distortion and with equal pixel scales in x and y, ``s1 == s2`` and the mean is exact. For distorted WCS or non-square pixels, the two singular values may differ, and the mean provides a balanced compromise. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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 ------- center : tuple of float The ``(x, y)`` pixel center position. mean_scale : float The mean scale factor (pixels per arcsec), computed as the mean of the two singular values of the Jacobian. """ center, jacobian, _ = _sky_to_pixel_jacobian(skycoord, wcs) scales = np.linalg.svd(jacobian, compute_uv=False) # Mean of singular values gives the best isotropic approximation return center, np.mean(scales) def jacobian_pixel_to_sky_mean_scale(pixcoord, wcs): """ Compute the sky center and isotropic (mean) scale factor for a pixel-to-sky conversion using SVD of the inverse Jacobian. This is the inverse of `jacobian_sky_to_pixel_mean_scale`. It is used for circular pixel regions (circles and circle annuli) where a single isotropic scale factor is needed to preserve the circular shape. The inverse Jacobian ``J^{-1} = d(sky)/d(pixel)`` maps pixel offsets to tangent-plane offsets. Its singular values represent the maximum and minimum angular extents per pixel. The mean of these singular values provides the best isotropic approximation for converting pixel radii to sky angular radii. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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 ------- center : `~astropy.coordinates.SkyCoord` The sky center position. mean_scale : float The mean scale factor (arcsec per pixel), computed as the mean of the two singular values of the inverse Jacobian. """ center, _, jacobian_inv, _ = _pixel_to_sky_jacobian(pixcoord, wcs) scales = np.linalg.svd(jacobian_inv, compute_uv=False) # Mean of singular values gives the best isotropic approximation return center, np.mean(scales) def compute_local_wcs_jacobian(skycoord, wcs): """ Compute the local 2x2 Jacobian matrix d(pixel)/d(tangent-plane) at the given sky coordinate using 1-pixel finite differences. The Jacobian matrix ``J`` linearizes the WCS transformation in the neighborhood of ``skycoord``. It maps infinitesimal offsets in the tangent-plane coordinate system (in arcsec) to pixel coordinate offsets (in pixels):: [dx, dy]^T ~ J @ [d_xi, d_eta]^T The tangent-plane coordinate system has two orthogonal axes: * ``xi`` (RA direction): offset along Right Ascension, increasing to the East. * ``eta`` (Dec direction): offset along Declination, increasing to the North. The Jacobian is computed by making 1-pixel offsets in x and y, converting the resulting pixel positions to sky coordinates, and measuring the tangent-plane displacements in arcsec. This gives the forward Jacobian ``F = d(sky_arcsec)/d(pixel)``, which is then inverted to obtain ``J = F^{-1} = d(pixel)/d(sky_arcsec)``. Using 1-pixel steps ensures numerical stability across all pixel scales. This function works with any WCS that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`), because it relies only on the ``world_to_pixel`` and ``pixel_to_world`` methods. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate at which to evaluate the Jacobian. 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 ------- jacobian : 2x2 `~numpy.ndarray` The Jacobian matrix ``J`` such that ``[dx, dy]^T ≈ J @ [d_xi, d_eta]^T``, with units of pixels/arcsec. """ # Reference pixel position x0, y0 = wcs.world_to_pixel(skycoord) # Sky positions at 1-pixel offsets in x and y sky0 = wcs.pixel_to_world(x0, y0) sky_x = wcs.pixel_to_world(x0 + 1, y0) sky_y = wcs.pixel_to_world(x0, y0 + 1) ra0 = sky0.spherical.lon.rad dec0 = sky0.spherical.lat.rad cos_dec = np.cos(dec0) # Tangent-plane offsets (xi, eta) in arcsec for a +1 pixel step # in x. xi = dRA * cos(dec), eta = dDec, both converted to arcsec. dra_x = sky_x.spherical.lon.rad - ra0 ddec_x = sky_x.spherical.lat.rad - dec0 dxi_x = dra_x * cos_dec * 3600.0 * np.degrees(1) deta_x = ddec_x * 3600.0 * np.degrees(1) # Same for a +1 pixel step in y dra_y = sky_y.spherical.lon.rad - ra0 ddec_y = sky_y.spherical.lat.rad - dec0 dxi_y = dra_y * cos_dec * 3600.0 * np.degrees(1) deta_y = ddec_y * 3600.0 * np.degrees(1) # Forward Jacobian F = d(sky_arcsec)/d(pixel), shape (2, 2) # Rows are (xi, eta), columns are (px_x, px_y). forward = np.array([[dxi_x, dxi_y], [deta_x, deta_y]]) # Invert to get J = d(pixel)/d(sky_arcsec) return np.linalg.inv(forward) def sky_to_pixel_scales(skycoord, wcs, sky_angle_rad): """ Convert sky region parameters (center, directional scales, angle) to pixel region parameters. For a WCS without distortion, this uses the `wcs_pixel_scale_angle` offset method (isotropic pixel scale with a North-based rotation angle). For a WCS with distortion (or a non-astropy WCS like GWCS), this uses the local Jacobian matrix via `jacobian_sky_to_pixel_scales` to compute directional scale factors. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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`). sky_angle_rad : float The sky rotation angle in radians as a position angle (PA). This is the angle of the region's width axis measured counterclockwise from North (the latitude/Dec axis) in the tangent-plane coordinate system. Returns ------- center : tuple of float The ``(x, y)`` pixel center position. scale_w : float The scale factor along the width direction (pixels per arcsec). scale_h : float The scale factor along the height direction (pixels per arcsec). pixel_angle : `~astropy.coordinates.Angle` The pixel rotation angle of the width axis, measured counterclockwise from the positive x-axis, in degrees. """ # Non-FITS WCS (e.g., GWCS) and astropy.wcs.WCS with distortions # should use the Jacobian method to compute the pixel scales and # angle. if not _has_distortion(wcs): center, pixscale, north_angle = wcs_pixel_scale_angle(skycoord, wcs) scale = 1.0 / pixscale pixel_angle = Angle(np.rad2deg(sky_angle_rad) * u.deg + north_angle, ).wrap_at(360 * u.deg) return center, scale, scale, pixel_angle return jacobian_sky_to_pixel_scales(skycoord, wcs, sky_angle_rad) def pixel_to_sky_scales(pixcoord, wcs, pixel_angle_rad): """ Convert pixel region parameters (center, directional scales, angle) to sky region parameters. For a WCS without distortion, this uses the `wcs_pixel_scale_angle` offset method (isotropic pixel scale with a North-based rotation angle). For a WCS with distortion (or a non-astropy WCS like GWCS), this uses the local Jacobian matrix via `jacobian_pixel_to_sky_scales` to compute directional scale factors. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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`). pixel_angle_rad : float The pixel rotation angle in radians. This is the angle of the region's width axis measured counterclockwise from the positive x-axis in the pixel coordinate system. Returns ------- center : `~astropy.coordinates.SkyCoord` The sky center position. scale_w : float The scale factor along the width direction (arcsec per pixel). scale_h : float The scale factor along the height direction (arcsec per pixel). sky_angle : `~astropy.coordinates.Angle` The sky position angle (PA) of the width axis, measured counterclockwise from North (the latitude/Dec axis), wrapped to [0, 360) degrees. """ # Non-FITS WCS (e.g., GWCS) and astropy.wcs.WCS with distortions # should use the Jacobian method to compute the pixel scales and # angle. if not _has_distortion(wcs): center = wcs.pixel_to_world(pixcoord[0], pixcoord[1]) _, pixscale, north_angle = wcs_pixel_scale_angle(center, wcs) sky_angle = Angle(np.rad2deg(pixel_angle_rad) * u.deg - north_angle, ).wrap_at(360 * u.deg) return center, pixscale, pixscale, sky_angle return jacobian_pixel_to_sky_scales(pixcoord, wcs, pixel_angle_rad) def sky_to_pixel_mean_scale(skycoord, wcs): """ Convert a sky region center to pixel coordinates with an isotropic scale factor. For a WCS without distortion, this uses the `wcs_pixel_scale_angle` offset method. For a WCS with distortion (or a non-astropy WCS like GWCS), this uses the SVD of the local Jacobian matrix via `jacobian_sky_to_pixel_mean_scale`. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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 ------- center : tuple of float The ``(x, y)`` pixel center position. mean_scale : float The mean scale factor (pixels per arcsec). """ # Non-FITS WCS (e.g., GWCS) and astropy.wcs.WCS with distortions # should use the Jacobian method to compute the pixel scales and # angle. if not _has_distortion(wcs): center, pixscale, _ = wcs_pixel_scale_angle(skycoord, wcs) return center, 1.0 / pixscale return jacobian_sky_to_pixel_mean_scale(skycoord, wcs) def pixel_to_sky_mean_scale(pixcoord, wcs): """ Convert a pixel region center to sky coordinates with an isotropic scale factor. For a WCS without distortion, this uses the `wcs_pixel_scale_angle` offset method. For a WCS with distortion (or a non-astropy WCS like GWCS), this uses the SVD of the inverse Jacobian matrix via `jacobian_pixel_to_sky_mean_scale`. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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 ------- center : `~astropy.coordinates.SkyCoord` The sky center position. mean_scale : float The mean scale factor (arcsec per pixel). """ # Non-FITS WCS (e.g., GWCS) and astropy.wcs.WCS with distortions # should use the Jacobian method to compute the pixel scales and # angle. if not _has_distortion(wcs): center = wcs.pixel_to_world(pixcoord[0], pixcoord[1]) _, pixscale, _ = wcs_pixel_scale_angle(center, wcs) return center, pixscale return jacobian_pixel_to_sky_mean_scale(pixcoord, wcs) def pixel_ellipse_to_sky_svd(pixcoord, wcs, width, height, pixel_angle_rad): """ Convert a pixel ellipse to a sky ellipse using SVD. This builds the composite matrix ``M_sky = J^{-1} @ M_pix`` where ``M_pix`` encodes the pixel ellipse semi-axes and rotation, and ``J^{-1}`` is the local inverse Jacobian. The SVD of ``M_sky`` gives the exact sky ellipse semi-axes and orientation. This handles WCS shear correctly: the sky image of a pixel ellipse is always an ellipse, and SVD extracts its true principal axes, regardless of whether the Jacobian's mapped width and height directions are orthogonal. Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the ellipse center. 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`). width : float The full width of the pixel ellipse (before rotation) in pixels. height : float The full height of the pixel ellipse (before rotation) in pixels. pixel_angle_rad : float The pixel rotation angle in radians. This is the angle of the ellipse's width axis measured counterclockwise from the positive x-axis. Returns ------- center : `~astropy.coordinates.SkyCoord` The sky center position. sky_width : float The full width of the sky ellipse in arcsec. sky_height : float The full height of the sky ellipse in arcsec. sky_angle : `~astropy.coordinates.Angle` The sky position angle (PA) of the width axis, measured counterclockwise from North (the latitude/Dec axis), wrapped to [0, 360) degrees. """ center, _, jacobian_inv, parity = _pixel_to_sky_jacobian(pixcoord, wcs) # Build M_pix: columns are pixel semi-axis vectors cos_a = np.cos(pixel_angle_rad) sin_a = np.sin(pixel_angle_rad) half_w = 0.5 * width half_h = 0.5 * height m_pix = np.array([[half_w * cos_a, -half_h * sin_a], [half_w * sin_a, half_h * cos_a]]) # M_sky = J^{-1} @ M_pix: columns are sky semi-axis vectors m_sky = jacobian_inv @ m_pix sky_width, sky_height, sky_angle = _svd_ellipse_from_composite( m_sky, use_parity_for_angle=True, parity=parity) return center, sky_width, sky_height, sky_angle def sky_ellipse_to_pixel_svd(skycoord, wcs, width_arcsec, height_arcsec, sky_angle_rad): """ Convert a sky ellipse to a pixel ellipse using SVD. This builds the composite matrix ``M_pix = J @ M_sky`` where ``M_sky`` encodes the sky ellipse semi-axes and rotation, and ``J`` is the local Jacobian. The SVD of ``M_pix`` gives the exact pixel ellipse semi-axes and orientation. This handles WCS shear correctly: the pixel image of a sky ellipse is always an ellipse, and SVD extracts its true principal axes, regardless of whether the Jacobian's mapped width and height directions are orthogonal. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the ellipse center. 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`). width_arcsec : float The full width of the sky ellipse in arcsec. height_arcsec : float The full height of the sky ellipse in arcsec. sky_angle_rad : float The sky rotation angle in radians as a position angle (PA). This is the angle of the ellipse's width axis measured counterclockwise from North (the latitude/Dec axis). Returns ------- center : tuple of float The ``(x, y)`` pixel center position. pixel_width : float The full width of the pixel ellipse in pixels. pixel_height : float The full height of the pixel ellipse in pixels. pixel_angle : `~astropy.coordinates.Angle` The pixel rotation angle of the width axis, measured counterclockwise from the positive x-axis, wrapped to [0, 360) degrees. """ center, jacobian, parity = _sky_to_pixel_jacobian(skycoord, wcs) # Build M_sky: columns are sky semi-axis vectors in tangent-plane # coordinates (xi=RA, eta=Dec). The width axis is at the given PA # from North. Apply parity to the RA (xi) component. cos_pa = np.cos(sky_angle_rad) sin_pa = np.sin(sky_angle_rad) half_w = 0.5 * width_arcsec half_h = 0.5 * height_arcsec m_sky = np.array([[-parity * half_w * sin_pa, -parity * half_h * cos_pa], [half_w * cos_pa, -half_h * sin_pa]]) # M_pix = J @ M_sky: columns are pixel semi-axis vectors m_pix = jacobian @ m_sky pixel_width, pixel_height, pixel_angle = _svd_ellipse_from_composite( m_pix) return center, pixel_width, pixel_height, pixel_angle def sky_to_pixel_svd_scales(skycoord, wcs): """ Compute the pixel center, principal-axis scale factors, and pixel angle for a sky-to-pixel conversion using SVD of the local Jacobian. Uses the singular value decomposition (SVD) of the local Jacobian ``J = d(pixel)/d(sky_arcsec)`` to find the natural principal axes of the WCS transformation at the given sky position. The singular values give the scale factors along the major and minor axes of the ellipse that a unit circle in sky space maps to in pixel space. The left singular vectors give the directions of those axes in pixel coordinates. This is the appropriate method for converting a circular sky region to a pixel ellipse, as the resulting ellipse accurately represents the true shape of the WCS mapping (i.e., the tightest-fitting pixel ellipse that contains the sky circle). Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. 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 ------- center : tuple of float The ``(x, y)`` pixel center position. scale_major : float The scale factor along the major (maximum-stretch) axis (pixels per arcsec). scale_minor : float The scale factor along the minor (minimum-stretch) axis (pixels per arcsec). pixel_angle : `~astropy.coordinates.Angle` The pixel rotation angle of the major axis, measured counterclockwise from the positive x-axis, wrapped to [0, 360) degrees. """ center, jacobian, _ = _sky_to_pixel_jacobian(skycoord, wcs) u_mat, s_vals, _vt = np.linalg.svd(jacobian) # Pixel angle of the major axis: direction of u_mat[:,0] in pixel # space. No parity correction needed — pixel space has no axis # reflection. pixel_angle = Angle( np.rad2deg(np.arctan2(u_mat[1, 0], u_mat[0, 0])) * u.deg).wrap_at( 360 * u.deg) return center, s_vals[0], s_vals[1], pixel_angle def pixel_to_sky_svd_scales(pixcoord, wcs): """ Compute the sky center, principal-axis scale factors, and sky angle for a pixel-to-sky conversion using SVD of the inverse Jacobian. Uses the singular value decomposition (SVD) of the local inverse Jacobian ``J^{-1} = d(sky)/d(pixel)`` to find the natural principal axes of the WCS transformation at the given pixel position. The singular values give the scale factors along the major and minor axes of the ellipse that a unit circle in pixel space maps to in sky space. The left singular vectors give the directions of those axes in tangent-plane coordinates. This is the appropriate method for converting a circular pixel region to a sky ellipse, as the resulting ellipse accurately represents the true shape of the WCS mapping (i.e., the tightest-fitting sky ellipse that contains the pixel circle). Parameters ---------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. 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 ------- center : `~astropy.coordinates.SkyCoord` The sky center position. scale_major : float The scale factor along the major (maximum-stretch) axis (arcsec per pixel). scale_minor : float The scale factor along the minor (minimum-stretch) axis (arcsec per pixel). sky_angle : `~astropy.coordinates.Angle` The sky position angle (PA) of the major axis, measured counterclockwise from North (the latitude/Dec axis), wrapped to [0, 360) degrees. """ center, _, jacobian_inv, _ = _pixel_to_sky_jacobian(pixcoord, wcs) u_mat, s_vals, _vt = np.linalg.svd(jacobian_inv) # Sky position angle (PA) of the major axis: u_mat columns are in # raw tangent-plane coordinates (xi=East, eta=North), so PA is # simply arctan2(xi, eta). sky_angle = Angle( np.rad2deg(np.arctan2(u_mat[0, 0], u_mat[1, 0])) * u.deg, ).wrap_at(360 * u.deg) return center, s_vals[0], s_vals[1], sky_angle def wcs_pixel_scale_angle(skycoord, wcs): """ Calculate the pixel coordinate, scale, and WCS rotation angle at the position of a sky 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`). Returns ------- pixcoord : tuple of float The ``(x, y)`` pixel coordinate. scale : float The pixel scale in arcsec/pixel. angle : `~astropy.coordinates.Angle` The angle measured counterclockwise from the positive x axis to the "North" axis of the celestial coordinate system, wrapped to [0, 360) degrees. Notes ----- If distortions are present in the WCS, the x and y pixel scales likely differ. This function computes independent x and y scales and takes their geometric mean. """ # Convert to pixel coordinates x, y = wcs.world_to_pixel(skycoord) pixcoord = (float(x), float(y)) # Position-dependent scale using 1-pixel offsets in x and y. # The pixel scale is the geometric mean of the two directional # scales. sky0 = wcs.pixel_to_world(x, y) sky_x = wcs.pixel_to_world(x + 1, y) sky_y = wcs.pixel_to_world(x, y + 1) cdelt_x = sky0.separation(sky_x).arcsec cdelt_y = sky0.separation(sky_y).arcsec scale = np.sqrt(cdelt_x * cdelt_y) # Compute the angle by offsetting in latitude by exactly the local # cdelt (geometric-mean pixel scale in degrees). This ensures # the finite-difference derivative probes the same scale of the # distortion field. cdelt_deg = scale / 3600 # arcsec -> deg skycoord_offset = skycoord.directional_offset_by( 0.0, cdelt_deg * u.deg) x_offset, y_offset = wcs.world_to_pixel(skycoord_offset) dx = x_offset - x dy = y_offset - y angle_rad = np.arctan2(dy, dx) angle = Angle(np.rad2deg(angle_rad) * u.deg).wrap_at(360 * u.deg) return pixcoord, scale, angle astropy-photutils-3322558/photutils/utils/colormaps.py000066400000000000000000000033031517052111400232010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating matplotlib colormaps. This module requires matplotlib to be installed. """ import numpy as np from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) __all__ = ['make_random_cmap'] @deprecated_renamed_argument('ncolors', 'n_colors', '3.0', until='4.0') @deprecated_positional_kwargs(since='3.0', until='4.0') def make_random_cmap(n_colors=256, seed=None): """ Make a matplotlib colormap consisting of (random) muted colors. A random colormap is very useful for plotting segmentation images. Parameters ---------- n_colors : int, optional The number of colors in the colormap. The default is 256. Must be at least 1. 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. """ if n_colors < 1: msg = 'n_colors must be at least 1' raise ValueError(msg) from matplotlib import colors rng = np.random.default_rng(seed) hue = rng.uniform(low=0.0, high=1.0, size=n_colors) sat = rng.uniform(low=0.2, high=0.7, size=n_colors) val = rng.uniform(low=0.5, high=1.0, size=n_colors) hsv = np.dstack((hue, sat, val)) rgb = np.squeeze(colors.hsv_to_rgb(hsv)) return colors.ListedColormap(colors.to_rgba_array(rgb)) astropy-photutils-3322558/photutils/utils/cutouts.py000066400000000000000000000261701517052111400227170ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating 2D image cutouts. """ import numpy as np from astropy.nddata import extract_array, overlap_slices from astropy.utils import lazyproperty from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['CutoutImage'] 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 : tuple of 2 ints The ``(y, x)`` position of the center of the cutout array with respect to the ``data`` array. shape : tuple of 2 ints 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. Notes ----- If the cutout array is not fully contained within the input ``data`` array and ``mode='partial'`` with ``fill_value=np.nan``, then the input ``data`` must have a float data type. 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.]] """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, data, position, shape, mode='trim', fill_value=np.nan, copy=False): self.position = position self.input_shape = tuple(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): """ Create the cutout data array. Parameters ---------- data : `~numpy.ndarray` The 2D data array from which to extract the cutout array. Returns ------- cutout_data : `~numpy.ndarray` The 2D cutout data array. """ 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 # NumPy calls `obj.__array__(dtype)` positionally with # `np.asarray(obj, dtype=int)`, so dtype must remain a positional # argument. def __array__(self, dtype=None, *, copy=None): """ Array representation of the cutout data array (e.g., for matplotlib). Parameters ---------- dtype : `~numpy.dtype`, optional The data type of the output array. If `None`, then the data type of the cutout data array is used. copy : bool, optional If `True`, then a copy of the underlying data array is returned. """ return np.array(self.data, dtype=dtype, copy=copy) 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 (f'{self.__class__.__name__}(position={self.position}, ' f'shape={self.shape})') @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. Parameters ---------- slices : tuple of slice The slices for the bounding box. """ # Prevent circular import from photutils.aperture import BoundingBox 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. Parameters ---------- slices : tuple of slice The slices for the bounding box. Returns ------- xyorigin : `~numpy.ndarray` The ``(x, y)`` integer index of the origin pixel of the cutout with respect to the original array. """ 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) def _make_cutouts(data, xpos, ypos, cutout_shape, *, fill_value=0.0): """ Make 2D cutouts from a data array at the given positions. Positions are rounded to the nearest integer pixel. Pixels that fall outside the image boundary are filled with ``fill_value``. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image array. xpos : 1D `~numpy.ndarray` The x pixel positions of the cutout centers. ypos : 1D `~numpy.ndarray` The y pixel positions of the cutout centers. cutout_shape : tuple of int The ``(ny, nx)`` shape of each cutout. fill_value : float, optional The value used to fill pixels that fall outside the image boundary. The default is 0.0. Use ``np.nan`` when out-of-bounds pixels must be distinguishable from real data (e.g., for sigma-clipped statistics on partial cutouts). Returns ------- cutouts : 3D `~numpy.ndarray` A 3D array of shape ``(n_sources, ny, nx)`` containing the cutout data. overlap_mask : 3D `~numpy.ndarray` of bool A boolean array with the same shape as ``cutouts``. `True` indicates a pixel that came from ``data``. `False` indicates a pixel that was filled with ``fill_value`` because it fell outside the image boundary. Per-source overlap status can be derived from this mask: * Fully inside the image: ``overlap_mask[i].all()`` * No overlap (entirely outside): ``~overlap_mask[i].any()`` * Partial overlap: neither of the above """ data = np.asarray(data) if data.ndim != 2: msg = 'data must be a 2D array' raise ValueError(msg) xpos = np.atleast_1d(np.asarray(xpos)) ypos = np.atleast_1d(np.asarray(ypos)) if xpos.ndim != 1 or ypos.ndim != 1: msg = 'xpos and ypos must be 1D arrays' raise ValueError(msg) if len(xpos) != len(ypos): msg = 'xpos and ypos must have the same length' raise ValueError(msg) if len(cutout_shape) != 2: msg = 'cutout_shape must have exactly 2 elements' raise ValueError(msg) ky, kx = cutout_shape hy, hx = ky // 2, kx // 2 yc = np.round(ypos).astype(int) xc = np.round(xpos).astype(int) # Build index grids: shape (n_sources, ky, kx) dy = np.arange(ky) - hy dx = np.arange(kx) - hx y_idx = yc[:, np.newaxis, np.newaxis] + dy[np.newaxis, :, np.newaxis] x_idx = xc[:, np.newaxis, np.newaxis] + dx[np.newaxis, np.newaxis, :] # Mask of pixels inside the image boundary overlap_mask = ((y_idx >= 0) & (y_idx < data.shape[0]) & (x_idx >= 0) & (x_idx < data.shape[1])) # Clip out-of-bounds indices to valid range so numpy indexing # doesn't raise. The out-of-bounds pixels are replaced below. y_safe = np.clip(y_idx, 0, data.shape[0] - 1) x_safe = np.clip(x_idx, 0, data.shape[1] - 1) cutouts = np.where(overlap_mask, data[y_safe, x_safe], fill_value) return cutouts, overlap_mask astropy-photutils-3322558/photutils/utils/depths.py000066400000000000000000000476231517052111400225060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating limiting fluxes. """ import warnings import astropy.units as u import numpy as np from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import binary_dilation from photutils.utils._coords import apply_separation from photutils.utils._deprecation import (deprecated_getattr, deprecated_renamed_argument) from photutils.utils._parameters import (SigmaClipSentinelDefault, create_default_sigmaclip) 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.*'): ['skimage']} SIGMA_CLIP = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) # Remove in 4.0 _DEPRECATED_ATTRIBUTES = { 'nsigma': 'n_sigma', 'napers': 'n_apertures', 'niters': 'n_iters', 'napers_used': 'n_apertures_used', } 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. n_sigma : float, optional The number of standard deviations at which to compute the image depths. .. deprecated:: 3.0 The ``nsigma`` keyword is deprecated. Use ``n_sigma`` instead. mask_pad : float, optional An additional padding (in pixels) to apply when dilating the input mask. n_apertures : int, optional The number of circular apertures used to compute the image depth. .. deprecated:: 3.0 The ``napers`` keyword is deprecated. Use ``n_apertures`` instead. n_iters : int, optional The number of iterations, each with randomly-generated apertures, for which the image depth will be calculated. .. deprecated:: 3.0 The ``niters`` keyword is deprecated. Use ``n_iters`` instead. 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`, 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. Attributes ---------- apertures : list of `~photutils.aperture.CircularAperture` A list of circular apertures for each iteration. napers_used : 1D `~numpy.ndarray` .. deprecated:: 3.0 Use ``n_apertures_used`` instead. n_apertures_used : 1D `~numpy.ndarray` An array 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 ``n_apertures`` 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 ``n_sigma`` 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 ``n_iters`` 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) >>> n_pixels = 10 >>> threshold = 3.2 >>> finder = SourceFinder(n_pixels=n_pixels, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> mask = segment_map.make_source_mask() >>> radius = 4 >>> depth = ImageDepth(radius, n_sigma=5.0, n_apertures=500, ... n_iters=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) n_pixels = 10 threshold = 3.2 finder = SourceFinder(n_pixels=n_pixels, progress_bar=False) segment_map = finder(convolved_data, threshold) mask = segment_map.make_source_mask() radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=500, n_iters=2, overlap=False, seed=123, progress_bar=False) limits = depth(data, mask) fig, ax = plt.subplots(nrows=2, ncols=1, figsize=(5, 7)) norm = simple_norm(data, 'sqrt', percent=99.5) ax[0].imshow(data, norm=norm, origin='lower') color = 'white' depth.apertures[0].plot(ax=ax[0], color=color) ax[0].set_title('Data with blank apertures') ax[1].imshow(mask, origin='lower') depth.apertures[0].plot(ax=ax[1], color=color) ax[1].set_title('Mask with blank apertures') fig.tight_layout() """ @deprecated_renamed_argument('nsigma', 'n_sigma', '3.0', until='4.0') @deprecated_renamed_argument('napers', 'n_apertures', '3.0', until='4.0') @deprecated_renamed_argument('niters', 'n_iters', '3.0', until='4.0') def __init__(self, aper_radius, *, n_sigma=5.0, mask_pad=0, n_apertures=1000, n_iters=10, overlap=True, overlap_maxiters=100, seed=None, zeropoint=0.0, sigma_clip=SIGMA_CLIP, progress_bar=True): if aper_radius <= 0: msg = 'aper_radius must be > 0' raise ValueError(msg) if mask_pad < 0: msg = 'mask_pad must be >= 0' raise ValueError(msg) self.aper_radius = aper_radius self.n_sigma = n_sigma self.mask_pad = mask_pad self.n_apertures = n_apertures self.n_iters = n_iters self.overlap = overlap self.overlap_maxiters = overlap_maxiters self.seed = seed self.zeropoint = zeropoint if sigma_clip is SIGMA_CLIP: sigma_clip = create_default_sigmaclip(sigma=SIGMA_CLIP.sigma, maxiters=SIGMA_CLIP.maxiters) if sigma_clip is not None and not callable(sigma_clip): msg = 'sigma_clip must be a callable (e.g., SigmaClip) or None' raise TypeError(msg) 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.n_apertures_used = np.array([]) self.fluxes = [] self.flux_limits = np.array([]) self.mag_limits = np.array([]) def __repr__(self): params = ('aper_radius', 'n_sigma', 'mask_pad', 'n_apertures', 'n_iters', 'overlap', 'overlap_maxiters', 'seed', 'zeropoint', 'sigma_clip', 'progress_bar') return make_repr(self, params) # Remove in 4.0 def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.0', until='4.0') 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: msg = ('There are no unmasked pixel values (including the ' 'masked image borders).') raise ValueError(msg) n_apertures = self.n_apertures if not self.overlap: n_apertures2 = 1.5 * self.n_apertures n_apertures = int(min(n_apertures2, 0.1 * len(all_xycoords))) iter_range = range(self.n_iters) if self.progress_bar: desc = 'Image Depths' iter_range = add_progress_bar(iter_range, desc=desc) flux_limits = [] apertures = [] for _ in iter_range: if self.overlap: xycoords = self._make_coords(all_xycoords, n_apertures) else: # Cut the number of coords (only need to input ~10x) xycoords = self._make_coords(all_xycoords, n_apertures * 10) min_separation = self.aper_radius * 2.0 xycoords = apply_separation(xycoords, min_separation) xycoords = xycoords[0:self.n_apertures] 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.n_sigma * np.std(fluxes)) self.apertures = apertures n_apertures_used = np.array([len(apers) for apers in apertures]) self.n_apertures_used = n_apertures_used if np.any(n_apertures_used < self.n_apertures): msg = (f'Unable to generate {self.n_apertures} ' 'non-overlapping apertures in unmasked regions. ' 'The number of apertures used was less than ' f'{self.n_apertures} (see the ' '"n_apertures_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') warnings.warn(msg, 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): msg = ('One or more flux_limit values was zero. This is ' 'likely due to constant image values. Check the ' 'input mask.') warnings.warn(msg, 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. """ mask = np.asarray(mask, dtype=bool).copy() if np.any(mask): mask = binary_dilation(mask, structure=self.dilate_footprint) return self._mask_border(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]: msg = 'Too many apertures for given unmasked area' raise ValueError(msg) 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 astropy-photutils-3322558/photutils/utils/errors.py000066400000000000000000000173571517052111400225340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating total error arrays. """ import astropy.units as u import numpy as np __all__ = ['calc_total_error'] def calc_total_error(data, bkg_error, effective_gain): r""" Calculate a total error array by 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: msg = ('If any of data, bkg_error, or effective_gain has units, ' 'then they all must have units.') raise ValueError(msg) if bkg_error.shape != data.shape: msg = ('bkg_error must have the same shape as the input data.') raise ValueError(msg) if use_units: if data.unit != bkg_error.unit: msg = 'data and bkg_error must have the same units' raise ValueError(msg) count_units = [u.electron, u.photon] datagain_unit = data.unit * effective_gain.unit if datagain_unit not in count_units: msg = ('(data * effective_gain) has units of ' f'{datagain_unit}, but it must have count units ' '(e.g., u.electron or u.photon).') raise u.UnitsError(msg) if not np.iterable(effective_gain): effective_gain = np.zeros(data.shape) + effective_gain else: effective_gain = np.asanyarray(effective_gain) if effective_gain.shape != data.shape: msg = ('If input effective_gain is 2D, then it must have ' 'the same shape as the input data.') raise ValueError(msg) if np.any(effective_gain < 0): msg = 'effective_gain must be non-negative everywhere' raise ValueError(msg) 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) astropy-photutils-3322558/photutils/utils/exceptions.py000066400000000000000000000004521517052111400233650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Custom exceptions. """ from astropy.utils.exceptions import AstropyWarning __all__ = ['NoDetectionsWarning'] class NoDetectionsWarning(AstropyWarning): """ A warning class to indicate no sources were detected. """ astropy-photutils-3322558/photutils/utils/footprints.py000066400000000000000000000030611517052111400234120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating footprints. """ import numpy as np from photutils.utils._deprecation import deprecated_positional_kwargs __all__ = ['circular_footprint'] @deprecated_positional_kwargs(since='3.0', until='4.0') 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 or float The radius of the circular footprint. If float, must be a positive finite whole number (e.g., 2.0 is accepted). 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 not np.isfinite(radius) or radius <= 0 or int(radius) != radius: msg = 'radius must be a positive, finite integer greater than 0' raise ValueError(msg) x = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(x, x) return np.array((xx**2 + yy**2) <= radius**2, dtype=dtype) astropy-photutils-3322558/photutils/utils/interpolation.py000066400000000000000000000322341517052111400240760ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for interpolating data. """ import numpy as np from scipy.spatial import cKDTree from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) __all__ = ['ShepardIDWInterpolator'] 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 an 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 """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, coordinates, values, weights=None, leafsize=10): 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: msg = 'coordinates must have at least one data point' raise ValueError(msg) if values.shape[0] != ncoords: msg = 'The number of values must match the number of coordinates.' raise ValueError(msg) if weights is not None: weights = np.asanyarray(weights).ravel() if weights.shape[0] != ncoords: msg = ('The number of weights must match the number of ' 'coordinates.') raise ValueError(msg) if np.any(weights < 0.0): msg = 'All weight values must be non-negative numbers.' raise ValueError(msg) self.coordinates = coordinates self.ncoords = ncoords self.coords_ndim = coordinates.shape[1] self.values = values self.weights = weights self.kdtree = cKDTree(coordinates, leafsize=leafsize) @deprecated_renamed_argument('reg', 'regularization', '3.0', until='4.0') @deprecated_positional_kwargs(since='3.0', until='4.0') def __call__(self, positions, n_neighbors=8, eps=0.0, power=1.0, regularization=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 an 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. regularization : 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 ``regularization`` 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. Returns ------- result : float or `~numpy.ndarray` The interpolated value(s). A scalar is returned when a single position is provided; otherwise a 1D array is returned. """ n_neighbors = int(n_neighbors) if n_neighbors < 1: msg = 'n_neighbors must be a positive integer' raise ValueError(msg) 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: msg = ('The dimensionality of the input position does ' 'not match the dimensionality of the coordinates ' 'used to initialize the interpolator.') raise ValueError(msg) elif positions.ndim == 1: # Assume we have a single point if self.coords_ndim not in (1, positions.shape[-1]): msg = ('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.') raise ValueError(msg) elif positions.ndim != 2: msg = ('The input positions must be an array_like object ' 'of dimensionality no larger than 2.') raise ValueError(msg) positions = np.reshape(positions, (-1, self.coords_ndim)) n_positions = positions.shape[0] distances, idx = self.kdtree.query(positions, k=n_neighbors, eps=eps) if n_neighbors == 1: result = self.values[idx] return result.item() if n_positions == 1 else result if dtype is None: dtype = self.values.dtype # distances and idx have shape (n_positions, n_neighbors). Mask # for valid (finite) distances; invalid entries arise when # n_neighbors exceeds the number of known data points. valid = np.isfinite(distances) # Replace non-finite distances and out-of-bound indices with # safe values so that vectorized indexing and arithmetic do not # raise errors; the ``valid`` mask zeroes them out later. safe_distances = np.where(valid, distances, 1.0) safe_idx = np.where(valid, idx, 0) # Inverse distance weights: w_i = 1 / (d_i^power + reg) The # errstate context suppresses divide-by-zero warnings that occur # when a query point coincides with a data point (distance = 0 # and reg = 0); these are handled by the conf_dist override. with np.errstate(invalid='ignore', divide='ignore'): weights = np.where(valid, 1.0 / (safe_distances ** power + regularization), 0.0) # Apply external (user-supplied) weights if self.weights is not None: weights *= np.where(valid, self.weights[safe_idx], 0.0) # Gather neighbor values and compute the weighted average neighbor_values = self.values[safe_idx] weights_tot = np.sum(weights, axis=1) weighted_sum = np.sum(weights * neighbor_values, axis=1) # Where total weight is positive, compute interpolation; # otherwise return NaN (covers both the "no valid # neighbours" and "all-zero external weights" cases). interp_values = np.where(weights_tot > 0.0, weighted_sum / weights_tot, np.nan).astype(dtype) # Confusion-distance override: if the nearest neighbour is # closer than ``conf_dist``, return its value directly instead # of interpolating (avoids singularities when reg == 0). if conf_dist is not None: min_dist = distances[:, 0] confused = np.isfinite(min_dist) & (min_dist <= conf_dist) if np.any(confused): interp_values[confused] = self.values[ idx[confused, 0] ].astype(dtype) if n_positions == 1: return interp_values[0] return interp_values astropy-photutils-3322558/photutils/utils/tests/000077500000000000000000000000001517052111400217735ustar00rootroot00000000000000astropy-photutils-3322558/photutils/utils/tests/__init__.py000066400000000000000000000000001517052111400240720ustar00rootroot00000000000000astropy-photutils-3322558/photutils/utils/tests/conftest.py000066400000000000000000000062741517052111400242030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Pytest configuration and WCS test fixtures for photutils.utils tests. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.wcs import WCS # WCS test constants WCS_CENTER = SkyCoord(100 * u.deg, 30 * u.deg) WCS_CDELT_ARCSEC = 0.1 def _make_simple_wcs(skycoord, resolution, size, rotation_deg=0.0): """ Create a simple TAN WCS with optional rotation. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The center sky coordinate (CRVAL). resolution : `~astropy.units.Quantity` The pixel scale (CDELT) as an angular quantity. size : int Number of pixels along each axis. rotation_deg : float, optional Rotation angle in degrees (default: 0). Returns ------- wcs : `~astropy.wcs.WCS` The WCS object. """ cdelt_deg = resolution.to(u.deg).value wcs = WCS(naxis=2) wcs.wcs.crpix = [size / 2 + 0.5, size / 2 + 0.5] wcs.wcs.crval = [skycoord.ra.deg, skycoord.dec.deg] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] if rotation_deg == 0.0: wcs.wcs.cdelt = [-cdelt_deg, cdelt_deg] else: angle_rad = np.radians(rotation_deg) cos_a = np.cos(angle_rad) sin_a = np.sin(angle_rad) wcs.wcs.cd = [[-cdelt_deg * cos_a, cdelt_deg * sin_a], [cdelt_deg * sin_a, cdelt_deg * cos_a]] return wcs def _make_sip_wcs(): """ Create a TAN WCS with small SIP distortion terms. Returns ------- wcs : `~astropy.wcs.WCS` The WCS object with SIP distortion. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.cdelt = [-WCS_CDELT_ARCSEC / 3600, WCS_CDELT_ARCSEC / 3600] wcs.wcs.ctype = ['RA---TAN-SIP', 'DEC--TAN-SIP'] # Small SIP distortion coefficients m = 2 # A/B order wcs.sip = None # will be populated from the header sip_header = wcs.to_header() sip_header['CTYPE1'] = 'RA---TAN-SIP' sip_header['CTYPE2'] = 'DEC--TAN-SIP' sip_header['A_ORDER'] = m sip_header['B_ORDER'] = m sip_header['A_2_0'] = 1e-7 sip_header['A_0_2'] = 1e-7 sip_header['B_2_0'] = 1e-7 sip_header['B_0_2'] = 1e-7 return WCS(sip_header) @pytest.fixture def simple_wcs(): """ Non-distorted TAN WCS aligned with the celestial axes. """ return _make_simple_wcs(WCS_CENTER, WCS_CDELT_ARCSEC * u.arcsec, 20) @pytest.fixture def rotated_wcs(): """ Non-distorted TAN WCS with a 25-degree rotation (CD matrix). """ return _make_simple_wcs(WCS_CENTER, WCS_CDELT_ARCSEC * u.arcsec, 20, rotation_deg=25.0) @pytest.fixture def sip_wcs(): """ TAN WCS with small SIP distortion terms. """ return _make_sip_wcs() @pytest.fixture def nonsquare_wcs(): """ Non-distorted TAN WCS with non-square pixels (0.03 x 0.05 deg). """ wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.cdelt = [-0.03, 0.05] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs astropy-photutils-3322558/photutils/utils/tests/test_colormaps.py000066400000000000000000000033271517052111400254100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the colormaps module. """ import pytest from astropy.utils.exceptions import AstropyDeprecationWarning 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(): """ Test make_random_cmap with default parameters. """ n_colors = 100 cmap = make_random_cmap(n_colors=n_colors, seed=0) assert len(cmap.colors) == n_colors assert cmap.colors.shape == (100, 4) assert_allclose(cmap.colors[0], [0.36951484, 0.42125961, 0.65984082, 1.0]) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_colormap_n_colors_one(): """ Test make_random_cmap with n_colors=1. """ cmap = make_random_cmap(n_colors=1, seed=0) assert len(cmap.colors) == 1 assert cmap.colors.shape == (1, 4) def test_colormap_n_colors_invalid(): """ Test make_random_cmap with invalid n_colors. """ match = 'n_colors must be at least 1' with pytest.raises(ValueError, match=match): make_random_cmap(n_colors=0) with pytest.raises(ValueError, match=match): make_random_cmap(n_colors=-1) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_colormap_ncolors_deprecated(): """ Test that using the deprecated ``ncolors`` keyword raises a deprecation warning. """ match = "'ncolors' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): cmap = make_random_cmap(ncolors=10, seed=0) assert len(cmap.colors) == 10 astropy-photutils-3322558/photutils/utils/tests/test_convolution.py000066400000000000000000000047751517052111400260000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the convolution module. """ import astropy.units as u import numpy as np import pytest from astropy.convolution import Gaussian2DKernel from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.utils._convolution import _filter_data 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): """ Test _filter_data with Kernel2D and array kernels. """ 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): """ Test _filter_data with Quantity input data. """ 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 that 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 _filter_data with kernel=None. """ kernel = None filt_data = _filter_data(self.data, kernel) assert_allclose(filt_data, self.data) def test_filter_data_unnormalized_kernel(self): """ Test that a warning is issued for an unnormalized kernel. """ kernel = np.ones((3, 3)) # sums to 9, not normalized match = 'The kernel is not normalized' with pytest.warns(AstropyUserWarning, match=match): _filter_data(self.data, kernel, check_normalization=True) astropy-photutils-3322558/photutils/utils/tests/test_coords.py000066400000000000000000000040211517052111400246720ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _coords module. """ import pytest from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._coords import make_random_xycoords @pytest.mark.parametrize('min_sep', [0.0, 5.7, 10.0, 17.4]) def test_make_random_xycoords(min_sep): """ Test make_random_xycoords with various minimum separations. """ xmin, xmax = 97, 903 ymin, ymax = 0, 501 ncoords = 100 xycoords = make_random_xycoords(ncoords, (xmin, xmax), (ymin, ymax), min_separation=min_sep, seed=0) assert xycoords.shape == (ncoords, 2) assert xycoords[:, 0].min() >= xmin assert xycoords[:, 0].max() <= xmax assert xycoords[:, 1].min() >= ymin assert xycoords[:, 1].max() <= ymax # Check that the minimum separation is met if min_sep > 0: dists2 = ((xycoords[:, None, 0] - xycoords[None, :, 0])**2 + (xycoords[:, None, 1] - xycoords[None, :, 1])**2) dists2 = dists2[dists2 > 0] assert dists2.min() >= min_sep**2 def test_make_random_xycoords_size_zero(): """ Test that size=0 returns an empty array with shape (0, 2). """ xycoords = make_random_xycoords(0, (0, 10), (0, 10), seed=0) assert xycoords.shape == (0, 2) def test_make_random_xycoords_invalid_range(): """ Test that invalid x_range or y_range raises ValueError. """ match = 'x_range and y_range must be .* with min < max' with pytest.raises(ValueError, match=match): make_random_xycoords(5, (10, 0), (0, 10), seed=0) with pytest.raises(ValueError, match=match): make_random_xycoords(5, (0, 10), (10, 0), seed=0) def test_make_random_xycoords_crowded(): """ Test that a warning is issued when coordinates are too crowded. """ match = 'coordinates within the given shape and minimum separation' with pytest.warns(AstropyUserWarning, match=match): make_random_xycoords(50, (0, 50), (10, 30), min_separation=10, seed=0) astropy-photutils-3322558/photutils/utils/tests/test_cutouts.py000066400000000000000000000230751517052111400251210ustar00rootroot00000000000000# 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 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, _make_cutouts class TestCutoutImage: """ Tests for the CutoutImage class. """ def test_cutout(self): """ Test CutoutImage with basic parameters. """ 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))) cutouts2 = CutoutImage(data, yxpos, np.array(shape)) assert cutouts2.input_shape == shape assert 'CutoutImage(' in repr(cutout) assert f'shape={shape}' in repr(cutout) assert f'Shape: {shape}' in str(cutout) def test_cutout_partial_overlap(self): """ Test CutoutImage with partial overlap modes. """ data = make_100gaussians_image() shape = (24, 57) # 'trim' mode cutout = CutoutImage(data, (11, 10), shape) assert cutout.input_shape == shape assert cutout.shape == (23, 39) # 'strict' mode match = 'Arrays overlap only partially' with pytest.raises(PartialOverlapError, match=match): CutoutImage(data, (11, 10), shape, mode='strict') # 'partial' mode 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(self): """ Test CutoutImage with copy=True and copy=False. """ 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]) class TestMakeCutouts: """ Tests for the _make_cutouts utility function. """ def setup_method(self): self.data = np.arange(100, dtype=float).reshape(10, 10) def test_fully_inside(self): """ Test a source fully inside the image. """ xpos = np.array([5.0]) ypos = np.array([5.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (3, 3)) assert cutouts.shape == (1, 3, 3) np.testing.assert_array_equal(cutouts[0], self.data[4:7, 4:7]) assert mask[0].all() def test_partial_overlap_corners(self): """ Test sources at image corners that partially overlap. """ xpos = np.array([0.0, 9.0]) ypos = np.array([0.0, 9.0]) _, mask = _make_cutouts(self.data, xpos, ypos, (5, 5)) # Corner (0, 0): top-left 2 rows and 2 cols are outside assert not mask[0].all() # not fully inside assert mask[0].any() # not fully outside assert not mask[0, 0, 0] # outside pixel assert mask[0, 2, 2] # center pixel (the position itself) # Corner (9, 9): bottom-right 2 rows and 2 cols are outside assert not mask[1].all() assert mask[1].any() assert mask[1, 2, 2] # center pixel assert not mask[1, 4, 4] # outside pixel def test_no_overlap(self): """ Test a source completely outside the image. """ xpos = np.array([-10.0]) ypos = np.array([-10.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (3, 3)) assert not mask[0].any() assert np.all(cutouts[0] == 0.0) def test_fill_value_nan(self): """ Test that fill_value=NaN fills out-of-bounds pixels with NaN. """ xpos = np.array([0.0]) ypos = np.array([0.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (5, 5), fill_value=np.nan) # Outside pixels should be NaN assert np.all(np.isnan(cutouts[0][~mask[0]])) # Inside pixels should not be NaN assert np.all(np.isfinite(cutouts[0][mask[0]])) def test_fill_value_custom(self): """ Test that a custom fill_value is used for out-of-bounds pixels. """ xpos = np.array([0.0]) ypos = np.array([0.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (3, 3), fill_value=-99.0) assert np.all(cutouts[0][~mask[0]] == -99.0) def test_overlap_mask_dtype(self): """ Test that overlap_mask is a boolean array. """ xpos = np.array([5.0]) ypos = np.array([5.0]) _, mask = _make_cutouts(self.data, xpos, ypos, (3, 3)) assert mask.dtype == bool def test_mixed_sources(self): """ Test a mix of fully-inside, partial, and outside sources. """ xpos = np.array([5.0, 0.0, -10.0]) ypos = np.array([5.0, 0.0, -10.0]) _, mask = _make_cutouts(self.data, xpos, ypos, (3, 3)) # Fully inside assert mask[0].all() # Partial overlap assert mask[1].any() assert not mask[1].all() # No overlap assert not mask[2].any() def test_even_shaped_cutout(self): """ Test _make_cutouts with an even-shaped cutout. """ xpos = np.array([5.0]) ypos = np.array([5.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (4, 4)) assert cutouts.shape == (1, 4, 4) assert mask[0].all() # fully inside # Half-widths: hy=2, hx=2; cutout rows [3..6], cols [3..6] expected = self.data[3:7, 3:7] np.testing.assert_array_equal(cutouts[0], expected) def test_even_shaped_cutout_at_edge(self): """ Test _make_cutouts with an even-shaped cutout at the image edge. """ xpos = np.array([0.0]) ypos = np.array([0.0]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (4, 4)) assert cutouts.shape == (1, 4, 4) # Some pixels should be outside assert not mask[0].all() assert mask[0].any() # Outside pixels should be zero (default fill_value) assert np.all(cutouts[0][~mask[0]] == 0.0) def test_data_not_2d(self): """ Test that a non-2D data array raises ValueError. """ match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): _make_cutouts(np.ones(10), np.array([5.0]), np.array([5.0]), (3, 3)) def test_xpos_not_1d(self): """ Test that non-1D xpos/ypos arrays raise ValueError. """ match = 'xpos and ypos must be 1D arrays' with pytest.raises(ValueError, match=match): _make_cutouts(self.data, np.ones((2, 2)), np.array([5.0]), (3, 3)) def test_cutout_shape_wrong_length(self): """ Test that cutout_shape with != 2 elements raises ValueError. """ match = 'cutout_shape must have exactly 2 elements' with pytest.raises(ValueError, match=match): _make_cutouts(self.data, np.array([5.0]), np.array([5.0]), (3, 3, 3)) def test_xpos_ypos_length_mismatch(self): """ Test that mismatched xpos/ypos lengths raise ValueError. """ match = 'xpos and ypos must have the same length' with pytest.raises(ValueError, match=match): _make_cutouts(self.data, np.array([5.0, 6.0]), np.array([5.0]), (3, 3)) astropy-photutils-3322558/photutils/utils/tests/test_deprecation.py000066400000000000000000001070011517052111400257000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _deprecation module. """ import warnings import numpy as np import pytest from astropy.table import QTable, Table, TableMergeError, join, unique from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.utils._deprecation import (DeprecatedColumnQTable, DeprecatedColumnTable, _future_column_names_var, create_deprecated_table_from_data, create_empty_deprecated_qtable, deprecated, deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument, use_future_column_names) DEPRECATION_MAP = {'old': 'new', 'old_b': 'new_b'} @pytest.fixture def raw_data(): """ Provide a raw data dictionary for table creation. """ return {'old': [3, 2, 1], 'old_b': [4, 5, 6], 'stable': [7, 8, 9]} class TestDeprecatedColumn: """ Tests for DeprecatedColumnTable and DeprecatedColumnQTable. """ def test_creation_and_type(self, raw_data): """ Test that the factory creates the correct object type. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) assert isinstance(table, DeprecatedColumnTable) assert not isinstance(table, DeprecatedColumnQTable) assert set(table.colnames) == {'new', 'new_b', 'stable'} qtable = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP, use_qtable=True) assert isinstance(qtable, DeprecatedColumnQTable) def test_masked_creation(self, raw_data): """ Test that kwargs like "masked" are passed through correctly. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP, masked=True, ) assert isinstance(table, DeprecatedColumnTable) assert table.masked is True table['new'].mask[0] = True assert np.all(table['new'].mask == [True, False, False]) def test_getitem_access(self, raw_data): """ Test deprecated access via __getitem__. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): col = table['old'] assert np.all(col == table['new']) match = "'old_b' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): sub_table = table[['stable', 'old_b']] assert sub_table.colnames == ['stable', 'new_b'] def test_setitem_assignment(self, raw_data): """ Test deprecated assignment via __setitem__. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table['old'] = [100, 200, 300] assert np.all(table['new'] == [100, 200, 300]) def test_delitem_and_remove(self, raw_data): """ Test deprecated deletion via __delitem__ and remove methods. """ table1 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): del table1['old'] assert 'new' not in table1.colnames table2 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning, match=match): table2.remove_column('old') assert 'new' not in table2.colnames def test_keep_columns(self, raw_data): """ Test deprecated use in keep_columns. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table.keep_columns(['stable', 'old']) assert set(table.colnames) == {'stable', 'new'} def test_rename_methods(self, raw_data): """ Test deprecated use in rename_column and rename_columns. """ table1 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table1.rename_column('old', 'final_name_1') assert 'final_name_1' in table1.colnames assert 'new' not in table1.colnames table2 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning): table2.rename_columns(['old', 'old_b'], ['final1', 'final2']) assert set(table2.colnames) == {'final1', 'final2', 'stable'} def test_data_operations(self, raw_data): """ Test deprecated use in sort, group_by, and unique. """ table_sort = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table_sort.sort('old') assert table_sort['new'][0] == 1 table_group = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old_b' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): groups = table_group.group_by('old_b') assert len(groups.groups) == 3 table_unique = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): unique_table = unique(table_unique, keys='old') assert len(unique_table) == 3 def test_join(self, raw_data, recwarn): """ Test deprecated use in the standalone join function. """ table1 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) table2 = Table({'new': [1, 3], 'extra': [1.1, 3.3]}) match = "Left table does not have key column 'old'" with pytest.raises(TableMergeError, match=match): join(table1, table2, keys='old') # Test that it works correctly with the new name and issues no # warnings joined = join(table1, table2, keys='new') assert 'extra' in joined.colnames assert len(joined) == 2 assert len(recwarn) == 0 def test_indexing(self, raw_data, recwarn): """ Test deprecated use in add_index and remove_indices. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table.add_index('old') assert len(table.indices) == 1 assert table.indices[0].columns[0].name == 'new' # The `pytest.warns` context manager consumes the warning. Now we can # test that the next operation issues no new warnings. table.remove_indices('new') assert not table.indices assert len(recwarn) == 0 def test_non_string_access_no_warning(self, raw_data, recwarn): """ Test that non-string access does not trigger warnings. This ensures that row access via integers or slices does not incorrectly engage the name translation logic. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) # Access a row by integer row = table[0] assert row['new'] == 3 # Slice rows sliced = table[0:2] assert len(sliced) == 2 # Assert that no warnings were recorded during these operations assert len(recwarn) == 0 def test_contains(self, raw_data): """ Test deprecated use in ``in`` checks. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): assert 'old' in table # Non-deprecated column: no warning assert 'stable' in table # Missing column: no warning assert 'missing' not in table def test_copy_preserves_deprecation(self, raw_data): """ Test that copy() preserves deprecation behavior. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) copied = table.copy() assert isinstance(copied, DeprecatedColumnTable) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): col = copied['old'] assert np.all(col == [3, 2, 1]) # Ensure it's a true copy; modifying original doesn't affect copy table['new'][0] = 999 assert copied['new'][0] == 3 def test_remove_columns(self, raw_data): """ Test deprecated use in remove_columns (plural). """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning): table.remove_columns(['old', 'old_b']) assert table.colnames == ['stable'] def test_replace_column(self, raw_data): """ Test deprecated use in replace_column. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): table.replace_column('old', [100, 200, 300]) assert np.all(table['new'] == [100, 200, 300]) def test_empty_deprecation_map(self, raw_data, recwarn): """ Test a table with an empty deprecation map (no deprecated names). """ table = create_deprecated_table_from_data(raw_data, {}) # empty map # All operations should work without any warnings assert set(table.colnames) == {'old', 'old_b', 'stable'} col = table['old'] assert np.all(col == [3, 2, 1]) table.sort('old') assert 'old' in table assert len(recwarn) == 0 def test_add_index_list(self, raw_data): """ Test deprecated use in add_index with a list of column names. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning): table.add_index(['old', 'old_b']) assert len(table.indices) == 1 index_col_names = [c.name for c in table.indices[0].columns] assert index_col_names == ['new', 'new_b'] def test_remove_indices_deprecated(self, raw_data): """ Test deprecated use in remove_indices. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) table.add_index('new') assert len(table.indices) == 1 with pytest.warns(AstropyDeprecationWarning): table.remove_indices('old') assert len(table.indices) == 0 def test_copy_with_none_deprecation_map(self): """ Test that copy() works when deprecation_map is None. """ table = DeprecatedColumnTable({'a': [1, 2, 3]}) table.deprecation_map = None copied = table.copy() assert copied.deprecation_map is None assert np.all(copied['a'] == [1, 2, 3]) def test_create_empty_deprecated_qtable(self): """ Test creating an empty QTable and adding columns incrementally. """ table = create_empty_deprecated_qtable(DEPRECATION_MAP) assert isinstance(table, DeprecatedColumnQTable) assert len(table) == 0 # Add columns using new names table['new'] = [1, 2, 3] table['stable'] = [4, 5, 6] # Access via deprecated name match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): col = table['old'] assert np.all(col == [1, 2, 3]) def test_slice_preserves_deprecation(self, raw_data): """ Test that slicing preserves deprecation behavior. """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) sliced = table[0:2] assert isinstance(sliced, DeprecatedColumnTable) match = "'old' was deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): col = sliced['old'] assert np.all(col == [3, 2]) def test_translate_names_non_string(self, raw_data, recwarn): """ Test that _translate_names passes through non-string/non-sequence values unchanged (e.g., a Table used as group_by keys). """ table = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) # group_by accepts a Table as keys; _translate_names should pass # it through without modification or warnings key_table = Table({'new': [3, 2, 1]}) groups = table.group_by(key_table) assert len(groups.groups) == 3 assert len(recwarn) == 0 class TestFutureColumnNames: """ Tests for the ``photutils.future_column_names`` opt-in flag. """ def setup_method(self): import photutils self._original = photutils.future_column_names photutils.future_column_names = True def teardown_method(self): import photutils photutils.future_column_names = self._original def test_from_data_returns_plain_table(self, raw_data): """ Test that create_deprecated_table_from_data returns a plain Table when the flag is set. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table) is Table assert set(table.colnames) == {'new', 'new_b', 'stable'} def test_from_data_returns_plain_qtable(self, raw_data): """ Test that create_deprecated_table_from_data returns a plain QTable when the flag is set and use_qtable=True. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP, use_qtable=True) assert type(table) is QTable def test_from_data_no_warnings(self, raw_data, recwarn): """ Test that accessing columns on a plain table created with the flag does not issue deprecation warnings. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) _ = table['new'] assert len(recwarn) == 0 def test_from_data_old_name_raises(self, raw_data): """ Test that accessing a deprecated name on a plain table created with the flag raises KeyError. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) with pytest.raises(KeyError): table['old'] def test_empty_returns_plain_qtable(self): """ Test that create_empty_deprecated_qtable returns a plain QTable when the flag is set. """ table = create_empty_deprecated_qtable(DEPRECATION_MAP) assert type(table) is QTable assert len(table) == 0 class TestUseFutureColumnNames: """ Tests for the ``use_future_column_names`` context manager. """ def test_context_manager_returns_plain_table(self, raw_data): """ Test that the context manager makes create_deprecated_table_from_data return a plain Table. """ with use_future_column_names(): table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table) is Table assert set(table.colnames) == {'new', 'new_b', 'stable'} def test_context_manager_returns_plain_qtable(self, raw_data): """ Test that the context manager makes create_deprecated_table_from_data return a plain QTable. """ with use_future_column_names(): table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP, use_qtable=True) assert type(table) is QTable def test_context_manager_empty_qtable(self): """ Test that the context manager makes create_empty_deprecated_qtable return a plain QTable. """ with use_future_column_names(): table = create_empty_deprecated_qtable(DEPRECATION_MAP) assert type(table) is QTable assert len(table) == 0 def test_global_unchanged_after_context(self): """ Test that the global flag is unchanged after the context manager exits. """ import photutils original = photutils.future_column_names with use_future_column_names(): pass assert photutils.future_column_names == original def test_outside_context_uses_global(self, raw_data): """ Test that outside the context manager, the global flag is respected and deprecated table behavior is used. """ import photutils assert not photutils.future_column_names with use_future_column_names(): pass # Outside the context, should use the deprecated table table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table) is DeprecatedColumnTable def test_nested_context_managers(self, raw_data): """ Test that nested context managers work correctly with different values. """ with use_future_column_names(enabled=True): table1 = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table1) is Table with use_future_column_names(enabled=False): table2 = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table2) is DeprecatedColumnTable # Back to the outer context table3 = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table3) is Table def test_context_manager_disabled(self, raw_data): """ Test that use_future_column_names(enabled=False) forces the deprecated table behavior even if the global flag is True. """ import photutils original = photutils.future_column_names try: photutils.future_column_names = True with use_future_column_names(enabled=False): table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP) assert type(table) is DeprecatedColumnTable finally: photutils.future_column_names = original def test_restores_on_exception(self): """ Test that the context manager restores state even if an exception occurs. """ sentinel_before = _future_column_names_var.get() msg = 'test error' with use_future_column_names(), pytest.raises(ValueError, match=msg): raise ValueError(msg) assert _future_column_names_var.get() == sentinel_before @deprecated_positional_kwargs('1.0', until='2.0') def _example_func(a, b=10, c=20): """ Example function for testing deprecated_positional_kwargs. """ return a + b + c class TestDeprecatedPositionalKwargs: """ Tests for the deprecated_positional_kwargs decorator. """ def test_no_warning_at_limit(self): with warnings.catch_warnings(): warnings.simplefilter('error') result = _example_func(1) assert result == 31 def test_no_warning_keyword_only(self): with warnings.catch_warnings(): warnings.simplefilter('error') result = _example_func(1, b=5, c=3) assert result == 9 def test_warns_when_exceeded(self): match = "'_example_func'" with pytest.warns(AstropyDeprecationWarning, match=match): result = _example_func(1, 2) assert result == 23 def test_warning_message_versions(self): with pytest.warns(AstropyDeprecationWarning) as record: _example_func(1, 2, 3) msg = str(record[0].message) assert '1.0' in msg assert '2.0' in msg def test_warning_names_single(self): with pytest.warns(AstropyDeprecationWarning) as record: _example_func(1, 2) msg = str(record[0].message) assert "Passing 'b' positionally" in msg assert "'c'" not in msg assert 'Pass it as a keyword argument' in msg assert 'b=...' in msg def test_warning_names_two(self): with pytest.warns(AstropyDeprecationWarning) as record: _example_func(1, 2, 3) msg = str(record[0].message) assert "'b' and 'c'" in msg assert 'Pass them as keyword arguments' in msg assert 'b=..., c=...' in msg def test_warning_names_three(self): @deprecated_positional_kwargs('1.0') def _func(a, b=1, c=2, d=3): return a + b + c + d with pytest.warns(AstropyDeprecationWarning) as record: _func(1, 2, 3, 4) msg = str(record[0].message) assert "'b', 'c', and 'd'" in msg assert 'Pass them as keyword arguments' in msg assert 'b=..., c=..., d=...' in msg def test_return_value_preserved(self): with warnings.catch_warnings(): warnings.simplefilter('ignore') assert _example_func(5, 3, 2) == 10 assert _example_func(5) == 35 def test_preserves_metadata(self): assert _example_func.__name__ == '_example_func' assert 'Example function' in _example_func.__doc__ def test_zero_positional(self): @deprecated_positional_kwargs('1.5', until='2.5') def _no_pos(x=0): return x with warnings.catch_warnings(): warnings.simplefilter('error') result = _no_pos(x=42) assert result == 42 with pytest.warns(AstropyDeprecationWarning) as record: result = _no_pos(42) assert result == 42 msg = str(record[0].message) assert "'x'" in msg assert 'Pass it as a keyword argument' in msg assert 'x=...' in msg def test_no_until(self): @deprecated_positional_kwargs('3.0') def _func(a, b=10): return a + b with pytest.warns(AstropyDeprecationWarning) as record: result = _func(1, 2) assert result == 3 msg = str(record[0].message) assert '3.0' in msg assert 'a future version' in msg assert "'b'" in msg assert 'b=...' in msg def test_until_keyword_only(self): match = 'takes 1 positional argument' with pytest.raises(TypeError, match=match): # until passed positionally deprecated_positional_kwargs('1.0', '2.0') def test_since_until_int(self): @deprecated_positional_kwargs(3, until=4) def _func(a, b=10): return a + b with pytest.warns(AstropyDeprecationWarning) as record: result = _func(1, 2) assert result == 3 msg = str(record[0].message) assert '3' in msg assert '4' in msg def test_multiple_required_args(self): @deprecated_positional_kwargs('1.0') def _func(a, b, c=10): return a + b + c # Two required positional args should not warn with warnings.catch_warnings(): warnings.simplefilter('error') result = _func(1, 2) assert result == 13 # Third (optional) arg passed positionally should warn with pytest.warns(AstropyDeprecationWarning) as record: result = _func(1, 2, 3) assert result == 6 msg = str(record[0].message) assert "'c'" in msg assert "Passing 'c'" in msg assert "'a'" not in msg assert "'b'" not in msg def test_positional_only_params(self): @deprecated_positional_kwargs('1.0') def _func(a, /, b=10): return a + b # Positional-only arg should not warn with warnings.catch_warnings(): warnings.simplefilter('error') result = _func(1) assert result == 11 # Optional arg passed positionally should warn with pytest.warns(AstropyDeprecationWarning) as record: result = _func(1, 2) assert result == 3 msg = str(record[0].message) assert "'b'" in msg @deprecated_renamed_argument('b', 'new', '1.0', until='2.0') def _example_func2(a, new, c=20): """ Example function for testing deprecated_renamed_argument. """ return a + new + c @deprecated_renamed_argument('b', 'new', '1.0', until=None) def _example_func3(a, new, c=20): """ Example function for testing deprecated_renamed_argument with no "until" version specified. """ return a + new + c def test_deprecated_renamed_argument(): # Test that using the new name works without warnings result = _example_func2(1, new=5, c=3) assert result == 9 # Test that using the old name issues a warning and still works with pytest.warns(AstropyDeprecationWarning) as record: result = _example_func2(1, b=5, c=3) assert result == 9 msg = str(record[0].message) assert "'b' was deprecated" in msg assert "'new'" in msg assert 'version 2.0' in msg # Test that if until=None, the warning is issued but no end version # is mentioned with pytest.warns(AstropyDeprecationWarning) as record: result = _example_func3(1, b=5, c=3) assert result == 9 msg = str(record[0].message) assert 'deprecated' in msg.lower() assert 'future version' in msg def test_deprecated_renamed_argument_always_warns(): # Test that the warning is issued on every call, not just the first # time from a given call site. with pytest.warns(AstropyDeprecationWarning): _example_func2(1, b=5) with pytest.warns(AstropyDeprecationWarning): _example_func2(1, b=5) class TestColumnDeprecationUntil: """ Tests for the ``until`` parameter in column deprecation. """ def test_until_in_warning_message(self): """ Test that the removal version appears in the warning message. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP, until='5.0') with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'version 5.0' in msg def test_until_none(self): """ Test that without ``until``, the message says "a future version". """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'a future version' in msg def test_future_column_names(self): """ Test that the warning mentions ``future_column_names``. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'future_column_names' in msg def test_until_preserved_on_copy(self): """ Test that copy() preserves the ``until`` value. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP, until='5.0') copied = table.copy() with pytest.warns(AstropyDeprecationWarning) as record: _ = copied['old'] msg = str(record[0].message) assert 'version 5.0' in msg def test_until_preserved_on_slice(self): """ Test that slicing preserves the ``until`` value. """ table = create_deprecated_table_from_data( {'old': [1, 2]}, DEPRECATION_MAP, until='5.0') sliced = table[0:1] with pytest.warns(AstropyDeprecationWarning) as record: _ = sliced['old'] msg = str(record[0].message) assert 'version 5.0' in msg def test_empty_qtable_until(self): """ Test that create_empty_deprecated_qtable passes ``until``. """ table = create_empty_deprecated_qtable( DEPRECATION_MAP, until='6.0') table['new'] = [1, 2] with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'version 6.0' in msg def test_since_in_warning_message(self): """ Test that the deprecation version appears in the warning message. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP, since='3.0') with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'in version 3.0' in msg def test_since_none(self): """ Test that without ``since``, the message does not mention a deprecation version. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP) with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'was deprecated.' in msg assert 'was deprecated in version' not in msg def test_since_and_until(self): """ Test that both ``since`` and ``until`` appear in the warning. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP, since='3.0', until='4.0') with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'in version 3.0' in msg assert 'version 4.0' in msg def test_since_preserved_on_copy(self): """ Test that copy() preserves the ``since`` value. """ table = create_deprecated_table_from_data( {'old': [1]}, DEPRECATION_MAP, since='3.0', until='4.0') copied = table.copy() with pytest.warns(AstropyDeprecationWarning) as record: _ = copied['old'] msg = str(record[0].message) assert 'in version 3.0' in msg assert 'version 4.0' in msg def test_since_preserved_on_slice(self): """ Test that slicing preserves the ``since`` value. """ table = create_deprecated_table_from_data( {'old': [1, 2]}, DEPRECATION_MAP, since='3.0', until='4.0') sliced = table[0:1] with pytest.warns(AstropyDeprecationWarning) as record: _ = sliced['old'] msg = str(record[0].message) assert 'in version 3.0' in msg def test_empty_qtable_since(self): """ Test that create_empty_deprecated_qtable passes ``since``. """ table = create_empty_deprecated_qtable( DEPRECATION_MAP, since='3.0', until='4.0') table['new'] = [1, 2] with pytest.warns(AstropyDeprecationWarning) as record: _ = table['old'] msg = str(record[0].message) assert 'in version 3.0' in msg assert 'version 4.0' in msg class _ExampleObj: """ A helper class for testing ``deprecated_getattr``. """ def __init__(self): self.new_attr = 42 self._deprecated_attrs = {'old_attr': 'new_attr'} def __getattr__(self, name): return deprecated_getattr(self, name, self._deprecated_attrs) class _ExampleObjSinceUntil: """ A helper class for testing ``deprecated_getattr`` with since/until. """ def __init__(self): self.new_attr = 42 self._deprecated_attrs = {'old_attr': 'new_attr'} def __getattr__(self, name): return deprecated_getattr(self, name, self._deprecated_attrs, since='3.0', until='4.0') class TestDeprecatedGetattr: """ Tests for the ``deprecated_getattr`` helper function. """ def test_deprecated_access_warns(self): """ Test that accessing a deprecated attribute issues a warning. """ obj = _ExampleObj() match = "'old_attr'.*deprecated" with pytest.warns(AstropyDeprecationWarning, match=match): val = obj.old_attr assert val == 42 def test_new_name_no_warning(self): """ Test that the new attribute does not trigger a warning. """ obj = _ExampleObj() assert obj.new_attr == 42 def test_unknown_attr_raises(self): """ Test that an unknown attribute raises AttributeError. """ obj = _ExampleObj() match = 'no attribute' with pytest.raises(AttributeError, match=match): _ = obj.nonexistent def test_message_no_since_no_until(self): """ Test the default message (no "since", no "until"). """ obj = _ExampleObj() with pytest.warns(AstropyDeprecationWarning) as record: _ = obj.old_attr msg = str(record[0].message) assert "'old_attr'" in msg assert "'new_attr'" in msg assert 'a future version' in msg assert 'in version' not in msg def test_message_with_since_and_until(self): """ Test the message includes "since" and "until" versions. """ obj = _ExampleObjSinceUntil() with pytest.warns(AstropyDeprecationWarning) as record: _ = obj.old_attr msg = str(record[0].message) assert 'in version 3.0' in msg assert 'version 4.0' in msg def test_message_with_since_only(self): """ Test the message when only "since" is provided. """ obj = _ExampleObj() dep_map = {'x': 'y'} obj.y = 99 with pytest.warns(AstropyDeprecationWarning) as record: val = deprecated_getattr(obj, 'x', dep_map, since='2.0') assert val == 99 msg = str(record[0].message) assert 'in version 2.0' in msg assert 'a future version' in msg def test_message_with_until_only(self): """ Test the message when only "until" is provided. """ obj = _ExampleObj() dep_map = {'x': 'y'} obj.y = 99 with pytest.warns(AstropyDeprecationWarning) as record: val = deprecated_getattr(obj, 'x', dep_map, until='5.0') assert val == 99 msg = str(record[0].message) assert 'version 5.0' in msg # since was not given, so "deprecated in version" should not appear assert 'deprecated in version' not in msg @deprecated('1.0', until='2.0') def _example_func4(a, b=10, c=20): """ Example function for testing deprecated_positional_kwargs. """ return a + b + c @deprecated('1.0') def _example_func5(a, b=10, c=20): """ Example function for testing deprecated_positional_kwargs. """ return a + b + c def test_deprecated(): """ Test the basic functionality of the @deprecated decorator. """ with pytest.warns(AstropyDeprecationWarning) as record: result = _example_func4(1, 2, 3) assert result == 6 msg = str(record[0].message) assert 'version 1.0' in msg assert 'version 2.0' in msg with pytest.warns(AstropyDeprecationWarning) as record: result = _example_func5(1, 2, 3) assert result == 6 msg = str(record[0].message) assert 'version 1.0' in msg assert 'a future version' in msg astropy-photutils-3322558/photutils/utils/tests/test_depths.py000066400000000000000000000203671517052111400247030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the depths module. """ 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 (AstropyDeprecationWarning, 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_SKIMAGE from photutils.utils.depths import ImageDepth bool_vals = (True, False) @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) n_pixels = 10 threshold = 3.2 finder = SourceFinder(n_pixels=n_pixels, progress_bar=False) segment_map = finder(convolved_data, threshold) self.data = data self.mask = segment_map.make_source_mask() @pytest.mark.parametrize('units', bool_vals) @pytest.mark.parametrize('overlap', bool_vals) def test_image_depth(self, units, overlap): """ Test ImageDepth with various unit and overlap settings. """ radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=100, n_iters=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): """ Test ImageDepth with mask=None. """ radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=100, n_iters=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): """ Test ImageDepth with too many apertures. """ radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=5000, n_iters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 20:] = True match = 'Too many apertures for given unmasked area' with pytest.raises(ValueError, match=match): depth(self.data, mask) depth = ImageDepth(radius, n_sigma=5.0, n_apertures=250, n_iters=2, mask_pad=5, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 100:] = True match = r'Unable to generate .* non-overlapping apertures' with pytest.warns(AstropyUserWarning, match=match): depth(self.data, mask) # Test for zero non-overlapping apertures before slow loop radius = 5 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=100, n_iters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 40:] = True match = r'Unable to generate .* non-overlapping apertures' with pytest.warns(AstropyUserWarning, match=match): depth(self.data, mask) def test_zero_data(self): """ Test ImageDepth with all-zero data. """ radius = 4 depth = ImageDepth(radius, n_apertures=500, n_iters=2, overlap=True, seed=123, progress_bar=False) data = np.zeros((300, 400)) mask = None match = 'One or more flux_limit values was zero' with pytest.warns(AstropyUserWarning, match=match): limits = depth(data, mask) assert_allclose(limits, (0.0, np.inf)) def test_all_masked(self): """ Test ImageDepth when all pixels are masked. """ radius = 4 depth = ImageDepth(radius, n_apertures=500, n_iters=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 match = 'There are no unmasked pixel values' with pytest.raises(ValueError, match=match): depth(data, mask) def test_mask_not_modified(self): """ Test that the input mask is not modified in place. """ radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=100, n_iters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) mask_orig = np.zeros(self.data.shape, dtype=bool) mask_copy = mask_orig.copy() depth(self.data, mask_orig) np.testing.assert_array_equal(mask_orig, mask_copy) # Also when mask has no True pixels (border-only masking path) mask_empty = np.zeros(self.data.shape, dtype=bool) mask_empty_copy = mask_empty.copy() depth(self.data, mask_empty) np.testing.assert_array_equal(mask_empty, mask_empty_copy) def test_inputs(self): """ Test ImageDepth with invalid input parameters. """ match = 'aper_radius must be > 0' with pytest.raises(ValueError, match=match): ImageDepth(0.0, n_sigma=5.0, n_apertures=500, n_iters=2, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) match = 'aper_radius must be > 0' with pytest.raises(ValueError, match=match): ImageDepth(-12.4, n_sigma=5.0, n_apertures=500, n_iters=2, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) match = 'mask_pad must be >= 0' with pytest.raises(ValueError, match=match): ImageDepth(12.4, n_sigma=5.0, n_apertures=500, n_iters=2, mask_pad=-7.1, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) match = 'sigma_clip must be a callable' with pytest.raises(TypeError, match=match): ImageDepth(4.0, n_sigma=5.0, n_apertures=500, n_iters=2, sigma_clip='not_callable', progress_bar=False) def test_repr(self): """ Test ImageDepth __repr__ output. """ depth = ImageDepth(aper_radius=4, n_sigma=5.0, n_apertures=100, n_iters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) cls_repr = repr(depth) assert cls_repr.startswith(f'{depth.__class__.__name__}') def test_progress_bar(self): """ Test running ImageDepth with progress_bar=True. """ radius = 4 depth = ImageDepth(radius, n_sigma=5.0, n_apertures=100, n_iters=1, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=True) limits = depth(self.data, self.mask) assert np.isfinite(limits[0]) assert np.isfinite(limits[1]) def test_deprecation(self): """ Test ImageDepth deprecation warnings. """ depth = ImageDepth(aper_radius=4, n_sigma=5.0, n_apertures=100, n_iters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) match = 'attribute was deprecated' with pytest.warns(AstropyDeprecationWarning, match=match): _ = depth.napers astropy-photutils-3322558/photutils/utils/tests/test_errors.py000066400000000000000000000073401517052111400247240ustar00rootroot00000000000000# 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(): """ Test that mismatched bkg_error shape raises ValueError. """ match = 'bkg_error must have the same shape as the input data' with pytest.raises(ValueError, match=match): calc_total_error(DATA, WRONG_SHAPE, EFFGAIN) def test_gain_shape(): """ Test that mismatched effective_gain shape raises ValueError. """ match = 'must have the same shape as the input data' with pytest.raises(ValueError, match=match): calc_total_error(DATA, BKG_ERROR, WRONG_SHAPE) @pytest.mark.parametrize('effective_gain', [-1, -100]) def test_gain_negative(effective_gain): """ Test that negative effective_gain raises ValueError. """ match = 'effective_gain must be non-negative everywhere' with pytest.raises(ValueError, match=match): calc_total_error(DATA, BKG_ERROR, effective_gain) def test_gain_scalar(): """ Test calc_total_error with a scalar effective_gain. """ error_tot = calc_total_error(DATA, BKG_ERROR, 2.0) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_array(): """ Test calc_total_error with an array effective_gain. """ error_tot = calc_total_error(DATA, BKG_ERROR, EFFGAIN) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_zero(): """ Test calc_total_error with zero effective_gain values. """ 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(): """ Test calc_total_error with Quantity inputs. """ 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(): """ Test that mismatched data and bkg_error units raises ValueError. """ units = u.electron / u.s match = 'must have the same units' with pytest.raises(ValueError, match=match): calc_total_error(DATA * units, BKG_ERROR * u.electron, EFFGAIN * u.s) def test_effgain_units(): """ Test that invalid effective_gain units raises UnitsError. """ units = u.electron / u.s match = 'it must have count units' with pytest.raises(u.UnitsError, match=match): calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN * u.km) def test_missing_bkgerror_units(): """ Test that missing bkg_error units raises ValueError. """ units = u.electron / u.s match = 'all must have units' with pytest.raises(ValueError, match=match): calc_total_error(DATA * units, BKG_ERROR, EFFGAIN * u.s) def test_missing_effgain_units(): """ Test that missing effective_gain units raises ValueError. """ units = u.electron / u.s match = 'all must have units' with pytest.raises(ValueError, match=match): calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN) astropy-photutils-3322558/photutils/utils/tests/test_footprints.py000066400000000000000000000024021517052111400256110ustar00rootroot00000000000000# 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(): """ Test circular_footprint with various radii and invalid inputs. """ 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) match = 'radius must be a positive, finite integer greater than 0' with pytest.raises(ValueError, match=match): circular_footprint(5.1) with pytest.raises(ValueError, match=match): circular_footprint(0) with pytest.raises(ValueError, match=match): circular_footprint(-1) with pytest.raises(ValueError, match=match): circular_footprint(np.inf) with pytest.raises(ValueError, match=match): circular_footprint(np.nan) astropy-photutils-3322558/photutils/utils/tests/test_interpolation.py000066400000000000000000000154571517052111400263070ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolation module. """ from unittest.mock import MagicMock import numpy as np import pytest from numpy.testing import assert_allclose from photutils.utils import ShepardIDWInterpolator as IDWInterp 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)) 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 = IDWInterp(self.x, self.y) @pytest.mark.parametrize('positions', [0.4, np.arange(2, 5) * 0.1]) def test_idw_1d(self, positions): """ Test 1D IDW interpolation. """ f = IDWInterp(self.x, self.y) assert_allclose(f(positions), np.sin(positions), atol=1e-2) def test_idw_weights(self): """ Test IDW interpolation with weights. """ weights = self.y * 0.1 f = IDWInterp(self.x, self.y, weights=weights) pos = 0.4 assert_allclose(f(pos), np.sin(pos), atol=1e-2) def test_idw_2d(self): """ Test 2D IDW interpolation. """ pos = self.rng.random((1000, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = IDWInterp(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): """ Test 3D IDW interpolation. """ val = np.ones((3, 3, 3)) pos = np.indices(val.shape) f = IDWInterp(pos, val) assert_allclose(f([0.5, 0.5, 0.5]), 1.0) def test_no_coordinates(self): """ Test that empty coordinates raises ValueError. """ match = 'coordinates must have at least one data point' with pytest.raises(ValueError, match=match): IDWInterp([], 0) def test_values_invalid_shape(self): """ Test that mismatched values shape raises ValueError. """ match = 'The number of values must match the number of coordinates' with pytest.raises(ValueError, match=match): IDWInterp(self.x, 0) def test_weights_invalid_shape(self): """ Test that mismatched weights shape raises ValueError. """ match = 'number of weights must match the number of coordinates' with pytest.raises(ValueError, match=match): IDWInterp(self.x, self.y, weights=10) def test_weights_negative(self): """ Test that negative weights raises ValueError. """ match = 'All weight values must be non-negative numbers' with pytest.raises(ValueError, match=match): IDWInterp(self.x, self.y, weights=-self.y) def test_n_neighbors_one(self): """ Test IDW interpolation with n_neighbors=1. """ result = self.f(0.5, n_neighbors=1) assert np.isscalar(result) assert_allclose(result, 0.479334, rtol=3e-7) def test_n_neighbors_negative(self): """ Test that negative n_neighbors raises ValueError. """ match = 'n_neighbors must be a positive integer' with pytest.raises(ValueError, match=match): self.f(0.5, n_neighbors=-1) def test_conf_dist_negative(self): """ Test IDW interpolation with negative conf_dist. """ assert_allclose(self.f(0.5, conf_dist=-1), self.f(0.5, conf_dist=None)) def test_dtype_none(self): """ Test IDW interpolation with dtype=None. """ result = self.f(0.5, dtype=None) assert result.dtype == float def test_positions_0d_nomatch(self): """ Test that a 0D position with wrong dimensionality raises ValueError. """ pos = self.rng.random((10, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = IDWInterp(pos, val) match = 'position does not match the dimensionality' with pytest.raises(ValueError, match=match): f(0.5) def test_positions_1d_nomatch(self): """ Test that a 1D position with wrong length raises ValueError. """ pos = self.rng.random((10, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = IDWInterp(pos, val) match = 'was provided as a 1D array, but its length does not match' with pytest.raises(ValueError, match=match): f([0.5]) def test_positions_3d(self): """ Test that a 3D positions array raises ValueError. """ match = 'array_like object of dimensionality no larger than 2' with pytest.raises(ValueError, match=match): self.f(np.ones((3, 3, 3))) def test_scalar_values_1d(self): """ Test IDW interpolation with a single 1D data point. """ value = 10.0 f = IDWInterp(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): """ Test IDW interpolation with a single 2D data point. """ value = 10.0 f = IDWInterp([[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): """ Test IDW interpolation with a single 3D data point. """ value = 10.0 f = IDWInterp([[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) def test_no_valid_distances(self): """ Test that NaN is returned when all distances are non-finite (dk.shape[0] == 0 after filtering). """ coords = np.array([0.0, 1.0, 2.0]) values = np.array([10.0, 20.0, 30.0]) f = IDWInterp(coords, values) # Replace the kdtree with a mock that returns all-inf distances mock_kdtree = MagicMock() inf_distances = np.array([[np.inf, np.inf, np.inf]]) inf_idx = np.array([[0, 1, 2]]) mock_kdtree.query.return_value = (inf_distances, inf_idx) f.kdtree = mock_kdtree result = f(0.5, n_neighbors=3) assert np.isnan(result) def test_zero_weights(self): """ Test that NaN is returned when all weights are zero, leading to wtot == 0. """ coords = np.array([0.0, 1.0, 2.0]) values = np.array([10.0, 20.0, 30.0]) weights = np.array([0.0, 0.0, 0.0]) f = IDWInterp(coords, values, weights=weights) result = f(0.5, n_neighbors=3) assert np.isnan(result) astropy-photutils-3322558/photutils/utils/tests/test_misc.py000066400000000000000000000033771517052111400243510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _misc module. """ import builtins from unittest.mock import patch import pytest from photutils.utils._misc import _get_date, _get_meta, _get_version_info def test_get_version_info_import_error(): """ Test that _get_version_info returns None for packages that cannot be imported. """ real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if name == 'gwcs': raise ImportError(name) return real_import(name, *args, **kwargs) with patch('builtins.__import__', side_effect=mock_import): versions = _get_version_info() assert versions['gwcs'] is None @pytest.mark.parametrize('utc', [False, True]) def test_get_date_oserror_fallback(utc): """ Test that _get_date falls back when astimezone raises OSError. """ with patch('photutils.utils._misc.datetime') as mock_dt: mock_now = mock_dt.now.return_value mock_now.astimezone.side_effect = OSError('no timezone') mock_now.strftime.return_value = '2025-01-01 00:00:00' mock_dt.now.return_value = mock_now from datetime import UTC mock_dt.UTC = UTC result = _get_date(utc=utc) assert isinstance(result, str) @pytest.mark.parametrize('utc', [False, True]) def test_get_meta(utc): """ Test _get_meta returns expected keys. """ meta = _get_meta(utc=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', 'matplotlib', 'gwcs', 'bottleneck') for key in keys: assert key in versions astropy-photutils-3322558/photutils/utils/tests/test_moments.py000066400000000000000000000035141517052111400250710ustar00rootroot00000000000000# 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 _image_moments def test_moments(): """ Test _image_moments with a simple 2x2 array (raw moments). """ data = np.array([[0, 1], [0, 1]]) moments = _image_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(): """ Test _image_moments with center=None (central moments). """ data = np.array([[0, 1], [0, 1]]) moments = _image_moments(data, center=None, 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(): """ Test _image_moments with center=None and a non-square array. """ data = np.array([[0, 1], [0, 1], [0, 1]]) moments = _image_moments(data, center=None, 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(): """ Test that _image_moments with non-2D data raises ValueError. """ data = np.arange(27).reshape(3, 3, 3) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): _image_moments(data, order=3) def test_moments_central_negative_order(): """ Test that _image_moments with negative order raises ValueError. """ data = np.array([[0, 1], [0, 1]]) match = 'order must be non-negative' with pytest.raises(ValueError, match=match): _image_moments(data, order=-1) astropy-photutils-3322558/photutils/utils/tests/test_optional_deps.py000066400000000000000000000146431517052111400262540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _optional_deps module. """ import importlib from unittest.mock import patch import pytest import photutils.utils._optional_deps as od_mod from photutils.utils._optional_deps import (_deps_by_key, _dist_to_has_key, _get_optional_deps, _pkg_dist_name) def _clear_cache(*names): """ Remove named entries from the module-level cache. This is necessary to ensure that tests of the caching behavior are valid and not affected by previous tests. """ for name in names: od_mod._cache.pop(name, None) class TestAll: def test_all_is_list(self): """ Test that ``__all__`` is a list. """ assert isinstance(od_mod.__all__, list) def test_all_entries_start_with_has(self): """ Test that every entry in ``__all__`` starts with ``HAS_``. """ for name in od_mod.__all__: assert name.startswith('HAS_') def test_all_is_sorted(self): """ Test that ``__all__`` is sorted alphabetically. """ assert od_mod.__all__ == sorted(od_mod.__all__) def test_skimage_always_in_all(self): """ Test that ``HAS_SKIMAGE`` is always present in ``__all__``. """ assert 'HAS_SKIMAGE' in od_mod.__all__ def test_all_matches_deps_by_key(self): """ Test that ``__all__`` matches the keys in ``_deps_by_key``. """ expected = sorted(f'HAS_{k}' for k in _deps_by_key) assert od_mod.__all__ == expected class TestHasAttributes: def test_installed_package_returns_true(self): """ Test that a ``HAS_*`` attribute is `True` when the import succeeds. """ _clear_cache('HAS_MATPLOTLIB') try: with patch.object(importlib, 'import_module'): assert od_mod.HAS_MATPLOTLIB is True finally: _clear_cache('HAS_MATPLOTLIB') def test_import_error_returns_false(self): """ Test that a ``HAS_*`` attribute is `False` when the import raises `ImportError`. """ _clear_cache('HAS_MATPLOTLIB') try: with patch.object(importlib, 'import_module', side_effect=ImportError): result = od_mod.HAS_MATPLOTLIB assert result is False finally: _clear_cache('HAS_MATPLOTLIB') def test_result_is_bool(self): """ Test that ``HAS_*`` attributes are `bool`. """ _clear_cache('HAS_MATPLOTLIB') try: assert isinstance(od_mod.HAS_MATPLOTLIB, bool) finally: _clear_cache('HAS_MATPLOTLIB') class TestCaching: def test_value_is_cached(self): """ Test that the result of a ``HAS_*`` lookup is stored in the cache. """ _clear_cache('HAS_MATPLOTLIB') try: _ = od_mod.HAS_MATPLOTLIB assert 'HAS_MATPLOTLIB' in od_mod._cache finally: _clear_cache('HAS_MATPLOTLIB') def test_cached_value_returned_without_reimport(self): """ Test that a cached value is returned without calling ``import_module`` again. """ _clear_cache('HAS_MATPLOTLIB') od_mod._cache['HAS_MATPLOTLIB'] = True try: with patch.object( importlib, 'import_module', side_effect=AssertionError('should not be called'), ): assert od_mod.HAS_MATPLOTLIB is True finally: _clear_cache('HAS_MATPLOTLIB') class TestAttributeErrors: def test_typo_raises(self): """ Test that a plausible typo like ``HAS_SKIIMAGE`` raises `AttributeError`. """ match = 'HAS_SKIIMAGE' with pytest.raises(AttributeError, match=match): _ = od_mod.HAS_SKIIMAGE def test_non_dependency_raises(self): """ Test that ``HAS_NUMPY`` raises `AttributeError` because numpy is not an optional dependency. """ if 'NUMPY' not in od_mod._deps_by_key: match = 'HAS_NUMPY' with pytest.raises(AttributeError, match=match): _ = od_mod.HAS_NUMPY def test_arbitrary_name_raises(self): """ Test that a completely unknown attribute raises `AttributeError`. """ with pytest.raises(AttributeError): _ = od_mod.SOME_UNKNOWN_ATTRIBUTE def test_has_prefix_only_raises(self): """ Test that ``HAS_`` with no suffix raises `AttributeError`. """ with pytest.raises(AttributeError): _ = od_mod.HAS_ class TestDistToHasKey: def test_simple(self): """ Test that a simple distribution name is uppercased. """ assert _dist_to_has_key('matplotlib') == 'MATPLOTLIB' def test_hyphenated(self): """ Test that a hyphenated distribution name is converted via the import-name lookup (e.g., ``scikit-image`` -> ``SKIMAGE``). """ assert _dist_to_has_key('scikit-image') == 'SKIMAGE' def test_dotted(self): """ Test that dots in a distribution name are replaced with underscores. """ assert _dist_to_has_key('stsci.stimage') == 'STSCI_STIMAGE' def test_mixed_case(self): """ Test that mixed-case distribution names are uppercased. """ assert _dist_to_has_key('MyPackage') == 'MYPACKAGE' class TestGetOptionalDeps: def test_returns_sorted_list(self): """ Test that ``_get_optional_deps`` returns a sorted list. """ result = _get_optional_deps(_pkg_dist_name, extra='all') assert isinstance(result, list) assert result == sorted(result) def test_scikit_image_present(self): """ Test that ``scikit-image`` appears in the optional dependencies. """ result = _get_optional_deps(_pkg_dist_name, extra='all') assert 'scikit-image' in result def test_returns_dist_names_not_import_names(self): """ Test that the returned names are distribution names, not import names (e.g., ``scikit-image`` instead of ``skimage``). """ result = _get_optional_deps(_pkg_dist_name, extra='all') assert 'scikit-image' in result assert 'skimage' not in result astropy-photutils-3322558/photutils/utils/tests/test_parameters.py000066400000000000000000000103071517052111400255500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the parameters module. """ import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_equal from photutils.utils._parameters import (SigmaClipSentinelDefault, as_pair, create_default_sigmaclip) class TestAsPairBasic: """ Tests for as_pair scalar/tuple broadcasting and basic validation. """ def test_scalar_broadcast(self): assert_equal(as_pair('p', 4), (4, 4)) def test_tuple_passthrough(self): assert_equal(as_pair('p', (3, 4)), (3, 4)) def test_scalar_zero(self): assert_equal(as_pair('p', 0), (0, 0)) def test_too_many_elements(self): match = 'must have 1 or 2 elements' with pytest.raises(ValueError, match=match): as_pair('p', (1, 2, 3)) def test_2d_input(self): match = 'must be 1D' with pytest.raises(ValueError, match=match): as_pair('p', np.array([[1, 2]])) def test_non_integer_dtype(self): match = 'must have integer values' with pytest.raises(ValueError, match=match): as_pair('p', 1.5) @pytest.mark.parametrize('value', [(1, np.nan), (1, np.inf)]) def test_non_finite(self, value): match = 'must be a finite value' with pytest.raises(ValueError, match=match): as_pair('p', value) class TestAsPairCheckOdd: """ Tests for the check_odd parameter. """ def test_odd_tuple(self): assert_equal(as_pair('p', (3, 5), check_odd=True), (3, 5)) def test_odd_scalar(self): assert_equal(as_pair('p', 3, check_odd=True), (3, 3)) @pytest.mark.parametrize('value', [(3, 4), 4]) def test_even_raises(self, value): match = 'must have an odd value for both axes' with pytest.raises(ValueError, match=match): as_pair('p', value, check_odd=True) class TestAsPairLowerBound: """ Tests for lower_bound validation. """ def test_exclusive_lower_bound(self): match = 'must be > 0' with pytest.raises(ValueError, match=match): as_pair('p', 0, lower_bound=(0, 0)) def test_inclusive_lower_bound(self): result = as_pair('p', 0, lower_bound=(0, 1)) assert_equal(result, (0, 0)) def test_inclusive_lower_bound_violation(self): match = r'must be >= 1' with pytest.raises(ValueError, match=match): as_pair('p', 0, lower_bound=(1, 1)) def test_lower_bound_wrong_length(self): match = 'lower_bound must contain only 2 elements' with pytest.raises(ValueError, match=match): as_pair('p', 1, lower_bound=(0,)) class TestAsPairUpperBound: """ Tests for upper_bound validation and clamping. """ def test_upper_bound_clipping(self): result = as_pair('p', (10, 20), upper_bound=(5, 15)) assert_equal(result, (5, 15)) def test_upper_bound_no_clipping(self): result = as_pair('p', (3, 4), upper_bound=(10, 10)) assert_equal(result, (3, 4)) def test_upper_bound_wrong_length(self): match = 'upper_bound must contain only 2 elements' with pytest.raises(ValueError, match=match): as_pair('p', (3, 4), upper_bound=(5,)) class TestSigmaClipSentinelDefault: """ Tests for SigmaClipSentinelDefault. """ def test_repr(self): sentinel = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) result = repr(sentinel) assert 'SigmaClip' in result assert '3.0' in result assert '10' in result def test_custom_params(self): sentinel = SigmaClipSentinelDefault(sigma=2.0, maxiters=5) assert sentinel.sigma == 2.0 assert sentinel.maxiters == 5 class TestCreateDefaultSigmaClip: """ Tests for create_default_sigmaclip. """ def test_defaults(self): sc = create_default_sigmaclip() assert isinstance(sc, SigmaClip) assert sc.sigma == 3.0 assert sc.maxiters == 10 def test_custom(self): sc = create_default_sigmaclip(sigma=2.5, maxiters=5) assert isinstance(sc, SigmaClip) assert sc.sigma == 2.5 assert sc.maxiters == 5 astropy-photutils-3322558/photutils/utils/tests/test_positional_kwargs.py000066400000000000000000000057551517052111400271570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for deprecation warnings when optional arguments are passed positionally. """ import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.colormaps import make_random_cmap from photutils.utils.cutouts import CutoutImage from photutils.utils.footprints import circular_footprint from photutils.utils.interpolation import ShepardIDWInterpolator class TestMakeRandomCmapPositionalKwargs: """ Test make_random_cmap warns for positional optional args. """ @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_positional_warns(self): match = 'make_random_cmap' with pytest.warns(AstropyDeprecationWarning, match=match): make_random_cmap(100) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_keyword_no_warning(self): make_random_cmap(n_colors=100) class TestCutoutImagePositionalKwargs: """ Test CutoutImage.__init__ warns for positional optional args. """ def setup_method(self): self.data = np.arange(100).reshape(10, 10).astype(float) def test_positional_warns(self): match = '__init__' with pytest.warns(AstropyDeprecationWarning, match=match): CutoutImage(self.data, (5, 5), (3, 3), 'trim') def test_keyword_no_warning(self): CutoutImage(self.data, (5, 5), (3, 3), mode='trim') class TestCircularFootprintPositionalKwargs: """ Test circular_footprint warns for positional optional args. """ def test_positional_warns(self): match = 'circular_footprint' with pytest.warns(AstropyDeprecationWarning, match=match): circular_footprint(3, float) def test_keyword_no_warning(self): circular_footprint(3, dtype=float) class TestShepardIDWInterpolatorPositionalKwargs: """ Test ShepardIDWInterpolator warns for positional optional args. """ def setup_method(self): rng = np.random.default_rng(0) self.coords = rng.random((100, 2)) self.values = np.sin(self.coords[:, 0] + self.coords[:, 1]) def test_init_positional_warns(self): weights = np.ones(100) match = 'init' with pytest.warns(AstropyDeprecationWarning, match=match): ShepardIDWInterpolator(self.coords, self.values, weights) def test_init_keyword_no_warning(self): weights = np.ones(100) ShepardIDWInterpolator(self.coords, self.values, weights=weights) def test_call_positional_warns(self): interp = ShepardIDWInterpolator(self.coords, self.values) match = '__call__' with pytest.warns(AstropyDeprecationWarning, match=match): interp([0.5, 0.6], 4) def test_call_keyword_no_warning(self): interp = ShepardIDWInterpolator(self.coords, self.values) interp([0.5, 0.6], n_neighbors=4) astropy-photutils-3322558/photutils/utils/tests/test_progress_bars.py000066400000000000000000000056341517052111400262670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _progress_bars module. """ import builtins import importlib from unittest.mock import patch import pytest from photutils.utils._optional_deps import HAS_TQDM from photutils.utils._progress_bars import add_progress_bar def test_add_progress_bar_no_tqdm(): """ Test that when tqdm is not available, the original iterable is returned. """ items = range(5) result = add_progress_bar(items, text=False) if not HAS_TQDM: assert result is items def test_add_progress_bar_text(): """ Test add_progress_bar with text=True. When tqdm is available, a tqdm progress bar is returned. """ items = range(5) result = add_progress_bar(items, desc='test', text=True) if HAS_TQDM: # Should be a tqdm instance wrapping the iterable assert hasattr(result, '__iter__') assert list(result) == list(range(5)) else: assert result is items @pytest.mark.skipif(not HAS_TQDM, reason='tqdm is required') def test_add_progress_bar_no_text(): """ Test add_progress_bar with text=False (default) when tqdm is available. This exercises the ipywidgets try/except branch. """ items = range(5) result = add_progress_bar(items, desc='test', text=False) assert hasattr(result, '__iter__') assert list(result) == list(range(5)) @pytest.mark.skipif(not HAS_TQDM, reason='tqdm is required') def test_add_progress_bar_no_ipywidgets(): """ Test add_progress_bar with text=False when ipywidgets is not available, exercising the ImportError fallback branch. """ real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if name == 'ipywidgets': raise ImportError(name) return real_import(name, *args, **kwargs) items = range(5) with patch('builtins.__import__', side_effect=mock_import): result = add_progress_bar(items, desc='test', text=False) assert hasattr(result, '__iter__') assert list(result) == list(range(5)) def test_dummy_tqdm_class(): """ Test the dummy tqdm class that is defined when tqdm is not installed. We reload the module with tqdm mocked as unavailable. """ real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if 'tqdm' in name: raise ImportError(name) return real_import(name, *args, **kwargs) with patch('builtins.__import__', side_effect=mock_import): import photutils.utils._progress_bars as pb_mod importlib.reload(pb_mod) # The dummy tqdm class should now be defined dummy = pb_mod.tqdm(total=10, desc='test') assert dummy.__enter__() is dummy assert dummy.__exit__(None, None, None) is None assert dummy.update(1) is None assert dummy.set_postfix_str('test') is None # Reload again to restore the original state importlib.reload(pb_mod) astropy-photutils-3322558/photutils/utils/tests/test_quantity_helpers.py000066400000000000000000000055411517052111400270110ustar00rootroot00000000000000# 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 (check_units, isscalar, process_quantities) @pytest.mark.parametrize('all_units', [False, True]) def test_units(all_units): """ Test process_quantities with uniform units. """ unit = u.Jy if all_units else 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, strict=True): assert_equal(arr.value, arr2) else: assert unit2 is None assert arrs2 == arrs def test_process_quantities_all_none(): """ Test that process_quantities with all None inputs returns None unit. """ values, unit = process_quantities([None, None], ['a', 'b']) assert values == [None, None] assert unit is None def test_isscalar(): """ Test isscalar with scalar and array inputs. """ assert isscalar(1) assert isscalar(1.0 * u.m) assert not isscalar([1, 2, 3]) assert not isscalar([1, 2, 3] * u.m) def test_inputs(): """ Test that mismatched values and names lengths raises ValueError. """ match = 'The number of values must match the number of names' with pytest.raises(ValueError, match=match): process_quantities([1, 2, 3], ['a', 'b']) with pytest.raises(ValueError, match=match): check_units([1, 2, 3], ['a', 'b']) def test_check_units(): """ Test check_units for unit consistency checking. """ # Valid: same units check_units((np.ones(3) * u.Jy, np.ones(3) * u.Jy), ('a', 'b')) # Valid: no units check_units((np.ones(3), np.ones(3)), ('a', 'b')) # Valid: with None values check_units((np.ones(3) * u.Jy, None), ('a', 'b')) def test_mixed_units(): """ Test that check_units with mixed units raises ValueError. """ arrs = (np.ones(3) * u.Jy, np.ones(3) * u.km) names = ('a', 'b') match = 'must all have the same units' with pytest.raises(ValueError, match=match): check_units(arrs, names) arrs = (np.ones(3) * u.Jy, np.ones(3)) names = ('a', 'b') with pytest.raises(ValueError, match=match): check_units(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): check_units(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): check_units(arrs, names) astropy-photutils-3322558/photutils/utils/tests/test_repr.py000066400000000000000000000047551517052111400243670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _repr module. """ import pytest from photutils.utils._repr import make_repr class ExampleClass: def __init__(self, x, y, *, z=1): self.x = x self.y = y self.z = z def test_make_repr(): """ Test make_repr with various parameter and override combinations. """ obj = ExampleClass(1, 2) params = ('x', 'y', 'z') repr_str = make_repr(obj, params) assert repr_str == 'ExampleClass(x=1, y=2, z=1)' params = ('x', 'y') repr_str = make_repr(obj, params) assert repr_str == 'ExampleClass(x=1, y=2)' params = 'x' repr_str = make_repr(obj, params) assert repr_str == 'ExampleClass(x=1)' params = ('x', 'y', 'z') repr_str = make_repr(obj, params, long=True) ref = ('\n' 'x: 1\n' 'y: 2\n' 'z: 1') assert repr_str == ref overrides = {'x': '...'} repr_str = make_repr(obj, params, overrides=overrides) assert repr_str == "ExampleClass(x='...', y=2, z=1)" overrides = {'x': '...', 'z': '...'} repr_str = make_repr(obj, params, overrides=overrides) assert repr_str == "ExampleClass(x='...', y=2, z='...')" params = ('a', 'x', 'y', 'z') match = 'not found in instance or overrides' with pytest.raises(ValueError, match=match): repr_str = make_repr(obj, params) params = ('x', 'y', 'z') overrides = {'a': '...'} match = 'The overrides keys must be a subset of the params list' with pytest.raises(ValueError, match=match): repr_str = make_repr(obj, params, overrides=overrides) params = ('a', 'x', 'y', 'z') overrides = {'a': '...'} repr_str = make_repr(obj, params, overrides=overrides) assert repr_str == "ExampleClass(a='...', x=1, y=2, z=1)" params = ('a', 'x', 'y', 'z') overrides = {'a': '...', 'z': '...'} repr_str = make_repr(obj, params, overrides=overrides) assert repr_str == "ExampleClass(a='...', x=1, y=2, z='...')" params = ('x', 'y', 'z') overrides = {'x': '...'} obj = ExampleClass(None, 2) repr_str = make_repr(obj, params, overrides=overrides) assert repr_str == 'ExampleClass(x=None, y=2, z=1)' def test_make_repr_brackets(): """ Test make_repr with brackets=True. """ obj = ExampleClass(1, 2) params = ('x', 'y', 'z') repr_str = make_repr(obj, params, brackets=True) assert repr_str == '' astropy-photutils-3322558/photutils/utils/tests/test_round.py000066400000000000000000000040031517052111400245300ustar00rootroot00000000000000# 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 round_half_away def test_round(): """ Test round_half_away with an array of values. """ a = np.arange(-2, 2, 0.5) ar = round_half_away(a) result = np.array([-2, -2, -1, -1, 0, 1, 1, 2]) assert isinstance(ar, np.ndarray) assert np.issubdtype(ar.dtype, np.integer) assert ar.shape == a.shape assert_equal(ar, result) def test_round_scalar(): """ Test round_half_away with scalar inputs. """ a = 0.5 ar = round_half_away(a) assert np.isscalar(ar) assert isinstance(ar, int) assert ar == 1 a = -0.5 ar = round_half_away(a) assert np.isscalar(ar) assert isinstance(ar, int) assert ar == -1 def test_round_nan(): """ Test round_half_away with NaN inputs. """ # Scalar NaN returns float NaN ar = round_half_away(np.nan) assert np.isscalar(ar) assert isinstance(ar, float) assert np.isnan(ar) # Array containing NaN returns float array a = np.array([1.5, np.nan, -0.5]) ar = round_half_away(a) assert np.issubdtype(ar.dtype, np.floating) assert_equal(ar[0], 2.0) assert np.isnan(ar[1]) assert_equal(ar[2], -1.0) def test_round_inf(): """ Test round_half_away with infinite inputs. """ # Scalar positive infinity returns float inf ar = round_half_away(np.inf) assert np.isscalar(ar) assert isinstance(ar, float) assert np.isposinf(ar) # Scalar negative infinity returns float -inf ar = round_half_away(-np.inf) assert np.isscalar(ar) assert isinstance(ar, float) assert np.isneginf(ar) # Array containing infinities returns float array a = np.array([np.inf, -np.inf, 1.5]) ar = round_half_away(a) assert np.issubdtype(ar.dtype, np.floating) assert np.isposinf(ar[0]) assert np.isneginf(ar[1]) assert_equal(ar[2], 2.0) astropy-photutils-3322558/photutils/utils/tests/test_stats.py000066400000000000000000000056541517052111400245540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _stats module. """ import importlib import pickle # nosec B403 from unittest.mock import patch import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.utils._optional_deps import HAS_BOTTLENECK from photutils.utils._stats import (nanmax, nanmean, nanmedian, nanmin, nanstd, nansum, nanvar) funcs = [(nansum, np.nansum), (nanmean, np.nanmean), (nanmedian, np.nanmedian), (nanstd, np.nanstd), (nanvar, np.nanvar), (nanmin, np.nanmin), (nanmax, np.nanmax)] @pytest.mark.skipif(not HAS_BOTTLENECK, reason='bottleneck is required') @pytest.mark.parametrize('func', funcs) @pytest.mark.parametrize('axis', [None, 0, 1, (0, 1), (1, 2), (2, 1), (0, 1, 2), (3, 1), (0, 3), (2, 0)]) @pytest.mark.parametrize('use_units', [False, True]) def test_nan_funcs(func, axis, use_units): """ Test nan functions with various axes and unit combinations. """ arr = np.ones((5, 3, 8, 9)) if use_units: arr <<= u.m result1 = func[0](arr, axis=axis) result2 = func[1](arr, axis=axis) assert_equal(result1, result2) @pytest.mark.skipif(not HAS_BOTTLENECK, reason='bottleneck is required') @pytest.mark.parametrize('func', funcs) def test_nan_funcs_float32(func): """ Test that non-float64 arrays dispatch to numpy instead of bottleneck. """ arr = np.ones((5, 3), dtype=np.float32) result1 = func[0](arr, axis=None) result2 = func[1](arr, axis=None) assert_equal(result1, result2) @pytest.mark.parametrize('axis', [None, 0, 1]) @pytest.mark.parametrize('func', funcs) def test_nan_function_pickle(axis, func): """ Test that nan functions can be pickled and unpickled without error. This should work regardless of whether Bottleneck is installed. """ nan_func = func[0] np_func = func[1] pickled_func = pickle.loads( # noqa: S301 pickle.dumps(nan_func)) # noqa: S301 # nosec B301 data = np.arange(10).reshape(2, 5) ** 2 result1 = nan_func(data, axis=axis) result2 = pickled_func(data, axis=axis) result3 = np_func(data, axis=axis) assert_allclose(result1, result2) assert_allclose(result2, result3) def test_nanmean_repr(): """ Test that the repr of nanmean is correct. """ result = repr(nanmean) assert 'nanmean' in result def test_nan_funcs_no_bottleneck(): """ Test that the functions work when bottleneck is not available by reloading the module with HAS_BOTTLENECK mocked to False. """ with patch('photutils.utils._optional_deps.HAS_BOTTLENECK', new=False): import photutils.utils._stats as stats_mod importlib.reload(stats_mod) arr = np.ones((5, 3)) result = stats_mod.nansum(arr) assert_equal(result, np.nansum(arr)) astropy-photutils-3322558/photutils/utils/tests/test_wcs_helpers.py000066400000000000000000001055661517052111400257370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _wcs_helpers module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import Angle, SkyCoord from numpy.testing import assert_allclose from photutils.utils._wcs_helpers import (compute_local_wcs_jacobian, jacobian_pixel_to_sky_mean_scale, jacobian_pixel_to_sky_scales, jacobian_sky_to_pixel_mean_scale, jacobian_sky_to_pixel_scales, pixel_ellipse_to_sky_svd, pixel_to_sky_mean_scale, pixel_to_sky_scales, pixel_to_sky_svd_scales, sky_ellipse_to_pixel_svd, sky_to_pixel_mean_scale, sky_to_pixel_scales, sky_to_pixel_svd_scales, wcs_pixel_scale_angle) from photutils.utils.tests.conftest import WCS_CDELT_ARCSEC, WCS_CENTER @pytest.fixture def center_xy_coord(simple_wcs): """ Return the center (x, y) tuple at CRPIX of the simple WCS. """ x, y = simple_wcs.world_to_pixel(WCS_CENTER) return (x, y) class TestComputeLocalWCSJacobian: """ Tests for `compute_local_wcs_jacobian`. """ def test_shape(self, simple_wcs): """ The Jacobian must be a 2x2 array. """ jac = compute_local_wcs_jacobian(WCS_CENTER, simple_wcs) assert jac.shape == (2, 2) def test_simple_wcs_diagonal(self, simple_wcs): """ For an axis-aligned TAN WCS the Jacobian should be nearly diagonal with magnitudes ~ 1/WCS_CDELT_ARCSEC. """ jac = compute_local_wcs_jacobian(WCS_CENTER, simple_wcs) # Off-diagonal elements should be near zero assert_allclose(jac[0, 1], 0.0, atol=1e-4) assert_allclose(jac[1, 0], 0.0, atol=1e-4) # Diagonal: RA axis (pix/arcsec) is negative (RA increases left) expected_scale = 1.0 / WCS_CDELT_ARCSEC assert_allclose(np.abs(jac[0, 0]), expected_scale) assert_allclose(np.abs(jac[1, 1]), expected_scale) def test_rotated_wcs(self, rotated_wcs): """ For a rotated WCS the off-diagonal elements should be nonzero, but the singular values should still match 1/WCS_CDELT_ARCSEC. """ jac = compute_local_wcs_jacobian(WCS_CENTER, rotated_wcs) sv = np.linalg.svd(jac, compute_uv=False) expected_scale = 1.0 / WCS_CDELT_ARCSEC assert_allclose(sv, expected_scale, rtol=1e-6) def test_sip_wcs(self, sip_wcs): """ For a SIP WCS the Jacobian should still be close to the undistorted value near the reference pixel. """ jac = compute_local_wcs_jacobian(WCS_CENTER, sip_wcs) sv = np.linalg.svd(jac, compute_uv=False) expected_scale = 1.0 / WCS_CDELT_ARCSEC assert_allclose(sv, expected_scale, rtol=1e-5) def test_inverse_of_forward(self, simple_wcs): """ The Jacobian should be the inverse of the forward d(sky)/d(pixel) matrix derived from 1-pixel offsets. """ jac = compute_local_wcs_jacobian(WCS_CENTER, simple_wcs) # A 1-pixel step should map to ~CDELT arcsec in sky forward = np.linalg.inv(jac) # Diagonal magnitudes should be ~WCS_CDELT_ARCSEC assert_allclose(np.abs(forward[0, 0]), WCS_CDELT_ARCSEC) assert_allclose(np.abs(forward[1, 1]), WCS_CDELT_ARCSEC) def test_determinant_sign(self, simple_wcs): """ Standard WCS (RA increasing to the left) should have negative determinant. """ jac = compute_local_wcs_jacobian(WCS_CENTER, simple_wcs) assert np.linalg.det(jac) < 0 class TestWcsPixelScaleAngle: """ Tests for `wcs_pixel_scale_angle`. """ def test_return_types(self, simple_wcs): """ Should return (tuple, float, Angle). """ xy_coord, scale, angle = wcs_pixel_scale_angle( WCS_CENTER, simple_wcs) assert isinstance(xy_coord, tuple) assert isinstance(scale, float) assert isinstance(angle, Angle) def test_simple_wcs_scale(self, simple_wcs): """ For a simple TAN WCS, scale should equal CDELT in arcsec/pixel. """ _, scale, _ = wcs_pixel_scale_angle(WCS_CENTER, simple_wcs) assert_allclose(scale, WCS_CDELT_ARCSEC) def test_simple_wcs_angle(self, simple_wcs): """ For an axis-aligned TAN WCS with CDELT=[-c, c], North is along +y, so the angle should be ~90 degrees. """ _, _, angle = wcs_pixel_scale_angle(WCS_CENTER, simple_wcs) assert_allclose(angle.deg, 90.0) def test_angle_wrapped(self, simple_wcs): """ The angle should be in [0, 360) degrees. """ _, _, angle = wcs_pixel_scale_angle(WCS_CENTER, simple_wcs) assert 0.0 <= angle.deg < 360.0 def test_rotated_wcs_angle(self, rotated_wcs): """ For a 25-degree rotated WCS, the North angle should shift by ~25 degrees from the axis-aligned value (~90 deg). """ _, _, angle = wcs_pixel_scale_angle(WCS_CENTER, rotated_wcs) # The rotation should be about 90 - 25 = 65 degrees assert_allclose(angle.deg, 90.0 - 25.0) def test_rotated_wcs_scale(self, rotated_wcs): """ Rotation should not change the pixel scale. """ _, scale, _ = wcs_pixel_scale_angle(WCS_CENTER, rotated_wcs) assert_allclose(scale, WCS_CDELT_ARCSEC) def test_nonsquare_wcs_scale(self, nonsquare_wcs): """ For non-square pixels the scale should be the geometric mean. """ _, scale, _ = wcs_pixel_scale_angle(WCS_CENTER, nonsquare_wcs) expected = np.sqrt(0.03 * 0.05) * 3600 assert_allclose(scale, expected, rtol=1e-5) def test_pixel_coordinate(self, simple_wcs): """ The returned xy_coord should match world_to_pixel. """ xy_coord, _, _ = wcs_pixel_scale_angle(WCS_CENTER, simple_wcs) x_exp, y_exp = simple_wcs.world_to_pixel(WCS_CENTER) assert_allclose(xy_coord[0], x_exp) assert_allclose(xy_coord[1], y_exp) def test_off_center_position(self, simple_wcs): """ Test a position away from the WCS reference pixel. """ skycoord = SkyCoord(100.5 * u.deg, 30.5 * u.deg) _, scale, angle = wcs_pixel_scale_angle(skycoord, simple_wcs) assert scale > 0 assert 0.0 <= angle.deg < 360.0 class TestJacobianDirectionalScales: """ Tests for `jacobian_sky_to_pixel_scales` and `jacobian_pixel_to_sky_scales`. """ @pytest.mark.parametrize('sky_angle_deg', [0, 30, 90, 150, 270]) def test_sky_to_pixel_return_types(self, simple_wcs, sky_angle_deg): """ Should return (tuple, float, float, Angle). """ sky_angle_rad = np.radians(sky_angle_deg) pix_position, sw, sh, pangle = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) assert isinstance(pix_position, tuple) assert isinstance(sw, (float, np.floating)) assert isinstance(sh, (float, np.floating)) assert isinstance(pangle, Angle) @pytest.mark.parametrize('pixel_angle_deg', [0, 45, 90, 180, 315]) def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord, pixel_angle_deg): """ Should return (SkyCoord, float, float, Angle). """ pixel_angle_rad = np.radians(pixel_angle_deg) sky_position, sw, sh, sangle = jacobian_pixel_to_sky_scales( center_xy_coord, simple_wcs, pixel_angle_rad) assert isinstance(sky_position, SkyCoord) assert isinstance(sw, (float, np.floating)) assert isinstance(sh, (float, np.floating)) assert isinstance(sangle, Angle) def test_sky_to_pixel_simple_scales(self, simple_wcs): """ For a simple WCS, the directional scale factors should both equal 1/WCS_CDELT_ARCSEC (pixels per arcsec). """ _, sw, sh, _ = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, 0.0) expected = 1.0 / WCS_CDELT_ARCSEC assert_allclose(sw, expected) assert_allclose(sh, expected) def test_pixel_to_sky_simple_scales(self, simple_wcs, center_xy_coord): """ For a simple WCS, the directional scale factors should both equal WCS_CDELT_ARCSEC (arcsec per pixel). """ _, sw, sh, _ = jacobian_pixel_to_sky_scales( center_xy_coord, simple_wcs, 0.0) assert_allclose(sw, WCS_CDELT_ARCSEC) assert_allclose(sh, WCS_CDELT_ARCSEC) def test_pixel_angle_wrapped(self, simple_wcs): """ The pixel angle must be in [0, 360) degrees. """ _, _, _, pangle = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, 0.0) assert 0.0 <= pangle.deg < 360.0 def test_sky_angle_wrapped(self, simple_wcs, center_xy_coord): """ The sky angle must be in [0, 360) degrees. """ _, _, _, sangle = jacobian_pixel_to_sky_scales( center_xy_coord, simple_wcs, 0.0) assert 0.0 <= sangle.deg < 360.0 @pytest.mark.parametrize('angle_deg', [0, 30, 90, 270]) def test_roundtrip_angle(self, simple_wcs, angle_deg): """ Converting sky -> pixel -> sky should recover the original angle. """ sky_angle_rad = np.radians(angle_deg) center_pix, _, _, pangle = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) _, _, _, sangle = jacobian_pixel_to_sky_scales( center_pix, simple_wcs, pangle.rad) assert_allclose(sangle.deg % 360, angle_deg % 360, atol=1e-6) @pytest.mark.parametrize('angle_deg', [0, 45, 135]) def test_roundtrip_scales(self, simple_wcs, angle_deg): """ Converting sky -> pixel -> sky should recover the original scale factors. """ sky_angle_rad = np.radians(angle_deg) center_pix, sw, sh, pangle = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) _, sw_rt, sh_rt, _ = jacobian_pixel_to_sky_scales( center_pix, simple_wcs, pangle.rad) # sw (pix/arcsec) * sw_rt (arcsec/pix) ~ 1 assert_allclose(sw * sw_rt, 1.0) assert_allclose(sh * sh_rt, 1.0) def test_sip_wcs_positive_scales(self, sip_wcs): """ Scale factors should be positive even for distorted WCS. """ _, sw, sh, _ = jacobian_sky_to_pixel_scales( WCS_CENTER, sip_wcs, np.radians(30)) assert sw > 0 assert sh > 0 def test_rotated_wcs(self, rotated_wcs): """ For a rotated (but isotropic) WCS, scales should still equal 1/WCS_CDELT_ARCSEC. """ _, sw, sh, _ = jacobian_sky_to_pixel_scales( WCS_CENTER, rotated_wcs, 0.0) expected = 1.0 / WCS_CDELT_ARCSEC assert_allclose(sw, expected, rtol=1e-6) assert_allclose(sh, expected, rtol=1e-6) class TestJacobianMeanScale: """ Tests for `jacobian_sky_to_pixel_mean_scale` and `jacobian_pixel_to_sky_mean_scale`. """ def test_sky_to_pixel_return_types(self, simple_wcs): """ Should return (tuple, float). """ pix_position, scale = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) assert isinstance(pix_position, tuple) assert isinstance(scale, (float, np.floating)) def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord): """ Should return (SkyCoord, float). """ sky_position, scale = jacobian_pixel_to_sky_mean_scale( center_xy_coord, simple_wcs) assert isinstance(sky_position, SkyCoord) assert isinstance(scale, (float, np.floating)) def test_sky_to_pixel_simple_scale(self, simple_wcs): """ For an isotropic WCS, the mean scale should equal 1/WCS_CDELT_ARCSEC. """ _, scale = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) assert_allclose(scale, 1.0 / WCS_CDELT_ARCSEC) def test_pixel_to_sky_simple_scale(self, simple_wcs, center_xy_coord): """ For an isotropic WCS, the mean scale should equal WCS_CDELT_ARCSEC. """ _, scale = jacobian_pixel_to_sky_mean_scale( center_xy_coord, simple_wcs) assert_allclose(scale, WCS_CDELT_ARCSEC) def test_roundtrip_scale(self, simple_wcs): """ Sky -> pixel mean_scale * pixel -> sky mean_scale should ~ 1. """ center_pix, s2p = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) _, p2s = jacobian_pixel_to_sky_mean_scale(center_pix, simple_wcs) assert_allclose(s2p * p2s, 1.0) def test_sip_wcs_positive(self, sip_wcs): """ Mean scale should be positive for distorted WCS. """ _, scale = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, sip_wcs) assert scale > 0 def test_center_coordinates(self, simple_wcs): """ The returned pix_position should match world_to_pixel. """ pix_position, _ = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) x_exp, y_exp = simple_wcs.world_to_pixel(WCS_CENTER) assert_allclose(pix_position[0], x_exp) assert_allclose(pix_position[1], y_exp) class TestDispatchScales: """ Tests for `sky_to_pixel_scales` and `pixel_to_sky_scales` dispatch helpers that route between offset and Jacobian methods based on ``wcs.has_distortion``. """ def test_no_distortion_dispatches_offset(self, simple_wcs): """ For a non-distorted WCS, the dispatch helper should produce equal w/h scales (isotropic offset method). """ assert not simple_wcs.has_distortion _, sw, sh, _ = sky_to_pixel_scales(WCS_CENTER, simple_wcs, 0.0) assert_allclose(sw, sh) def test_distortion_dispatches_jacobian(self, sip_wcs): """ For a SIP WCS, the dispatch helper should use the Jacobian path. """ assert sip_wcs.has_distortion _, sw, sh, _ = sky_to_pixel_scales( WCS_CENTER, sip_wcs, np.radians(30)) # Scales should be close but not necessarily identical assert sw > 0 assert sh > 0 @pytest.mark.parametrize('angle_deg', [0, 45, 90, 180]) def test_sky_to_pixel_return_types(self, simple_wcs, angle_deg): """ Should return (tuple, float, float, Angle). """ pix_position, _sw, _sh, pangle = sky_to_pixel_scales( WCS_CENTER, simple_wcs, np.radians(angle_deg)) assert isinstance(pix_position, tuple) assert isinstance(pangle, Angle) assert 0.0 <= pangle.deg < 360.0 @pytest.mark.parametrize('angle_deg', [0, 45, 90, 180]) def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord, angle_deg): """ Should return (SkyCoord, float, float, Angle). """ sky_position, _sw, _sh, sangle = pixel_to_sky_scales( center_xy_coord, simple_wcs, np.radians(angle_deg)) assert isinstance(sky_position, SkyCoord) assert isinstance(sangle, Angle) assert 0.0 <= sangle.deg < 360.0 @pytest.mark.parametrize('angle_deg', [0, 30, 90, 270]) def test_roundtrip_simple_wcs(self, simple_wcs, angle_deg): """ Converting sky -> pixel -> sky with a simple WCS should recover the original angle. """ sky_angle_rad = np.radians(angle_deg) center_pix, _, _, pangle = sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) _, _, _, sangle = pixel_to_sky_scales( center_pix, simple_wcs, pangle.rad) assert_allclose(sangle.deg % 360, angle_deg % 360, atol=1e-6) @pytest.mark.parametrize('angle_deg', [0, 45, 90]) def test_roundtrip_sip_wcs(self, sip_wcs, angle_deg): """ Roundtrip through the Jacobian path should recover the original angle within tolerance. """ sky_angle_rad = np.radians(angle_deg) center_pix, _, _, pangle = sky_to_pixel_scales( WCS_CENTER, sip_wcs, sky_angle_rad) _, _, _, sangle = pixel_to_sky_scales( center_pix, sip_wcs, pangle.rad) assert_allclose(sangle.deg % 360, angle_deg % 360, atol=1e-6) @pytest.mark.parametrize('angle_deg', [0, 45, 90]) def test_roundtrip_rotated_wcs(self, rotated_wcs, angle_deg): """ Roundtrip through a rotated WCS should recover the original angle. """ sky_angle_rad = np.radians(angle_deg) center_pix, _, _, pangle = sky_to_pixel_scales( WCS_CENTER, rotated_wcs, sky_angle_rad) _, _, _, sangle = pixel_to_sky_scales( center_pix, rotated_wcs, pangle.rad) diff = abs(sangle.deg - angle_deg) % 360 assert min(diff, 360 - diff) < 0.5 def test_simple_wcs_scale_value(self, simple_wcs): """ For a simple WCS, scales should be 1/WCS_CDELT_ARCSEC. """ _, sw, sh, _ = sky_to_pixel_scales( WCS_CENTER, simple_wcs, 0.0) expected = 1.0 / WCS_CDELT_ARCSEC assert_allclose(sw, expected) assert_allclose(sh, expected) def test_pixel_to_sky_scale_value(self, simple_wcs, center_xy_coord): """ For a simple WCS, scales should be WCS_CDELT_ARCSEC. """ _, sw, sh, _ = pixel_to_sky_scales( center_xy_coord, simple_wcs, 0.0) assert_allclose(sw, WCS_CDELT_ARCSEC) assert_allclose(sh, WCS_CDELT_ARCSEC) def test_zero_angle(self, simple_wcs): """ A zero sky angle should work without error. """ _center, sw, sh, _pangle = sky_to_pixel_scales( WCS_CENTER, simple_wcs, 0.0) assert sw > 0 assert sh > 0 def test_two_pi_angle(self, simple_wcs): """ An angle of 2*pi (360 degrees) should be equivalent to 0. """ _, sw_0, sh_0, pa_0 = sky_to_pixel_scales( WCS_CENTER, simple_wcs, 0.0) _, sw_2pi, sh_2pi, pa_2pi = sky_to_pixel_scales( WCS_CENTER, simple_wcs, 2 * np.pi) assert_allclose(sw_0, sw_2pi, rtol=1e-10) assert_allclose(sh_0, sh_2pi, rtol=1e-10) assert_allclose(pa_0.deg % 360, pa_2pi.deg % 360, atol=1e-6) def test_negative_angle(self, simple_wcs): """ A negative sky angle should be handled correctly. """ _, sw, sh, _pangle = sky_to_pixel_scales( WCS_CENTER, simple_wcs, -np.pi / 4) assert sw > 0 assert sh > 0 def test_consistency_offset_jacobian(self, simple_wcs): """ For a simple WCS (no distortion), the offset method and the Jacobian method should give consistent results. """ sky_angle_rad = np.radians(30.0) # Offset path (via dispatch) c1, sw1, sh1, pa1 = sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) # Jacobian path (direct call) c2, sw2, sh2, pa2 = jacobian_sky_to_pixel_scales( WCS_CENTER, simple_wcs, sky_angle_rad) assert_allclose(c1[0], c2[0]) assert_allclose(c1[1], c2[1]) assert_allclose(sw1, sw2) assert_allclose(sh1, sh2) assert_allclose(pa1.deg % 360, pa2.deg % 360, atol=1e-5) def test_consistency_pixel_offset_jacobian(self, simple_wcs, center_xy_coord): """ For a simple WCS, both paths should give consistent pixel -> sky results. """ pixel_angle_rad = np.radians(45.0) # Offset path (via dispatch) c1, sw1, sh1, sa1 = pixel_to_sky_scales( center_xy_coord, simple_wcs, pixel_angle_rad) # Jacobian path (direct call) c2, sw2, sh2, sa2 = jacobian_pixel_to_sky_scales( center_xy_coord, simple_wcs, pixel_angle_rad) assert_allclose(c1.ra.deg, c2.ra.deg) assert_allclose(c1.dec.deg, c2.dec.deg) assert_allclose(sw1, sw2) assert_allclose(sh1, sh2) assert_allclose(sa1.deg % 360, sa2.deg % 360, atol=1e-5) class TestDispatchMeanScale: """ Tests for `sky_to_pixel_mean_scale` and `pixel_to_sky_mean_scale` dispatch helpers. """ def test_no_distortion_returns(self, simple_wcs): """ Should return (tuple, float) for non-distorted WCS. """ pix_position, scale = sky_to_pixel_mean_scale(WCS_CENTER, simple_wcs) assert isinstance(pix_position, tuple) assert isinstance(scale, float) def test_distortion_returns(self, sip_wcs): """ Should return (tuple, float/np.floating) for distorted WCS. """ pix_position, scale = sky_to_pixel_mean_scale(WCS_CENTER, sip_wcs) assert isinstance(pix_position, tuple) assert isinstance(scale, (float, np.floating)) def test_no_distortion_scale(self, simple_wcs): """ For a simple WCS, mean scale should be 1/WCS_CDELT_ARCSEC. """ _, scale = sky_to_pixel_mean_scale(WCS_CENTER, simple_wcs) assert_allclose(scale, 1.0 / WCS_CDELT_ARCSEC) def test_distortion_scale(self, sip_wcs): """ For a SIP WCS near the reference pixel, the mean scale should be close to the undistorted value. """ _, scale = sky_to_pixel_mean_scale(WCS_CENTER, sip_wcs) assert_allclose(scale, 1.0 / WCS_CDELT_ARCSEC, rtol=1e-6) def test_pixel_to_sky_no_distortion(self, simple_wcs, center_xy_coord): """ For a simple WCS, pixel_to_sky mean scale should be WCS_CDELT_ARCSEC. """ sky_position, scale = pixel_to_sky_mean_scale( center_xy_coord, simple_wcs) assert isinstance(sky_position, SkyCoord) assert_allclose(scale, WCS_CDELT_ARCSEC) def test_pixel_to_sky_distortion(self, sip_wcs): """ For a SIP WCS, pixel_to_sky mean scale should be close to WCS_CDELT_ARCSEC near the reference pixel. """ xy_coord = (9.5, 9.5) sky_position, scale = pixel_to_sky_mean_scale(xy_coord, sip_wcs) assert isinstance(sky_position, SkyCoord) assert_allclose(scale, WCS_CDELT_ARCSEC, rtol=1e-6) def test_roundtrip(self, simple_wcs): """ Sky -> pixel mean_scale * pixel -> sky mean_scale should ~ 1. """ center_pix, s2p = sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) _, p2s = pixel_to_sky_mean_scale(center_pix, simple_wcs) assert_allclose(s2p * p2s, 1.0) def test_roundtrip_sip(self, sip_wcs): """ Roundtrip with SIP WCS should give product ~ 1. """ center_pix, s2p = sky_to_pixel_mean_scale( WCS_CENTER, sip_wcs) _, p2s = pixel_to_sky_mean_scale(center_pix, sip_wcs) assert_allclose(s2p * p2s, 1.0) def test_consistency_offset_jacobian(self, simple_wcs): """ For a simple WCS, both mean-scale paths should agree. """ # Offset path (via dispatch) c1, s1 = sky_to_pixel_mean_scale(WCS_CENTER, simple_wcs) # Jacobian path (direct call) c2, s2 = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) assert_allclose(c1[0], c2[0]) assert_allclose(c1[1], c2[1]) assert_allclose(s1, s2) class TestGWCSDispatch: """ Test that dispatch helpers correctly handle WCS objects without the ``has_distortion`` attribute (e.g., GWCS), defaulting to the Jacobian path. """ @pytest.fixture def mock_gwcs(self, simple_wcs): """ Create a mock WCS that wraps a simple WCS but has no has_distortion attribute, simulating GWCS behavior. """ class MockGWCS: def __init__(self, real_wcs): self._wcs = real_wcs def world_to_pixel(self, *args, **kwargs): return self._wcs.world_to_pixel(*args, **kwargs) def pixel_to_world(self, *args, **kwargs): return self._wcs.pixel_to_world(*args, **kwargs) return MockGWCS(simple_wcs) def test_no_has_distortion_attr(self, mock_gwcs): """ The mock should not have has_distortion. """ assert not hasattr(mock_gwcs, 'has_distortion') def test_sky_to_pixel_scales_uses_jacobian(self, mock_gwcs): """ Without has_distortion, should use the Jacobian path and still produce valid results. """ pix_position, sw, sh, pangle = sky_to_pixel_scales( WCS_CENTER, mock_gwcs, 0.0) assert isinstance(pix_position, tuple) assert sw > 0 assert sh > 0 assert isinstance(pangle, Angle) def test_pixel_to_sky_scales_uses_jacobian(self, mock_gwcs): """ Without has_distortion, pixel_to_sky_scales should use the Jacobian path. """ xy_coord = (9.5, 9.5) sky_position, sw, sh, _sangle = pixel_to_sky_scales( xy_coord, mock_gwcs, 0.0) assert isinstance(sky_position, SkyCoord) assert sw > 0 assert sh > 0 def test_sky_to_pixel_mean_scale_uses_jacobian(self, mock_gwcs): """ Without has_distortion, should use the Jacobian path. """ pix_position, scale = sky_to_pixel_mean_scale(WCS_CENTER, mock_gwcs) assert isinstance(pix_position, tuple) assert scale > 0 def test_pixel_to_sky_mean_scale_uses_jacobian(self, mock_gwcs): """ Without has_distortion, should use the Jacobian path. """ xy_coord = (9.5, 9.5) sky_position, scale = pixel_to_sky_mean_scale(xy_coord, mock_gwcs) assert isinstance(sky_position, SkyCoord) assert scale > 0 def test_gwcs_scale_matches_simple(self, mock_gwcs, simple_wcs): """ The mock GWCS (Jacobian path) should give scales close to the simple WCS (offset path). """ _, scale_offset = sky_to_pixel_mean_scale( WCS_CENTER, simple_wcs) _, scale_jac = sky_to_pixel_mean_scale( WCS_CENTER, mock_gwcs) assert_allclose(scale_offset, scale_jac) class TestNonsquarePixels: """ Tests for WCS with non-square pixels to verify scale separation. """ def test_nonsquare_mean_scale(self, nonsquare_wcs): """ The mean scale should be near the arithmetic mean of the two singular values (1/cdelt_x and 1/cdelt_y in pix/arcsec). """ _, scale = jacobian_sky_to_pixel_mean_scale( WCS_CENTER, nonsquare_wcs) # Arithmetic mean of singular values (SVD): # 1/cdelt_x_arcsec and 1/cdelt_y_arcsec cdelt_x = 0.03 * 3600 cdelt_y = 0.05 * 3600 expected = 0.5 * (1.0 / cdelt_x + 1.0 / cdelt_y) assert_allclose(scale, expected, rtol=1e-6) def test_nonsquare_directional_scales(self, nonsquare_wcs): """ For non-square pixels, the directional scales should differ. """ _, sw, sh, _ = jacobian_sky_to_pixel_scales( WCS_CENTER, nonsquare_wcs, 0.0) # Scale factors should differ since x and y pixel scales differ assert not np.isclose(sw, sh) class TestSVDEllipseConversions: """ Tests for `pixel_ellipse_to_sky_svd` and `sky_ellipse_to_pixel_svd`. """ def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord): """ Should return (SkyCoord, float, float, Angle). """ center, w, h, angle = pixel_ellipse_to_sky_svd( center_xy_coord, simple_wcs, 10.0, 5.0, 0.5) assert isinstance(center, SkyCoord) assert isinstance(w, (float, np.floating)) assert isinstance(h, (float, np.floating)) assert isinstance(angle, Angle) def test_sky_to_pixel_return_types(self, simple_wcs): """ Should return (tuple, float, float, Angle). """ center, w, h, angle = sky_ellipse_to_pixel_svd( WCS_CENTER, simple_wcs, 36.0, 18.0, 0.5) assert isinstance(center, tuple) assert isinstance(w, (float, np.floating)) assert isinstance(h, (float, np.floating)) assert isinstance(angle, Angle) def test_roundtrip_sky_pixel_sky(self, simple_wcs): """ Sky -> pixel -> sky should recover the original ellipse. """ sky_w, sky_h, sky_a = 36.0, 18.0, 0.5 center_pix, pw, ph, pa = sky_ellipse_to_pixel_svd( WCS_CENTER, simple_wcs, sky_w, sky_h, sky_a) _, rw, rh, ra = pixel_ellipse_to_sky_svd( center_pix, simple_wcs, pw, ph, pa.rad) assert_allclose(rw, sky_w, rtol=1e-6) assert_allclose(rh, sky_h, rtol=1e-6) assert_allclose(ra.rad, sky_a, rtol=1e-4) def test_roundtrip_pixel_sky_pixel(self, simple_wcs, center_xy_coord): """ Pixel -> sky -> pixel should recover the original ellipse. """ pix_w, pix_h, pix_a = 10.0, 5.0, 0.3 _, sw, sh, sa = pixel_ellipse_to_sky_svd( center_xy_coord, simple_wcs, pix_w, pix_h, pix_a) _, rw, rh, ra = sky_ellipse_to_pixel_svd( WCS_CENTER, simple_wcs, sw, sh, sa.rad) assert_allclose(rw, pix_w, rtol=1e-6) assert_allclose(rh, pix_h, rtol=1e-6) assert_allclose(ra.rad, pix_a, rtol=1e-4) def test_simple_wcs_width_height_scale(self, simple_wcs, center_xy_coord): """ For a simple WCS, pixel dimensions should scale by WCS_CDELT_ARCSEC. """ pix_w, pix_h = 10.0, 5.0 _, sw, sh, _ = pixel_ellipse_to_sky_svd( center_xy_coord, simple_wcs, pix_w, pix_h, 0.0) assert_allclose(sw, pix_w * WCS_CDELT_ARCSEC, rtol=1e-5) assert_allclose(sh, pix_h * WCS_CDELT_ARCSEC, rtol=1e-5) def test_height_larger_than_width(self, simple_wcs, center_xy_coord): """ When height > width, the SVD should still correctly assign widths and heights. """ pix_w, pix_h = 5.0, 10.0 _, sw, sh, _ = pixel_ellipse_to_sky_svd( center_xy_coord, simple_wcs, pix_w, pix_h, 0.0) # Width should be smaller than height in sky coords too assert sw < sh def test_sip_wcs_positive_sizes(self, sip_wcs): """ Sizes should be positive for distorted WCS. """ xy_coord = (9.5, 9.5) _, sw, sh, _ = pixel_ellipse_to_sky_svd( xy_coord, sip_wcs, 8.0, 4.0, 0.0) assert sw > 0 assert sh > 0 def test_sip_wcs_roundtrip(self, sip_wcs): """ Roundtrip with SIP WCS should recover the original ellipse. """ sky_w, sky_h, sky_a = 0.36, 0.18, 0.7 center_pix, pw, ph, pa = sky_ellipse_to_pixel_svd( WCS_CENTER, sip_wcs, sky_w, sky_h, sky_a) _, rw, rh, ra = pixel_ellipse_to_sky_svd( center_pix, sip_wcs, pw, ph, pa.rad) assert_allclose(rw, sky_w, rtol=1e-5) assert_allclose(rh, sky_h, rtol=1e-5) assert_allclose(ra.rad, sky_a, rtol=1e-4) def test_angle_wrapped(self, simple_wcs, center_xy_coord): """ The output angle should be in [0, 360) degrees. """ _, _, _, angle = pixel_ellipse_to_sky_svd( center_xy_coord, simple_wcs, 10.0, 5.0, 0.5) assert 0.0 <= angle.deg < 360.0 class TestSVDScales: """ Tests for `sky_to_pixel_svd_scales` and `pixel_to_sky_svd_scales`. """ def test_sky_to_pixel_return_types(self, simple_wcs): """ Should return (tuple, float, float, Angle). """ center, smaj, smin, angle = sky_to_pixel_svd_scales( WCS_CENTER, simple_wcs) assert isinstance(center, tuple) assert isinstance(smaj, (float, np.floating)) assert isinstance(smin, (float, np.floating)) assert isinstance(angle, Angle) def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord): """ Should return (SkyCoord, float, float, Angle). """ center, smaj, smin, angle = pixel_to_sky_svd_scales( center_xy_coord, simple_wcs) assert isinstance(center, SkyCoord) assert isinstance(smaj, (float, np.floating)) assert isinstance(smin, (float, np.floating)) assert isinstance(angle, Angle) def test_simple_wcs_isotropic(self, simple_wcs): """ For an isotropic WCS, major and minor scales should be equal. """ _, smaj, smin, _ = sky_to_pixel_svd_scales( WCS_CENTER, simple_wcs) expected = 1.0 / WCS_CDELT_ARCSEC assert_allclose(smaj, expected, rtol=1e-5) assert_allclose(smin, expected, rtol=1e-5) def test_pixel_to_sky_simple_scales(self, simple_wcs, center_xy_coord): """ For an isotropic WCS, pixel-to-sky scales should equal WCS_CDELT_ARCSEC. """ _, smaj, smin, _ = pixel_to_sky_svd_scales( center_xy_coord, simple_wcs) assert_allclose(smaj, WCS_CDELT_ARCSEC, rtol=1e-5) assert_allclose(smin, WCS_CDELT_ARCSEC, rtol=1e-5) def test_major_geq_minor(self, simple_wcs): """ The major scale should always be >= minor scale (SVD ordering). """ _, smaj, smin, _ = sky_to_pixel_svd_scales( WCS_CENTER, simple_wcs) assert smaj >= smin def test_nonsquare_different_scales(self, nonsquare_wcs): """ For non-square pixels, major and minor scales should differ. """ _, smaj, smin, _ = sky_to_pixel_svd_scales( WCS_CENTER, nonsquare_wcs) assert not np.isclose(smaj, smin) def test_roundtrip_inverse(self, simple_wcs, center_xy_coord): """ The product of sky->pixel major scale and pixel->sky major scale should be ~1. """ _, smaj_s2p, smin_s2p, _ = sky_to_pixel_svd_scales( WCS_CENTER, simple_wcs) _, smaj_p2s, smin_p2s, _ = pixel_to_sky_svd_scales( center_xy_coord, simple_wcs) assert_allclose(smaj_s2p * smaj_p2s, 1.0, rtol=1e-6) assert_allclose(smin_s2p * smin_p2s, 1.0, rtol=1e-6) def test_angle_wrapped(self, simple_wcs): """ The output angle should be in [0, 360) degrees. """ _, _, _, angle = sky_to_pixel_svd_scales( WCS_CENTER, simple_wcs) assert 0.0 <= angle.deg < 360.0 def test_sip_wcs_positive_scales(self, sip_wcs): """ Scales should be positive for distorted WCS. """ _, smaj, smin, _ = sky_to_pixel_svd_scales( WCS_CENTER, sip_wcs) assert smaj > 0 assert smin > 0 astropy-photutils-3322558/pyproject.toml000066400000000000000000000165321517052111400203610ustar00rootroot00000000000000[project] name = 'photutils' description = 'An Astropy package for source detection and photometry' readme = 'README.rst' license = 'BSD-3-Clause' license-files = ['LICENSE.rst'] authors = [ {name = 'Photutils Developers', email = 'astropy.team@gmail.com'}, ] keywords = [ 'astronomy', 'astrophysics', 'photometry', 'aperture', 'psf', 'source detection', 'background', 'segmentation', 'centroids', 'isophote', 'morphology', 'radial profiles', ] classifiers = [ 'Intended Audience :: Science/Research', '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.11' dependencies = [ 'astropy >= 6.1.4', 'numpy >= 2.0', 'scipy >= 1.13', ] [project.urls] Homepage = 'https://github.com/astropy/photutils' Documentation = 'https://photutils.readthedocs.io/en/stable/' [project.optional-dependencies] all = [ 'bottleneck >= 1.4', 'gwcs >= 0.20', 'matplotlib >= 3.9', 'rasterio >= 1.4', 'regions >= 0.9', 'scikit-image >= 0.23', 'shapely >= 2.0', 'tqdm >= 4.66', ] test = [ 'pytest-astropy >= 0.11', 'pytest-xdist >= 3.5', 'tox >= 4.12', ] docs = [ 'photutils[all]', 'sphinx >= 8.2', # keep in sync with docs/conf.py 'sphinx-astropy[confv2] >= 1.9.1', 'sphinx_design >= 0.6', 'sphinx-reredirects >= 1.1', ] dev = [ 'photutils[docs,test]', 'pre-commit >= 4.0', ] [build-system] requires = [ 'cython >= 3.1.2, < 4', 'extension-helpers >= 1.3, < 2', 'numpy >= 2.0', 'setuptools >= 77.0', 'setuptools_scm >= 8.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.datasets' = [ 'data/*', ] 'photutils.detection.tests' = [ 'data/*', ] 'photutils.isophote.tests' = [ 'data/*', ] 'photutils.psf.tests' = [ 'data/*', ] [tool.pytest.ini_options] minversion = "8.0" testpaths = [ 'photutils', 'docs', ] norecursedirs = [ 'docs/_build', 'extern', ] astropy_header = true doctest_plus = 'enabled' text_file_format = 'rst' addopts = [ '-ra', '--color=yes', '--doctest-rst', '--strict-config', '--strict-markers', ] log_level = 'INFO' log_cli_level = 'INFO' xfail_strict = true remote_data_strict = true filterwarnings = [ 'error', # turn warnings into exceptions ] [tool.coverage.run] omit = [ 'photutils/__init__*', 'photutils/**/conftest.py', 'photutils/**/setup*', 'photutils/**/tests/*', 'photutils/extern/*', 'photutils/version*', '*/photutils/__init__*', '*/photutils/**/conftest.py', '*/photutils/**/setup*', '*/photutils/**/tests/*', '*/photutils/extern/*', '*/photutils/version*', ] [tool.coverage.report] exclude_lines = [ 'pragma: no cover', 'raise NotImplementedError', 'def main\\(.*\\):', ] [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'] [tool.cibuildwheel] # explicitly skip free-threaded builds skip = ["cp*t-*"] [tool.repo-review] ignore = [ 'MY', # ignore MyPy 'PC110', # ignore using black or ruff-format in pre-commit 'PC111', # ignore using blacken-docs in pre-commit 'PC140', # ignore using mypy in pre-commit 'PC180', # ignore using prettier in pre-commit 'PC901', # ignore using custom pre-commit update message 'PC902', # ignore custom pre-commit CI autofix message 'PP006', # ignore missing dependency-groups in pyproject.toml 'PY005', # ignore having a tests/ folder ] [tool.codespell] ignore-words-list = """ conver, exten, fom, ned, """ [tool.docformatter] wrap-summaries = 72 pre-summary-newline = true make-summary-multi-line = true [tool.numpydoc_validation] checks = [ 'all', # report on all checks, except the below 'ES01', # missing extended summary 'EX01', # missing "Examples" 'RT01', # do not require return type for lazy properties 'RT02', # only type in "Returns" section (no name) 'SA01', # missing "See Also" 'SA04', # missing "See Also" description 'SS06', # single-line summary ] # don't report on objects that match any of these regex; # remember to use single quotes for regex in TOML exclude = [ '__init__', '\._.*', # private functions/methods '^test_*', # test code '^conftest.*$', # pytest configuration # PR01: private classes/functions 'SigmaClipSentinelDefault$', 'create_default_sigmaclip$', 'create_matching_kernel$', # PR02: subclasses without __init__ 'Background$', 'BackgroundRMS$', 'RadialProfile$', # GL08: docstrings defined by a decorator '\.plot_grid$', 'PSFPhotometry\.make_model_image$', 'PSFPhotometry\.make_residual_image$', 'IterativePSFPhotometry\.make_model_image$', 'IterativePSFPhotometry\.make_residual_image$', # GL08: docstrings inherited from base classes '\.to_mask$', '\.calc_background$', '\.calc_background_rms$', # GL08: property setters '\.normalization_correction$', '\.origin$', '\.fill_value$', '\.cutout_center$', '\.data', # GL08: inner function '\.optimize_func$', ] [tool.ruff] line-length = 79 [tool.ruff.lint.pylint] max-statements = 130 [tool.ruff.lint] select = ['ALL'] exclude = ["photutils/extern/*"] ignore = [ 'ANN', # type annotations 'B028', # no-explicit-stacklevel (warnings) 'BLE001', # blind-except 'C901', # complex-structure 'D102', # undocumented-public-method 'D105', # undocumented-magic-method 'D200', # unnecessary-multiline-docstring 'D205', # missing-blank-line-after-summary 'D301', # backslashes in docstrings (need line continuation) 'D401', # non-imperative-mood 'FBT002', # boolean-default-value-positional-argument 'I001', # import-order (conflicts with isort) 'N803', # invalid-argument-name 'PLC0415', # import-outside-top-level 'PLR0912', # too-many-branches 'PLR0913', # too-many-arguments 'PLR2004', # magic-value-comparison 'PLW1641', # eq-without-hash 'PLW2901', # redefined-loop-name 'PTH', # Pathlib usage 'RUF015', # unnecessary-iterable-allocation-for-first-element 'RUF100', # unused-noqa 'SLF001', # private-member-access ] [tool.ruff.lint.per-file-ignores] '__init__.py' = [ 'D104', # undocumented-public-package 'I', # isort ] 'conftest.py' = [ 'D103', # undocumented-public-function ] 'docs/conf.py' = [ 'ERA001', # commented-out-code 'INP001', # implicit-namespace-package 'TRY400', # error-instead-of-exception ] 'test_*.py' = [ 'D', # pydocstyle 'S101', # assert ] 'model_io.py' = [ 'ARG001', # unused-function-argument ] [tool.ruff.lint.pydocstyle] convention = 'numpy' [tool.ruff.lint.flake8-quotes] inline-quotes = 'single' [tool.distutils.bdist_wheel] py-limited-api = "cp311" astropy-photutils-3322558/tox.ini000066400000000000000000000074351517052111400167620ustar00rootroot00000000000000[tox] envlist = py{311,312}-test-oldestdeps py{311,312,313,314}-test{,-alldeps,-devdeps,-devinfra}{,-cov} 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 # The following provides some specific pinnings for key packages deps = cov: pytest-cov oldestdeps: minimum_dependencies devdeps: numpy>=0.0.dev0 devdeps: scipy>=0.0.dev0 devdeps: scikit-image>=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_pre = oldestdeps: minimum_dependencies photutils --filename requirements-min.txt oldestdeps: pip install -r requirements-min.txt commands = 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=79 [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